β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-2664

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.

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

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2664 Β· 15 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download 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 β€” only outputSize==0 is rejected before the main loop; there is no inputSize==0 guard.
  • sys/vfs/hammer2/hammer2_lz4.c:418 β€” token = *ip++; executes with iend == ip when inputSize==0: a 1-byte OOB read at source[0].
  • The decoder cannot make progress afterwards (ip(=src+1) > iend β‡’ the exact-end condition ip+length != iend at :447 always fails β‡’ _output_error at :515), so the byte is never emitted β€” no information crosses to the output.

2. Provenance β€” vendored version pinned, guard history traced

  • LZ4_decompress_generic in hammer2_lz4.c:372-517 is a verbatim logic copy of upstream LZ4 r97 (lz4.c @ svn trunk@97, commit 16c0942822, 2013-06-10): identical statements, identical bounds checks; only comments/const differ. r97 is the revision that de-genericized the decoder out of lz4_decoder.h.
  • r96 (lz4_decoder.h, commit cd3bcd0043, 2013-05-27) still had if 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_NONE guard page after; LZ4_decompress_safe(src, dst, 0, 65536) β†’ SIGSEGV at src (delta = 0) inside the decoder β€” the token fetch read source[0], past the declared (empty) input buffer.
  • Test A2 (control): inputSize=1, {0x00} β†’ returns 0 (the canonical null stream). Only the inputSize==0 case 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 at iend[0..] faults), destinations carry canaries past oend (any write past oend detected), 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 bound compressed_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==0 call executed in-kernel (exactly one dmesg error line), without tripping the INVARIANTS KKASSERT β€” unlike DF-0805's oversized value, 0 is 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 --check clean against sys/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 -1 for inputSize==0 with 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

fixed
baseline reproduced→ patch + rebuild →patched clean

Unit-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; }'"]
↓ fix.diffper-fix-DF-2664

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.