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

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.

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2628 Β· 12 files
FileTypeDescriptionSize
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

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.