Vendored LZ4 r97: LZ4_decompress_safe() reads one byte past the source buffer when inputSize==0 (upstream r96/v1.9.4 guard missing)
| Field | Value |
|---|---|
| ID | DF-2664 |
| Status | new |
| Severity | Low |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:N |
| CWE | CWE-125 Out-of-bounds Read |
| File | sys/vfs/hammer2/hammer2_lz4.c |
| Lines | 407 (guard gap), 418 (token fetch) |
| Area | vfs |
| Confidence | certain |
| Discovered | 2026-08-29 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | hammer2 |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
The pre-loop validation in LZ4_decompress_generic rejects only
outputSize==0 (lz4.c:407); there is no inputSize==0 guard, so the
first statement of the main loop, token = *ip++ (:418), dereferences
source[0] with iend==ip β a 1-byte OOB read past the declared input
buffer, contradicting the in-tree API contract "never reads outside of
input buffer" (hammer2_lz4.h:62-65). Provenance: verbatim logic copy of
upstream LZ4 r97 (2013-06-10, the revision whose de-genericization
dropped r96's !inputSize guard); upstream v1.9.4 has the guard back.
Verified on the guest: unit proof (source flush against PROT_NONE β
SIGSEGV at exactly src, delta=0; control inputSize=1 returns 0) and
in-kernel (on-media LZ4 block with compressed_size==0 passes the
strategy.c:199 KKASSERT even on stock INVARIANTS, produces one dmesg
error line, reader gets 65535 zero bytes, no panic). Impact fully
contained: the speculative byte lies inside the dio allocation, the
decode always fails, the caller zeroes both buffers β the byte is never
emitted. A 2,000,000-iteration guard-paged adversarial fuzz shows this
is the decoder's only memory-safety defect on LP64; DF-0805's heap OOB
remains purely the caller's unchecked compressed_size.
Recommended fix
--- a/sys/vfs/hammer2/hammer2_lz4.c
+++ b/sys/vfs/hammer2/hammer2_lz4.c
@@ -406,6 +406,13 @@
// targetOutputSize too large, better decode everything
if unlikely(outputSize==0) goto _output_error;
// Empty output buffer
+ if ((endOnInput) && unlikely(inputSize==0)) goto _output_error;
+ // Error : empty input stream. A correctly formed null-compressed
+ // LZ4 stream must have at least one byte (token=0). Restores the
+ // upstream r96 / v1.9.4 guard dropped in the r97 reorganization
+ // this file was vendored from.
Validated at unit level (clean -1; full fuzz stays green);
git apply --check clean; never applied to sys/.
References
- DF-0805 (the caller-side compressed_size OOB β separate defect), DF-2665 (vendored-staleness cross-reference)
Timeline
- 2026-08-29 Discovered during pass-2 audit of hammer2_lz4.c (GLM 5.3); unit + in-kernel verified, fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2664 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| lz4_zero_input_harness.c | β | 23.6 KB | view raw | |
| lz4_zero_input_fixed.c | β | 24.0 KB | view raw | |
| hammer2_trigger.sh | β | 3.8 KB | view raw | |
| build.sh | β | 302 B | view raw | |
| run.sh | β | 364 B | view raw | |
| fix_run.sh | β | 308 B | view raw | |
| build.log | β | 139 B | view raw | |
| run.log | β | 929 B | view raw | |
| run.2.log | β | 1.1 KB | view raw | |
| run_kernel.log | β | 1.5 KB | view raw | |
| fix_run.log | β | 923 B | view raw | |
| env.txt | β | 318 B | view raw | |
| fix.diff | β | 774 B | view raw | |
| VERDICT.md | β | 6.2 KB | β raw | |
| verdict.json | β | 5.2 KB | view raw |
DF-2664 β VERDICT
Status: REPRODUCED (Low, contained). The vendored hammer2 LZ4
decompressor (sys/vfs/hammer2/hammer2_lz4.c, upstream LZ4 r97)
reads one byte past the declared source buffer when called with
inputSize == 0, violating the documented "never reads outside of
input buffer" contract (hammer2_lz4.h:62-65). The call is reachable
in-kernel from on-media data (compressed_size == 0 passes the
KKASSERT at hammer2_strategy.c:199 even on INVARIANTS kernels),
but the impact in this caller is fully contained: no panic, no leak,
no corruption β the decode always errors and the caller zeroes the
output. Fix (restore the upstream r96 / v1.9.4 guard) validated.
1. Root cause (path:line)
sys/vfs/hammer2/hammer2_lz4.c:407β onlyoutputSize==0is rejected before the main loop; there is noinputSize==0guard.sys/vfs/hammer2/hammer2_lz4.c:418βtoken = *ip++;executes withiend == ipwheninputSize==0: a 1-byte OOB read atsource[0].- The decoder cannot make progress afterwards (
ip(=src+1) > iendβ the exact-end conditionip+length != iendat:447always fails β_output_errorat:515), so the byte is never emitted β no information crosses to the output.
2. Provenance β vendored version pinned, guard history traced
LZ4_decompress_genericinhammer2_lz4.c:372-517is a verbatim logic copy of upstream LZ4 r97 (lz4.c@ svn trunk@97, commit16c0942822, 2013-06-10): identical statements, identical bounds checks; only comments/const differ. r97 is the revision that de-genericized the decoder out oflz4_decoder.h.- r96 (
lz4_decoder.h, commitcd3bcd0043, 2013-05-27) still hadif unlikely(!inputSize) goto _output_error; // A correctly formed null-compressed LZ4 must have at least one byte (token=0)β the r97 reorganization dropped it. - v1.9.4 (current upstream) has it back:
if (unlikely(srcSize==0)) { return -1; }(lib/lz4.c:1983). - DragonFly imported r97 the same day it landed (hammer2 LZ4 support, June 2013) and never re-synced β 13 years of upstream hardening are absent (see the pass-2 divergence cross-reference in the audit JSON).
3. Proof (what was run, on the guest)
Guest: DragonFly dfbsd 6.5-DEVELOPMENT #0 β¦ X86_64_GENERIC
(INVARIANTS ON), stock #0 kernel, cc 8.3.
A. Unit proof β run.log, run.2.log
lz4_zero_input_harness.c compiles the verbatim in-tree decoder
(macros + LZ4_decompress_generic + LZ4_decompress_safe) and:
- Test A: source placed flush at the end of an RW page with a
PROT_NONEguard page after;LZ4_decompress_safe(src, dst, 0, 65536)β SIGSEGV atsrc(delta = 0) inside the decoder β the token fetch readsource[0], past the declared (empty) input buffer. - Test A2 (control):
inputSize=1,{0x00}β returns 0 (the canonical null stream). Only theinputSize==0case is broken. - Tests B/C (negative proof): 2,000,000 randomized + adversarial
(all-0xFF / all-0x00 / structured tokens with offsets 0,1,2,7,8,
0xFFFF / 0xFF-extension runs / valid-shaped streams + byte-flip and
truncation mutations) with honest
(inputSize, outputSize): sources end flush against guard pages (any read atiend[0..]faults), destinations carry canaries pastoend(any write pastoenddetected), leading guard pages catch before-dest accesses. Result: 0 faults, 0 canary corruptions across all runs β the r97 decoder is memory-safe on LP64 when given honest sizes. The heap-OOB in DF-0805 is purely the caller's failure to boundcompressed_size.
B. In-kernel reachability + containment β run_kernel.log
hammer2_trigger.sh (adapted from the DF-0805 forger family; only
change: on-disk 4-byte compressed_size set to 0x00000000):
[trigger] current size field: 1c 01 00 00 [trigger] overwriting field with 0x00000000 (compressed_size = 0) [trigger] reading the file as maxx β¦ β 65535 bytes of 0x00 dmesg: READ PATH: Error during decompression.bio 0000000000000000/1024 [trigger] DONE β no panic, image detached cleanly
- The
inputSize==0call executed in-kernel (exactly one dmesg error line), without tripping the INVARIANTS KKASSERT β unlike DF-0805's oversized value,0is within the asserted bound. - The speculative token byte (
data[4]) sits inside the block content / dio allocation; the decode fails; the caller bzeros (hammer2_strategy.c:206-216); the unprivileged reader gets zeros. Guest healthy throughout.
4. Exploit chain
None β and none possible in this caller: the 1-byte speculative read
is (a) within the backing allocation for every realistic block layout,
(b) never copied to user-visible output (error path zeroes everything),
(c) read-only. Impact ceiling: none observable; this is a contract
violation / hardening defect. (A different caller that sized the
source allocation to exactly inputSize bytes would get a 1-byte heap
OOB read β the API contract exists precisely for that.)
5. Fix β fix.diff (validated)
One guard, restoring upstream r96 / v1.9.4 semantics:
if unlikely(outputSize==0) goto _output_error;
// Empty output buffer
+ if ((endOnInput) && unlikely(inputSize==0)) goto _output_error;
git apply --checkclean againstsys/vfs/hammer2/hammer2_lz4.c(never applied to the read-only tree).- Validation level: unit (verbatim decoder copy) β deliberate: in the kernel the bad behavior (silent 1-byte speculative read) has no observable runtime delta vs. the fixed behavior (both produce the dmesg error + zeroed output), so a kernel rebuild cannot distinguish baseline from fixed; the unit harness is the only level where the delta is observable.
fix_run.log: fixed build returns-1forinputSize==0with no fault ("FIX VALIDATED"), Test A2 control still passes, and all 2,000,000 fuzz iterations stay green β the guard breaks nothing.
6. Attempts / notes
3 verification stages (unit, unit-fix, in-kernel), all first-try. Guest left healthy (no panic, no reset needed); artifacts and temp images cleaned.
Bottom line: real, upstream-corroborated contract violation, reproducible in-kernel even on INVARIANTS kernels, fully contained by the hammer2 caller β Low severity, one-line fix, fix validated.
Fix verification
fixedUnit-level validation (verbatim decoder copy): baseline faults at src with inputSize==0; patched build returns -1 with no fault and stays green across the full 2M-iteration fuzz and the null-stream control (fix_run.log). Kernel-level rebuild validation intentionally not performed: the in-kernel bad behavior has no observable runtime delta vs fixed (both yield the dmesg error and zeroed output), so only the unit level can demonstrate the fix; fix.diff is git-apply clean against sys/vfs/hammer2/hammer2_lz4.c.
["fix_run.log β 'FIX VALIDATED - inputSize==0 cleanly rejected, no OOB read', control + 2M fuzz green", 'fix.diff β git apply --check clean vs sys/vfs/hammer2/hammer2_lz4.c', "upstream corroboration: r96 lz4_decoder.h guard; v1.9.4 lib/lz4.c:1983 'if (unlikely(srcSize==0)) { return -1; }'"]
Confirmed kernel references
Detail
Evidence (decisive lines)
['run.log / run.2.log β Test A: SIGSEGV at src (delta=0) inside LZ4_decompress_safe with inputSize==0; Test A2 control (inputSize=1 token=0x00 β 0); Tests B+C: 2,000,000 iterations, 0 faults, 0 canary corruptions', "run_kernel.log β on-media compressed_size=0 reaches LZ4_decompress_safe(inputSize=0) on stock INVARIANTS kernel: no panic, dmesg 'READ PATH: Error during decompression', unprivileged read returns zeros, guest healthy", "fix_run.log β fixed build (r96/v1.9.4 guard): returns -1 cleanly, no fault, control + 2M fuzz green ('FIX VALIDATED')", 'fix.diff β one-line guard vs sys/vfs/hammer2/hammer2_lz4.c, git apply --check clean']
PoC changes
Harness written fresh (verbatim decoder excerpt pattern reused from DF-0805's pack): guard-page token-fetch proof for inputSize==0, canonical 1-byte null-stream control, 2M-iteration guard-paged fuzz (random + all-0xFF/0x00 + structured tokens with offsets 0/1/2/7/8/0xFFFF + valid-shaped streams with flip/truncate mutations) with output canaries, plus in-kernel trigger adapted from DF-0805's hammer2_trigger.sh with compressed_size=0x00000000 instead of 0x7FFFFFFF.
Verified recommended fix
Restore the upstream r96/v1.9.4 guard: reject inputSize==0 before the main loop ('if ((endOnInput) && unlikely(inputSize==0)) goto _output_error;' after the outputSize==0 check).
Verdict
LZ4_decompress_safe() (sys/vfs/hammer2/hammer2_lz4.c:418, vendored upstream LZ4 r97) dereferences source[0] when inputSize==0 β the pre-loop validation only rejects outputSize==0 (hammer2_lz4.c:407) β a 1-byte OOB read past the declared input buffer that violates the documented 'never reads outside of input buffer' contract (hammer2_lz4.h:62-65). Unit-proven on the guest: SIGSEGV exactly at src with a guard page (run.log). In-kernel reachable from on-media compressed_size==0, which passes the strategy.c:199 KKASSERT even on INVARIANTS kernels (run_kernel.log: no panic, one 'READ PATH: Error during decompression', file reads as zeros). Impact fully contained by the caller: the speculative byte sits inside the dio allocation for every realistic layout, the decode always errors, and the output is zeroed β no leak, no corruption. Negative proof: 2,000,000 guard-paged adversarial fuzz iterations with honest (inputSize,outputSize) β 0 faults, 0 output-canary corruptions β the r97 decoder is otherwise memory-safe on LP64; DF-0805's heap OOB is purely the caller's unchecked compressed_size. Guard (upstream r96 / v1.9.4 semantics) restored in fix.diff and validated at unit level: clean -1, no fault, fuzz still green.
No comments yet.