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

hammer2 ZLIB decompression failure returns zero-filled SUCCESS (b_error never set) β€” silent data substitution on malformed compressed blocks

Field Value
ID DF-2655
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N
CWE CWE-754 Improper Check for Exceptional Conditions
File sys/vfs/hammer2/hammer2_strategy.c
Lines 258-272 (ZLIB callback; check LZ4 sibling too)
Area vfs
Confidence certain
Discovered 2026-08-29
Pass 2 (GLM 5.3 second pass β€” surfaced during the zlib inflate/deflate batch audit)
Bucket hammer2
Reported pending
Known CVE none
CVE match novel

Summary

hammer2_decompress_ZLIB_callback (strategy.c:258-265): when inflate(..., Z_FINISH) returns anything other than Z_STREAM_END, the handler kprintf's "Fatal error during decompression", bzero's the scratch buffer, then unconditionally bcopy's it into bp->b_data, sets bp->b_resid = 0, and returns without setting b_error β€” the bio completes successfully and read(2) returns fabricated zeros for the block instead of EIO. (Check the LZ4 sibling callback for the same pattern.)

Root cause

Error return of the decompressor is not propagated into bp->b_error; the zero-fill-on-error is treated as a substitute result rather than a failure.

Threat model & preconditions

A hammer2 block that is checksum-VALID but zlib-malformed (crafted image β€” attacker authors garbage with a recomputed check, CHECK_NONE ancestors make this trivial with the DF-2616..2654 forging family; or a compressed block written by a buggy/foreign implementation). Any read of the file silently substitutes zeros: backup/verification tooling that trusts read(2) integrity sees success with corrupted data. No memory corruption; pure integrity failure. Not reachable via ordinary bit-rot on default check methods (the checksum fails first β†’ EIO).

On decompression failure set bp->b_error = EIO; bp->b_flags |= B_ERROR; (and skip the bcopy) so the read fails loudly; same for the LZ4 callback if it shares the pattern.

References

  • findings/poc/DF-2656 (the zlib batch audit that surfaced this)
  • DF-2633/DF-2642 (the sibling swallowed-error family)

Timeline

  • 2026-08-29 Surfaced during the pass-2 zlib batch audit (GLM 5.3); line-verified by the orchestrator (strategy.c:258-272).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2655 Β· 28 files
FileTypeDescriptionSize
h2zsub.c β€” 20.5 KB view raw
build.sh β€” 128 B view raw
mkimage.sh β€” 2.5 KB view raw
run_stock_ab.sh β€” 1.9 KB view raw
run_lz4.sh β€” 1.6 KB view raw
dump.pristine.log β€” 1.0 KB view raw
dump.patched-zlib.log β€” 1.0 KB view raw
dump.patched-lz4.log β€” 1.0 KB view raw
patch.zlib.log β€” 900 B view raw
patch.lz4.log β€” 944 B view raw
patch.lz4.small.log β€” 944 B view raw
run.a.pristine.log β€” 1.2 KB view raw
run.b.patched.log β€” 2.0 KB view raw
lz4small-run.log β€” 502 B view raw
panic.txt β€” 2.9 KB view raw
panic.lz4-small.txt β€” 2.9 KB view raw
fixrun1.log β€” 972 B view raw
fixrun2.log β€” 789 B view raw
k1build.log β€” 5.6 MB ↓ download
k1install.log β€” 80.4 KB view raw
mkfs.log β€” 751 B view raw
mkimage.log β€” 2.8 KB view raw
fix.diff β€” 1.5 KB view raw
env.txt β€” 1.5 KB view raw
VERDICT.md β€” 7.1 KB ↓ raw
README.md β€” 5.3 KB ↓ raw
manifest.json β€” 1.6 KB view raw
verdict.json β€” 5.3 KB view raw

DF-2655 β€” hammer2 ZLIB decompression failure returns zero-filled SUCCESS

