DF-2642 / verdict.json
{ "finding_id": "DF-2642", "status": "reproduced", "reproduced": 1, "impact": "none", "confidence": "certain", "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.", "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": "findings/poc/DF-2642/run1/stage3_ov.log \u2014 OV_OK i=43..63 phase=W fsync=-1 errno=32 free=4194304 (write() success at the wall)\nfindings/poc/DF-2642/run1/stage5_rb_cached.log \u2014 READBACK_DONE old=0 zeros=64 (the lie: all zeros from cache)\nfindings/poc/DF-2642/run1/stage7_rb_final.log \u2014 READBACK_DONE old=1 zeros=57 mixed=6 (post-churn resurrection)\nfindings/poc/DF-2642/run1/console.txt + serial log \u2014 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)\nlive 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\nsys/vfs/hammer2/hammer2_strategy.c:1298-1302 \u2014 hammer2_chain_delete() return discarded\nsys/vfs/hammer2/hammer2_chain.c:3542 \u2014 _hammer2_chain_delete_helper fails at hammer2_chain_modify(parent) under ENOSPC before any topology change", "kernel_refs": [ "sys/vfs/hammer2/hammer2_strategy.c:1298", "sys/vfs/hammer2/hammer2_strategy.c:1299", "sys/vfs/hammer2/hammer2_strategy.c:1217", "sys/vfs/hammer2/hammer2_strategy.c:931", "sys/vfs/hammer2/hammer2_strategy.c:670", "sys/vfs/hammer2/hammer2_chain.c:3542", "sys/vfs/hammer2/hammer2_freemap.c:358" ], "poc_changes": "Evolved through 9 host-driven attempts: (1) df-based gate stalled at the wall \u2014 replaced with in-C statfs gating; (2) fixed pacing consumed victims above the failure band \u2014 replaced with descending ladder; (3) fast-fill window too narrow (filler exit-on-ENOSPC killed pressure) \u2014 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 \u2014 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).", "attempts": 9, "guest_uname": "DragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (stock baseline); fixed kernel #1: Sat Aug 29 16:32:55 UTC 2026", "runtime_sec": 5400, "guest_dirty": 0, "build_cmd": "cc -O2 -o zero2642 zero2642.c (guest)", "run_cmd": "sh findings/poc/DF-2642/run.sh (host; stages via dfbsd-qemu/vm.sh run_root)", "code_hash": "6b186c82b6b92ccd5030495768a86f63184bf4a6a78b3a9ac5fa97f795914870", "notes": "fsync(2) concurrently leaks the raw HAMMER2 error code as errno 32 (EPIPE) \u2014 separate error-mapping bug, noted for the record. Post-crash media state at the wall predates the victims (flusher cannot rotate the volhdr \u2014 DF-2633 class), so the decisive channel is the live-mount read after buffer-cache eviction; walk2642.py included for completeness. Distinct from DF-2633: no new allocation is involved; DF-2633's ip->error fix does not cover this site because zero_write swallows the error before the feed. Fix validation: patched kernel #1 built+booted, 5 re-runs showed no regression (healthy victims zeros, fill/overwrite flow normal) but the racy failed-delete event did not recur in any of them, so the live A/B is inconclusive (fix correct by construction: error now reaches the strategy completion branch which sets B_ERROR/EIO). Guest reset to clean-source snapshot (stock kernel #0) after the run; /usr/src unpatched, /mnt clean.", "recommended_fix": "Propagate the deletion failure: *errorp |= hammer2_chain_delete(*parentp, chain, mtid, HAMMER2_DELETE_PERMANENT); count wzero only on success (fix.diff).", "fix_status": "inconclusive", "fix_kernel_uname": "DragonFly 6.5-DEVELOPMENT #1 (in-guest nativekernel + DF-2642 fix.diff; see VERDICT.md)", "fix_baseline_reproduced": 1, "fix_patched_reproduced": 0, "fix_verdict": "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_evidence": "fix_build.log / fix_run.log / VERDICT.md sections 4-5" } |