Image decoder exploitation

Image decoder exploitation

Image decoders are the softest 0-click attack surface left in modern stacks. They parse adversary-controlled binary blobs in privileged-ish processes (browser renderer, messenger preview, OS thumbnailer, lock-screen wallpaper handler), they ship in every OS, and they are written in C/C++ with hand-rolled bitstream parsers. A single malformed WebP, AVIF, or PNG can land you code execution in iMessage’s ImageIO, in Chrome’s renderer, or in Windows Explorer’s preview pane — often before the user does anything. If you ship offensive tooling or you defend a fleet, the libwebp BLASTPASS chain (CVE-2023-4863 / CVE-2023-41064) is the reference case study for the decade.

Mental model

An image decoder is a state machine over a chunked container (RIFF for WebP, ISO-BMFF for AVIF/HEIC, IHDR/IDAT chain for PNG, SOI/SOF/SOS for JPEG). For each chunk the parser reads a declared length, allocates buffers sized from header fields, then walks a bit-packed inner stream (Huffman, VP8L prefix codes, arithmetic coding) to fill pixel buffers. Bugs cluster into five families:

  • Heap overflow in code-table construction. The classic. libwebp CVE-2023-4863 lives in BuildHuffmanTable() — VP8L lets the encoder declare prefix codes whose worst-case expansion is larger than the conservatively sized table. Crafted color-cache + meta-Huffman combinations write past the second-level table.
  • OOB read from malformed chunk length. libpng historically trusts the IHDR-derived row stride; AVIF tile grids (tileWidth * tileHeight * bpp) can wrap. libjpeg-turbo had JPEG_REACHED_SOS state machine confusion (CVE-2020-13790) leading to OOB reads in get_8bit_row.
  • Integer overflow into stride/allocation math. width * height * bytes_per_pixel overflows a 32-bit size_t on 32-bit builds; allocation is small, the loop is big. Skia had several variants in SkBitmap::tryAllocPixels.
  • Type confusion in colorspace conversion. ICC profile parsing in Apple ImageIO (CVE-2021-30860 FORCEDENTRY) — a JBIG2-in-PDF-in-image trick, but the pattern of “wrong codec dispatch on attacker-controlled MIME” recurs in libavif and HEIF.
  • Use-after-free across progressive decode. JPEG progressive scans, PNG APNG frame disposal, AVIF animated grids — frame N frees a buffer frame N+1 still references.

The BLASTPASS chain, end to end:

1
2
3
4
5
6
7
8
9
10
iMessage attachment (PassKit .pkpass)
  -> BlastDoor sandboxed parse (entitlements stripped, seatbelt)
  -> ImageIO dispatches WebP -> libwebp VP8L decode
  -> BuildHuffmanTable() heap overflow corrupts adjacent libmalloc metadata
  -> shape libmalloc tiny/small zone via repeated chunk allocs
  -> arbitrary read/write inside BlastDoor
  -> JBIG2-style integer VM (FORCEDENTRY-era trick) for stable primitive
  -> escape BlastDoor via XPC message to imagent / identityservicesd
  -> kernel LPE chain (separate bug, often DCP or AppleAVE2)
  -> persistent implant (Pegasus/Predator class)

Citizen Lab caught the in-the-wild sample on a Catalan civil-society device; Apple shipped iOS 16.6.1 within a week, Google force-patched Chrome libwebp the next day, and every Electron app on earth had to re-roll.

Tradecraft

Build a fuzzing harness against the library, not the app. libwebp ships a libFuzzer target already:

1
2
3
4
5
6
7
8
9
10
git clone https://chromium.googlesource.com/webm/libwebp
cd libwebp
CC=clang CXX=clang++ CFLAGS="-fsanitize=address,fuzzer-no-link -g" \
  ./autogen.sh && ./configure --enable-libwebpdecoder
make -j
clang++ -fsanitize=address,fuzzer -g \
  tests/fuzzer/advanced_api_fuzzer.cc \
  src/.libs/libwebp.a src/.libs/libwebpdecoder.a -o webp_fuzz
mkdir corpus && cp tests/fuzzer/dictionaries/webp.dict .
./webp_fuzz corpus/ -dict=webp.dict -max_len=65536 -jobs=8

For VP8L specifically, the productive mutation is bit-level on the prefix-code header. Stock libFuzzer mutates bytes; you want a custom mutator that respects the VP8L bitstream:

1
2
3
4
5
6
extern "C" size_t LLVMFuzzerCustomMutator(uint8_t *Data, size_t Size,
                                          size_t MaxSize, unsigned int Seed) {
    // flip bits inside the meta-Huffman region (offset derived from RIFF parse)
    // bias toward color_cache_bits and num_huff_groups fields
    ...
}

