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-0003

low confirmed

MJ2 box parser: headerSize underflow in read_url/read_urn yields heap out-of-bounds read

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 (read)
Topmost entry point unknown — establish reachability
Verified through no real consumer named
Real-world impact 3.1 CVSS · low

Classification

TargetGrok
ComponentJP2 file format
Locationsrc/lib/core/fileformat/decompress/FileFormatMJ2Decompress.cpp · FileFormatMJ2Decompress::read_url / read_urn:335-380
Vuln classoob-read
CVE
CVSS3.1 (CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:L)
Discovered2026-06-16

Verification

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

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

grk_decompress auto-detects the MJ2 format from file magic (offset 0 JP2_RFC3745_MAGIC + offset 20 MJ2_MAGIC, stream/MemStream.cpp:42-47) and routes the file to FileFormatMJ2Decompress (grok.cpp:198-199) — no special flag required. Inside the MJ2 dref box handler, read_url/read_urn use the unchecked read_version_and_flag and then subtract 4 from headerSize without a >= 4 guard. A url/urn box with a payload smaller than 4 bytes underflows headerSize (uint32) to ~4 billion, which then defeats the bounds check in the subsequent grk_read calls, producing a heap out-of-bounds read.

bool FileFormatMJ2Decompress::read_url(uint8_t* headerData, uint32_t headerSize) {
  read_version_and_flag(&headerData, version, flag);   // :339 reads 4 bytes, NO size check
  headerSize -= 4;                                      // :340 underflows if headerSize < 4
  if(flag != 1) {
    mj2_url url;
    for(uint32_t i = 0; i < 4; ++i)
      grk_read(&headerData, &headerSize, url.location_ + i);   // :350 bounds-check fooled
  }
}

read_version_and_flag (:117-126) calls the no-bytesRemaining grk_read overload (stream/StreamIO.h:119, passes nullptrno bounds check), so the 4-byte version/flag read itself already over-reads when the box payload is < 4 bytes. The checked grk_read overload (StreamIO.h:106) throws on bytesRemaining < numBytes, but after the underflow headerSize ≈ 0xFFFFFFFF, so it does not throw and the four (read_url) / eight (read_urn) 4-byte reads walk past the box buffer.

Contrast read_dref itself (:384) and other MJ2 readers, which correctly use read_version_and_flag_check (:127, guards *headerSize < 4). read_url/read_urn are the outliers that skipped the checked variant.

Reproduction

Reachability: read_dref is registered as the MJ2_DREF box handler (FileFormatMJ2Decompress.cpp:79); it iterates entry_count child boxes and dispatches MJ2_URL → read_url / MJ2_URN → read_urn (:399-410).

Craft a .mj2 whose header chain reaches a dref box containing one entry that is a url ('url ') or urn box with a declared length giving a payload of 0-3 bytes, and flag != 1. Run grk_decompress -i poc.mj2 -o /tmp/out.png under ASAN to observe heap-buffer-overflow READ.

Root cause

read_url/read_urn use the unchecked read_version_and_flag and an unguarded headerSize -= 4, where the rest of the MJ2 parser uses read_version_and_flag_check. The unsigned underflow turns the downstream length-checked reads into unbounded reads.

Verification evidence

Confirmed by source trace (MJ2 auto-detected; read_version_and_flag is the unchecked overload; headerSize -= 4 has no guard; the checked grk_read is defeated by the underflowed headerSize).

Runtime finding (2026-06-16, ASAN build): the documented over-read is real but NOT independently crashable. It is bounded to ≤16 bytes (4 for read_version_and_flag + 4×4 for the url.location_ reads). On the file path the input is mmap-backed and box content is zero-copy, so the over-read lands in the zero-filled tail page (no fault); on the non-zero-copy path the per-box buffer is a reused, only-growing allocation (FileFormatJP2Family.cpp:347-356) sized to the largest box seen (e.g. tkhd=84 B), whose slack absorbs the ≤16 B over-read. So it manifests as reading a few bytes of stale adjacent buffer into url.location_ (minor), not a crash. Severity lowered to low accordingly. The crashable defect in the same read_url path (null current_track_) is filed and verified separately as [[GROK-0013]].

Impact

Heap out-of-bounds read reachable from a single untrusted local file via the default grk_decompress path (MJ2 is auto-detected from magic), causing a crash/DoS and possible limited info-disclosure into the parsed url/urn structures. Local-untrusted-file reachability. Severity medium (CVSS 6.5).

The MJ2 decompress parser has sibling robustness defects worth fixing together (not separately filed): read_dref advances only by the 8-byte box header (not the child payload) and passes the full remaining headerSize to children (:395-406); read_stsd advances by box.length in the entry_count loop without re-validating it fits (:490-493); read_jp2x does throw new std::runtime_error(...) — throwing a pointer, which no catch(const&) matches → std::terminate (DoS) (:189); several table readers (read_stts/read_stsz/read_stco) take unbounded attacker counts before the per-element checked reads (DoS-ish).

References