hammer2_vop_nlink increments meta.nlinks even when hammer2_dirent_create fails β permanent link-count inflation and unfreeable inodes
| Field | Value |
|---|---|
| ID | DF-2628 |
| Status | new |
| Severity | Low |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:N |
| CWE | CWE-754 Improper Check for Unusual or Exceptional Conditions |
| File | sys/vfs/hammer2/hammer2_vnops.c |
| Lines | 1599-1606 |
| Area | vfs |
| Confidence | likely |
| Discovered | 2026-08-28 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | hammer2 |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
In hammer2_vop_nlink the nlinks increment is executed unconditionally
after hammer2_dirent_create(): error = hammer2_dirent_create(...);
hammer2_inode_modify(ip); ++ip->meta.nlinks; ip->meta.ctime = cmtime; β
even when dirent_create returned an error (ENOSPC from backend allocation or
collision exhaustion, EIO from backend xop failure). Every failing link(2)
permanently inflates the persistent link count by 1, so a file whose real
links are later removed never reaches nlinks==0 and its inode plus all data
blocks can never be freed. The sibling ops (ncreate/nmkdir/nmknod) correctly
dispose on dirent_create failure via hammer2_inode_unlink_finisher,
proving the intent.
Root cause
vnops.c:1599-1606: the if (error == 0) guard at :1604 only protects the
tdip mtime update and namecache finalization; the inode mutation at
:1601-1603 runs regardless. hammer2_dirent_create fails with ENOSPC when
the mkdirent backend xop hits HAMMER2_ERROR_ENOSPC (full filesystem) or when
the lhc collision space is exhausted (hammer2_inode.c:1324-1326), and with
EIO on backend errors β all persisted by the unconditional
hammer2_inode_modify(ip) at :1601. Since vop_nlink only pre-checks
enospc > 1 (:1563), a full-but-not-red filesystem still reaches the
failing create. hammer2_inode_unlink_finisher (hammer2_inode.c:1623-1628)
only zeroes nlinks when <= 1, so the inflated count blocks reclamation.
Threat model & preconditions
- Attacker position: unprivileged local user on a writable hammer2 filesystem.
- Privileges gained or impact: fill the PFS to capacity, then loop
link("existing","newname")β each fails (ENOSPC) but increments the target's persistent nlinks; afterwards the file cannot be fully unlinked: permanent disk-space leak and nlink mismatch. Multi-user systems can weaponize it to leak space attributed to other users' files. - Required config or capabilities: write permission on the directory.
- Reachability: link(2) on a full/collision-exhausted PFS.
Proof of concept
Build & run
fill /h2 with junk until write() returns ENOSPC; then
for (i=0;i<1000;i++) link("/h2/victim", "/h2/l%d", i); /* each -1 ENOSPC */
stat("/h2/victim").st_nlink /* now 1001 with no new entries */
Expected output
st_nlink inflated by the number of failed links; after rm of all real names st_nlink stays > 0 and the inode/blocks stay allocated (freemap occupancy before/after).
Impact
Persistent metadata integrity corruption + unbounded on-disk space leak; no memory-safety impact.
Recommended fix
Only account the link when the dirent was actually created:
--- a/sys/vfs/hammer2/hammer2_vnops.c
+++ b/sys/vfs/hammer2/hammer2_vnops.c
@@ -1599,11 +1599,12 @@ hammer2_vop_nlink(struct vop_nlink_args *ap)
error = hammer2_dirent_create(tdip, name, name_len,
ip->meta.inum, ip->meta.type);
- hammer2_inode_modify(ip);
- ++ip->meta.nlinks;
- ip->meta.ctime = cmtime;
if (error == 0) {
+ hammer2_inode_modify(ip);
+ ++ip->meta.nlinks;
+ ip->meta.ctime = cmtime;
/*
* Update dip's [cm]time
*/
Timeline
- 2026-08-28 Discovered during automated audit (pass 2, GLM 5.3).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2628 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df2628.c | β | fill/trigger/ctrl/probe PoC; trigger uses distinct target names per attempt and reports nlink delta plus created-name count | 4.8 KB | view raw |
| gen_names.c | β | CRC32C multicollision name generator (meet-in-the-middle over 4-char halves, GF(2) shift32 inverse, self-test 123456789->0xE3069283); 34000 names with crc32c=0xdeadbeef in 5.5s on guest | 6.2 KB | view raw |
| drainfill.c | β | ENOSPC race drain harness (incompressible blocks) | 2.0 KB | view raw |
| linkloop.c | β | ENOSPC race link loop with nlink-inflation detector | 1.5 KB | view raw |
| build.sh | β | exact guest build commands | 282 B | view raw |
| run.sh | β | full run sequence incl. expected stock-kernel panic at the fill stage | 1.2 KB | view raw |
| attempts_enospace_race.log | β | all live-repro attempts with measured rates and outcomes | 3.6 KB | view raw |
| serial_panic_invariants.log | β | stock INVARIANTS kernel panic transcript (flusher indirect-collapse spin bug) blocking the deterministic fill | 1.6 KB | view raw |
| serial_panic_noinv.log | β | same panic on a no-INVARIANTS rebuild of unmodified source: structural 'insert base overlapping elements' panic | 1.9 KB | view raw |
| success_path_fixedkernel.log | β | fixed-kernel success-path nlink arithmetic (1->4->1 round trip exact) | 1.3 KB | view raw |
| fix.diff | β | move nlinks increment inside the error==0 guard (git apply -p1) | 453 B | view raw |
| env.txt | β | guest/kernel/compiler environment for all phases | 1.3 KB | view raw |
Confirmed kernel references
- sys/vfs/hammer2/hammer2_vnops.c:1599
- sys/vfs/hammer2/hammer2_vnops.c:1601
- sys/vfs/hammer2/hammer2_vnops.c:1602
- sys/vfs/hammer2/hammer2_vnops.c:1604
- sys/vfs/hammer2/hammer2_inode.c:1675
- sys/vfs/hammer2/hammer2_inode.c:1324
- sys/vfs/hammer2/hammer2_inode.c:1311
- sys/vfs/hammer2/hammer2_vfsops.c:3076
- sys/vfs/hammer2/hammer2_subr.c:178
- sys/vfs/hammer2/hammer2_xops.c:842
Detail
Evidence (decisive lines)
['VERDICT.md section 1: three-line source trace of the unconditional increment at sys/vfs/hammer2/hammer2_vnops.c:1601-1602 vs the guarded pattern in hammer2_vop_ncreate (:1663-1682)', 'VERDICT.md section 2 + gen_names.c: deterministic trigger design - all 32768 slots of the 64K dirhash window (HAMMER2_DIRHASH_LOMASK=0x7FFF) filled by same-CRC32C names makes hammer2_dirent_create return ENOSPC at hammer2_inode.c:1324 with a nearly-empty fs', 'attempts_enospace_race.log: measured allocator rates proving the tick-race unreachable (needs >=280MB/s in-tick, sustains ~2MB/s)', 'serial_panic_invariants.log + serial_panic_noinv.log: the independent stock-kernel flusher panic that blocks the deterministic fill on both INVARIANTS and no-INVARIANTS builds', 'success_path_fixedkernel.log: fixed kernel link/unlink nlink arithmetic exact (1->4->1)']
PoC changes
Seed PoC replaced entirely: (1) trigger rewritten to use a distinct target name per attempt (the finding's sketch looped one name, which yields EEXIST after the first success and fakes a delta); it now also counts how many target names actually exist so a delta from successful links can never be mistaken for inflation. (2) Added gen_names.c CRC32C multicollision generator (hammer2_icrc32 is CRC32C, not zlib crc32), drainfill/linkloop race harnesses, and the window-fill phase. (3) ENOSPC pre-check of the seed (fill a PFS with dd then loop link) does not work on this kernel: writes are cut at the reserve by the pre-check and allocation is flush-bound, so no failure is ever delivered to link().
Verified recommended fix
Move hammer2_inode_modify(ip)/++ip->meta.nlinks/ip->meta.ctime inside the 'if (error == 0)' block in hammer2_vop_nlink (see fix.diff).
Verdict
The code defect is certain by trace: hammer2_vnops.c:1601-1602 executes hammer2_inode_modify(ip) and ++ip->meta.nlinks unconditionally after hammer2_dirent_create() (vnops.c:1599), outside the error==0 guard at :1604; the modified-flag + delayed sideq (hammer2_inode.c:1675-1682) then flushes the inflated count to media with no rollback, so any dirent_create failure (ENOSPC window exhaustion at hammer2_inode.c:1324-1326, or ENOSPC/EIO from the mkdirent xop) permanently desynchronizes st_nlink from the number of real names and can make the inode unfreeable. Live reproduction was not achievable on the audit guest: the finding's own full-PFS ENOSPC scenario requires consuming >= free_reserved (2.5-5% of the volume) within one 10ms tick of the per-tick enospace cache, but measured allocator consumption on this INVARIANTS kernel is flush-bound at ~1.3-2MB/s (~150-300x too slow) with device failures absorbed asynchronously; the deterministic hash-window-exhaustion trigger I derived (34000 same-CRC32C names via meet-in-the-middle, gen_names.c) is source-verified but the required 32768-entry dense fill deterministically panics the stock kernel first in an independent flusher bug (hammer2_base_insert 'overlapping elements' via hammer2_chain_indirect_maintenance, reproduced with and without INVARIANTS, transcripts in pack). The defect, its error paths, and the fix semantics are fully established by source; only the on-guest demonstration is blocked.
No comments yet.