Honggfuzz with a seed corpus pulled from Open Images V7 plus the WebP test suite finds the boring crashes in hours:

1
honggfuzz -i corpus/ -o findings/ -n 16 -- ./webp_fuzz ___FILE___

Differential fuzzing across decoders catches the interesting class — same input, different output across libwebp, Skia’s SkWebpCodec, and Apple ImageIO means one of them is wrong about chunk length or color-cache state. Wrap each in a small process, render to PNG, hash, diverge.

For triage of a candidate crash, ASan + a minimal repro driver beats running the full browser:

1
2
3
clang++ -fsanitize=address -g repro.cc -lwebp -o repro
./repro crash-9f3b1c.webp
# look for heap-buffer-overflow WRITE of size 4 in BuildHuffmanTable

Real exploitation past the crash is heap-shape work. On macOS, study nano_zone / tiny allocator interleaving; on Windows, the segment heap LFH; on Linux glibc, tcache poisoning. See heap-exploitation-linux and heap-exploitation-windows for primitives. Browser specifics — V8 sandbox, PartitionAlloc, site isolation — live in browser-exploitation-primer. Pwn2Own entries from 2024-2025 leaned on image and font parsers repeatedly; see pwn2own-2024-2025-research-roundup.

Detection

Pre-exploit detection of an image bug is approximately impossible — the bytes look like a valid image until the decoder gets confused. You hunt post-exploit:

  • macOS: ReportCrash entries for MessagesBlastDoorService, com.apple.WebKit.WebContent, quicklookd, Preview with EXC_BAD_ACCESS near WebPDecode, BuildHuffmanTable, VP8LDecodeImage. ESF events ES_EVENT_TYPE_NOTIFY_EXIT with non-zero status from these binaries are the signal.
  • Windows: Defender for Endpoint surfaces BrowserRenderProcessTerminated and ImageParsingException. Hunt in advanced hunting:
DeviceProcessEvents
| where InitiatingProcessFileName in ("msedge.exe","chrome.exe","explorer.exe")
| where ProcessCommandLine has_any ("--type=renderer","preview")
| join kind=inner (DeviceEvents
    | where ActionType == "ProcessCrash"
    | where AdditionalFields has_any ("libwebp","WebPDecode","libavif"))
  on DeviceId
| project Timestamp, DeviceName, FileName, AdditionalFields
  • Linux/Chrome: chrome_crashpad_handler minidumps with libwebp.so frames; journalctl -u gdm for repeated gnome-shell thumbnailer crashes.
  • Network: BLASTPASS-style 0-click attachments arrive over iMessage (APNs), WhatsApp media CDN, Signal attachment service — TLS-encrypted, so you correlate on attachment-fetch timing + subsequent process crash, not payload.
  • Heap-spray tells: GPU process or renderer RSS jumping 200-800 MB in seconds before crash; MADV_HUGEPAGE advice from a non-JIT process.

See macos-userland-mitigations for what BlastDoor, PAC, and Lockdown Mode actually buy you, and fuzzing-windows-drivers for the kernel-side analogue once you escape the renderer.

OPSEC pitfalls

  • Testing exploits against your own iCloud or WhatsApp account burns the bug — Apple and Meta collect crashes from every device and Citizen Lab gets notified. Use offline rigs with telemetry disabled.
  • Distributing a PoC .webp over GitHub gets the repo nuked within hours by GitHub’s malware scanner once any AV vendor flags the hash. Ship the generator, not the artifact.
  • Forgetting that ImageIO dispatches by magic bytes, not extension. Renaming .webp to .jpg still triggers the WebP decoder on macOS — useful for delivery, also useful for accidentally popping your own QuickLook while triaging.
  • Differential fuzzing harnesses that link multiple decoders into one process — one decoder’s ASan poisoning hides another’s bug. Run each in a child process.
  • Assuming the patch in upstream libwebp means the bug is dead. Every Electron app, every Android OEM fork, every embedded set-top box ships its own pinned copy. The long tail is years.

References

  • https://blog.isosceles.com/the-webp-0day/
  • https://citizenlab.ca/2023/09/blastpass-nso-group-iphone-zero-click-zero-day-exploit-captured-in-the-wild/
  • https://nvd.nist.gov/vuln/detail/CVE-2023-4863
  • https://googleprojectzero.blogspot.com/2021/12/a-deep-dive-into-nso-zero-click.html
  • https://chromium.googlesource.com/webm/libwebp/+/refs/heads/main/src/utils/huffman_utils.c
  • https://llvm.org/docs/LibFuzzer.html

See also: browser-exploitation-primer, pwn2own-2024-2025-research-roundup, fuzzing-windows-drivers, heap-exploitation-linux, heap-exploitation-windows, macos-userland-mitigations