GHSA-gx8f-4rpc-4fjw: PNG decoder: a duplicate IHDR chunk after IDAT resizes the image after allocation, causing a heap out-of-bounds write
GHSA ID |
|
|---|---|
CVE ID |
CVE‑2026‑91186 |
Severity |
High |
CVSS score |
7.8 ( |
CWE |
|
Published |
2026‑10‑02 |
Last updated |
2026‑10‑02 |
Description
Summary
The PNG chunk reader has no notion of chunk ordering. It records whether an IDAT has been seen and whether a tRNS has already supplied transparency, but nothing prevents an IHDR being read a second time.
A PNG carries exactly one IHDR and it is the first chunk. A file that places a second one after IDAT rewrites the image width, height, bit depth and pixel depth after the decoded-data buffer has already been allocated and filled from the original values. The pass that reverses the PNG row filters then derives its own extent from the new dimensions and walks the old allocation, moving image bytes past the end of it.
The buffer is allocated by GUIX rather than supplied by the caller, so the application has no way to size it defensively. The decoder is compiled in by default.
Details
No ordering state, so IHDR can repeat. The chunk dispatch loop in _gx_image_reader_png_decode() guards IDAT and tRNS but not IHDR:
if ((strncmp(chunk_type, "IDAT", 4) == 0) && (!idat_done)) /* guarded */
...
else if (strncmp(chunk_type, "IHDR", 4) == 0) /* not */
{
status = _gx_image_reader_png_IHDR_chunk_read(&png);
}
...
else if (strncmp(chunk_type, "PLTE", 4) == 0) /* not */
...
else if ((strncmp(chunk_type, "tRNS", 4) == 0) && (png.gx_png_trans == GX_NULL)) /* guarded */
_gx_image_reader_png_IHDR_chunk_read() re-reads and re-validates the dimension fields each time it is called. Its checks are individually correct — width and height against GX_MAX_PIXELMAP_RESOLUTION, bit depth against the five legal values, colour type against the five legal values — but they say nothing about whether this IHDR is the first, and the function never consults gx_png_decoded_data.
The buffer was already sized and filled. _gx_image_reader_png_IDAT_chunk_read() allocates from the dimensions in force at the time:
alloc_size = (UINT)(png -> gx_png_height * ((png -> gx_png_width * png -> gx_png_bpp + 7) / 8));
alloc_size = alloc_size + (UINT)png -> gx_png_height;
png -> gx_png_decoded_data = (GX_UBYTE *)_gx_system_memory_allocator(alloc_size);
The one extra byte per row is the PNG per-row filter byte. Note that the inflate path itself is sound: it bounds each write against the allocation (that memmove, that memmove, that memmove) and then requires the decompressed length to equal the reservation exactly. Nothing there can be made to overflow.
The filter pass trusts the current dimensions. _gx_image_reader_png_unfilter() recomputes the row stride and the row count from whatever the dimensions now are, with no reference to how large the allocation is:
byte_width = (png -> gx_png_width * bpp + 7) >> 3;
...
for (y = 0; y < png -> gx_png_height; y++)
{
filter_type = png -> gx_png_decoded_data[y * byte_width + y];
pos = y * byte_width;
memmove(png -> gx_png_decoded_data + pos, png -> gx_png_decoded_data + pos + y + 1, (UINT)byte_width);
With the second IHDR’s dimensions in place, byte_width and the loop bound both describe an image larger than the buffer, and the memmove at that memmove writes past the end of it. The destination is the start of the allocation and the extent is height * ((width * bpp + 7) >> 3), both attacker-chosen, and the content is inflated IDAT data with filter arithmetic applied, so it is attacker-influenced rather than arbitrary.
What the caller is handed back. If decoding is allowed to complete, the returned pixelmap is internally inconsistent: gx_pixelmap_width and gx_pixelmap_height come from the second IHDR, while gx_pixelmap_data_size still carries the length of the original allocation. The structure therefore describes more pixels than it owns. This is stated as a property of the returned structure, read from the code; no consumer has been built to observe a draw operation reading out of bounds from such a pixelmap, and none is claimed.
Impact
A cross-allocation heap out-of-bounds write, reached by decoding a single small PNG through the documented public API, with no user interaction beyond the application choosing to decode an image it was given. On an MMU-less MCU it corrupts whatever the allocator placed after the buffer; on a hosted build it is a reliable crash.
No exploit was developed and no claim of code execution is made.
The reporter demonstrated this with a 94 byte file: an 8 by 8 grayscale PNG followed by a second IHDR declaring 16 by 256. The write was confirmed independently of the sanitizer with a guard page allocator, which faulted on the write rather than the read, so the overflow is a write and not merely an over-read. A single IHDR file of the same shape decodes cleanly as a negative control.
Affected configurations
GX_SOFTWARE_DECODER_SUPPORT is opt-out rather than opt-in:
#ifndef GX_DISABLE_SOFTWARE_DECODER_SUPPORT #define GX_SOFTWARE_DECODER_SUPPORT #endif
so the decoder is present unless an integrator removed it deliberately. No GX_DISABLE_ERROR_CHECKING is required, and the error-checking wrapper does not help: _gxe_image_reader_create validates only for NULL pointers and a minimum data size of 20 bytes, and gx_image_reader_start performs no inspection of image content before decoding.
Every GUIX release up to and including 6.5.1.202602a is affected. The chunk loop has kept an idat_done flag with no IHDR equivalent since v6.0_rel.
Per the project security policy the fix ships in 6.5.x, with no backport to 6.4.x.
Severity
Scored CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H (7.8, High), deliberately for the library rather than for any particular product using it. The vulnerable component is a decoder handed a byte buffer; it is not bound to a network stack and does not choose where its input comes from. Whether a given product obtains that PNG from a network interface or from local storage is a property of the integrator’s application.
Integrators should score this for their own product. A product that decodes PNGs arriving over a network interface without user action should score it AV:N and UI:N, which gives 9.8 (Critical). That is the number to act on where it applies.
Patches
Fixed in GUIX 6.5.2.202603. The chunk loop now rejects chunk orders a valid PNG cannot have: a second IHDR, a second PLTE, and an IDAT before any IHDR. Independently, _gx_image_reader_png_unfilter() confirms that its walk fits the decoded data before it starts, so if the dimensions and the allocation ever disagree again, the image is rejected rather than written past.
Four chunk ordering cases were added to the PNG decode validation test. With the fix reverted, two of them return the wrong status and the other two crash the decoder.
The fix was verified against an input built independently from the report rather than against the reporter’s file, so the reproduction and the fix do not share an author. It came out at the same size and produced the same three frames.
Workarounds
Do not decode PNG data from an untrusted source. Where the software decoder is not needed, building with GX_DISABLE_SOFTWARE_DECODER_SUPPORT removes the affected path entirely.
Credits
Reported by Fatullayev Asadbek, who supplied the reproducer, the AddressSanitizer output, the guard-page confirmation of the write, the version coverage check, and a review of this write-up. Fixed by Frédéric Desbiens.
The fix and regression tests were produced with AI assistance under human review, in accordance with the Eclipse Foundation’s Generative AI Usage Guidelines.