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

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.

--- 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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2642 Β· 19 files
FileTypeDescriptionSize
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):

  1. 64MB vn-over-tmpfs image, newfs_hammer2, mount at /mnt/h42. ./zero2642 setup /mnt/h42 64 β€” 64 victim files, each 128KB: block0+block1 filled with DF2642-Vnn-Bx-OLD-... patterns, fsync'd.
  2. ./zero2642 fill /mnt/h42 90000 β€” fast incompressible fill until free <= 8MB (3 polls).
  3. ./zero2642 overwrite /mnt/h42 64 β€” v0 as healthy control, then waits for free <= 12MB, forks an mmap drain child (mmap-dirties free+8MB of drain.bin β€” mmap putpages bypass the vop_write frontend ENOSPC gate, so the freemap drains to true exhaustion while pmp->free_nominal is still cached high), then round-robin hammers zero-pwrites over all remaining victims every ~3ms.
  4. 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), plus hammer2_chain_create_indirect: error 00000020 No Space on Device and hammer2: 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
VERDICT.md
↓ download raw

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) whenever check_algo != HAMMER2_CHECK_NONE (default XXHASH64) and test_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, *errorp untouched, ++hammer2_iod_file_wzero counts a success regardless.
  • hammer2_chain_delete β†’ _hammer2_chain_delete_helper (hammer2_chain.c:3542) fails at hammer2_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 == 0 the xop feeds error 0 (strategy.c:632), collect returns success/ENOENT, and the completion branch (strategy.c:670-674) does bp->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_NONE bcopy(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 storm xop_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 readback old=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

inconclusive
baseline reproduced→ patch + rebuild →patched clean

fix.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
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1 (in-guest nativekernel + DF-2642 fix.diff; see VERDICT.md)

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.