← back to research

Guarded on read, unguarded on write: an attacker-controlled heap overflow in GraphicsMagick's PCD decoder

GraphicsMagick ported ImageMagick's out-of-bounds read fix for the PCD decoder but not the write-side bound that shipped alongside it, leaving an attacker-controlled heap overflow in DecodeImage: the un-ported other half of the same hardening.


GraphicsMagick is the image-processing library that sits, often invisibly, behind a great many thumbnailers, upload pipelines, and CMS attachments, packaged by nearly every Linux distribution and embedded in software that decodes whatever bytes a user hands it. We found a heap out-of-bounds write in its PCD (Kodak Photo CD) decoder, coders/pcd.c DecodeImage, present and unfixed at the development HEAD we tested. It is the un-ported half of a hardening that ImageMagick had already shipped: GraphicsMagick took the read-side fix and left the write-side bound behind.

IMPORTANT

Who’s exposed. You’re affected if you run GraphicsMagick (1.3.47 and earlier, plus current development HEAD) and decode untrusted files and your pipeline asks for a specific page or frame, or passes a size hint of 1536×1024 or larger: the shape of most thumbnailers and “render this frame” jobs. A bare default decode that takes no page or size argument does not reach the bug. PCD is recognized by its content (magic bytes at offset 0x800), not by file extension, so renaming an upload does not change whether it is decoded as PCD.

Two halves of one fix, and only one was ported

Earlier this year ImageMagick hardened the same PCD decoder against an out-of-bounds read (CVE-2026-26284). That hardening had two parts: a loop-initialization change that fixed the read, and a per-write sentinel bound plus an over-allocation of the plane buffers that fixed the write.

GraphicsMagick ported the first part. Its own ChangeLog says so: “Correct loop initialization to prevent out of bounds read. Similar to ImageMagick commit 5204a16 which addresses … GHSA-wrhr-rf8j-r842.” It did not port the second. The write-side sentinel and the over-allocation never came across, and so the write the sentinel was added to bound is still unbound.

This is not a duplicate of CVE-2026-26284. That advisory is the out-of-bounds read; GraphicsMagick already fixed it. The bug here is the write leg of the same routine (a different sink, never guarded), and it carries its own identifier, CVE-2026-13606.

GraphicsMagick portImageMagick fix: GHSA-wrhr-rf8j-r842portednot portedread guard (loop-init)write guard (sentinel +over-allocation)read path boundedwrite path unboundedheap OOB write inDecodeImage
ImageMagick's fix carried two guards; only the read guard was ported downstream.

The bug, traced in DecodeImage

The three Photo CD plane buffers are allocated at an exact fit, with one byte of slack and no over-provisioning:

// coders/pcd.c: DecodeImage
number_pixels = MagickArraySize(image->columns, image->rows);   // columns * rows
chroma1 = MagickAllocate...(number_pixels + 1);
chroma2 = MagickAllocate...(number_pixels + 1);
luma    = MagickAllocate...(number_pixels + 1);                 // exact fit, +1 byte only

The decode loop then performs an unguarded read-modify-write on the luma plane and advances the destination pointer with a bare q++. The per-row count cap that would have stopped it is commented out:

q = luma + row * (size_t) image->columns;   // start within a valid row
/* count = (long) image->columns; */         // <-- per-row cap COMMENTED OUT
...
if (r->key < 128U)
  quantum = (long)(*q) + r->key;             // READ  *q
else
  quantum = (long)(*q) + r->key - 256;       // READ  *q  (high-key branch)
*q = (unsigned char) clamp_0_255(quantum);   // WRITE *q   <-- unguarded store
q++;                                         // bare advance, no per-write bound
/* count--; */                               // <-- cap decrement COMMENTED OUT

The row index is checked before any store, so a large row does not reach the sink. The overflow is the unbounded q++ accumulating within one valid row’s Huffman delta run: with the per-row cap removed and no per-write sentinel, q marches off the end of luma. Both the value written (*q + key, with key attacker-controlled) and where the run stops (the shape of the Huffman table) are controlled by the crafted file.

What we observed

Against a sanitizer build, a crafted PCD triggers a heap-buffer-overflow precisely at the read-modify-write, “0 bytes after” the exact-fit plane allocation:

==ERROR: AddressSanitizer: heap-buffer-overflow
READ of size 1 at 0x...   (located 0 bytes after the luma plane region)
  #0 DecodeImage coders/pcd.c
  #1 ReadPCDImage coders/pcd.c

To prove the store is itself out of bounds and not merely the load, we patched out only the read and rebuilt. The crash relocates to the write at the same address, “0 bytes after” the same region. A current, hardened ImageMagick build decodes the identical bytes cleanly: its sentinel breaks the loop and it reports a graceful CorruptImage, no sanitizer hit. A well-formed PCD is the negative control and decodes clean.

