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

ntfs_nthashlookup returns unreferenced ntnode after releasing token - UAF in NTFS inode lookup

Field Value
ID DF-0930
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H
CWE CWE-416 Use After Free
File sys/vfs/ntfs/ntfs_ihash.c
Lines 90-103
Area vfs
Confidence likely
Discovered 2026-07-05
Reported pending
Known CVE none
CVE match dfly_specific

Summary

ntfs_nthashlookup() walks the NTFS inode hash under the lwkt token but releases the token at line 100 BEFORE returning the ntnode pointer at line 102, without taking any reference on the ntnode. The lone caller ntfs_ntlookup() (ntfs_subr.c:369-374) then races to call ntfs_ntget() on that pointer. A concurrent ntfs_ntput() on another CPU can drop i_usecount to 0, run ntfs_nthashrem()+kfree() (ntfs_subr.c:421-457), leaving the caller dereferencing freed memory. This is the classic inode-hash UAF that ext2_ihashget() / ufs_ihashget() avoid by taking the reference (vget) under the token and re-verifying after blocking.

Root cause

ntfs_nthashlookup (ntfs_ihash.c:90-103):

struct ntnode *
ntfs_nthashlookup(cdev_t dev, ino_t inum)
{
    struct ntnode *ip;

    lwkt_gettoken(&ntfs_nthash_slock);
    for (ip = NTNOHASH(dev, inum)->lh_first; ip; ip = ip->i_hash.le_next) {
        if (inum == ip->i_number && dev == ip->i_dev)
            break;
    }
    lwkt_reltoken(&ntfs_nthash_slock);     /* <-- token released */

    return (ip);                           /* <-- returned w/ NO reference */
}

The serializing token does not cover the returned pointer's lifetime. The ntnode lifetime is governed solely by i_usecount (ntfs_inode.h:58) β€” there is no vnode refcount safety net because ntnode != vnode in NTFS. ntfs_nthashlookup never bumps i_usecount, and the caller ntfs_ntlookup (ntfs_subr.c:368-375) does:

do {
    if ((ip = ntfs_nthashlookup(ntmp->ntm_dev, ino)) != NULL) {
        ntfs_ntget(ip);                    /* <-- touches ip with NO lock/ref */
        ...
        return (0);
    }
} while (LOCKMGR(&ntfs_hashlock, LK_EXCLUSIVE | LK_SLEEPFAIL));

ntfs_ntget (ntfs_subr.c:343-352) does ip->i_usecount++ at :348 and LOCKMGR(&ip->i_lock, LK_EXCLUSIVE) at :349 β€” both touch ip with no protection. Meanwhile ntfs_ntput (ntfs_subr.c:413-458) decrements i_usecount at :421-422 and, on reaching 0, calls ntfs_nthashrem(ip) at :449 then kfree(ip) at :457.

The ext2/ufs equivalents (ext2_ihash.c:88-119, ufs_ihash.c:98-126) explicitly hold the token across the blocking vget() and re-walk the hash afterward because, as ext2_ihash.c:83-86 documents, "the serializing tokens do not prevent other processes from playing with the data structure being protected while we are blocked." NTFS omits this discipline entirely.

Corroborating evidence: ntfs_ihash.h:36 declares struct ntnode *ntfs_nthashget(cdev_t, ino_t) β€” the safe accessor mirroring ext2_ihashget β€” but it is never defined anywhere in the tree (grep confirms only the header declaration). The unsafe ntfs_nthashlookup is used instead.

Threat model & preconditions

  • Attacker position: Anyone who can cause concurrent NTFS inode lookups for the same inode number.
  • Privileges gained or impact: On a successful race, ntfs_ntget writes to ip->i_usecount and ip->i_lock inside freed heap (M_NTFSNTNODE), and the subsequent LOCKMGR operates on freed/reallocated memory β€” kernel heap corruption, potential code execution, or panic. At minimum a reliable local/remote DoS; at most local kernel-memory corruption with priv-escalation potential if the freed ntnode slab is reclaimed with attacker-influenced data.
  • Required config or capabilities:
  • Local: mount_ntfs access (gated by vfs.usermount / the mount privilege). Trigger via two threads racing open() on the same path.
  • Remote: NTFS volume exported read-only via NFS; an unauthenticated NFS peer can pump concurrent lookups for the same handle through VFS_VGET with an attacker-controlled ino from the mounted volume.
  • Reachability:
  • ntfs_vget (.vfs_vget, ntfs_vfsops.c:794,807) β†’ ntfs_vgetex (:692) β†’ ntfs_ntlookup (:710); NFS file-handle resolution drives this with an attacker-controlled ino.
  • Locally via ntfs_ntlookupfile (ntfs_subr.c:839) β†’ ntfs_vgetex (:946).
  • Race tightness (AC:High) is the only mitigating factor; it can be widened by pinning concurrent lookups and by cache pressure on the ntnode slab.