sys/vfs/hammer2/hammer2_strategy.c:229-271 β€” hammer2_decompress_ZLIB_callback(): when inflate(Z_FINISH) != Z_STREAM_END the handler prints HAMMER2 ZLIB: Fatal error during decompression., bzeros the scratch buffer, unconditionally bcopies it into bp->b_data, sets b_resid=0 and never sets b_error β†’ the biodone completes the read(2) as SUCCESS with kernel-fabricated zeros. The LZ4 sibling (:182-220) has the same missing-b_error pattern AND an out-of-bounds bzero(bp->b_data + result, bp->b_bufsize - result) with a NEGATIVE result (see VERDICT.md β€” separate, more severe issue flagged).

Verified on the QEMU guest

DragonFly 6.5-DEVELOPMENT, stock INVARIANTS kernel (#0 Thu Jul 2 06:02:54 UTC 2026), guest /usr/src == audited tree (md5 4c0018e3b60e8a23d41863f85f5644d4).

Demonstrated (stock kernel)

# observation evidence
1 Crafted image mounts with no check failure: the corrupted ZLIB payload block is checksum-VALID (XXH64 chain checks + volhdr iCRC32s all recomputed by the patcher) patch.zlib.log, dump.patched-zlib.log
2 PRISTINE image: zfile reads back bit-identical (md5 876569bd…) run.a.pristine.log
3 PATCHED image: read(2) returns 114688 bytes with exit 0 (SUCCESS) while the entire first 64KB block is fabricated zeros (md5 b1e63222…); block 2 intact; dmesg shows the "Fatal error" kprintf β€” silent data substitution run.b.patched.log
4 Deterministic: identical fabricated md5 across 3 independent mounts (vn8/vn9/vn10) run.b.patched.log, run_stock_ab.sh transcript
5 LZ4 sibling, small failure (result = -9): same silent substitution (zeros + SUCCESS) plus a silent 9-byte OOB write before the 64KB bp->b_data (mapped heap β€” no crash) lz4small-run.log
6 LZ4 sibling, attacker-chosen magnitude (result = -16381 = -(size-prefix+1)): the OOB bzero starts at bp->b_data - 16381 in an unmapped page β†’ Fatal trap 12 at memset+0xf0, all CPUs stopped, machine halted at db> panic.txt

Fault-address math (stock run): fault VA 0xfffff80062a22003 = page-aligned bp->b_data (0xfffff80062a26000) βˆ’ 16381 = βˆ’(prefix+1) β†’ the OOB write length is attacker-chosen via the crafted image (up to the stored block size).

Fix (validated A/B on rebuilt kernel #1, Tue Oct 20 03:15:00 UTC 2026)

fix.diff: on decompression failure both callbacks now set B_ERROR | b_error = EIO | b_resid = b_bcount and return early (the LZ4 early return also removes the negative-result OOB bzero entirely).

test stock kernel fixed kernel
pristine zfile/lzfile md5 876569bd… βœ“ 876569bd… βœ“ (bit-identical)
zlib-patched read(2) SUCCESS + zeros EIO (fixrun1.log)
lz4-small read(2) SUCCESS + zeros + 9B OOB EIO (fixrun2.log)
lz4-big read(2) kernel panic, machine halted EIO, no panic (fixrun2.log)

Reproduce (in the DragonFly guest, as root)

Copy this directory to /root/df2655, then:

./build.sh                     # cc h2zsub.c + kernel icrc32.c + xxhash.c
./mkimage.sh                   # newfs, setcomp zlib:6, files, patch, checks
./run_stock_ab.sh              # A pristine / B+C patched (substitution)
./run_lz4.sh small             # LZ4 sibling, |result| = 9
./run_lz4.sh big               # WARNING: panics + halts the stock kernel

Fixed-kernel validation: apply fix.diff in /usr/src, make nativekernel KERNCONF=X86_64_GENERIC && make installkernel, reboot, re-run the same tests (expect EIO everywhere, pristine bit-identical).

Attack preconditions

Crafted hammer2 media (image or disk): the attacker must control the on-media payload of a compressed block and recompute its XXH64 β€” i.e. a malicious image/disk producer, or media corruption + an attacker who can fix checksums. Mounting requires root on this config (vfs.usermount=0), matching the Medium "malicious media / integrity" class of DF-2606. There is no unprivileged-local or remote trigger.

LZ4 OOB write β€” flag for a NEW finding

Observations 5-6 (kernel heap OOB write before bp->b_data, attacker-chosen length, panic on unmapped start) are a distinct memory-safety defect at hammer2_strategy.c:215-216, beyond DF-2655's substitution claim. It is fully evidenced in this pack (panic.txt, lz4small-run.log) and covered by the same fix.diff hunk; the orchestrator should file it as its own finding (High: kernel heap OOB write via crafted media; zero-content, controlled length).

Files

h2zsub.c             recursive walker/patcher (dirent->inum->inode->DATA,
                     corrupts payload, fixes XXH64 chain + volhdr iCRCs)
build.sh mkimage.sh run_stock_ab.sh run_lz4.sh    exact runnable commands
dump.pristine.log dump.patched-zlib.log dump.patched-lz4.log
patch.zlib.log patch.lz4.log patch.lz4.small.log
run.a.pristine.log run.b.patched.log     stock-kernel A/B
lz4small-run.log                         LZ4 sibling substitution (stock)
panic.txt                               LZ4-big OOB write -> Fatal trap 12
fixrun1.log fixrun2.log                  fixed-kernel A/B validation
k1build.log k1install.log                fix-kernel build (BUILD_RC=0)
mkfs.log mkimage.log content md5s
fix.diff VERDICT.md manifest.json verdict.json env.txt
VERDICT.md
↓ download raw

DF-2655 VERDICT β€” REPRODUCED (silent data substitution; LZ4 sibling additionally panics via OOB write)

What was claimed

hammer2_decompress_ZLIB_callback() (sys/vfs/hammer2/hammer2_strategy.c:229-271): when inflate(&strm_decompress, Z_FINISH) returns anything other than Z_STREAM_END (:257-261), the callback kprintf's, bzeros the scratch buffer, then unconditionally bcopies the (zeroed) scratch into bp->b_data (:262), zero-fills the tail and sets bp->b_resid = 0 (:263-269) without ever setting bp->b_error/B_ERROR. biodone() therefore completes the read as a full SUCCESS and read(2) hands the caller kernel-fabricated zeros for that 64KB block. Precondition: a compressed DATA block whose integrity check PASSES but whose payload is not a valid zlib stream β€” i.e. crafted media with recomputed checksums.

How it was reproduced (stock kernel, no code changes)

  1. Image construction (mkimage.sh): newfs_hammer2 a 2GB image; mount; hammer2 setcomp zlib:6 /mnt/zdir (verified on media: dir inode comp_algo=0x63); write a 114688-byte compressible zfile (two 64KB ZLIB blocks, methods=0x33, stored 8192B each); default-LZ4 lzfile likewise; clean unmount; pristine copies kept.
  2. Offline patch (h2zsub patch zfile zlib 0): resolves the file exactly as the kernel does β€” DIRENT bref (name embedded in check.buf, key=name-hash) -> embed.dirent.inum -> INODE bref keyed by inum (mirrors hammer2_xop_nresolve / hammer2_chain_inode_find) -> DATA bref at lbase 0. The patcher VERIFIES the original XXH64 first (refuses otherwise), overwrites all 8192 stored bytes with 0xFF (invalid zlib header -> inflate returns Z_DATA_ERROR != Z_STREAM_END), then rewrites the DATA bref check.xxhash64 and cascades new XXH64s up the blockref chain (file inode -> root INDIRECT -> LOCAL root -> sroot -> volume header) and refreshes the three volhdr iCRC32s in every valid volhdr zone. Result: fully checksum-valid metadata + a checksum-valid decompression bomb.
  3. Stock-kernel A/B/C (run_stock_ab.sh): - A pristine: dd reads 114688B, md5 876569bdd45df2756a69a3fe85660f2a == original. Zero anomalies in dmesg. - B patched: dd exit 0, 114688 bytes read, md5 b1e63222a373994ee242ce1a7e3da0fc, first 64KB = all 0x00 (nonzero byte count in first 64K: 0), cmp differs at byte 1, block 2 (lbase 0x10000, unpatched) intact. dmesg: HAMMER2 ZLIB: Fatal error during decompression. The read neither fails nor warns the user. - B2/B3 determinism: identical fabricated md5 on two more fresh mounts (vn9, vn10); kprintf visible in both dmesg tails.

That is the complete substitution proof: same image, same file, checksums valid, decompression demonstrably fails (kernel says so) β€” yet read(2) returns SUCCESS with fabricated content. No error path exists in userspace to detect this (short of separately knowing the true content).

LZ4 sibling (:182-220) β€” same missing b_error, PLUS an OOB write

Mechanically tested per directive. The LZ4 callback fails identically silent (result < 0 -> kprintf, scratch zeroed, bcopied, b_resid=0, no b_error). Additionally the tail-fill

if (result < bp->b_bufsize)
    bzero(bp->b_data + result, bp->b_bufsize - result);   /* :215-216 */

executes with a NEGATIVE result: start = bp->b_data βˆ’ |result|, length = b_bufsize + |result| β€” an out-of-bounds zeroing of |result| bytes BEFORE the 64KB bp->b_data. |result| is -(ipβˆ’source)βˆ’1 (hammer2_lz4.c:515-517) and with an all-0xFF stream the walk consumes the whole input, so |result| = stored size-prefix + 1 β€” attacker-chosen via the image.

  • small (prefix 8, result βˆ’9): read(2) SUCCESS + zeros (lz4small-run.log, md5 b1e63222…), the 9-byte OOB landed in mapped heap β€” silent corruption, guest survived.
  • big (prefix 16380, result βˆ’16381): OOB bzero began at bp->b_data βˆ’ 16381 (0xfffff80062a22003; bp->b_data page-aligned at 0xfffff80062a26000) in an unmapped page -> Fatal trap 12 ... Stopped at memset+0xf0: movq %r10,(%rdi) -> all CPUs stopped, machine dead at db> (panic.txt). Fault-VA math proves the controlled magnitude.

The OOB write is a distinct memory-safety defect (flagged for a new finding; High: heap OOB write, crafted media, controlled length). It is fixed by the same early-return hunk.

Fix validation (kernel #1 = stock + fix.diff, 03:15 UTC build)

  • Pristine zfile AND lzfile: md5s bit-identical to originals (fixrun1.log, fixrun2.log F1) β€” no regression.
  • zlib-patched: dd: /mnt/zdir/zfile: Input/output error β€” read(2)=EIO; kprintf still logs for the operator (fixrun1.log).
  • lz4-small patched: EIO (fixrun2.log F3).
  • lz4-big patched: EIO, no panic β€” the operation that halted the stock kernel is cleanly failed (fixrun2.log F4), guest alive afterwards.
  • (F5 in fixrun2.log hit the unrelated vn-device wedge from repeated mounts; the pristine-identity result was already established in F1.)

fix.diff matches the file's own error idiom (B_ERROR + b_error = EIO + b_resid = b_bcount; same as the strategy-read default case at :428-437) and adds the early return that also eliminates the negative-result bzero.

Impact, reachability, honesty

  • Primary claim: CONFIRMED silent data substitution β€” integrity attack, Medium, malicious-media precondition (root mounts image / attacker controls media bytes). Not a privilege escalation; no memory-safety primitive on the ZLIB path itself.
  • Sibling LZ4 OOB write: kernel heap OOB (zero-content) write with attacker-chosen length (up to stored block size, ~32KB) before a 64KB buffer β€” panic-class and potentially corruption-class; flagged separately.
  • Everything was produced on the stock INVARIANTS kernel first, fix validated on a rebuilt kernel afterwards; guest reset afterwards.

Kernel references

Bottom line

REPRODUCED, deterministic, on the stock kernel: checksum-valid but zlib-invalid block -> read(2) SUCCESS with fabricated zeros. The LZ4 sibling reproduces the substitution AND exhibits an attacker-length out-of-bounds kernel write (panic captured). fix.diff turns both into clean EIO with zero behavioral delta on pristine images (validated A/B on a rebuilt kernel).

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff validated on rebuilt kernel K1: pristine zfile/lzfile reads bit-identical (md5 876569bd...); zlib-patched, lz4-small and lz4-big patched images now read(2)=EIO (dd: Input/output error) with zero panics - the lz4-big operation that halted the stock kernel is cleanly failed; kprintf retained for operators (fixrun1.log, fixrun2.log)

fixrun1.log (F1 pristine md5s identical; F2 zlib EIO), fixrun2.log (F3 lz4-small EIO; F4 lz4-big EIO no panic), k1build.log BUILD_RC=0
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Tue Oct 20 03:15:00 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

craft hammer2 image with compressible file -> offline patch one stored ZLIB payload to 0xFF and recompute its XXH64 plus every ancestor check up to the volume header iCRCs (h2zsub) -> root mounts image (vfs.usermount=0) -> any read(2) of the file -> kernel decompression fails -> callback returns fabricated zeros with b_resid=0 and no b_error -> caller receives silent data substitution (no error, no warning). LZ4 variant of the same construction additionally drives an OOB zeroing write before a 64KB kernel buffer with attacker-chosen length up to the stored block size. No privilege escalation path exists on these paths: impact ceiling is integrity substitution plus the LZ4 OOB write/panic.

Evidence (decisive lines)

run.b.patched.log (substitution proof: dd exit 0, md5 b1e63222..., first 64KB zero, kprintf), run.a.pristine.log (md5 876569bd...), panic.txt (Fatal trap 12 at memset+0xf0, fault VA 0xfffff80062a22003 = bp->b_data-16381), lz4small-run.log (LZ4 substitution + silent 9-byte OOB), patch.zlib.log (checksum cascade), fixrun1.log/fixrun2.log (fixed kernel: EIO everywhere, pristine bit-identical, no panic)

PoC changes

No runnable seed existed: wrote the full trigger machinery (h2zsub.c) - a recursive on-media walker resolving names exactly like hammer2_xop_nresolve (DIRENT check.buf name -> embed.dirent.inum -> INODE bref keyed by inum), verifying the original XXH64 before patching, corrupting the stored payload, and cascading recomputed XXH64 checks up through the indirect/inode chain into every valid volume header iCRC. Guest quirks handled: vn device nodes are devfs-clone allocated (vnconfig -c vn file prints the unit); ro mounts used for read stability.

Verified recommended fix

On decompression failure in both hammer2_decompress_LZ4_callback and hammer2_decompress_ZLIB_callback set B_ERROR, b_error=EIO, b_resid=b_bcount and return early before any bcopy/tail-fill (see fix.diff)

Verdict

REPRODUCED on the stock INVARIANTS kernel: a ZLIB-compressed DATA block whose XXH64 check (and the whole chain up to the volume header iCRCs) is valid but whose payload is not a zlib stream mounts cleanly and, on read(2), returns kernel-fabricated zeros as SUCCESS (dd exit 0, first 64KB all zero, md5 b1e63222a373994ee242ce1a7e3da0fc vs original 876569bdd45df2756a69a3fe85660f2a, dmesg shows HAMMER2 ZLIB Fatal error during decompression). Deterministic across three independent mounts. impact is recorded as panic because the same missing-b_error bug family in the LZ4 sibling callback (hammer2_strategy.c:215-216) additionally produced an attacker-length OOB kernel write: bzero(bp->b_data + result, ...) with result negative; |result| = stored size prefix + 1 chosen via the image; with -16381 the write began at bp->b_data-16381 in an unmapped page causing Fatal trap 12 at memset+0xf0 and a full machine halt (panic.txt); with -9 it silently corrupted 9 bytes of mapped heap adjacent to the 64KB buffer. The substitution itself (the DF-2655 claim) is an integrity attack (Medium, malicious-media precondition: root mounts crafted image, no unprivileged or remote trigger). fix.diff (both callbacks: B_ERROR + EIO + b_resid=b_bcount + early return) validated A/B on rebuilt kernel: pristine images bit-identical, zlib/lz4-small/lz4-big patched images now read(2)=EIO with no panic.