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

hammer2 write() silently succeeds while backend allocation fails on full PFS (ENOSPC never reaches the writer)

Field Value
ID DF-2633
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-672 Improper Error Handling / CWE-754 Improper Check
File sys/vfs/hammer2/hammer2_strategy.c
Lines write path (backend allocation), see also hammer2_inode.c:1324-1326
Area vfs
Confidence likely
Discovered 2026-08-28
Pass 2 (GLM 5.3 second pass β€” surfaced during DF-2628 PoC verification)
Bucket hammer2
Reported pending
Known CVE none
CVE match novel

Summary

On an ENOSPC-exhausted hammer2 PFS, write(2) continues to return success to userspace while the kernel console floods with hammer2: backend unable to insert inode ...: No Space β€” allocation failures are absorbed asynchronously by the xop backend and never propagated to the writing syscall. Observed live during DF-2628 verification: a dd-loop "succeeded" 33652 times with the volume showing 4MB free while every backend insert failed (findings/poc/DF-2628/ attempts_enospace_race.log). Data the application believes durable is silently dropped β€” a data-integrity failure far worse than a clean ENOSPC.

Root cause

hammer2's write path completes the UIO copy and returns success; actual media allocation (blockref insertion, inode flush) happens later in the xop backend threads (hammer2_inode.c:1324-1326 ENOSPC path, strategy backend). When the backend fails there is no error-propagation channel back to the file's error state that write/fsync consults β€” the failure is logged (kprintf flood) and dropped. (fsync behavior to be confirmed during verification: if fsync also returns 0, POSIX durability is fully broken; if fsync reports EIO/ENOSPC, the bug is bounded to unsynced writes.)

Threat model & preconditions

  • Attacker position: any local user (or the system itself) filling a hammer2 filesystem past its allocation reserve β€” including legitimate workloads (builds, logs, databases) on the default root filesystem.
  • Impact: silent data loss presented as successful writes; the kprintf flood additionally degrades console/serial performance. No memory-safety impact.
  • Reachability: default configuration, no special privileges.

Proof of concept

fill hammer2 PFS to reserve with dd loop β†’ writes keep "succeeding"
watch console: hammer2: backend unable to insert inode ...: No Space

Transcripts: findings/poc/DF-2628/attempts_enospace_race.log.

Impact

Silent write loss on full hammer2 volumes (integrity), console spam; misleads backups, databases, and package builds that trust write(2) error reporting.

Propagate backend allocation failures into the inode/buffer error state so subsequent write/fsync return ENOSPC/EIO (mirror of the UFS softdep error-reporting discipline); rate-limit the kprintf.

References

Timeline

  • 2026-08-28 Discovered during DF-2628 PoC verification (pass 2, GLM 5.3).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2633 Β· 26 files
FileTypeDescriptionSize
README.md β€” 2.9 KB ↓ raw
VERDICT.md β€” 6.9 KB ↓ raw
write2633.c β€” 7.6 KB view raw
phase2.c β€” 4.7 KB view raw
verify2633.c β€” 2.2 KB view raw
run_df2633.sh β€” 1.6 KB view raw
run_planD.sh β€” 953 B view raw
validate_fix.sh β€” 2.2 KB view raw
build.sh β€” 143 B view raw
run.sh β€” 698 B view raw
stock_run.attempt1.log β€” 3.2 KB view raw
planD.log β€” 4.0 KB view raw
phase2_run2.log β€” 428 B view raw
storm_measurements.txt β€” 1.9 KB view raw
umount_hang.attempt1.log β€” 124 B view raw
postcrash_verify.log β€” 190 B view raw
control_roundtrip.log β€” 246 B view raw
control_healthy.log β€” 646 B view raw
fix_run.log β€” 4.4 KB view raw
fix_run_roundtrip.log β€” 379 B view raw
build.log β€” 285 B view raw
fix_build.log β€” 322 B view raw
env.txt β€” 1.0 KB view raw
fix.diff β€” 10.8 KB view raw
verdict.json β€” 5.6 KB view raw
manifest.json β€” 1.5 KB view raw

DF-2633 β€” hammer2 write() silently succeeds while backend allocation fails on full PFS

ENOSPC never reaches the writer: write(2) keeps returning success (and fsync(2) returns 0) while the backend flusher fails to allocate, floods the console, wedges the mount (EBUSY forever), and the "successfully written" data is silently lost.

Reproduce (stock kernel)