Proof of concept

PoC source: findings/poc/DF-0930/race_ntfs.c

Build & run

# 1. Build a minimal valid NTFS image (mkntfs from ntfs-3g, or reuse an
#    existing small NTFS image) containing a file with a known inode
#    number.

# 2. As a user with mount_ntfs access (vfs.usermount=1 or via a setuid
#    mount helper):
mount_ntfs -o ro,-C=utf8 /tmp/evil.ntfs /mnt/ntfs

# 3. Trigger (two pinned CPUs racing the same inode):
cc -O2 -pthread -o race_ntfs race_ntfs.c
./race_ntfs /mnt/ntfs/knownfile

# 4. Repeat to widen the race window:
while true; do ./race_ntfs; dmesg | tail; done

Expected output

Kernel panic in lockmgr/ntfs_ntget on a freed ntnode:

spin lock held too long
Fatal trap 12: page fault while in kernel mode
fault virtual address = 0x...
...
lockmgr(...)        at lockmgr+0x...
ntfs_ntget(...)     at ntfs_ntget+0x...      (ntfs_subr.c:349)
ntfs_ntlookup(...)  at ntfs_ntlookup+0x...   (ntfs_subr.c:370)
ntfs_vgetex(...)    at ntfs_vgetex+0x...
...

Or a kmalloc/type-panic from the M_NTFSNTNODE slab.

For the remote variant: export the NTFS volume read-only via NFS and have an attacker client issue fork()+open() storms on the same file handle from two processes, driving VFS_VGET concurrently.

Impact

  • Local DoS via kernel panic β€” reliable on race win.
  • Remote DoS on NFS-exported NTFS volumes.
  • Plausible local kernel heap corruption β†’ priv escalation via slab grooming of the freed ntnode slab (M_NTFSNTNODE).

Implement the missing ntfs_nthashget() that the header already declares (ntfs_ihash.h:36), modeled on ext2_ihashget (ext2_ihash.c:88-119): take a usecount reference under the hash token and the i_interlock that ntfs_ntput uses for the usecount, so the ntnode cannot be freed between lookup and the caller taking the lock; then switch ntfs_ntlookup's found-path to use it.

Add a duplicate-check + KKASSERT to ntfs_nthashins for defense in depth.

--- a/sys/vfs/ntfs/ntfs_ihash.c
+++ b/sys/vfs/ntfs/ntfs_ihash.c
@@ -88,6 +88,42 @@
  * Use the device/inum pair to find the incore inode, and return a pointer
  * to it. If it is in core, return it, even if it is locked.
  */
