GROK-0008
low confirmedPNM writer: packed_row_bytes/packer precision mismatch in streaming-strip output → heap overflow for precision not in {8,16}
⚠ Harness reproduced — not real-world verified. Reproduce through the public API, a real application, or a platform decoder before treating this as verified.
Classification
| Target | Grok |
|---|---|
| Component | Codec / image-format I/O |
| Location | src/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 class | oob-write |
| CVE | — |
| CVSS | 3.9 (CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:L) |
| Discovered | 2026-06-16 |
Verification
| Evidence | Static only (not real-world verified) |
|---|---|
| Harness fired | ❌ no |
| Protocol | 1.0 |
| Sanitizer | none |
| Crash type | heap-buffer-overflow (write) |
Disclosure
| Reported to | Grok maintainers — support@grokcompression.com (private email; listed as a source-confirmed lead, not yet runtime-verified) |
|---|---|
| Reported | 2026-06-23 |
| Vendor ack | — |
| Embargo until | — |
| Public | 2026-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 +ioBandCallbackpath (the ImageFormat writer path) builds ascratchImgwithpacked_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.