The 2017 Ghost in the Time Machine — Part 2: Exploitation Attempts
A PAC-clean kernel write primitive hiding in plain sight — and five mitigations standing between it and a full exploit.
GanaSec Date: May 2026 Series: Part 1 — Discovery and Crash Site RE
CVE / Advisory Details
- CVE: CVE-2026-28969
- CVSS 3.1: 7.5 HIGH
- CWE: CWE-416 (Use After Free)
- Fixed in: macOS Tahoe 26.5, Sequoia 15.7.7, Sonoma 14.8.7, iOS/iPadOS 18.7.9 and 26.5, tvOS 26.5, visionOS 26.5, watchOS 26.5
- Advisory: HT127115
Preface
In Part 1, I found a use-after-free race in IOTimeSyncFamily on macOS 26.4 — four kernel panics, consistent crash signatures across KASLR slides. After reporting the bug, I continued analyzing the crash site to understand the exploitation potential.
What I found was a PAC-clean kernel write primitive hiding in plain sight — and a wall of mitigations standing between it and a full exploit. This post walks through the write primitive, the exploitation attempts, and what XNU's modern defenses look like from the attacker's side.
Part I — The Write Primitive
After reporting the crash to Apple, I extracted the IOTimeSyncFamily kext from the macOS 26.4 kernelcache and disassembled the crash site to map out what an attacker could actually do with this UAF.
Here's IOTimeSyncClockManager::addgPTPServices() at kext offset +0x178D0:
; x19 = internal object pointer (NULL after race, controlled if reclaimed)
0xfffffe00093c42a0: ldr x0, [x0, #0xf0] ; Load mutex pointer from [object+0xF0]
0xfffffe00093c42a4: bl _lck_mtx_lock ; Lock the mutex with loaded value
0xfffffe00093c42a8: ldr x8, [x19, #0x90] ; Read branch condition field
0xfffffe00093c42ac: cbnz x8, +0xb0 ; Skip vtable dispatch if non-null
...
0xfffffe00093c42d4: ldr x16, [x19] ; Load vtable pointer
0xfffffe00093c42dc: autda x16, x17 ; PAC-authenticate the vtable
0xfffffe00093c4314: blraa x9, x17 ; Authenticated indirect call
The function accesses the freed object at three offsets. Here's what each one looks like from an exploitation perspective:
Freed/Reclaimed Object Layout
┌──────────────────────────────────────────────────────┐
│ +0x00 vtable pointer │
│ └─ LDR x16, [x19] → AUTDA → BLRAA │
│ PAC-SIGNED ── cannot forge without key │
│ │
│ +0x90 branch condition field │
│ └─ LDR x8, [x19, #0x90] → CBNZ │
│ NOT SIGNED ── controls code path │
│ set to 0 → skips vtable, hits mutex path │
│ │
│ +0xF0 mutex pointer (lck_mtx_t *) │
│ └─ LDR x0, [x0, #0xF0] → BL _lck_mtx_lock │
│ NOT SIGNED ── attacker controls argument │
│ THIS IS THE WRITE PRIMITIVE │
└──────────────────────────────────────────────────────┘
Two code paths, two protection levels. The vtable dispatch is PAC-protected — AUTDA authenticates the pointer before BLRAA. Classic vtable hijacking is dead. But the mutex load at +0xF0 is a plain LDR. The value goes straight into _lck_mtx_lock as a function argument. PAC protects indirect branches, not function arguments.
_lck_mtx_lock on XNU ARM64:
ADD X10, X0, #8 ; x10 = mutex_addr + 8
CASA X2, X8, [X10] ; if [mutex_addr+8] == 0:
; write TPIDR_EL1 → [mutex_addr+8]
CASA (Compare-And-Swap, Acquire) atomically writes the current thread's TPIDR_EL1 — a pointer to the kernel thread_t struct — if and only if the target is zero.
Exploitation Flow
─────────────────
Win race ──► Reclaim freed slot with controlled data
│
├─ Set [+0x90] = 0 (skip PAC vtable path)
├─ Set [+0xF0] = addr - 8 (mutex ptr → target)
│
▼
addgPTPServices() runs ──► ldr x0, [x0, #0xF0]
│
▼
lck_mtx_lock(addr - 8)
│
▼
CASA: if [addr] == 0
write thread_t ptr → [addr]
│
▼
┌────────────────────────────────┐
│ 8-byte kernel pointer written │
│ to attacker-chosen address │
│ │
│ Address: controlled │
│ Value: TPIDR_EL1 (thread_t) │
│ Cond: [addr] must be 0 │
│ PAC: not involved │
└────────────────────────────────┘
The write value isn't arbitrary — it's always a kernel heap pointer. But kernel heap pointers are useful: write one into a Mach port's ip_kobject field (starts at NULL for user-created ports), read it back via mach_port_get_attributes(), and you've converted a blind write into a kernel address leak. Chain from there.
Part II — Seven Failures
With the write primitive mapped out, the next step was reclaiming the freed object with controlled data.
Attempt 1 & 2: OOL Messages and IOSurface Spray
The classic macOS heap spray: Mach OOL messages or IOSurface property dictionaries. Both allocate in kalloc.data — the untyped data zone.
The freed IOTimeSyncFamily internal object is a C++ class allocated via KALLOC_TYPE_DEFINE, routing to a typed zone: kalloc.type.296 in the GEN0 region. OOL and IOSurface data goes to DATA. Different virtual ranges, different physical pages. My spray filled the wrong zone.
XNU Zone Architecture (macOS 26.4)
───────────────────────────────────
┌─ GEN0 ──────────────────────┐ 0xfffffe186a000000
│ kalloc.type.296 │ ◄── IOTimeSyncFamily object HERE
│ kalloc.type.320 │
│ (typed C++ objects) │
├─ GEN1 ──────────────────────┤ 0xfffffe1e36000000
│ thread structs │ ◄── TPIDR_EL1 points here
├─ GEN2 ──────────────────────┤ 0xfffffe2402000000
│ general typed objects │
├─ GEN3 ──────────────────────┤ 0xfffffe29ce000000
│ general typed objects │
├─ DATA ──────────────────────┤ 0xfffffe2f9a000000
│ kalloc.data.256 │ ◄── OOL / IOSurface / pipes HERE
│ kalloc.data.512 │
│ (untyped buffers) │ ✗ NEVER reaches GEN0
└─────────────────────────────┘ 0xfffffe3604000000
This isn't a tuning problem. It's architectural. The 2020-era technique of spraying ipc_kmsg buffers to reclaim freed IOKit objects is dead on modern macOS.
Attempt 3: Same-Type Spray
Open hundreds of new IOTimeSyncClockManager connections — same type, same zone, should land in the freed slot. The zone allocator does reuse freed slots for same-type allocations.
The problem is content control. The newly allocated object is initialized by the kext's constructor. I don't get to choose what goes at +0xF0 — the constructor writes a valid mutex pointer. My replacement object works correctly. The race doesn't crash; the kext happily continues on a healthy object.
The allocator reuses the slot, but the constructor initializes it.
Attempt 4: Stale Handle After Close
Call IOServiceClose(conn) then immediately IOConnectCallMethod(conn, 5, ...) on the same handle. This fails at the Mach layer — IOServiceClose() destroys the send right. mach_msg_send() returns MACH_SEND_INVALID_DEST before the message leaves userspace.
The race in the original PoC worked because both threads were operating concurrently — IOServiceClose() still in progress while IOConnectCallMethod() was already past MIG dispatch inside the kernel. Calling after close completes is too late.
Attempts 5-7
Zone fragmentation — open 512 connections, close alternating ones to create holes. Correct in concept, useless without controlled data to fill the holes.
CPU pinning — pin both threads to one core. Makes the race harder: concurrent execution across multiple cores is what wins. The original PoC with 64 racers panicked in seconds. The pinned version ran 4 million cycles clean.
Alternative selectors (13/14/16) — 2.8 million cycles, zero panics. These selectors don't hit the +0xF0 dereference path.
Part III — zone_require and Typed Zones
Every failed attempt traced back to the same root cause: XNU's typed zone allocator.
Prior to the redesign (~macOS 12 / iOS 15), kernel objects lived in shared kalloc zones segregated only by size. A freed 256-byte IOKit object could be replaced by any 256-byte allocation. The entire heap spray technique that Project Zero and others used for a decade relied on this.
The new architecture tags allocations by C++ type via KALLOC_TYPE_DEFINE. Each type gets its own zone bucket in a generational region. zone_require enforces this at free time — any cross-zone free triggers a kernel panic.
But zone_require only fires on free operations. It does NOT prevent writing to arbitrary addresses via lck_mtx_lock (the CASA never goes through the zone allocator) or same-type slot reuse.
The Zone Probe
I built a probe to empirically confirm the zone type: spray 4,096 pipe buffers with a canary (0xDEADBEEFCAFEBABE) at offset +0xF0, trigger the race, check the panic log.
memset(buf, 0x41, PIPE_BUF_SIZE);
*(uint64_t *)(buf + 0x00) = 0x0000000000000000; /* vtable = NULL */
*(uint64_t *)(buf + 0x90) = 0x0000000000000000; /* skip PAC path */
*(uint64_t *)(buf + 0xF0) = 0xDEADBEEFCAFEBABE; /* canary */
FAR in panic log Meaning
───────────────────── ──────────────────────────────
0xDEADBEEFCAFEBAC6 Canary + 8 → pipe spray reclaimed
(canary + 8) the slot → object is in DATA zone
→ pipe spray exploitation viable
0x00000000000000F0 NULL + 0xF0 → pipe spray went to
(NULL deref) wrong zone → object is in TYPED zone
→ need intra-zone strategy
Even if the canary lands, lck_mtx_lock(0xDEADBEEFCAFEBABE) faults on an unmapped address before the CASA executes — the panic log tells you the zone type without a kernel debugger.
Part IV — Remaining Exploitation Paths
Three strategies that could reach the write primitive. Apple shipped the patch in macOS 26.5 before I could fully pursue them.
Info leak chain — The write primitive needs a target address, which means defeating KASLR, which means an info leak. The canonical target: a Mach port's ip_kobject (starts at NULL). Write the thread pointer there, read it back via mach_port_get_attributes(), convert the blind write into a read-back primitive. Requires chaining with a separate info leak — a standard requirement for any modern kernel exploit.
Intra-zone feng shui — If the kext provides API calls that modify the +0xF0 field after construction, the attack becomes: open a new connection (fills the freed slot), call a selector that overwrites +0xF0 with attacker-influenced data, trigger the race. Requires reverse-engineering all 17 selectors' data flow in the closed-source ARM64e binary.
IOConnectMapMemory — If the user client exposes shared kernel memory, you get a known address without an info leak. I tested all 8 memory type indices — kIOReturnUnsupported for every one. Dead for this kext, worth checking on other targets.
Part V — The Mitigation Stack
IOTimeSyncFamily UAF → Full Kernel R/W
What stands in the way:
┌─────────────────────────────────────────────────────────────┐
│ │
│ ① Typed zones + zone_require │
│ Blocks all cross-type heap spray (OOL, IOSurface, pipe) │
│ Status: BLOCKING ─ primary barrier │
│ │
│ ② PAC (AUTDA / BLRAA) │
│ Blocks vtable hijacking at [object+0x00] │
│ Status: BYPASSED ─ mutex path at +0xF0 is not signed │
│ │
│ ③ KASLR │
│ Blocks knowing the write target address │
│ Status: BLOCKING ─ needs info leak │
│ │
│ ④ Mach port invalidation on IOServiceClose │
│ Blocks stale handle use-after-close │
│ Status: BLOCKING ─ must win race before close returns │
│ │
│ ⑤ Constructor initialization │
│ Blocks controlling same-type replacement content │
│ Status: BLOCKING ─ kext sets +0xF0 to valid mutex │
│ │
└─────────────────────────────────────────────────────────────┘
The write primitive is real — lck_mtx_lock will write a thread pointer to any zero-initialized kernel address, without PAC, if the attacker controls [object+0xF0]. The zone allocator ensures that controlling [object+0xF0] requires either a kext API that writes to that offset or a separate vulnerability.
Each additional chain link reduces the probability that all links are simultaneously available. This is the equilibrium Apple's defense-in-depth is designed to create.
Part VI — Outcome
I reported the bug to Apple on 2026-03-30 with the race condition PoC and four panic logs with full register state. After continued analysis revealed the write primitive, I included that in the report as well. Apple reproduced the bug and shipped the fix in macOS 26.5 — plus backports to Sonoma 14.8.7, Sequoia 15.7.7, iOS/iPadOS 18.7.9 and 26.5, tvOS, visionOS, and watchOS.
I verified the fix on macOS 26.5 beta 4 (Build 25F5068a): 43 million race cycles, zero panics.
CVE-2026-28969. CWE-416 (Use After Free). CVSS 7.5 HIGH.
Takeaways
Run the zone probe first. Before spending time on spray techniques, determine whether your target object is in a typed zone. The pipe canary test costs one panic — if the canary doesn't land, cross-zone spray will never work.
Reverse-engineer the kext's own API. If the object is typed, your only path to content control is through the kext itself. Map every selector for writes to user-influenced offsets.
Chain, don't solo. A single IOKit UAF on modern macOS is unlikely to reach code execution alone. Info leak, write target, content control — treat each as a separate primitive.
References
- NVD — CVE-2026-28969 (CVSS 7.5 HIGH, CWE-416)
- Apple security advisories: HT127115
- CVE-2017-13847 — Ian Beer's original IOTimeSyncFamily finding
- Project Zero Issue #1377 — Beer's 2017 IOTimeSyncClockManager UAFs
About the Author
Ashish Kunwar · Founder, GanaSec
Ashish Kunwar is the founder of GanaSec, an offensive security research firm. GanaSec focuses on vulnerability research across multiple platforms. Ashish has over 500 responsible disclosures, 8+ CVEs, and part of his research was presented at DEF CON 29. He was previously with Microsoft MSTIC and has been recognized by Forbes for his contributions to cybersecurity.
If you want us to hunt for bugs like this in your environment, book a 30-minute scoping call.