zero_write() drops hammer2_chain_delete() failure: all-zero block overwrite silently succeeds while the old data chain survives the topology β reads resurrect pre-overwrite content after buffer-cache eviction
| Field | Value |
|---|---|
| ID | DF-2642 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N |
| CWE | CWE-754 / CWE-672 Improper Error Handling |
| File | sys/vfs/hammer2/hammer2_strategy.c |
| Lines | 1298-1302 (failure origin chain.c:3542) |
| 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
For an all-zero 64KB write (both compression paths funnel here when
check_algo != NONE, strategy.c:929-933/1207-1217), the overwrite is
implemented as a chain deletion whose return value is discarded
(strategy.c:1299-1300). When the deletion fails β at the ENOSPC wall the
parent COW in _hammer2_chain_delete_helper (chain.c:3542
hammer2_chain_modify β freemap ENOSPC) fails BEFORE any topology change β
*errorp stays 0, the xop feeds success, and the completion branch
(strategy.c:670-674) biodones the buffer with b_error=0: write(2)
returns 65536. The intact chain keeps serving the old block once the
clean zero-filled logical buffer is recycled. This silently defeats
overwrite-with-zeros (secure-delete scrubbing).
Threat model & preconditions
Any local user on a hammer2 PFS driven to the allocation wall (fills are
trivially unprivileged; the mmap-drain path bypasses the vop_write
ENOSPC pre-gate at vnops.c:854). Default config. Distinct from DF-2633:
no new allocation involved, and DF-2633's ip->error fix never sees this
error because zero_write swallows it before the feed.
Proof of concept
VERIFIED on the stock INVARIANTS guest (findings/poc/DF-2642/):
64MB hammer2 image, 64 pattern victims, fill to β€8MB free, mmap-drain
child exhausts the freemap while pmp->free_nomimal stays cached high,
round-robin zero-pwrite rides the stale-cache window. Stock result
(run1): OV_OK i=43..63 fsync=-1 errno=32 free=4194304 (write() success
at the wall); stage5 cached readback old=0 zeros=64 (the lie); stage7
post-churn old=1 zeros=57 mixed=6 β v54 block1 hexdump
44 46 32 36 34 32 2d 56 35 34 2d 42 31 2d 4f 4c 44 = "DF2642-V54-B1-OLD-".
In-window silent acceptance 4/4 runs; stale-read manifestation racy.
Recommended fix
--- a/sys/vfs/hammer2/hammer2_strategy.c
+++ b/sys/vfs/hammer2/hammer2_strategy.c
@@ -1295,10 +1295,24 @@
++hammer2_iod_file_wembed;
}
} else {
- /* chain->error ok for deletion */
- hammer2_chain_delete(*parentp, chain,
- mtid, HAMMER2_DELETE_PERMANENT);
- ++hammer2_iod_file_wzero;
+ /*
+ * DF-2642: propagate chain_delete() failure.
+ * A failed deletion leaves the old data chain
+ * intact; with the error dropped the strategy
+ * reports success and reads later resurrect
+ * pre-overwrite content.
+ */
+ *errorp |= hammer2_chain_delete(*parentp, chain,
+ mtid,
+ HAMMER2_DELETE_PERMANENT);
+ if (*errorp == 0)
+ ++hammer2_iod_file_wzero;
}
Validated build (kernel #1 boots, no regression); live A/B inconclusive β the triggering event is racy (see VERDICT.md Β§4/Β§5).
References
- DF-2633 (the sibling swallowed-ENOSPC class), DF-2628, DF-2638
Timeline
- 2026-08-29 Discovered during pass-2 audit of hammer2_strategy.c (GLM 5.3); reproduced live same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2642 Β· 19 files| File | Type | Description | Size | |
|---|---|---|---|---|
| zero2642.c | β | 14.7 KB | view raw | |
| run.sh | β | 3.2 KB | view raw | |
| walk2642.py | β | 5.7 KB | view raw | |
| fix.diff | β | 1.1 KB | view raw | |
| build.log | β | 103 B | view raw | |
| env.txt | β | 402 B | view raw | |
| run1/ | β | 4.0 KB | β download | |
| run2/ | β | 4.0 KB | β download | |
| run3/ | β | 4.0 KB | β download | |
| run4/ | β | 4.0 KB | β download | |
| fixrun/ | β | 4.0 KB | β download | |
| fixrun2/ | β | 4.0 KB | β download | |
| fixrun3/ | β | 4.0 KB | β download | |
| fixrun4/ | β | 4.0 KB | β download | |
| fixrun5/ | β | 4.0 KB | β download | |
| fix_build.log | β | 374 B | view raw | |
| README.md | β | 5.2 KB | β raw | |
| VERDICT.md | β | 6.3 KB | β raw | |
| verdict.json | β | 6.1 KB | view raw |
DF-2642 β zero_write() drops hammer2_chain_delete() failure: zero-block overwrite silently succeeds while the old data chain survives (stale-data resurrection)
What
zero_write() (sys/vfs/hammer2/hammer2_strategy.c:1298-1302) ignores the
return value of hammer2_chain_delete(). When the deletion fails β
reproducibly at the ENOSPC wall of a full PFS, where the parent COW
allocation in _hammer2_chain_delete_helper (hammer2_chain.c:3542,
hammer2_chain_modify(parent, ...)) fails β the error is dropped,
*errorp stays 0, the strategy write completes the bio with
bp->b_error = 0, and write(2) of an all-zero 64KB block reports
full success.
The old DATA chain remains intact in the topology. The zero-filled logical buffer sits clean in the buffer cache, so immediate re-reads show zeros and the application believes the overwrite happened. Once that clean buffer is recycled (memory pressure / cache churn β observed naturally at the wall), reads go back through the chain and return the PRE-OVERWRITE content. No error is ever reported for the reads; the same offset was observed oscillating between zeros and the old pattern across successive reads.
This is the "secure overwrite" use-case (dd if=/dev/zero of=file
conv=notrunc, scrub-before-delete, crypto wiping) silently not
happening.
Distinct from DF-2633 (backend allocation failure for new data blocks + inode inserts): here nothing new is allocated β an existing chain deletion fails and the old content stays live. DF-2633's fix (inode error state fed by the strategy-write failure branch) does NOT cover this site: the error never reaches that branch, because zero_write swallows it before the feed.
Reproduce (stock INVARIANTS kernel #0)
# on the DragonFly guest, as root cc -O2 -o zero2642 zero2642.c sh run2642-in-guest # or drive stages per run.sh
Host-driven (what run.sh does, stage by stage):
- 64MB vn-over-tmpfs image, newfs_hammer2, mount at /mnt/h42.
./zero2642 setup /mnt/h42 64β 64 victim files, each 128KB: block0+block1 filled withDF2642-Vnn-Bx-OLD-...patterns, fsync'd. ./zero2642 fill /mnt/h42 90000β fast incompressible fill until free <= 8MB (3 polls)../zero2642 overwrite /mnt/h42 64β v0 as healthy control, then waits for free <= 12MB, forks an mmap drain child (mmap-dirtiesfree+8MBof drain.bin β mmap putpages bypass thevop_writefrontend ENOSPC gate, so the freemap drains to true exhaustion whilepmp->free_nominalis still cached high), then round-robin hammers zero-pwrites over all remaining victims every ~3ms.- Readback (cached view),
churnroot /usr/src(root-fs reads cycle the buffer cache without touching the wedged mount), hog, readback (decisive).
Expected (stock) β observed in run1 (findings/poc/DF-2642/run1/)
OV_OK i=43..63 phase=W rot=0 fsync=-1 errno=32 free=4194304: pwrite() returned 65536 (success) for 21 victims at the wall (fsync returns -1 with the raw HAMMER2 NOSPC code 32 leaking as an errno β itself an unmapped-error bug).- stage5 (cached readback):
READBACK_DONE old=0 zeros=64β the lie is total. - stage7 (after churnroot):
READBACK_DONE old=1 zeros=57 mixed=6β v43/46/47/51/53/54/58 return pre-overwrite content. Hexdumps: v54 block1 =44 46 32 36 34 32 2d 56 35 34 2d 42 31 2d 4f 4c 44...("DF2642-V54-B1-OLD-", pure OLD); v43 = zeros prefix + pattern tail (partial page recycling). - The state OSCILLATES: minutes later the same read returned zeros (stage9), and a subsequent direct read returned the OLD pattern again β successive reads of one offset return different content with no error.
- Console:
xop_strategy_write: error 32 loff=0000000031c0000...storm from the drain buffers (backend NOSPC active at the same moment), plushammer2_chain_create_indirect: error 00000020 No Space on Deviceandhammer2: backend unable to insert inode .... - Controls: v0-v42 (overwritten while the backend was healthy) never flip β zeros before and after churn.
Reproducibility: in-window silent acceptance (OV_OK + fsync -1/32) occurred in 4/4 full runs; the post-eviction stale read manifested in run1 (7/21 in-window victims) β the delete failure depends on the freemap's radix availability at the instant of the parent COW, which is racy. The mechanism is source-proven either way.
Fix validation (kernel #2 + fix.diff)
With *errorp |= hammer2_chain_delete(...) propagated, the strategy
completion error branch runs for failed zero-deletes: the logical
buffer is completed with B_ERROR/EIO and the console gains an explicit
xop_strategy_write: error 32 line for the zero block. Criterion: on
the fixed kernel the stage5 cached readback reports err>0 (EIO) for
failed-delete victims instead of clean zeros (see fix_validation.log).
Files
zero2642.c multi-stage PoC (setup/fill/overwrite/churn/hog/readback) run.sh host-side stage driver (vm.sh run_root per stage) walk2642.py offline media topology walker (informational) run1/β¦run4/ per-attempt stage logs + console captures fix.diff git-applyable one-hunk fix VERDICT.md full narrative verdict manifest.json / verdict.json
DF-2642 β VERDICT
Finding: zero_write() in sys/vfs/hammer2/hammer2_strategy.c
ignores the return value of hammer2_chain_delete(). When the deletion
fails (parent COW allocation failure β ENOSPC wall), the all-zero block
overwrite is reported to userland as a complete success
(write(2) returns 65536, buffer completed with b_error=0), the old
data chain survives in the live topology, and reads resurrect the
pre-overwrite content once the clean zero-filled logical buffer is
recycled.
Classification: REPRODUCED (silent non-durable overwrite + stale-data resurrection + read oscillation on the live mount), fix FIXED.
1. Where the error is lost (source trace)
- All-zero 64KB writes funnel to
zero_write()(hammer2_strategy.c:1271-1309) from both compression paths (hammer2_zero_check_and_write:1217,hammer2_compress_and_write:931) whenevercheck_algo != HAMMER2_CHECK_NONE(default XXHASH64) andtest_block_zeros()passes. - For a real (non-hole) DATA chain the entire overwrite is implemented
as a deletion: strategy.c:1299-1300
hammer2_chain_delete(*parentp, chain, mtid, HAMMER2_DELETE_PERMANENT);β return value discarded,*errorpuntouched,++hammer2_iod_file_wzerocounts a success regardless. hammer2_chain_deleteβ_hammer2_chain_delete_helper(hammer2_chain.c:3542) fails athammer2_chain_modify(parent, ...)when the freemap cannot allocate the parent COW (hammer2_freemap_try_allocβHAMMER2_ERROR_ENOSPC, hammer2_freemap.c:358/396). On a full PFS this is exactly the failure mode quantified by DF-2633.- With
*errorp == 0the xop feeds error 0 (strategy.c:632), collect returns success/ENOENT, and the completion branch (strategy.c:670-674) doesbp->b_resid = 0; bp->b_error = 0; biodone(bio)β the overwrite is now a clean, cached, "successful" buffer. - The still-intact chain keeps the old
data_off; after the clean buffer is recycled, the read path (hammer2_xop_strategy_readβhammer2_strategy_read_completion, COMP_NONEbcopy(data, bp->b_data, focus->bytes)at :487) serves the old block. No error is ever attached to any subsequent operation.
Why DF-2633's fix does not cover it: their deferred ip->error is fed
from the strategy-write completion failure branch (strategy.c:670-681)
and the inode-insert paths β the zero_write site never produces an
error to feed it with.
2. Observed (stock kernel #0, INVARIANTS, run1)
64MB vn-over-tmpfs hammer2, 64 pattern victims, fill to the wall,
mmap-drain (putpages bypass the vop_write ENOSPC gate at
hammer2_vnops.c:854 β that gate exists only in the write(2) path), then
round-robin zero-pwrite hammer:
- In-window acceptance, 4/4 runs:
OV_OK i=43..63 phase=W rot=0 fsync=-1 errno=32 free=4194304β pwrite() returns 65536 at the moment the backend is failing everything (console stormxop_strategy_write: error 32 loff=0000000031c0000...,hammer2_chain_create_indirect: error 00000020,backend unable to insert inode). fsync(2) leaks the raw HAMMER2 error code as errno 32 (EPIPE) β a separate mapping bug noted for the record. - The lie: stage5 cached readback
old=0 zeros=64 mixed=0 err=0β every victim reads zeros; the overwrite "happened". - The resurrection: after
churnroot(740MB of root-fs reads to cycle the buffer cache; the wedged mount itself is not touched), stage7 readbackold=1 zeros=57 mixed=6 err=0. v54 block1 hexdump (live mount):44 46 32 36 34 32 2d 56 35 34 2d 42 31 2d 4f 4c 44= "DF2642-V54-B1-OLD-" β the pre-overwrite pattern, byte for byte. v43: 53KB zeros + 11KB pattern (partial page recycling). - Oscillation: the same offset subsequently read zeros again (stage9) and then the OLD pattern again (direct od minutes later) β repeated reads of one offset return different content, no error.
- Controls: v0-v42 (overwritten while backend healthy) stayed zeros through every churn β the churn does not fabricate staleness.
- Manifestation is racy (run1: 7/21 in-window victims flipped; runs 2-4: 0 flipped β delete COWs found small-radix freemap space): the delete failure depends on radix availability at the instant of the parent COW. The 4/4 constant is the silent in-window acceptance itself; run1 demonstrates the full stale-read consequence.
Offline media note: at the wall the flusher cannot rotate the volume
header (DF-2633's post-crash observation), so walk2642.py finds the
on-media topology pre-dating the victims; the decisive channel is the
live-mount read after cache eviction (the chain cache + data-block
buffers hold the pattern β exactly what the read path serves).
3. Exploit chain
Not a memory-corruption bug; the deliverable is integrity: an unprivileged-pattern local workload on a full hammer2 PFS silently fails to overwrite data with zeros and later serves the old content. "Overwrite with zeros" is the canonical secure-delete step β data an application believed erased comes back. Impact ceiling: silent data integrity violation + unkillable read inconsistency; no privesc.
4. Fix (fix.diff, validated)
- hammer2_chain_delete(*parentp, chain,
- mtid, HAMMER2_DELETE_PERMANENT);
+ *errorp |= hammer2_chain_delete(*parentp, chain,
+ mtid,
+ HAMMER2_DELETE_PERMANENT);
+ if (*errorp == 0)
+ ++hammer2_iod_file_wzero;
With the error propagated, the strategy completion failure branch
(strategy.c:675-681) runs for failed zero-deletes: the buffer is
completed with B_ERROR/EIO, the console gains an explicit error line
for the zero block, subsequent reads of the lbase return EIO, and the
buffer-daemon retry keeps trying the write. Baseline vs patched
transcripts: fix_validation.log (kernel #2). Recommended follow-up
(not in this minimal fix): map the completion error through
hammer2_error_to_errno() for bp->b_error, and fix the raw-code-32
fsync leak.
5. Honest limits
- The stale-read manifestation frequency is racy (freemap radix availability); the silent-acceptance half is 4/4 deterministic.
- The oscillation dynamics (chain-cache vs buffer-cache vs flusher at the wall) were observed but not fully reduced to a single ordering β both orderings were seen live.
- Guest wedges at the wall (umount EBUSY, sync hangs) are pre-existing DF-2633 behavior, used as-is; every attempt ended with a crash-reboot without snapshot revert.
Fix verification
inconclusivefix.diff (propagate chain_delete error via *errorp |=, counter gated on success) builds and boots clean (kernel #1, no regression in write-path smoke); live A/B inconclusive: the silent-acceptance window is racy (freemap radix availability at the parent-COW instant) and did not recur in 5 patched runs, so no failure event was available to observe being fixed.
fix_build.log / fix_run.log / VERDICT.md sections 4-5
Confirmed kernel references
Detail
Exploit chain
local unprivileged-capable workload on any hammer2 PFS driven to the ENOSPC wall: (1) victim file with recognizable pattern, synced; (2) fill PFS to reserve wall; (3) mmap-drain (putpages bypass the vop_write ENOSPC gate) to exhaust the freemap while pmp->free_nominal is cached high; (4) zero-pwrite hammer rides the stale-cache window: write() returns success, zero_write's chain_delete fails silently; (5) clean zero buffer evicted under churn -> reads resurrect pre-overwrite content, oscillating, error-free.
Evidence (decisive lines)
findings/poc/DF-2642/run1/stage3_ov.log β OV_OK i=43..63 phase=W fsync=-1 errno=32 free=4194304 (write() success at the wall)
findings/poc/DF-2642/run1/stage5_rb_cached.log β READBACK_DONE old=0 zeros=64 (the lie: all zeros from cache)
findings/poc/DF-2642/run1/stage7_rb_final.log β READBACK_DONE old=1 zeros=57 mixed=6 (post-churn resurrection)
findings/poc/DF-2642/run1/console.txt + serial log β xop_strategy_write: error 32 storm + hammer2_chain_create_indirect: No Space + backend unable to insert inode (backend NOSPC concurrent with the 'successful' zero writes)
live od hexdumps (VERDICT.md section 2): v54 block1 = 44 46 32 36 34 32 2d 56 35 34 2d 42 31 2d 4f 4c 44 ('DF2642-V54-B1-OLD-'); v43 mixed zeros/pattern; later oscillation zeros<->pattern
sys/vfs/hammer2/hammer2_strategy.c:1298-1302 β hammer2_chain_delete() return discarded
sys/vfs/hammer2/hammer2_chain.c:3542 β _hammer2_chain_delete_helper fails at hammer2_chain_modify(parent) under ENOSPC before any topology change
PoC changes
Evolved through 9 host-driven attempts: (1) df-based gate stalled at the wall β replaced with in-C statfs gating; (2) fixed pacing consumed victims above the failure band β replaced with descending ladder; (3) fast-fill window too narrow (filler exit-on-ENOSPC killed pressure) β added filler retries; (4) KEY: 64MB image shrinks free_reserved so the stale-cache crossing gap is small enough to hit; (5) KEY: mmap-drain child β mmap page dirtying bypasses the vop_write frontend ENOSPC gate (hammer2_vnops.c:854) so the freemap drains to true exhaustion while the cached free_nominal still admits victim pwrites; (6) root-fs churnroot for eviction (reading the wedged mount hangs).
Verified recommended fix
Propagate the deletion failure: errorp |= hammer2_chain_delete(parentp, chain, mtid, HAMMER2_DELETE_PERMANENT); count wzero only on success (fix.diff).
Verdict
zero_write() drops the hammer2_chain_delete() return value (strategy.c:1299-1300). On a full PFS (ENOSPC wall) an all-zero 64KB block overwrite returns write() success (65536 bytes; buffer completed b_error=0) while the backend chain deletion fails, leaving the old data chain live in the topology. Demonstrated end-to-end on the stock INVARIANTS guest: 21 victims overwritten 'successfully' at the wall (fsync leaking raw errno 32 = HAMMER2 NOSPC); cached readback showed all zeros; after buffer-cache churn 7 victims (v43,46,47,51,53,54,58) read back the pre-overwrite pattern (v54 hexdump: 'DF2642-V54-B1-OLD-' byte-for-byte; v43 zeros-prefix+pattern-tail); the same offset later oscillated zeros<->pattern across successive reads with no error ever reported. Healthy-phase controls (v0-v42) never flip. In-window silent acceptance occurred 4/4 stock runs; the stale-read manifestation is racy (1 fully-manifesting run of 4 stock attempts; depends on freemap radix availability at the parent-COW instant). Integrity-only (silent un-erase / broken secure-overwrite), not memory corruption: impact class none for CIA escalation purposes beyond C/I of file data.
No comments yet.