cc -O2 -o write2633 write2633.c
cc -O2 -o phase2 phase2.c
truncate -s 256M /tmp/h33.img
vnconfig -c vn1 /tmp/h33.img
newfs_hammer2 -L DATA /dev/vn1
mkdir -p /mnt/h33 && mount_hammer2 /dev/vn1@DATA /mnt/h33
mkdir -p ctrl
./write2633 /mnt/h33 ctrl 6000        # fill to the wall (~5 min)
./phase2 /mnt/h33 ctrl                # probe the wall state
# console: unbounded "xop_strategy_write: error 32 loff=..." flood
#         (325891+ lines, 213/s, never stops β€” see storm_measurements.txt)
# umount:  EBUSY forever (umount_hang.attempt1.log)

Observed (stock, planD.log / stock_run.attempt1.log):

  • fill reaches the wall: write() ENOSPC at block 3836-3841 β€” but the last ~8-9 MB of write()-SUCCESSFUL 64KB buffers fail asynchronously: 134-137 distinct loffs stuck failing xop_strategy_write: error 32 (HAMMER2 NOSPC) forever.
  • FILL_FSYNC rc=0 errno=0 β€” fsync(2) returns 0 while its own data is failing at the backend (POSIX violation), at 325k+ console errors.
  • creates in the wall: 500/500 ENOSPC (pre-check catches them here).
  • umount: Resource temporarily unavailable forever; sync hangs; the only escape from the wedged mount is a crash.
  • crash + remount (media snapshot md5-verified while mounted+storming): the filesystem recovers COMPLETELY EMPTY β€” 251,658,240 bytes that write() returned success for and fsync() returned 0 for: 100% silent loss (postcrash_verify.log). Control: healthy fs + sync + clean umount/remount roundtrips md5-identical (control_roundtrip.log).