+
+/*
+ * Look up an ntnode and return it with its usecount incremented and i_lock
+ * held exclusively.  Mirrors ext2_ihashget(): the usecount reference is taken
+ * under the hash token (and i_interlock, which ntfs_ntput uses for the
+ * usecount) so the ntnode cannot be torn down between the lookup and the
+ * caller taking the reference.  Returns NULL if not present.
+ */
+struct ntnode *
+ntfs_nthashget(cdev_t dev, ino_t inum)
+{
+   struct ntnode *ip;
+
+   lwkt_gettoken(&ntfs_nthash_slock);
+   for (ip = NTNOHASH(dev, inum)->lh_first; ip; ip = ip->i_hash.le_next) {
+       if (inum == ip->i_number && dev == ip->i_dev) {
+           spin_lock(&ip->i_interlock);
+           if (ip->i_usecount == 0) {
+               /* Being torn down; treat as absent. */
+               spin_unlock(&ip->i_interlock);
+               ip = NULL;
+               break;
+           }
+           ip->i_usecount++;
+           spin_unlock(&ip->i_interlock);
+           break;
+       }
+   }
+   lwkt_reltoken(&ntfs_nthash_slock);
+
+   if (ip)
+       LOCKMGR(&ip->i_lock, LK_EXCLUSIVE);
+   return (ip);
+}
+
 struct ntnode *
 ntfs_nthashlookup(cdev_t dev, ino_t inum)
 {
@@ -108,6 +144,8 @@ void
 ntfs_nthashins(struct ntnode *ip)
 {
+   struct ntnode *iq;
+
+   KKASSERT((ip->i_flag & IN_HASHED) == 0);
    lwkt_gettoken(&ntfs_nthash_slock);
+   LIST_FOREACH(iq, NTNOHASH(ip->i_dev, ip->i_number), i_hash) {
+       if (iq->i_dev == ip->i_dev && iq->i_number == ip->i_number)
+           panic("ntfs_nthashins: duplicate inode %ju",
+               (uintmax_t)ip->i_number);
+   }
    ipp = NTNOHASH(ip->i_dev, ip->i_number);
    LIST_INSERT_HEAD(ipp, ip, i_hash);
    ip->i_flag |= IN_HASHED;

And the required caller change in ntfs_subr.c so the found-path no longer takes an unreferenced pointer (it now receives an already-referenced, already-locked ntnode from ntfs_nthashget):

--- a/sys/vfs/ntfs/ntfs_subr.c
+++ b/sys/vfs/ntfs/ntfs_subr.c
@@ -366,12 +366,13 @@
    do {
-       if ((ip = ntfs_nthashlookup(ntmp->ntm_dev, ino)) != NULL) {
-           ntfs_ntget(ip);
-           dprintf(("ntfs_ntlookup: ntnode %ju: %p, usecount: %d\n",
-               (uintmax_t)ino, ip, ip->i_usecount));
-           *ipp = ip;
-           return (0);
-       }
+       if ((ip = ntfs_nthashget(ntmp->ntm_dev, ino)) != NULL) {
+           /* ntfs_nthashget already bumped i_usecount and took i_lock */
+           dprintf(("ntfs_ntlookup: ntnode %ju: %p, usecount: %d\n",
+               (uintmax_t)ino, ip, ip->i_usecount));
+           *ipp = ip;
+           return (0);
+       }
    } while (LOCKMGR(&ntfs_hashlock, LK_EXCLUSIVE | LK_SLEEPFAIL));

References

Timeline

  • 2026-07-05 Discovered during automated audit.
  • pending Reported to DragonFlyBSD security contact.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0930 Β· 14 files
FileTypeDescriptionSize
race_ntfs.c trigger-source multi-threaded concurrent NTFS inode lookup race harness 4.7 KB view raw
craft_ntfs_file.py trigger-source builds minimal NTFS image with a regular file (inode 32) for racing 11.3 KB view raw
ntfs_file.img test-image 512KB NTFS image with 'target' file at inode 32 512.0 KB ↓ download
VERDICT.md verdict full code-level trace: trigger -> primitive -> effect with path:line citations 6.3 KB ↓ raw
build.sh build-script cc -O2 -pthread -o race_ntfs race_ntfs.c 148 B view raw
run.sh run-script runs race_ntfs against mounted NTFS volume 350 B view raw
run.log run-log unpatched kernel race output (3 runs, ~210s total, no panic β€” AC:High race) 1.2 KB view raw
fix.diff suggested-fix implements ntfs_nthashget() (declared but never defined), switches ntfs_ntlookup to use it, adds KKASSERT + duplicate-check to ntfs_nthashins 3.1 KB view raw
fix_build.log build-log patched ntfs.ko module build output (compiles clean with -Werror) 16.6 KB view raw
fix_run.log run-log patched module race output (60s+30s, no panic, NTFS functional) 1.3 KB view raw
env.txt environment uname, cc version, sysctl, kldstat 407 B view raw
README.md readme human reproduce doc 1.7 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme human reproduce doc
↓ download raw

DF-0930 β€” PoC: NTFS inode-hash UAF

Goal

Race concurrent ntfs_ntlookup() calls for the same inode to win the window between ntfs_nthashlookup (token released at ntfs_ihash.c:100) and ntfs_ntget (touches ip at ntfs_subr.c:348-349). A concurrent ntfs_ntput on another CPU can drop usecount to 0 and kfree(ip), making ntfs_ntget dereference freed memory.

Build & run

# 1. Build a minimal NTFS image (mkntfs from ntfs-3g) with a known file.

# 2. Mount read-only (user-mount or setuid mount helper):
mount_ntfs -o ro,-C=utf8 /tmp/evil.ntfs /mnt/ntfs

# 3. Trigger:
cc -O2 -pthread -o race_ntfs race_ntfs.c
./race_ntfs /mnt/ntfs/knownfile

# 4. Repeat to widen the race window:
while true; do ./race_ntfs; dmesg | tail; done

Expected output

Kernel panic in lockmgr/ntfs_ntget on a freed ntnode:

spin lock held too long
Fatal trap 12: page fault while in kernel mode
fault virtual address = 0x...
...
lockmgr(...)        at lockmgr+0x...
ntfs_ntget(...)     at ntfs_ntget+0x...      (ntfs_subr.c:349)
ntfs_ntlookup(...)  at ntfs_ntlookup+0x...   (ntfs_subr.c:370)
ntfs_vgetex(...)    at ntfs_vgetex+0x...
...

Or a kmalloc/type-panic from the M_NTFSNTNODE slab.

Notes

  • The remote variant: export the NTFS volume read-only via NFS and have an attacker client issue fork()+open() storms on the same file handle from two processes, driving VFS_VGET concurrently.
  • The correct fix is to implement the missing ntfs_nthashget() (which ntfs_ihash.h:36 already declares) mirroring ext2_ihashget: take the usecount reference under the token + i_interlock so the ntnode cannot be torn down between lookup and the caller taking the reference.
VERDICT.md verdict full code-level trace: trigger -> primitive -> effect with path:line citations
↓ download raw

DF-0930 β€” ntfs_nthashlookup returns unreferenced ntnode after releasing token β€” UAF

Verdict

REPRODUCED (code-level trace; race not triggered from userspace)

The bug is real and confirmed by line-by-line source trace. The race window is genuinely tight (CVSS AC:High) and did not trigger a panic in ~210 seconds of aggressive userspace racing (see run.log/run.2.log/ run.3.log), which is consistent with the finding's own "Race tightness is the only mitigating factor" assessment.

Mechanism (trigger β†’ primitive β†’ effect)

The vulnerable function: ntfs_nthashlookup (sys/vfs/ntfs/ntfs_ihash.c:90-103)

struct ntnode *
ntfs_nthashlookup(cdev_t dev, ino_t inum)
{
    struct ntnode *ip;

    lwkt_gettoken(&ntfs_nthash_slock);          /* :95 β€” acquire hash token */
    for (ip = NTNOHASH(dev, inum)->lh_first; ip; ip = ip->i_hash.le_next) {
        if (inum == ip->i_number && dev == ip->i_dev)
            break;
    }
    lwkt_reltoken(&ntfs_nthash_slock);           /* :100 β€” RELEASE token     */
    return (ip);                                 /* :102 β€” return w/ NO ref  */
}

The hash serializing token does NOT cover the returned pointer's lifetime. The ntnode is returned without incrementing i_usecount or taking any reference.

The caller: ntfs_ntlookup (sys/vfs/ntfs/ntfs_subr.c:362-405)

int
ntfs_ntlookup(struct ntfsmount *ntmp, ino_t ino, struct ntnode **ipp)
{
    struct ntnode *ip;
    ...
    do {
        if ((ip = ntfs_nthashlookup(ntmp->ntm_dev, ino)) != NULL) {  /* :369 */
            ntfs_ntget(ip);    /* :370 β€” touches ip with NO protection */
            ...
            return (0);
        }
    } while (LOCKMGR(&ntfs_hashlock, LK_EXCLUSIVE | LK_SLEEPFAIL));

ntfs_ntget(ip) at :370 dereferences ip β€” specifically, ip->i_usecount++ at :348 and LOCKMGR(&ip->i_lock, LK_EXCLUSIVE) at :349 β€” with no lock or reference protecting the pointer between the token release at ntfs_ihash.c:100 and the first dereference at ntfs_subr.c:348.

The concurrent free: ntfs_ntput (sys/vfs/ntfs/ntfs_subr.c:413-458)

void
ntfs_ntput(struct ntnode *ip)
{
    ...
    spin_lock(&ip->i_interlock);       /* :421 */
    ip->i_usecount--;                  /* :422 */
    if (ip->i_usecount > 0) {          /* :432 β€” still referenced */
        spin_unlock(&ip->i_interlock);
        LOCKMGR(&ip->i_lock, LK_RELEASE);
        return;
    }
    ...
    ntfs_nthashrem(ip);                /* :449 β€” remove from hash (needs token) */
    ...
    spin_unlock(&ip->i_interlock);     /* :455 */
    vrele(ip->i_devvp);                /* :456 β€” can block! */
    kfree(ip, M_NTFSNTNODE);           /* :457 β€” FREE */
}

Called from ntfs_reclaim (ntfs_vnops.c:234-254) when the vnode layer reclaims the last vnode referencing the ntnode. The vrele at :456 can block, widening the window during which ip is removed from the hash but not yet freed.

The race sequence

  1. CPU A: ntfs_ntlookup β†’ ntfs_nthashlookup acquires hash token, finds ip (still in hash), releases token at ntfs_ihash.c:100.
  2. CPU B: vnode reclaim β†’ ntfs_reclaim β†’ ntfs_ntget(ip) (acquires i_lock) β†’ ntfs_frele(fp) (drops fnode ref) β†’ ntfs_ntput(ip): - usecount-- β†’ reaches 0 - ntfs_nthashrem(ip) β€” acquires hash token (now available), removes ip from hash, releases token. - vrele(ip->i_devvp) β€” may block. - kfree(ip) β€” ntnode freed.
  3. CPU A: ntfs_ntget(ip) at ntfs_subr.c:370: - ip->i_usecount++ β€” writes to freed heap (UAF write). - LOCKMGR(&ip->i_lock, ...) β€” operates on freed/corrupted lock.

Result: kernel heap corruption (M_NTFSNTNODE slab), potential code execution if the slab is groomed, or panic from corrupted lock state.

The missing safe accessor

ntfs_ihash.h:36 declares:

struct ntnode *ntfs_nthashget (cdev_t, ino_t);

A grep of the entire tree confirms this function is never defined β€” only declared. The safe accessor that should mirror ext2_ihashget / ufs_ihashget (which take the reference under the token) was intended but never implemented. NTFS uses the unsafe ntfs_nthashlookup instead.

Correct reference pattern: ext2_ihashget (sys/vfs/ext2fs/ext2_ihash.c:88-119)

ext2 holds the hash token across the blocking vget() and re-walks after. The comment at ext2_ihash.c:83-86 documents: "the serializing tokens do not prevent other processes from playing with the data structure being protected while we are blocked." NTFS omits this discipline entirely.

Reproduction attempt

A multi-threaded race harness (race_ntfs.c) was run against a mounted NTFS volume (/mnt/ntfs/target, inode 32, crafted via craft_ntfs_file.py) with:

  • 3 open/close lookup threads on different CPUs
  • 2 stat() threads (shorter vnode lifetime)
  • 2 churn threads (create/delete /tmp files to force vnode recycling)
  • kern.maxvnodes reduced to 50-300 (from 109306) to force aggressive vnode reclaim
  • Run times: 30s, 60s, 120s (total ~210s)

The race did NOT trigger a panic. This is expected for AC:High races β€” the window between lwkt_reltoken (ntfs_ihash.c:100) and ip->i_usecount++ (ntfs_subr.c:348) is only 2-5 instructions, while the concurrent free path (ntfs_nthashrem + vrele + kfree) requires hundreds of cycles. The race requires the vnode recycler to be precisely mid-teardown in that window.

The code-level trace above definitively confirms the vulnerability exists.

Impact

  • Local DoS via kernel panic (corrupted lock state on freed ntnode).
  • Kernel heap corruption of the M_NTFSNTNODE slab β€” potential priv escalation if the freed slab is reclaimed with attacker-influenced data.
  • Remote DoS on NFS-exported NTFS volumes (concurrent VFS_VGET).
  • Preconditions: NTFS module loaded + volume mounted (admin action); attacker races concurrent inode lookups.

Fix validation

The fix implements the missing ntfs_nthashget() (declared in the header but never defined) to take a reference under the hash token, and switches ntfs_ntlookup's found-path to use it. A patched ntfs.ko module was built and loaded; the race harness ran cleanly against the patched module. See fix.diff, fix_build.log, fix_run.log.

Fix verification

fixed
baseline no→ patch + rebuild →patched clean

VALIDATED the fix by code-level trace + module build/load test: patched ntfs.ko compiles cleanly, loads, mounts NTFS, handles 90s of aggressive racing without panic. nm confirms ntfs_nthashget compiled in. Before = ntfs_nthashlookup returns unreferenced pointer (UAF window open); after = ntfs_nthashget returns referenced+locked pointer under token (UAF window eliminated).

BASELINE: ntfs_nthashlookup releases token at :100, returns ip at :102 with NO reference -> UAF window. ntfs_nthashget declared but NEVER DEFINED. PATCHED: ntfs_nthashget at 0x54e0 (now defined). Module compiles -Werror, loads rc=0, mounts rc=0, 90s race no panic.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (ntfs is module-only; patched ntfs.ko loaded on #0 kernel)

Confirmed kernel references

Detail

Exploit chain

Primitive: UAF write to freed M_NTFSNTNODE slab. OUTCOME: NOT ESCALATED -- the race window (2-5 instructions between token release and usecount++) is too tight to trigger from userspace despite ~210s of aggressive racing. The primitive was never obtained at runtime. This is a genuine AC:High race-tightness limitation. The code-level trace definitively confirms the UAF exists.

Evidence (decisive lines)

Code trace: ntfs_ihash.c:100 lwkt_reltoken before return at :102. ntfs_subr.c:369-370 ntfs_ntlookup dereferences ip with NO lock/ref. ntfs_subr.c:449,457 concurrent free path. ntfs_ihash.h:36 ntfs_nthashget declared but NEVER DEFINED. Race harness: 3x (30s/60s/120s, maxvnodes=50-300) -- no panic (AC:High). Patched module: nm ntfs.ko => T ntfs_nthashget at 0x54e0 (now defined). Patched module race (60s+30s): no panic, NTFS functional.

PoC changes

Added craft_ntfs_file.py (builds minimal mountable NTFS image with regular file at inode 32). Rewrote race_ntfs.c (stat threads, CPU pinning, configurable duration). Added VERDICT.md with full code-level trace. Added fix.diff (implements ntfs_nthashget mirroring ext2_ihashget, switches ntfs_ntlookup). Added build.sh, run.sh, env.txt, manifest.json, fix_build.log, fix_run.log, run.log, ntfs_file.img.

Verified recommended fix

Implement the missing ntfs_nthashget() modeled on ext2_ihashget: acquire the hash token, walk to find the ntnode, call ntfs_ntget(ip) UNDER the token, release token, return the referenced+locked ntnode. Switch ntfs_ntlookup's found-path from ntfs_nthashlookup+ntfs_ntget to ntfs_nthashget. Add KKASSERT + DIAGNOSTIC duplicate-inode check to ntfs_nthashins. SUPERSIDES the finding proposal. Full git-apply-able diff in findings/poc/DF-0930/fix.diff.

Verdict

REPRODUCED (code-level trace). The bug is real and confirmed by line-by-line source trace. ntfs_nthashlookup (ntfs_ihash.c:90-103) releases the hash token at :100 and returns the ntnode pointer at :102 WITHOUT incrementing i_usecount. The sole caller ntfs_ntlookup (ntfs_subr.c:368-376) then calls ntfs_ntget(ip) at :370 which dereferences ip (usecount++ at :348, LOCKMGR at :349) with no protection. A concurrent ntfs_ntput from vnode reclaim can drop usecount to 0, call ntfs_nthashrem+kfree(ip), leaving the caller writing to freed M_NTFSNTNODE slab. The safe accessor ntfs_nthashget is declared in ntfs_ihash.h:36 but NEVER defined (grep confirms only the header declaration); ext2_ihashget shows the correct pattern. The race did not trigger a runtime panic in ~210s of aggressive racing (AC:High).