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.
Recommended fix
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
- findings/poc/DF-2628/attempts_enospace_race.log (transcript)
- sys/vfs/hammer2/hammer2_inode.c:1324-1326, strategy backend paths
- DF-2628, DF-2632 (same verification run)
Timeline
- 2026-08-28 Discovered during DF-2628 PoC verification (pass 2, GLM 5.3).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2633 Β· 26 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 unavailableforever;synchangs; 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
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-checkhammer2_vfs_enospace(ip, uio_resid, cred)(:854-864) whosefree_nominalis 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: unboundedkprintfper 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_waitwaits for I/O completion (errors included) but discards them;hammer2_inode_chain_sync/chain_flushonly see chain-level errors, never buffer-level ones β fsync returns 0 while its own buffers failed (measured:FILL_FSYNC rc=0at 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 iskprintf(...); /* 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 32retry forever (storm_measurements.txt). FILL_FSYNC rc=0 errno=0while 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:
df0 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_inodegains an atomicint error(errno) field (hammer2.h).- Producers:
hammer2_xop_strategy_writefailure branch (strategy.c:670-681):atomic_swap_int(&ip->error, hammer2_error_to_errno(error)),bp->b_errornow the mapped errno (ENOSPC, was EIO), and the console print becomeskrateprintf(1/s).hammer2_inode_chain_inserror path (inode.c:1760-1763): recordsip->error, rate-limited print (was the unbounded "backend unable to insert inode" flood).hammer2_inode_chain_syncerror 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 clearsip->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
fixedA/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
Confirmed kernel references
- sys/vfs/hammer2/hammer2_vnops.c:854-864
- sys/vfs/hammer2/hammer2_vnops.c:999-1213
- sys/vfs/hammer2/hammer2_vnops.c:216-276
- sys/vfs/hammer2/hammer2_vnops.c:240
- sys/vfs/hammer2/hammer2_strategy.c:537-565
- sys/vfs/hammer2/hammer2_strategy.c:624-631
- sys/vfs/hammer2/hammer2_strategy.c:670-681
- sys/vfs/hammer2/hammer2_inode.c:1743-1767
- sys/vfs/hammer2/hammer2_inode.c:1760-1763
- sys/vfs/hammer2/hammer2_vfsops.c:2685
- sys/vfs/hammer2/hammer2_vfsops.c:3076-3127
- sys/vfs/hammer2/hammer2_strategy.c:1310-1356
- sys/vfs/hammer2/hammer2_inode.c:1324-1326
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.
No comments yet.