Skip to content

fix: heap overflows in V1/V2 log decode (crafted counts_limit / word_size) - #146

Merged
fcostaoliveira merged 4 commits into
mainfrom
fix/v1-decode-counts-oob
Sep 14, 2026
Merged

fix: heap overflows in V1/V2 log decode (crafted counts_limit / word_size)#146
fcostaoliveira merged 4 commits into
mainfrom
fix/v1-decode-counts-oob

Conversation

@filipecosta90

Copy link
Copy Markdown
Contributor

Security fix — heap buffer-overflow write, reachable from the public decode API

Surfaced by adversarial review during the new ClusterFuzzLite fuzzing effort (the random fuzzer didn't hit the specific crafted input in its budget; a reviewer found it by analysis).

Root cause

hdr_decode_compressed_v1 computes counts_limit = be32toh(payload_len) / word_size from the attacker-controlled payload_len field, then calls apply_to_counts(h, word_size, counts_array, counts_limit), which loops for (i=0; i<counts_limit; i++) h->counts[i] = .... With no bound against h->counts_len, a crafted log (tiny histogram + large payload_len) writes past the hdr_calloc(counts_len, 8) buffer → heap-buffer-overflow WRITE. Reachable via hdr_log_decode / hdr_log_read. V0 (passes h->counts_len) and V2 (bounds internally) are unaffected — only V1 trusted the payload length.

Fix

Reject counts_limit < 0 || counts_limit > h->counts_len with HDR_ENCODED_INPUT_TOO_LONG right after hdr_init, before allocation/apply. Also prevents an int32 overflow of counts_array_len = counts_limit * word_size.

Verification (local, ASan)

  • Crafted a minimal malicious V1 blob. Before: AddressSanitizer: heap-buffer-overflow WRITE of size 8 in apply_to_counts_64 ← hdr_decode_compressed_v1:538 ← hdr_log_decode. After: returns HDR_ENCODED_INPUT_TOO_LONG, no ASan error.
  • Added a regression test (test_v1_decode_rejects_oversized_counts) with the crafted blob; full suite passes under ASan+UBSan, valid encode/decode round-trips unaffected.

Note: the ASan/UBSan ctest CI job that turns this regression test into a deterministic guard is added in the companion PR #145; until that merges this PR's own CI runs the assertion in a normal build.

🤖 Generated with Claude Code

hdr_decode_compressed_v1 derived counts_limit from the attacker-controlled
payload_len field and passed it to apply_to_counts() without bounding it
against h->counts_len. A crafted log (tiny histogram, large payload_len) makes
apply_to_counts write past h->counts -> heap-buffer-overflow WRITE. Reachable
from the public hdr_log_decode / hdr_log_read API. (V0 passes h->counts_len; V2
bounds internally -- only V1 trusted the payload length.)

Reject counts_limit < 0 or > h->counts_len with HDR_ENCODED_INPUT_TOO_LONG
before allocation/apply; this also prevents counts_array_len = counts_limit *
word_size from overflowing int32_t.

Verified with ASan: a crafted V1 blob triggers 'heap-buffer-overflow WRITE ...
apply_to_counts_64' on the old code and returns HDR_ENCODED_INPUT_TOO_LONG with
no error after the fix. Added a regression test with the crafted blob.

Found-by: adversarial review during the ClusterFuzzLite fuzzing effort
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@filipecosta90 filipecosta90 changed the title fix: heap buffer overflow in V1 log decode (crafted payload_len) fix: heap overflows in V1/V2 log decode (crafted counts_limit / word_size) Jul 23, 2026
@filipecosta90
filipecosta90 requested review from giltene and mikeb01 July 24, 2026 09:09
Adversarial review of the initial V1 counts_limit fix surfaced two more
attacker-reachable memory-safety bugs in the decode path:

- hdr_decode_compressed_v1 with word_size == 1 routes counts to the zig-zag
  reader (apply_to_counts_zz), whose LEB128 lookahead reads up to 9 bytes past
  the counts buffer. V2 allocates +9 padding for this; V1 does not -> heap OOB
  READ. V1 only ever uses fixed-width 2/4/8; reject any other word size.

- hdr_decode_compressed_v2 read counts_limit = be32toh(payload_len) (signed)
  with no check. A negative value makes hdr_calloc((size_t)counts_limit + 9)
  wrap to a tiny buffer while strm.avail_out = (uInt)counts_limit becomes ~4GB,
  so inflate writes gigabytes past it -> heap OOB WRITE. V2 is the default
  encoding, so this is the common path. Reject counts_limit < 0.

Added regression tests (crafted blobs) for the V1 negative-payload_len, V1
word_size==1, and V2 negative-payload_len vectors; each reproduces a sanitizer
crash on the old code and returns a clean error now. Full suite passes under
ASan+UBSan.

Found-by: adversarial review during the ClusterFuzzLite fuzzing effort
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@fcostaoliveira
fcostaoliveira force-pushed the fix/v1-decode-counts-oob branch from f616390 to 50c87d9 Compare August 28, 2026 10:23
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

🤖 Automated first-pass review — a human maintainer's review is still required before merge.

Read the diff against the current decode paths; the three OOBs look real and the guards are in the right places. Each new early return goes through the existing cleanup label, so h gets torn down with hdr_close() rather than hdr_free() — that's the exact thing PR #122 had to fix, and it's right here.

Four things worth a look before merge:

  • The word_size != 2/4/8 rejection matches what Java's AbstractHistogram decode requires for the non-V2 cookie base. One nuance I can't verify from here: I believe Java masks the size nibble (sizeByte & 0xe) before comparing, so a cookie with nibble 3 decodes as word size 2 upstream but would now be rejected here. Worth checking against the Java source — likely academic, since nothing emits that.
  • The V2 cap at MAX_BYTES_LEB128 * counts_len matches this library's own encoder bound (the encoded_len calculation in the V2 encode path), so it can't reject anything we produce. Java bounds payload_len against the input buffer's remaining bytes, which isn't knowable post-inflate here, so the encoder bound looks like the right substitute.
  • Error codes are inconsistent for the same class of input: V1 negative payload_lenHDR_ENCODED_INPUT_TOO_LONG, V2 negative → EINVAL. Worth picking one.
  • In a normal build the new tests only assert return codes; the per-PR ClusterFuzzLite job runs ASan over the fuzz targets, not ctest, so nothing runs these blobs under a sanitizer until fix: signed-shift overflow in hdr_calculate_bucket_config (UBSan, found by fuzzing) #145 lands. .clusterfuzzlite/hdr_decode_fuzzer.c already covers this exact entry point — adding the five blobs as a seed corpus would keep them under ASan (and UBSan in the weekly batch) regardless of merge order.

No struct layout or public signature change, so no SOVERSION implication — but it does change what the public decode API accepts on untrusted input, and the downstreams in this tracker do parse logs they didn't produce, so this probably wants an actual release rather than just sitting at head. The description also still only covers the V1/V2 fixes; the V0 word_size guard and the V2 ~2GB cap arrived in later commits.

I haven't built this or run the crafted blobs under ASan myself, so the before/after sanitizer output is the author's claim rather than something verified here.

fcostaoliveira and others added 2 commits September 2, 2026 12:34

@paulorsousa paulorsousa left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM :)

@fcostaoliveira
fcostaoliveira merged commit 6d40ddb into main Sep 14, 2026
31 of 44 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants