PUBLIC MIRROR A read-only public view of Anvil. Only publicly-disclosed findings are shown; the Playbook, techniques, sessions and embargoed research are hidden.

← Findings

GROK-0008

low confirmed

PNM writer: packed_row_bytes/packer precision mismatch in streaming-strip output → heap overflow for precision not in {8,16}

Static only Source / code-trace analysis only. Nothing has been executed.

⚠ Harness reproduced — not real-world verified. Reproduce through the public API, a real application, or a platform decoder before treating this as verified.

Crash heap-buffer-overflow (write)
Topmost entry point unknown — establish reachability
Verified through no real consumer named
Real-world impact 3.9 CVSS · low

Classification

TargetGrok
ComponentCodec / image-format I/O
Locationsrc/lib/core/tile_processor/TileProcessor.cpp · TileProcessor (streaming-strip ioBandCallback scratchImg) vs PNMFormat interleaver:1417 (vs src/lib/codec/formats/PNMFormat.h:260-272,303-315; src/lib/core/util/GrkImage.cpp:571)
Vuln classoob-write
CVE
CVSS3.9 (CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:L)
Discovered2026-06-16

Verification

Evidence Static only (not real-world verified)
Harness fired❌ no
Protocol1.0
Sanitizernone
Crash typeheap-buffer-overflow (write)

Disclosure

Reported toGrok maintainers — support@grokcompression.com (private email; listed as a source-confirmed lead, not yet runtime-verified)
Reported2026-06-23
Vendor ack
Embargo until
Public2026-06-24
Patched in

Writeup

Summary

The PNM (PXM) output writer packs samples at a byte-rounded precision — its interleaver is chosen as decompress_prec > 8 ? 16-bit : 8-bit, writing 1 or 2 bytes per sample (PNMFormat.h:260-261/303-304; packer PlanarToInterleaved8 writes width*numcomps bytes/row, packer.h:478-507). The output buffer is pool.get(packed_row_bytes * stripRows) strided by packed_row_bytes. There are two inconsistent computations of packed_row_bytes:

  • GrkImage::postReadHeader (util/GrkImage.cpp:571) — PXM path, correct: uses the rounded precision: getPackedBytes(ncmp, width, prec > 8 ? 16 : 8) ⇒ matches the packer.
  • TileProcessor.cpp:1417 — the streaming-strip + ioBandCallback path (the ImageFormat writer path) builds a scratchImg with packed_row_bytes = ceil(w*nc*prec/8) using the actual precision, despite the adjacent comment “packed_row_bytes … must match what the format writer header used.”

For a decoded component precision not in {8,16} (JPEG 2000 allows arbitrary 1–38-bit precision), the two disagree:

prec=4, nc=3, w=100:
  scratchImg packed_row_bytes (1417) = ceil(100*3*4/8) = 150
  PNM 8-bit packer writes            = 100*3*1        = 300 bytes/row
  → 300 written into rows strided by 150, buffer = 150*stripRows  ⇒ heap overflow
prec=12 → packer writes 2 bytes/sample (600) vs packed_row_bytes ceil(100*3*12/8)=450 ⇒ overflow

Reproduction (requires two non-default env vars)

A 2026-06-16 reachability trace established the buggy scratchImg (TileProcessor.cpp:1417) is only reached when BOTH env vars are set: GRK_SCHEDULER=freebyrd (else the default DecompressScheduler is used, TileProcessor.cpp:1255; SchedulerFactory.h:53-55) and GRK_STRIP=1 (SchedulerFreebyrd.h:60-61), gating the strip block at TileProcessor.cpp:1392/1394. The default grk_decompress flow passes scratchImage_, whose packed_row_bytes is inherited via copyHeaderTo (GrkImage.cpp:389) from postReadHeader’s correct PXM value (GrkImage.cpp:571) — so the default flow is SAFE.

To trigger: GRK_SCHEDULER=freebyrd GRK_STRIP=1 grk_decompress -i evil.j2k -o out.ppm, where evil.j2k decodes to ≥2 components at precision ∉ {8,16} (e.g. 4 or 12). The PNM strip writer (PNMFormat.h:266-284/308-324) then packs w*nc*{1,2} bytes/row into the smaller actual-prec buffer.

Root cause

Two divergent packed_row_bytes formulas. The streaming-strip scratchImg (TileProcessor:1417) uses real precision while the PNM writer (header + packer) uses byte-rounded precision (8/16). They only agree when prec ∈ {8,16}.

Verification evidence

The two-formula mismatch is confirmed (getPackedBytes(ncmp,w,bits)=ceil(w*ncmp*bits/8), packer.h:115; rounded at GrkImage.cpp:571 vs actual at TileProcessor.cpp:1417). The buggy value is consumed only under GRK_SCHEDULER=freebyrd + GRK_STRIP=1; the default grk_decompress flow inherits the correct value (CodeStreamDecompress.cpp:2362 → GrkImage.cpp:389). So this is a real heap overflow but not reachable without local control of two non-default env vars.

Runtime status (2026-06-16): not ASAN-reproduced — the env-gated path is itself broken upstream. With GRK_SCHEDULER=freebyrd GRK_STRIP=1 on a 12-bit 3-component J2K, decode crashes earlier with a SEGV/null-write in ShiftFilter<int>::copy (PostDecodeFilters.h:57) inside the freebyrd post-decode path, before ever reaching the strip-composite writer where this overflow lives. I.e. the freebyrd strip mode is unfinished/broken, so this overflow is effectively unreachable in practice — further justifying the low rating. (The freebyrd null-write is a separate defect in a non-default experimental scheduler; not separately filed.)

Impact

Controlled heap buffer overflow on the decompress output path when writing PNM/PAM from a crafted J2K of precision ∉ {8,16} — but only when the operator has set GRK_SCHEDULER=freebyrd and GRK_STRIP=1 (non-default). In the default flow it is unreachable (the writer reads the correct byte-rounded packed_row_bytes). Requires non-default local config, hence severity low (CVSS 3.9) — a latent defect worth fixing for defense-in-depth. Fix: compute the streaming-strip scratchImg->packed_row_bytes with the same byte-rounded precision the PNM packer uses (getPackedBytes(nc, w, prec>8?16:8)), or share one helper between the header path and the strip path.

References