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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0805 Β· 16 files
FileTypeDescriptionSize
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
README.md readme human-facing PoC summary and reproduction instructions
↓ download 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 inputSize flows 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 into bp->b_data, which is then bcopyd 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

  1. Confirm the running kernel is the unpatched baseline: ssh dfbsd 'sysctl -n kern.version | head -1' β†’ #0.
  2. Run ./hammer2_trigger.sh as root.
  3. Watch the serial log: tail -F dfbsd-qemu/boot.log.
  4. 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.

VERDICT.md verdict full narrative: mechanism, proof points, fix validation
↓ download raw

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)

  1. An attacker-controlled compressed_size field is read from on-media data at sys/vfs/hammer2/hammer2_strategy.c:198: c compressed_size = *(const int *)data;
  2. The only bound is the KKASSERT at hammer2_strategy.c:199: c KKASSERT((uint32_t)compressed_size <= bytes - sizeof(int)); On a kernel without INVARIANTS, KKASSERT expands to do { } while (0) (sys/sys/systm.h:117-118) β€” the check is gone.
  3. With compressed_size unchecked, 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 as inputSize to LZ4.
  4. Inside LZ4_decompress_generic (sys/vfs/hammer2/hammer2_lz4.c:391): c BYTE* iend = ip + inputSize; The decoder uses iend as the source bound (ip < iend in the literal-run-extension loop at :422 and the match-length-extension loop at :467). An oversized inputSize reads past the chain dio buffer (HAMMER2_PBUFSIZE = 65536 bytes; the dio buffer is allocated at hammer2_io.c:113) into adjacent kernel memory.
  5. The decoded bytes β€” including the OOB-read ones emitted as LZ4 literals β€” land in compressed_buffer, then bcopyd into bp->b_data at :214 and 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 at hammer2_strategy.c:199 (panic.txt). βœ… reproduced.
  • Patched (#1, single-fix): same trigger returns EIO cleanly, no panic, guest stays up. The fix's kprintf lands in dmesg: 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)

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: 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.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Mon Jul 13 23:15:52 UTC 2026

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.