Fix validation (kernel #2 + fix.diff)

validate_fix.sh (+ fix_validation.log, fix_validation_roundtrip.log):

  • at the wall: FILL_FSYNC rc=-1 errno=28, APPEND_FSYNC rc=-1 errno=28, subsequent writes AND fsyncs all -1 ENOSPC (RETRY_* lines) β€” the deferred error now surfaces through write(2)/fsync(2);
  • console: rate-limited to exactly 1 line/s (stock: 213+/s unbounded);
  • healthy image: dd 1.1GB/s, md5 roundtrip MATCH, umount clean.

Files

write2633.c / phase2.c / verify2633.c      harness
run_df2633.sh / run_planD.sh / validate_fix.sh
stock_run.attempt1.log / planD.log / phase2_run2.log
storm_measurements.txt     (console-storm + loss quantification)
umount_hang.attempt1.log   (EBUSY-forever transcript)
postcrash_verify.log       (recovered fs completely empty)
control_roundtrip.log / control_healthy.log
fix_validation.log / fix_validation_roundtrip.log
fix.diff (shared DF-2632+DF-2633), VERDICT.md, verdict.json, manifest.json
VERDICT.md
↓ download raw

DF-2633 β€” VERDICT

Finding: hammer2 write(2) silently succeeds while backend allocation fails on a full PFS β€” ENOSPC never reaches the writer; fsync(2) returns 0 over failing data; the loss is silent and (in the observed regime) total.

Classification: REPRODUCED (silent data loss + fsync lies + console flood + permanent mount wedge), fix FIXED (errors surface, flood rate-limited; mount-wedge/livelock residual documented).

1. Where the error is lost (source trace)

  • write(2) β†’ hammer2_vop_write (hammer2_vnops.c:830) β†’ hammer2_write_file (:999-1213) writes into the logical buffer cache (getblk/uiomovebp/cluster_write) and returns success. The only space check is the frontend pre-check hammer2_vfs_enospace(ip, uio_resid, cred) (:854-864) whose free_nominal is cached per tick (10 ms, hammer2_vfsops.c:3086-3107: pmp->free_ticks != ticks) β€” so a fast writer out-runs the cache and buffers are accepted after the allocator is actually empty.
  • The real allocation happens later, asynchronously: hammer2_strategy_write (hammer2_strategy.c:537-565) β†’ xop β†’ hammer2_write_file_core (:624-626) β†’ hammer2_assign_physical (:717+) β†’ hammer2_chain_create β†’ freemap. On a full PFS this fails with HAMMER2 NOSPC (0x20); the error handler (strategy.c:670-681) does: unbounded kprintf per failing buffer (the 213/s console flood), bp->b_error = EIO, biodone β€” and nothing else. write(2) already returned; no inode-level error state exists to carry the failure to the next syscall.
  • fsync(2) β†’ hammer2_vop_fsync (hammer2_vnops.c:216-276): vfsync(...) return value is ignored (:240), bio_track_wait waits for I/O completion (errors included) but discards them; hammer2_inode_chain_sync/chain_flush only see chain-level errors, never buffer-level ones β†’ fsync returns 0 while its own buffers failed (measured: FILL_FSYNC rc=0 at 325,891 console errors).
  • File-creation flavor: the syncer inserts new inodes via hammer2_inode_chain_ins (hammer2_inode.c:1743-1767) whose error path is kprintf(...); /* XXX return error somehow? */ (:1760-1763), called from the syncer with the return ignored (hammer2_vfsops.c:2685) β€” files whose inode never made it to media appear created (dirent live) and vanish on remount (DF-2628's dd observations).

2. Measured stock behavior (this run)

Single-filler 256MB hammer2 (vn over tmpfs β€” reproduces the fast-backend conditions; vn-over-hammer2-root behaves slower and the pre-check catches the wall first, see Β§4):

  • write() ENOSPC at block 3836-3841 (251.4-251.7MB); free floor 5568KB.
  • 8.7-8.9 MB of write()-successful 64KB buffers permanently failed at the backend: 134-137 distinct logical offsets stuck in xop_strategy_write: error 32 retry forever (storm_measurements.txt).
  • FILL_FSYNC rc=0 errno=0 while the storm is active β€” POSIX violation.
  • Console flood: 325,891+ lines, growth 213 lines/s, never terminates; guest degrades (ps ax >120 s).
  • umount: EBUSY forever (dirty buffers can never flush) β€” the only escape from the wedged mount is a crash (umount_hang.attempt1.log).
  • Crash + recovery: media snapshot taken while mounted+storming (crash semantics, md5-verified) recovers as a completely empty filesystem β€” all 251,658,240 write()-successful bytes gone (postcrash_verify.log: df 0 used, no fill.bin). No error was ever delivered to the writer.
  • Controls: healthy fs + sync + clean umount/remount β†’ md5 MATCH (control_roundtrip.log); controls written to the root fs survived crashes (control_healthy.log).
  • Wall-state probes (phase2): creates 500/500 ENOSPC, append write ENOSPC, APPEND_FSYNC rc=0 (again the lie), tail.bin/mm.bin O_CREAT ENOSPC. Note: on DragonFly the same O_CREAT failure can still leave the file present (observed once in the DF-2632 natural-name probe on a space-exhausted volume: create returns ENOSPC, file exists afterward) β€” consistent with the error-handling gaps traced above.

3. Fix (fix.diff, validated)

Minimal correct channel modeled on UFS softdep-style deferred errors:

  • struct hammer2_inode gains an atomic int error (errno) field (hammer2.h).
  • Producers:
  • hammer2_xop_strategy_write failure branch (strategy.c:670-681): atomic_swap_int(&ip->error, hammer2_error_to_errno(error)), bp->b_error now the mapped errno (ENOSPC, was EIO), and the console print becomes krateprintf (1/s).
  • hammer2_inode_chain_ins error path (inode.c:1760-1763): records ip->error, rate-limited print (was the unbounded "backend unable to insert inode" flood).
  • hammer2_inode_chain_sync error path (inode.c:1725-1733): same.
  • Consumers:
  • hammer2_vop_write (vnops.c:830+): at entry (user I/O only; the VM pager's UIO_NOCOPY writes are excluded) reports and clears the deferred error β€” the next write(2) after an async failure returns ENOSPC instead of silently continuing.
  • hammer2_vop_fsync (vnops.c:216-276): after vfsync + chain flushes, reports and clears ip->error β€” fsync(2) now returns ENOSPC for failed backend writes.

4. Fix validation (kernel #2 = stock config + fix.diff)

A/B, same 256MB vn-over-tmpfs fill:

probe stock #0 fixed #2
FILL fsync at/after the wall rc=0 errno=0 (during 325k+ error storm) rc=-1 errno=28 (No space left on device)
append write at the wall ENOSPC (pre-check) + APPEND_FSYNC rc=0 ENOSPC + APPEND_FSYNC rc=-1 errno=28
subsequent writes/fsyncs (RETRY loop) (not applicable β€” errors invisible) all -1 ENOSPC, consistently
console flood 213+/s unbounded, 325k+ lines exactly 1 line/s (krate)
healthy fs dd+md5 roundtrip ok ok (match, 1.1GB/s dd, clean umount)

fix_validation.log, fix_validation_roundtrip.log.

5. Residual / honest limits

  • The dirty-buffer retry loop itself is stock behavior and remains: in the exhausted state the failed buffers keep being retried (now silently except 1/s), the mount stays busy, and the guest is heavily loaded β€” a soft livelock until space is freed or the fs is unmounted by force. My fix does not change the retry design (out of minimal-fix scope); it guarantees the writer is TOLD (write/fsync ENOSPC) and the console no longer floods. fix_status: fixed for the reported finding (error surfacing + flood); the livelock is noted as residual.
  • The pre-check's per-tick staleness (vfsops.c:3086-3107) still lets a fast writer accept a final buffer batch; those now surface as ENOSPC on the next write/fsync instead of being lost silently β€” the POSIX contract is restored.
  • 100%-empty recovery after crash: dominated by the retry-wedge (no transaction could ever commit). With errors now reported, a writer that checks its return codes stops feeding the wedge; the crash-loss ceiling collapses to the accepted-but-unreported window, which the fix reports.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

A/B on identical KERNCONF: stock #0 - FILL_FSYNC rc=0 during a 325891-line error storm and 100% silent loss after the forced crash; fixed #2 - FILL_FSYNC rc=-1 errno=28, APPEND_FSYNC rc=-1 errno=28, all retry writes/fsyncs -1 ENOSPC, console rate-limited to 1 line/s, healthy-image writes + md5 roundtrip unaffected. The silent-success behavior is GONE; errors surface at the latest at the next write/fsync.

fix_validation.log (fill + phase2 + RETRY probes), fix_run_roundtrip.log, fix_run.log, fix_build.log
↓ fix.diffDragonFly 6.5-DEVELOPMENT #2: Sat Aug 29 08:00:46 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Evidence (decisive lines)

['stock_run.attempt1.log - write() ENOSPC at the wall but FILL_FSYNC rc=0 errno=0 while the storm rages', 'storm_measurements.txt - 325891+ console lines, 213/s, 134 distinct failing offsets (8.7MB of accepted-then-lost writes), 30s growth measurement', 'umount_hang.attempt1.log - EBUSY forever; only escape is a crash', 'planD.log + postcrash_verify.log - md5-verified crash-state image recovers COMPLETELY EMPTY: 251,658,240 write()-successful bytes lost', 'control_roundtrip.log - healthy fs + sync + clean umount/remount roundtrips md5-identical (methodology control)', 'fix_validation.log + fix_run_roundtrip.log - fixed kernel: fsync/write return ENOSPC at the wall; console 1 line/s; healthy roundtrip unaffected']

PoC changes

write2633.c/phase2.c built from scratch per the finding (per-write rc/errno counters, statfs polling, wall-regime probes: creates/append/tail/mmap + fsync rc at each stage, deterministic content + healthy-fs md5 controls). verify2633.c needed sys/stat.h added for fstat(2). Plan D added the crash-state media snapshot (dd of the tmpfs image while mounted+storming, md5-verified) because the tmpfs image cannot survive the crash otherwise.

Verified recommended fix

Add an atomic inode error state (int error on hammer2_inode) produced by the strategy-xop allocation-failure, inode-insert and inode-sync error paths (rate-limited kprintf) and consumed by hammer2_vop_write entry and hammer2_vop_fsync exit so write(2)/fsync(2) return ENOSPC/EIO for backend failures instead of silently losing data.

Verdict

Reproduced standalone on the stock kernel with a recording writer on a dedicated 256MB hammer2: (1) 8.7-8.9MB of write()-SUCCESSFUL 64KB buffers permanently fail backend allocation (134-137 distinct logical offsets stuck in an endless 'xop_strategy_write: error 32' retry storm, 213 console lines/s unbounded, 325891+ lines measured); (2) fsync(2) returns 0 while its own data is failing (FILL_FSYNC rc=0 errno=0 at peak storm) - POSIX violation, the errno channel is entirely absent (strategy.c:670-681 sets b_error=EIO and prints; hammer2_vop_fsync ignores the vfsync return at vnops.c:240); (3) the wedged mount cannot be unmounted (EBUSY forever) and the only escape is a crash; (4) after the crash the recovered filesystem is COMPLETELY EMPTY - all 251,658,240 write()-successful, fsync()==0 bytes silently lost (postcrash_verify.log); file-creation flavor drops the insert error on the floor (hammer2_inode.c:1760-1763 'XXX return error somehow?'). fix.diff adds an atomic inode error field produced by the strategy-xop/chain-ins/chain-sync failure paths (rate-limited prints) and consumed by write(2) entry (user I/O) and fsync(2) exit; validated on the identical-config rebuilt kernel: FILL_FSYNC rc=-1 errno=28, APPEND_FSYNC rc=-1 errno=28, all retry writes/fsyncs -1 ENOSPC, console rate-limited to exactly 1 line/s, healthy-image dd+md5 roundtrip unaffected.