The PoC for CVE-2026-28956 is a 149-byte JPEG XL image. ImageIO decodes it through the same code path Safari's WebContent uses for <img> tags, so the trigger is one tag on any page the target visits. The crash is a use-after-free in JxlDecoderDestroy: the destructor walks a std::vector<Stage> backing buffer the decoder freed two callbacks earlier, and a DYLD interposer that scribbles the freed buffer with the Apple Security Bounty target address turns the same primitive into register control plus arbitrary read and write. Apple paid one thousand dollars.
CVE-2026-28956: One Thousand Dollars For Register Control From `<img src>`
pattern
cve
proof of concept
The container says size zero. The parser keeps reading.
poc.jxl is 149 bytes. It is a standard ISOBMFF wrapper containing a JPEG XL codestream. The first 12 bytes are the JXL signature box. The next 20 bytes are the ftypjxl box. Then comes a jxlc codestream box whose 32-bit size field is 00 00 00 00. In ISOBMFF that is not a zero-length box. It is the spec's reserved value for "this box extends to the end of the file."
The codestream that follows is a valid, short JPEG XL codestream. Then, inside the region the outer jxlc box already claimed for itself, the file embeds a second jxl and jxlc sequence with identical codestream parameters.
Box: JXL signature (12 bytes)
Box: ftyp jxl (20 bytes)
Box: jxlc size=0 (extends to EOF)
body of jxlc:
[valid codestream bytes]
[second jxl / jxlc box markers]
[second codestream bytes]The container layer and the codestream parser are operating on different layers, and the bug lives in the gap. The outer box says "every byte to EOF is mine." The parser inside its body walks the bytes looking for codestream markers and treats the second jxlc it finds as a new stream to initialize. The pipeline object built for the first stream is reused; the second stream's setup releases the first's std::vector<Stage> backing buffer. The wrapper object retains pointers into that buffer. The wrapper object's destructor has not run yet.
This is the shape we name duplicate-chunk-reinit: a parser that treats a second occurrence of a stream-defining marker as a signal to reset per-stream state in place, releasing the first cycle's allocations as a side effect, while an outer wrapper that owns the lifecycle keeps the original pointers and reaches them at teardown.
The destructor walks the buffer the decoder already freed.
The vulnerable structure is a std::vector<Stage> at offset +0x1330 (decimal 4912) inside the pipeline object. When the caller eventually invokes JxlDecoderDestroy, the destructor chain runs:
JxlDecoderDestroy
└── sub_23547A8F0 // swap and destroy pipeline
└── sub_23547B140 // vector destructor entry
└── sub_235460544 // iterate backing buffer backwards
└── sub_235451C2C // per-Stage destructor, per elementsub_235451C2C is the per-element teardown. With the live vector it walks a real Stage object and runs its cleanups. With the freed vector it walks whatever now occupies the freed allocation. Under Guard Malloc that is 0xaaaaaaaaaaaaaaaa, the scribble pattern, and the destructor segfaults indirecting through it. That is the crash signature in the PoC's expected output.
Under an attacker controlling what occupies the freed allocation, the per-element destructor performs three observable operations against attacker-supplied pointers loaded from that buffer:
v1 = *(element_ptr + 24); // read from freed vector buffer
atomic_fetch_add(&ref, -*(v1 - 24)); // write at (v1 - 24)
free(*(v1 - 32)); // read at (v1 - 32), then free that pointerBy controlling element_ptr+24, the attacker steers v1. By controlling what sits at v1 - 24 and v1 - 32, the attacker steers both an atomic write to an arbitrary 8-byte slot and a free of an arbitrary pointer. That is a write-anywhere primitive, a read-anywhere primitive (the value loaded from v1 - 32 is the next iteration's seed), and an arbitrary-free primitive, all from one allocation in a fixed-size bucket roughly 6144 bytes wide.
The PoC ships two ways to land the bucket. interpose_free.c hooks free directly and overwrites the freed bytes with crafted contents the moment they are released. That is the lab-clean way to prove the primitive exists. The real-world way is heap massage: pick another caller in the same parse whose input lands the same ~6144 bucket, fill it with the eight-byte pointer pairs the destructor expects, and let JxlDecoderDestroy walk the assembly. AppleJPEGXL has plenty of attacker-influenced allocations in its decode path. The PoC author chose the interposer because the interposer is faster to write, not because the heap-massage path is hard.
What looks at first read like a duplicate-free quirk is a write-anywhere, read-anywhere, free-anywhere primitive inside a sandboxed but JIT-bearing process.
ImageIO is the reach.
The PoC's entry point is CGImageSourceCreateWithData followed by CGImageSourceCreateImageAtIndex. This is the ImageIO API surface every macOS process that displays an image goes through. The list is not short.
Safari's WebContent calls ImageIO for every <img> tag on every page. JPEG XL has been a default-on content type in Safari since version 17, which shipped in 2023. A web page with <img src="poc.jxl"> triggers the bug zero-click inside WebContent.
Mail calls ImageIO when rendering inline images in HTML email. An email with an inline JXL image triggers the bug the moment the message is displayed in the preview pane. The user has not clicked on the attachment; the user has clicked on the message.
Messages renders inline image previews automatically when a JXL attachment arrives in a conversation that is already open. AirDrop renders a preview thumbnail on the recipient's screen before the recipient accepts or declines the transfer. Spotlight calls ImageIO to extract thumbnails during file-system indexing, which means a JXL file dropped into any indexed location is decoded by the OS before any user interaction. Finder calls ImageIO for the thumbnails in icon view. Quick Look calls ImageIO when the user presses Space on an image in Finder. Preview calls it on every open.
A 149-byte JXL file delivered to any of those surfaces decodes through AppleJPEGXL.framework. The disclosed PoC stops at the crash; the PoC does not need to do more. Anything that decides what bytes occupy the bucket reachable from the ~6144-byte freed allocation gets register control on the per-Stage destructor's first dereference.
The comm page hosts the bounty's targets. The PoC reads them.
The two odd globals in interpose_free.c are not invented. _COMM_PAGE_ASB_TARGET_VALUE lives at _COMM_PAGE64_BASE_ADDRESS + 0x320. _COMM_PAGE_ASB_TARGET_ADDRESS lives at +0x328. The commpage is the read-only region the kernel maps into every userspace process at a fixed address. Apple ships these two slots specifically for Apple Security Bounty submissions. The values change between boots.
A researcher demonstrating that a primitive yields register control fills the freed buffer with the TARGET_VALUE and submits the crash log, in which the corrupted register state shows the value across x0, x1, x16. A researcher demonstrating arbitrary read and write fills the freed buffer with TARGET_ADDRESS and submits a log showing the destructor's writes and reads land at exactly that address.
__attribute__((constructor))
static void init_interpose(void) {
g_asb_target_value = *(volatile uint64_t *)ASB_TARGET_VALUE_ADDR;
g_asb_target_address = *(volatile uint64_t *)ASB_TARGET_ADDRESS_ADDR;
...
}This is Apple's own validation protocol. Apple built the institutional machinery to evaluate primitive submissions rigorously. They shipped the kernel hooks that let researchers prove register control and arbitrary memory access against per-boot, per-machine target values. They published the protocol on the same page as the bounty schedule. The PoC's interpose_free.c reads both slots, fills the freed AppleJPEGXL buffers with the target address in arbrw mode, and lets the destructor's reads and writes hit Apple's own commpage flag for the bounty reviewer to confirm.
The protocol exists because Apple wants verifiable submissions. The PoC submits a verifiable submission.
The bounty schedule names six figures for the category demonstrated here.
Apple's published bounty schedule names "WebContent sandbox escape" via "navigating to a malicious webpage in Safari" at up to three hundred thousand dollars. The network-attack-with-user-interaction tier going through kernel arbitrary code execution tops at one million dollars. The same page that publishes those numbers is the page that publishes the commpage target protocol the researcher's submission used.
What the PoC demonstrates is not a sandbox escape. It is the building block of a sandbox escape: register control on a writeable instruction stream inside WebContent, an arbitrary write to any address the attacker chooses, an arbitrary free of any address the attacker chooses, and an arbitrary read of any address the attacker chooses, all from one <img> tag. The researcher's own writeup identifies candidate non-PAC br x3 gadgets in WebKit but does not develop the chain to code execution.
The disclosure timeline:
| Date | Event |
|---|---|
| 2026-03-05 | PoC delivered to Apple Product Security. |
| 2026-05-12 | CVE-2026-28956 assigned. The patch had already shipped silently. |
| 2026-05-12 | Researcher integrates the commpage target protocol; submits artifact. |
| 2026-05-15 | Apple Security Bounty awards one thousand dollars. |
The CVE was assigned the same day the researcher submitted the commpage-validated artifact. Between March and May, Apple held the report, patched the bug, and did not assign a CVE. The CVE arrived after the researcher proved the primitive against Apple's own validation protocol. This is a familiar Apple shape: silent patch first, public number assigned only when the reporter demonstrates the severity bar Apple has already privately accepted.
JPEG XL is one of three image codecs Apple has added to ImageIO in the last four years; HEIF, AVIF, and JPEG XL all expose the same shape of surface to the same set of decoder-reaching surfaces. Every multi-stream container format the framework absorbs is another duplicate-chunk-reinit waiting on a parser whose state machine treats inner markers as instructions to reset. The risk profile of the broad default-on adoption is the structural reason a 149-byte file reaches Mail, Messages, AirDrop, Spotlight, Finder, Quick Look, Preview, and Safari. The codec's adoption was a product decision. The substrate that decision created is what produced this CVE.
The bounty schedule does not enumerate a "demonstrated primitive without a developed chain" line item. That is the discretion the four figures spent. We are not required to guess which line of the schedule the four figures came from.
PoC: impost0r/CVE-2026-28956. Writeup: ret2p.lt/2025/05/17/AppleJPEGXL-CVE.html.
Apple's bounty schedule names six figures for the category demonstrated here. The receipt says four.