LZ4 read path compressed_size bounds checked only by KKASSERT β heap OOB read on non-INVARIANTS kernels
Summary
hammer2_strategy.c:198 compressed_size=*(const int*)data from on-media file data signed attacker-controlled. :199 KKASSERT((uint32_t)compressed_size<=bytes-sizeof(int)) β ONLY bound compiled out without INVARIANTS (systm.h:117). :202-205 LZ4_decompress_safe(data+4,compressed_buffer,compressed_size,bp_bufsize) with attacker value as inputSize. LZ4_decompress_generic iend=ip+inputSize parse loop reads past chain dio buffer (64KB hammer2_io.c:113) into adjacent kernel heap. OOB bytes emitted as literals into bp->b_data then bcopy to user read buffer = kernel heap info leak. Or page fault panic. Stock GENERIC has INVARIANTS KKASSERT panics first = DoS. Non-INVARIANTS (custom/embedded/perf) = heap disclosure. Same KKASSERT-as-bounds at :197/:213/:486/:613 all lose memory-safety on non-INVARIANTS. Trigger: crafted HAMMER2 image LZ4 compressed_size=0x7FFFFFFF mount read file. Fix: if(compressed_size<0||(u_int)compressed_size>bytes-sizeof(int)) return EIO+bzero.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0805 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| lz4_oob_harness.c | trigger-source | unit-level proof of the LZ4 OOB primitive (verbatim in-tree decompressor + guard-page trap) | 13.4 KB | view raw |
| hammer2_trigger.sh | exploit-chain | in-kernel trigger via crafted HAMMER2 image; reads corrupted LZ4 block as unprivileged user | 5.2 KB | view raw |
| setcheck.c | trigger-source | HAMMER2IOC_INODE_SET helper that disables per-file check_algo so the corrupted block reaches the LZ4 path | 2.4 KB | view raw |
| build.sh | build-script | builds the unit-level harness | 706 B | view raw |
| run.sh | run-script | runs the unit-level harness | 980 B | view raw |
| build.log | build-log | final successful build of lz4_oob_harness | 128 B | view raw |
| run.log | run-log | decisive unit-harness run, SIGSEGV inside LZ4_decompress_safe | 333 B | view raw |
| panic.txt | panic-signature | KKASSERT panic at hammer2_strategy.c:199 from boot.log (in-kernel trigger on unpatched GENERIC) | 601 B | view raw |
| fix_build.log | build-log | single-fix kernel build output (nativekernel, rc=0) | 5.6 MB | β download |
| fix_run.log | run-log | patched-kernel trigger β dmesg shows 'HAMMER2 LZ4: bad compressed_size 2147483647', no panic | 213 B | view raw |
| env.txt | environment | uname, cc version, root filesystem mount | 379 B | view raw |
| fix.diff | suggested-fix | git-apply-able fix: replace KKASSERT-only bound with real EIO guard | 1.4 KB | view raw |
| README.md | readme | human-facing PoC summary and reproduction instructions | 5.7 KB | β raw |
| VERDICT.md | verdict | full narrative: mechanism, proof points, fix validation | 7.1 KB | β raw |
| ../fix_build_combined.log | build-log | Combined 41-finding kernel build (rc=0, -Werror clean) | 5.6 MB | β download |
| ../fix_build_summary.txt | build-summary | Summary of the combined 41-finding kernel build | 826 B | view raw |
DF-0805 β LZ4 read-path OOB primitive (HAMMER2)
Summary
The HAMMER2 LZ4 decompression callback at
sys/vfs/hammer2/hammer2_strategy.c:198-205 reads the on-media
compressed_size field straight from disk as a signed int and uses it
as the inputSize argument to LZ4_decompress_safe(). The only bound
is a KKASSERT at line 199:
compressed_size = *(const int *)data;
KKASSERT((uint32_t)compressed_size <= bytes - sizeof(int));
KKASSERT is a real guard only on kernels built with INVARIANTS.
On a non-INVARIANTS kernel (sys/sys/systm.h:117-118) the macro
expands to do { } while (0) β a no-op β and LZ4_decompress_safe()
is called with an attacker-controlled inputSize. LZ4 trusts
inputSize as the source-buffer bound (iend = ip + inputSize at
hammer2_lz4.c:391); an oversized value causes the decoder to read
past the chain dio buffer into adjacent kernel memory.
- Default
X86_64_GENERIC(INVARIANTS ON): the KKASSERT fires first, panicking the kernel. Local DoS when reading a malicious HAMMER2 file. - Non-INVARIANTS kernel (custom/embedded/perf builds): the KKASSERT is
gone, so the oversized
inputSizeflows through. The decoder reads attacker-controlled counts of bytes past the chain dio buffer β kernel heap disclosure (the OOB bytes are emitted as LZ4 literals intobp->b_data, which is thenbcopyd into the user's read buffer at line 214), or a page-fault panic depending on adjacent VA layout.
Same KKASSERT-as-bounds pattern at :197, :213, :486, :613.
PoC components
| File | What it does |
|---|---|
lz4_oob_harness.c |
Unit-level proof of the LZ4 OOB primitive. Compiles a verbatim copy of the in-tree LZ4_decompress_safe decompressor (the function under test is byte-equivalent to sys/vfs/hammer2/hammer2_lz4.c), mmaps a 3-page region [guard-LO | data | guard-HI], fills data with an LZ4 stream, then calls LZ4_decompress_safe(data+4, dst, 0x10000, 65536) β exactly mirroring the HAMMER2 read path with an attacker-inflated compressed_size of 64 KiB but only a 4096-byte source buffer. The decoder walks off the data page into guard-HI and SIGSEGVs, proving the OOB read definitively. |
hammer2_trigger.sh |
In-kernel trigger via a crafted HAMMER2 image. Run as root. Builds a fresh HAMMER2 image, writes a 64 KiB file with a unique literal signature, disables the per-file block check via HAMMER2IOC_INODE_SET (so a corrupted block reaches the LZ4 path instead of being rejected at hammer2_chain.c:1071), unmounts, surgically overwrites the on-disk 4-byte compressed_size header with 0x7FFFFFFF, re-mounts, and reads the file as the unprivileged user. |
setcheck.c |
Helper that uses HAMMER2IOC_INODE_GET/SET to flip a file's check_algo to HAMMER2_CHECK_NONE so the corrupted block reaches the LZ4 path on next read. |
build.sh / run.sh |
Build and run the unit-level harness. |
fix.diff |
Standalone git apply-able unified diff against sys/vfs/hammer2/hammer2_strategy.c that replaces the KKASSERT-only bound with a real guard returning EIO. |
Build & run
Unit-level harness (proves the primitive)
./build.sh
./lz4_oob_harness
Expected output ends with SIGSEGV at <addr> while inside LZ4_decompress_safe()
β definitive proof that the algorithm under test reads past the source
buffer when inputSize exceeds the backing allocation.
In-kernel trigger (root on the guest)
cc -O2 -Wall -o setcheck setcheck.c # helper, built as root
./hammer2_trigger.sh # root; reads as maxx (uid 1001)
On the unpatched #0 GENERIC kernel (INVARIANTS ON), the read panics
with the KKASSERT at hammer2_strategy.c:199. After applying fix.diff
and rebuilding the kernel, the same trigger returns EIO and the kernel
logs HAMMER2 LZ4: bad compressed_size 2147483647 (bytes=1024) β clean
rejection, no panic.
How to reproduce the panic signature
- Confirm the running kernel is the unpatched baseline:
ssh dfbsd 'sysctl -n kern.version | head -1'β#0. - Run
./hammer2_trigger.shas root. - Watch the serial log:
tail -F dfbsd-qemu/boot.log. - You will see:
panic: assertion "(uint32_t)compressed_size <= bytes - sizeof(int)" failed in hammer2_decompress_LZ4_callback at /usr/src/sys/vfs/hammer2/hammer2_strategy.c:199
How to validate the fix
scp findings/poc/DF-0805/fix.diff dfbsd:/root/fix.diff
ssh dfbsd '/bin/sh -c "cd /usr/src && patch -p1 --forward < /root/fix.diff"'
ssh dfbsd '/bin/sh -c "cd /usr/src && make -j6 nativekernel KERNCONF=X86_64_GENERIC"'
ssh dfbsd '/bin/sh -c "cp /usr/obj/usr/src/sys/X86_64_GENERIC/kernel.stripped /boot/kernel/kernel && cp /usr/obj/usr/src/sys/X86_64_GENERIC/kernel.debug /boot/kernel/kernel.debug"'
./dfbsd-qemu/vm.sh down && ./dfbsd-qemu/vm.sh up 180
ssh dfbsd '/root/hammer2_trigger.sh' # now returns EIO, no panic
ssh dfbsd 'dmesg | tail' # logs "HAMMER2 LZ4: bad compressed_size 2147483647 (bytes=1024)"
Realistic impact
The trigger requires a corrupted/malicious HAMMER2 file block. On the
default HAMMER2 configuration (XXHASH64 check), the kernel rejects
corrupted blocks at hammer2_chain.c:1071 BEFORE reaching the LZ4
decompression β so the bug is reachable in practice only when (a) the
admin has disabled the per-file check (HAMMER2_CHECK_NONE), or (b)
the attacker supplies a malicious image with validly-computed XXHASH64
check codes for their LZ4 payloads, or (c) the kernel is built without
INVARIANTS and a bit-flip / fault corrupts the compressed_size field.
Severity Low is correct: on stock GENERIC + XXHASH64 the bug is defense-in-depth; on non-INVARIANTS builds it becomes a real heap disclosure / DoS via a malicious image.
This is a pure OOB read primitive β no escalation chain applies.
DF-0805 β VERDICT
Verdict: REPRODUCED. In-kernel KKASSERT panic confirmed on stock
GENERIC (INVARIANTS ON) via a crafted HAMMER2 image; LZ4 OOB-read
primitive confirmed at the unit level via a verbatim copy of the
in-tree LZ4_decompress_safe. Fix validated on a single-fix kernel:
panic is replaced by a clean EIO return.
Mechanism (confirmed)
- An attacker-controlled
compressed_sizefield is read from on-media data atsys/vfs/hammer2/hammer2_strategy.c:198:c compressed_size = *(const int *)data; - The only bound is the KKASSERT at
hammer2_strategy.c:199:c KKASSERT((uint32_t)compressed_size <= bytes - sizeof(int));On a kernel withoutINVARIANTS,KKASSERTexpands todo { } while (0)(sys/sys/systm.h:117-118) β the check is gone. - With
compressed_sizeunchecked, the call at:202-205:c LZ4_decompress_safe(__DECONST(char *, &data[sizeof(int)]), compressed_buffer, compressed_size, /* <-- attacker value as inputSize */ bp->b_bufsize);passes the attacker value asinputSizeto LZ4. - Inside
LZ4_decompress_generic(sys/vfs/hammer2/hammer2_lz4.c:391):c BYTE* iend = ip + inputSize;The decoder usesiendas the source bound (ip < iendin the literal-run-extension loop at:422and the match-length-extension loop at:467). An oversizedinputSizereads past the chain dio buffer (HAMMER2_PBUFSIZE= 65536 bytes; the dio buffer is allocated athammer2_io.c:113) into adjacent kernel memory. - The decoded bytes β including the OOB-read ones emitted as LZ4
literals β land in
compressed_buffer, thenbcopyd intobp->b_dataat:214and ultimately to the user's read buffer.
Proof points
A. Unit-level harness (lz4_oob_harness.c) β definitive primitive proof
Maps a 3-page region [guard-LO (PROT_NONE) | data (RW) | guard-HI (PROT_NONE)],
fills data with an LZ4 stream of 0xF0 0xFF 0xFF β¦, sets the on-media
compressed_size int to 0x10000, and calls the verbatim in-tree
LZ4_decompress_safe(). The decoder walks off the data page into the
high guard page and SIGSEGVs:
[harness] trigger: compressed_size=0x10000 with bytes=4096 (valid bound 4092); calling LZ4_decompress_safe... [harness] SIGSEGV at 0x800475000 while inside LZ4_decompress_safe() [harness] -> out-of-bounds read past the source buffer
This proves the primitive independent of any kernel-config or
check-code interaction. (Full log in run.log.)
B. In-kernel trigger (hammer2_trigger.sh) β KKASSERT panic on stock GENERIC
The trigger builds a fresh HAMMER2 image, writes a 64 KiB file with a
unique literal signature, uses HAMMER2IOC_INODE_SET (via the
setcheck helper) to disable the per-file block check, unmounts,
finds the on-disk LZ4 block by its signature, overwrites the 4-byte
compressed_size header with 0x7FFFFFFF, re-mounts, and reads the
file as the unprivileged maxx user.
On the unpatched #0 GENERIC kernel (INVARIANTS ON), the KKASSERT at
hammer2_strategy.c:199 fires and the kernel panics (full trace in
panic.txt):
panic: assertion "(uint32_t)compressed_size <= bytes - sizeof(int)" failed in hammer2_decompress_LZ4_callback at /usr/src/sys/vfs/hammer2/hammer2_strategy.c:199
cpuid = 4
Trace beginning at frame 0xfffff801187f9950
hammer2_xop_strategy_read() at hammer2_xop_strategy_read+0x9a6 0xffffffff80984c76
hammer2_xop_strategy_read() at hammer2_xop_strategy_read+0x9a6 0xffffffff80984c76
hammer2_primary_xops_thread() at hammer2_primary_xops_thread+0x280 0xffffffff8095da30
Debugger("panic")
C. Reachability note (defense-in-depth)
On the default HAMMER2 configuration, the per-block XXHASH64 check
(hammer2_chain.c:1071) rejects corrupted blocks before they reach
the LZ4 path. The trigger therefore explicitly disables the check via
the HAMMER2IOC_INODE_SET ioctl (setcheck helper) to expose the
vulnerable code path. This is consistent with the realistic threat
model β a malicious downloaded HAMMER2 image can ship with
HAMMER2_CHECK_NONE inodes or with validly-computed XXHASH64 codes
for malicious LZ4 payloads.
Exploit chain
Not applicable. The primitive is a pure OOB read β there is no write, no UAF, no type confusion, no corruption of a victim object. On stock GENERIC + XXHASH64, the check code prevents the LZ4 path from being reached with corrupted data, so the bug is defense-in-depth under default config. The realistic impact is:
- GENERIC (INVARIANTS ON) + check disabled: local DoS via KKASSERT panic.
- Non-INVARIANTS + check disabled: kernel heap disclosure (the OOB bytes flow through to userspace via the read buffer) or page-fault panic depending on adjacent VA layout.
- Non-INVARIANTS + check enabled: latent β only a bit-flip or attacker-controlled image with matching XXHASH64 reaches the LZ4 path.
No uid=0 chain derivable from this primitive alone. Severity Low is
correct.
Fix
fix.diff replaces the KKASSERT-only bound with a real guard that
returns EIO (and zeroes the user buffer) when the on-media
compressed_size is negative or exceeds bytes - sizeof(int). The
guard is compiled in unconditionally β it does not depend on
INVARIANTS, so the path is safe on non-INVARIANTS kernels too.
+ if (compressed_size < 0 ||
+ (u_int)compressed_size > bytes - sizeof(int)) {
+ kprintf("HAMMER2 LZ4: bad compressed_size %d (bytes=%u)\n",
+ compressed_size, bytes);
+ bp->b_error = EIO;
+ bp->b_flags |= B_ERROR;
+ bp->b_resid = bp->b_bufsize;
+ bzero(bp->b_data, bp->b_bufsize);
+ return;
+ }
Fix validation (Phase 8)
- Baseline (
#0, unpatched): trigger panics with the assertion athammer2_strategy.c:199(panic.txt). β reproduced. - Patched (
#1, single-fix): same trigger returnsEIOcleanly, no panic, guest stays up. The fix'skprintflands indmesg:HAMMER2 LZ4: bad compressed_size 2147483647 (bytes=1024)β fix validated.
PoC changes
The finding folder was seeded empty (no prior PoC). I authored:
- lz4_oob_harness.c β unit-level primitive proof with verbatim in-tree LZ4
- hammer2_trigger.sh β in-kernel trigger via crafted HAMMER2 image
- setcheck.c β ioctl helper to disable per-file check
- build.sh / run.sh β driver scripts
- fix.diff β the fix
- VERDICT.md, README.md, manifest.json β evidence pack
Kernel refs (confirmed)
sys/vfs/hammer2/hammer2_strategy.c:198βcompressed_size = *(const int *)datasys/vfs/hammer2/hammer2_strategy.c:199βKKASSERT(...)(no-op without INVARIANTS)sys/vfs/hammer2/hammer2_strategy.c:202-205βLZ4_decompress_safe(..., compressed_size, ...)sys/vfs/hammer2/hammer2_lz4.c:391βiend = ip + inputSize(oversized inputSize reads OOB)sys/sys/systm.h:117-118βKKASSERTno-op without INVARIANTSsys/vfs/hammer2/hammer2_io.c:113β dio buffer size =HAMMER2_PBUFSIZE(65536)sys/vfs/hammer2/hammer2_chain.c:1071β XXHASH64 check rejection (defense-in-depth)sys/vfs/hammer2/hammer2_strategy.c:929-933β all-zeros writes are stored as holes (not LZ4)
Fix verification
fixedVALIDATED: baseline panic at :199 KKASSERT; patched EIO + dmesg 'bad compressed_size', guest up x2.
BEFORE: panic assertion compressed_size<=bytes at hammer2_strategy.c:199. AFTER: cat EIO, dmesg 'bad compressed_size 2147483647', guest up.
Confirmed kernel references
Detail
Exploit chain
none -- pure OOB read. No write. Info leak on non-INVARIANTS; KKASSERT panic on GENERIC. Defense-in-depth under default XXHASH64 config.
Evidence (decisive lines)
BASELINE: panic assertion compressed_size<=bytes at hammer2_strategy.c:199 (KKASSERT). PATCHED: EIO + dmesg 'HAMMER2 LZ4: bad compressed_size 2147483647', guest up.
PoC changes
Authored from scratch: lz4_oob_harness.c (unit proof with verbatim LZ4_decompress_safe + guard page), hammer2_trigger.sh (crafted HAMMER2 image + check disable + read as maxx), setcheck.c (ioctl helper), fix.diff, VERDICT.md, manifest.json.
Verified recommended fix
Replace KKASSERT-only guard at hammer2_strategy.c:199 with real check: if(compressed_size<0 || (u_int)compressed_size > bytes-sizeof(int)) return EIO+bzero. Full git-apply-able diff in findings/poc/DF-0805/fix.diff.
Verdict
REPRODUCED. HAMMER2 LZ4 read path reads on-media compressed_size as signed int with only KKASSERT guard (no-op on non-INVARIANTS). Unchecked value flows into LZ4_decompress_safe as inputSize -> OOB read past chain dio buffer. Unit harness SIGSEGVs into guard page; in-kernel trigger via crafted HAMMER2 image with check_algo=0 + compressed_size=0x7FFFFFFF panics at KKASSERT :199.
No comments yet.