Reachability

This is not a zero-click bug. A bare default decode of an untrusted file does not reach the sink:

InvocationResult
decode the file with no page/size hintclean: the vulnerable path is not reached
request subimage [0..3]clean
request a subimage index ≥ 4crash: heap-buffer-overflow
apply a read hint of 1536×1024 or largercrash

PCD is detected by content (its magic at file offset 0x800), not by extension, which matches the upload-then-decode threat model: a file with any name is recognized as PCD on the server. The precondition is that the pipeline requests a specific page/frame, or passes a large size hint: exactly what many thumbnailers and “render this frame” paths do. The Red Hat CNA assigned the finding CVSS 8.1 (High) as CVE-2026-13606. Our own read is higher for that common deployment: where the caller always requests a page or passes a size, the precondition is environmental rather than attacker-borne (AV:N/AC:L), which puts the base score at 9.8 and leaves the attacker needing only to get the file decoded. The published 8.1 scores that same page/size precondition as attack complexity (AC:H), the one metric the two readings turn on. Where nothing ever requests a page, a bare default decode does not reach the sink and the practical exposure is lower, though the corruption primitive itself does not change.

Impact ceiling

The write is value- and position-controlled, which is what lifts it above pixel corruption. GraphicsMagick’s resource-tracked allocator prepends a small bookkeeping header to every allocation, including a raw pointer it later passes to free() and a fixed signature word. The three plane buffers are laid out contiguously, so the forward overflow reaches the header of the adjacent allocation at a fixed, ASLR-independent offset. An attacker can overwrite that stored pointer while preserving the signature (so the allocator’s own integrity check still passes), and the corrupted pointer then flows into free():

SIGSEGV in __libc_free (mem = 0x...attacker-shaped...)
  #1 MagickFree
  #4 ReadPCDImage coders/pcd.c

The freed pointer tracks the attacker’s key byte, and we reproduced the controlled-pointer free repeatedly with full ASLR enabled, because the target is a relative offset within the heap rather than an absolute address.

So what is proven is concrete: an attacker controls both the value and the heap address of an out-of-bounds write, corrupts an adjacent live allocation, and drives free() on a pointer they shaped: a classic arbitrary-write / control-flow precursor. That is a memory-corruption primitive with a Critical ceiling, not remote code execution. The remaining leg to a hijacked program counter would require allocator-specific exploitation (forging a chunk that survives modern free() integrity and reclaiming it), which we did not carry out.

Remediation

If you maintain the code. Port the other half of the upstream fix into DecodeImage: over-allocate the plane buffers and add a per-write bound that breaks the loop before any store once the destination pointer leaves the buffer: the guard ImageMagick already ships. The minimal alternative is to re-enable the per-row count cap the code already had and merely commented out. This has been fixed in GraphicsMagick (changeset 937cdd99).

If you operate it. Upgrade to a build that carries changeset 937cdd99, or the next release that includes it. Until then the mitigation is structural, and it is worth noting that unlike ImageMagick, GraphicsMagick has no policy.xml coder allowlist you can use to switch a single decoder off:

  • Reject PCD at the boundary by content, not extension: refuse uploads whose bytes carry the PCD magic at offset 0x800.
  • Don’t forward attacker-controlled page/subimage indices or size hints into a PCD decode; a bare default decode does not reach the sink.
  • If you don’t need PCD at all, build GraphicsMagick without the coder.

Disclosure

  • Software: GraphicsMagick 1.3.47 and earlier, plus development HEAD (reviewed at changeset 74c629d3, dated 2026-06-11).
  • Class: CWE-787, heap out-of-bounds write.
  • Severity: High, CVSS 8.1, assigned by the Red Hat CNA. Our own read is higher where the caller always requests a page or size hint (see Reachability).
  • CVE: CVE-2026-13606.
  • Advisory: changeset 937cdd99.
  • Fix: merged upstream in changeset 937cdd99, at parity with ImageMagick’s existing guard. Reported to the GraphicsMagick maintainers.
  • Reported by: Cipher / Causal Security.

All testing was in a self-contained lab against sanitizer builds; the triggering file is synthetic.

Why we look at the un-ported half

When an upstream project hardens a routine against an advisory, the visible artifact is usually the one-line change that closes the reported sink. But a single advisory often fixes two things at once (here, a read and a write in the same loop), and a downstream fork that ports the fix can carry across the half matching the headline and miss the half that does not. We call this fork drift: the defect lives not in either codebase’s logic but in the delta between the fix upstream and the fix as adopted downstream. Finding it is not a from-scratch re-audit. It is a diff. Line up each guard the upstream patch introduced against its counterpart in the fork, and look for the one with no counterpart. The read sentinel came across. The write sentinel did not. That gap is the bug.


This work is part of our ongoing vulnerability research into the open-source image and media-processing code that everyone else builds on.