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).
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2655 Β· 28 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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
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)
- Image construction (
mkimage.sh):newfs_hammer2a 2GB image; mount;hammer2 setcomp zlib:6 /mnt/zdir(verified on media: dir inode comp_algo=0x63); write a 114688-byte compressiblezfile(two 64KB ZLIB blocks, methods=0x33, stored 8192B each); default-LZ4lzfilelikewise; clean unmount; pristine copies kept. - 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. - 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),cmpdiffers 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
- sys/vfs/hammer2/hammer2_strategy.c:229-271 (ZLIB callback; :257-261 error path, :262 bcopy of zeroed scratch, :269 b_resid=0, no b_error)
- sys/vfs/hammer2/hammer2_strategy.c:182-220 (LZ4 callback; :206-212 error path, :215-216 negative-result OOB bzero, :218 b_resid=0, no b_error)
- sys/vfs/hammer2/hammer2_strategy.c:428-437 (in-file error idiom mirrored by the fix)
- sys/vfs/hammer2/hammer2_chain.c:5380-5428 (setcheck XXH64 over chain->bytes of stored payload β what the patcher recomputes)
- sys/vfs/hammer2/hammer2_lz4.c:515-517 (_output_error return value)
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
fixedfix.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
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.
No comments yet.