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.
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.
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.
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.
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:
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 W
row_buf size
memcpy size
OOB delta
4 (canonical)
10 B
12 B
3 B
16
4 B
48 B
45 B
64
4 B
192 B
189 B
256
4 B
768 B
765 B
1,024
4 B
3,072 B
3,069 B
4,096 (harness MAX_DIM)
4 B
12,288 B
12,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.
--- 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 }
Reproducer
Pre-patch
Post-patch
iter_apng_write_leak_001.bin (263 B)
LSan: 4 allocs / 52 B leak
clean (exit 0)
crash-…7f7c72b (603 B)
ASAN heap-buffer-overflow
clean (exit 0)
finding_003_min.png (467 B)
ASAN heap-buffer-overflow
clean (exit 0)
variant_a_2235B.bin (2,235 B)
ASAN heap-buffer-overflow at variant alloc site
clean (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
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
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
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:
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
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 · DEMONSTRATIONVIDEO · 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:
Tool
libpng write API used
Reproduces finding_003?
apngasm v3.1.10
png_write_image only
Not reproduced
apngopt 1.4
png_write_image only
Not reproduced
ImageMagick (coders/png.c)
png_write_row + png_write_info / png_write_end
Not 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.
0 (1 harness-misuse class on png_set_background_fixed - hardening, not security)
APNG patch-diff sweep
7,375 inputs + 2 h libFuzzer + 90 min MSAN
7,375 + 616 M execs
0 patched-side trips
Cleanup-audit
3 micro-harnesses
~358 M
0
Chaos pipeline + critic-mode
7 harnesses
~4 B
0
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.
2026-05-06Linear OOB scaling verified to ~4 MB ceiling at PNG_USER_WIDTH_MAX. Working control-flow hijack demonstrated against purpose-built target.
2026-05-11GitHub Security Advisory GHSA-wr84-h9jm-6g23 opened against pnggroup/libpng.
2026-06-23Maintainer 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.
2026-06-23Finding 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.
2026-06-29CVE request re-issued by the maintainer after the first went unanswered.
2026-07-10GitHub declines the CVE request under CNA rule 4.2.11 - the advisory covered more than one independently-fixable vulnerability.
2026-07-11Advisory narrowed to a single vulnerability (CWE-787), with the CWE-401 leak folded in as its same-patch companion and finding 001 removed.
2026-08-06Published. 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:
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 / Category
Verdict
Rationale
pngtest (Debian libpng-tools)
Affected
The only in-tree caller of the per-frame APNG write API. Reachable and deterministic.
imagecodecs (Python)
API path confirmed in source
Direct 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.4
Not affected
png_write_image per frame; APNG container assembled manually with fwrite. Does not reach the per-frame write API.
ImageMagick
Not affected
png_write_row with png_write_info / png_write_end, without png_write_frame_head / tail.
Pillow, GraphicsMagick
Not affected
Pillow writes its own chunk headers; GraphicsMagick ignores APNG chunks even against a patched libpng.
Browsers and image viewers
Not affected
Read-only consumers. Displaying an APNG does not call png_write_* at all. This is an encoder-path defect.
.NET ImageSharp, Rust image-png, Go apng
Not affected
Pure-language APNG implementations - no libpng linkage.
libvips (APNG branch, 2026)
Worth watching
An 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.