dirfs_alloc_file openat error path leaks dirfs node and parent refcount
Summary
dirfs_subr.c:182 dnp=dirfs_node_alloc(mp). :186 name set. :187-188 dnp->dn_parent=pdnp dirfs_node_ref(pdnp). :193-199 if openat()==-1: dirfs_dropfd + return errno WITHOUT dirfs_node_free(dmp,dnp). Compare stat error path :202-210 correctly calls dirfs_node_free. Each failed openat leaks one dirfs_node (~200B+name) + permanently inflates parent refcount preventing free. Sustained: kernel heap exhaustion + unmount fail. Trigger: open O_CREAT in read-only/quota-exceeded dir. Fix: dirfs_node_free(dmp,dnp) before return on openat error.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0856 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| trigger.c | trigger-source | Annotated source-level reproduction (no-op runtime; documents the openat error-path leak and the contrast with the sibling stat error path) | 3.4 KB | view raw |
| fix.diff | suggested-fix | One-line git-apply-able fix: dirfs_node_free(dmp,dnp) before return on openat error | 355 B | view raw |
| build.sh | build-script | Build the (no-op) trigger | 199 B | view raw |
| run.sh | run-script | Run the (no-op) trigger; bug verified by source trace | 438 B | view raw |
| build.log | build-log | Trigger build output (guest cc 8.3) | 69 B | view raw |
| run.log | run-log | Trigger run output (exit 0, no side effects) | 506 B | view raw |
| env.txt | environment | uname, cc version, kernel info | 737 B | view raw |
| baseline_vkernel64_dirfs_build.log | build-log | Custom VKERNEL64_DIRFS nativekernel build WITHOUT fix: dirfs fails to compile (11 dirfs_subr.c errors + 20+ in vnops/vfsops) | 610.6 KB | β download |
| fix_vkernel64_dirfs_build.log | build-log | Custom VKERNEL64_DIRFS nativekernel build WITH fix: identical 11 dirfs_subr.c errors (only line numbers shift +1) β fix introduces zero new errors | 610.8 KB | β download |
| VERDICT.md | verdict | Full narrative: mechanism, dead-code analysis, no-new-errors proof | 8.3 KB | β raw |
| README.md | readme | Human overview + reproduce instructions | 2.6 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 |
DF-0856 β dirfs_alloc_file openat error path leaks dirfs node + parent refcount
Finding
- Severity: Low (CWE-401 Memory Leak)
- CVSS: 3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L
- File:
sys/vfs/dirfs/dirfs_subr.clines 182β199 - Function:
dirfs_alloc_file
Summary
In dirfs_alloc_file, the openat(2) error path (lines 196β199) returns
errno after only calling dirfs_dropfd, leaking the dirfs node allocated
at line 182 and permanently inflating the parent's refcount (incremented at
line 188). The sibling dirfs_node_stat error path at lines 203β209
correctly calls dirfs_node_free(dmp, dnp) first; the openat error path
omits this.
Build
Not applicable β dirfs is optional dirfs in
sys/platform/vkernel64/conf/files and is not present in the default
VKERNEL64 config (and absent entirely from the running X86_64_GENERIC
guest: nm /boot/kernel/kernel.debug | grep -i dirfs returns 0). The
reproduction for this finding is source-level: see trigger.c for the
annotated source trace, and VERDICT.md for the full mechanism.
To build the (no-op) trigger for completeness:
cc -o trigger trigger.c
Run / Expected
./trigger
trigger.c returns 0 with no side effects β it is a documented source
trace, not a runtime trigger. The bug is verified by reading
sys/vfs/dirfs/dirfs_subr.c:193-200 against the matching pattern at
sys/vfs/dirfs/dirfs_subr.c:202-210.
To exercise the bug at runtime you would need to:
1. Build a vkernel64 with options dirfs enabled (test harness β the
default VKERNEL64 config does not include it).
2. Boot that vkernel on a host directory backing the dirfs mount.
3. As a vkernel user, open(O_CREAT, ...) against a directory the vkernel
process cannot write (read-only / quota-exceeded / no-permission) so the
host openat(2) inside dirfs returns -1.
4. Observe leaked dirfs_node slab growth in the vkernel process and a
dangling refcount on the parent node.
This is out of scope for the default-kernel threat model: dirfs only ships in vkernel64, and only when explicitly enabled.
Fix
fix.diff adds dirfs_node_free(dmp, dnp); immediately before the existing
dirfs_dropfd / return errno on the openat failure path, mirroring the
correct stat error path. dirfs_node_free (line 106) drops the parent ref
via dirfs_node_drop(dnp->dn_parent) (lines 123β126), kfrees dn_name
(line 128β130), closes dn_fd if open (line 138β143), uninits the lock,
and kfrees the node (line 145β147). The freshly allocated node has refcount
0 (only the parent was ref'd), so KKASSERT(dirfs_node_refcnt(dnp) == 0)
at line 115 holds.
DF-0856 β dirfs_alloc_file openat error path leaks dirfs node + parent refcount
Verdict
REPRODUCED (source-level) β the leak in the openat error path is real
and unambiguous in the source. Impact: none at runtime β dirfs is a
vkernel64-only filesystem that does not compile on master DEV (it is broken
upstream independently of this bug), and is not present in the running
X86_64_GENERIC guest kernel. The fix is correct, applies cleanly, and
introduces no new compile errors.
Mechanism (the bug)
sys/vfs/dirfs/dirfs_subr.c, function dirfs_alloc_file:
182: dnp = dirfs_node_alloc(mp); // kmalloc(sizeof(*dnp))
...
186: dirfs_node_setname(dnp, ncp->nc_name, ...); // kmalloc name
187: dnp->dn_parent = pdnp;
188: dirfs_node_ref(pdnp); // ++parent refcount
...
193: if (openflags && vap != NULL) {
194: dnp->dn_fd = openat(pathnp->dn_fd, tmp,
195: openflags, vap->va_mode);
196: if (dnp->dn_fd == -1) {
197: dirfs_dropfd(dmp, pathnp, pathfree); // frees path only
198: return errno; // LEAKS dnp + parent ref
199: }
200: }
Compare the sibling dirfs_node_stat error path at lines 202β210, which is
correct:
202: error = dirfs_node_stat(pathnp->dn_fd, tmp, dnp);
203: if (error) {
204: error = errno;
205: if (vp)
206: dirfs_free_vp(dmp, dnp);
207: dirfs_node_free(dmp, dnp); // <-- frees dnp AND drops
208: dirfs_dropfd(dmp, pathnp, pathfree); // parent ref via line
209: return error; // 123-126 of _free
210: }
dirfs_node_free() (line 106) drops the parent reference via
dirfs_node_drop(dmp, dnp->dn_parent) at lines 123β126, then kfrees
dn_name (128β130), closes dn_fd if open (138β143), uninits the lock
(145), and kfrees the node (146). So the openat error path must call
dirfs_node_free(dmp, dnp) before returning, exactly as the stat error
path does.
Each failed openat therefore leaks:
- one struct dirfs_node (sizeof ~200+ bytes, includes a struct lock),
- one kmalloc'd dn_name (ncp->nc_nlen + 1 bytes),
- and one outstanding reference on the parent node, which prevents the
parent from being freed for the life of the mount (and can wedge
unmount).
Why runtime impact is none
dirfs is gated by optional dirfs in sys/platform/vkernel64/conf/files
and the option DIRFS is declared in
sys/platform/vkernel64/conf/options as opt_dontuse.h. The default
sys/config/VKERNEL64 does not enable it (lines 35β55 of VKERNEL64 list
HAMMER, HAMMER2, NULLFS, EXT2FS, FFS, SOFTUPDATES, UFS_DIRHASH, MFS, TMPFS,
NFS, MSDOSFS, CD9660, PROCFS β no DIRFS).
Critically, dirfs does not compile on master DEV even when explicitly
enabled. Building a custom VKERNEL64_DIRFS config (a copy of VKERNEL64
plus options DIRFS) fails immediately on all three dirfs source files
with multiple errors β see baseline_vkernel64_dirfs_build.log:
dirfs_subr.c:62:3: error: implicit declaration of function 'kfree' dirfs_subr.c:63:17: error: implicit declaration of function 'kmalloc' dirfs_subr.c:63:48: error: 'M_WAITOK' undeclared dirfs_subr.c:63:59: error: 'M_ZERO' undeclared dirfs_subr.c:392:46: error: 'M_WAITOK' undeclared (dirfs_node_absolute_path_plus) dirfs_subr.c:466:46: error: 'M_WAITOK' undeclared (dirfs_findfd) dirfs_subr.c:466:57: error: 'M_ZERO' undeclared dirfs_vnops.c:653:11: error: implicit declaration of function 'uiomovebp' dirfs_vnops.c:1057:10: error: implicit declaration of function 'kmalloc' dirfs_vnops.c:1078:3: error: implicit declaration of function 'kfree' dirfs_vnops.c:1333:11: error: implicit declaration of function 'uiomove' ... (and so on; 20+ errors total across the three dirfs files)
The root cause of those errors is that the dirfs source files were
imported in commit 6cc80ee9 kernel/apple_ir: Add Apple IR receiver driver
(June 2026) and have been bit-rotted: they call kmalloc/kfree and use
M_WAITOK/M_ZERO without including <sys/malloc.h>, and reference
uiomovebp/uiomove without the right header.
This means dirfs is currently dead code: it cannot be built into a
vkernel64, cannot be kldload'd as a module (it isn't structured as one),
and is absent from every default kernel config. There is therefore no
runtime primitive reachable from any user, on any default-kernel threat
model.
Why this is still a real finding worth fixing
The bug is a textbook resource-leak pattern (missing cleanup in an error
path) that the author of dirfs_alloc_file already got right in the
sibling error path 10 lines below. It is a latent code-quality defect
that becomes live the moment someone fixes the bit-rot and re-enables
DIRFS (dirfs is documented in share/man/man7/vkernel.7 and is
intentionally a supported vkernel filesystem; the import broke it
accidentally). The fix is one line and matches the existing correct
pattern, so it costs nothing to apply now alongside the wider dirfs
rehabilitation.
No escalation
CWE-401 (memory leak). No memory corruption, no primitive for uid=0.
Realistic impact ceiling on a hypothetical-fixed dirfs: DoS of the
vkernel64 process via heap growth + unmount failure. No host-kernel
impact because dirfs runs as a host userland process.
Fix
fix.diff:
--- a/sys/vfs/dirfs/dirfs_subr.c
+++ b/sys/vfs/dirfs/dirfs_subr.c
@@ -194,6 +194,7 @@
dnp->dn_fd = openat(pathnp->dn_fd, tmp,
openflags, vap->va_mode);
if (dnp->dn_fd == -1) {
+ dirfs_node_free(dmp, dnp);
dirfs_dropfd(dmp, pathnp, pathfree);
return errno;
}
git apply --check passes on the audit tree. The freshly-allocated dnp
has refcount 0 (only pdnp was dirfs_node_ref'd on line 188), so the
KKASSERT(dirfs_node_refcnt(dnp) == 0) inside dirfs_node_free at
dirfs_subr.c:115 holds.
Fix validation
A standard before/after kernel-build validation is not possible here because dirfs itself does not compile on master DEV (see above). Instead we validated the fix at the compile-unit level:
git apply --checkβ passes onsys/vfs/dirfs/dirfs_subr.c.- No-new-errors proof: built a custom
VKERNEL64_DIRFSconfig both before and after applyingfix.diff. The dirfs_subr.c error count is identical (11 errors in both). The only diff is that line numbers in the unrelated pre-existing errors shift by +1 (e.g.:392β:393,:466β:467), exactly because our fix adds one line at line 197. Seebaseline_vkernel64_dirfs_build.log(before) andfix_vkernel64_dirfs_build.log(after).diffof the sorted dirfs_subr.c error sets: ``` < dirfs_subr.c:392:46: error: 'M_WAITOK' undeclared ... < dirfs_subr.c:466:46: error: 'M_WAITOK' undeclared ... < dirfs_subr.c:466:57: error: 'M_ZERO' undeclared ...
dirfs_subr.c:393:46: error: 'M_WAITOK' undeclared ... dirfs_subr.c:467:46: error: 'M_WAITOK' undeclared ... dirfs_subr.c:467:57: error: 'M_ZERO' undeclared ...
`` I.e. the fix is **clean, compilable C** that mirrors the already-correctdirfs_node_free(dmp, dnp);call atdirfs_subr.c:207` in the sibling error path. No new errors are introduced.
Per the AGENT.md taxonomy this is fix_status: "not_testable" β the PoC
cannot run on this guest (dirfs is dead code on master DEV), but we
validated the diff applies (git apply --check) and compiles as part
of the C parse (no new errors), and traced line-by-line that the new
call closes the leak by mirroring the existing correct path 10 lines
below.
PoC changes
The finding shipped with no PoC folder; I created:
- trigger.c β annotated source-level reproduction (no-op at runtime,
documents the bug location and the contrast with the sibling error path).
- README.md, VERDICT.md (this file), build.sh, run.sh, fix.diff,
manifest.json, env.txt, plus the two vkernel64 build logs that
prove the no-new-errors property.
Recommended fix
fix.diff adds dirfs_node_free(dmp, dnp); immediately before the
existing dirfs_dropfd / return errno on the openat failure path at
sys/vfs/dirfs/dirfs_subr.c:197, mirroring the correct stat error path
at lines 207β208. This is novel (the finding markdown was not
pre-written; the DB summary field already proposed the same one-line
fix, so this matches the finding's stated recommendation).
Fix verification
not_testableNOT_TESTABLE: dirfs dead code. Compile-unit validated: 11 errors before == 11 errors after (only +1 line shift on unrelated errors).
baseline dirfs_subr.c errors: 11. patched: 11. Zero new errors introduced.
Confirmed kernel references
- sys/vfs/dirfs/dirfs_subr.c:182
- sys/vfs/dirfs/dirfs_subr.c:186
- sys/vfs/dirfs/dirfs_subr.c:188
- sys/vfs/dirfs/dirfs_subr.c:196
- sys/vfs/dirfs/dirfs_subr.c:197
- sys/vfs/dirfs/dirfs_subr.c:198
- sys/vfs/dirfs/dirfs_subr.c:207
- sys/vfs/dirfs/dirfs_subr.c:106
- sys/vfs/dirfs/dirfs_subr.c:115
- sys/vfs/dirfs/dirfs_subr.c:123
Detail
Exploit chain
none -- CWE-401 memory leak, no corruption, no escalation.
Evidence (decisive lines)
Source-level proof: :182 node alloc, :188 dirfs_node_ref(pdnp), :196-199 openat error -> dropfd + return WITHOUT free. Compare :207 correct free. 0 dirfs symbols in /boot/kernel/kernel.debug.
PoC changes
Authored from scratch: trigger.c (source-level repro), VERDICT.md, fix.diff (add dirfs_node_free before return), manifest.json.
Verified recommended fix
Add dirfs_node_free(dmp, dnp) before dirfs_dropfd+return at :197, mirroring the correct stat error path at :207. Full diff in findings/poc/DF-0856/fix.diff.
Verdict
REPRODUCED (source-level). dirfs_alloc_file openat error path at dirfs_subr.c:196-199 calls dirfs_dropfd + return errno WITHOUT dirfs_node_free(dmp,dnp), leaking ~200B node + kmalloc'd name + unbalanced parent ref. Sibling stat error path at :207-210 correctly calls dirfs_node_free. dirfs is dead code on master DEV (vkernel64-only, not in default VKERNEL64, does not compile).
No comments yet.