sys_fhopen returns spurious success (fd 0) on VREG-without-VM-object invariant violation
| Field | Value |
|---|---|
| ID | DF-0002 |
| Status | new |
| Severity | Info |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:N/I:N/A:N |
| CWE | CWE-388 7PK - Errors |
| File | sys/kern/vfs_syscalls.c |
| Lines | 4933-4938 |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-06-29 |
| Reported | pending |
Summary
In sys_fhopen, when VOP_OPEN succeeds for a VREG vnode that unexpectedly
has no VM object, the code branches to the error-cleanup label without first
setting error. Because error is still 0 from the successful VOP_OPEN,
the syscall returns 0 (success) to the caller while the result fd
(sysmsg_result, initialized to 0 at the syscall entry) is never assigned a
real descriptor and the reserved slot is freed. This is a correctness bug,
reported as Info because fhopen is gated by SYSCAP_RESTRICTEDROOT
(root-only).
Root cause
sys/kern/vfs_syscalls.c:4933-4938:
if (vp->v_type == VREG && vp->v_object == NULL) {
kprintf("fhopen: regular file did not have VM object: %p\n", vp);
goto bad_drop; /* error is still 0 here from VOP_OPEN */
}
The bad_drop path (4986-4988) clears the reserved fd slot
(fsetfd(fdp, NULL, indx)) and fdrop()s fp, then falls into bad
(vput(vp)) and done (mount_drop(mp)), finally return (error); with
error == 0. sysmsg->sysmsg_result is only written on the true success path
at line 4981, so it retains the syscall-entry default of 0. The caller
therefore sees "success, fd 0" (its own stdin) even though no descriptor was
allocated for the fhopen result.
Threat model & preconditions
- Attacker position: root-equivalent only (
caps_priv_check_td(td, SYSCAP_RESTRICTEDROOT)atsys/kern/vfs_syscalls.c:4832). - Privileges gained or impact: none (no privilege-boundary crossing). Practical effect is limited to caller confusion (operating on fd 0 instead of a new fd).
- Required config or capabilities: root. Trigger requires a
VREGvnode whosev_objectis stillNULLafter a successfulVOP_OPEN, i.e. an internal FS invariant violation (e.g. a buggy/out-of-tree/fuse filesystem). - Reachability:
fhopen(2)on such a vnode. On normal filesystems the invariant holds and the path is unreachable.
Proof of concept
PoC source: findings/poc/DF-0002/fhopen_spur.c
Build & run
cc -o fhopen_spur findings/poc/DF-0002/fhopen_spur.c ./fhopen_spur /some/target # as root, on a filesystem that violates the invariant
Expected output
fhopen returned 0 (success) BUG: fd 0 is stdin, no real descriptor was allocated
(Fixed: fhopen failed as expected: Invalid argument.)
Impact
None security-wise β root-only. Reported for correctness/hardening: an FS invariant violation should not present itself to userspace as a silent success returning a recycled descriptor.
Recommended fix
Set error before the goto.
--- a/sys/kern/vfs_syscalls.c
+++ b/sys/kern/vfs_syscalls.c
@@ -4933,6 +4933,7 @@
kprintf("fhopen: regular file did not "
"have VM object: %p\n",
vp);
+ error = EINVAL;
goto bad_drop;
}
References
sys/kern/vfs_syscalls.c:4832βSYSCAP_RESTRICTEDROOTgate onfhopen.sys/kern/vfs_syscalls.c:4981β the only success-path assignment tosysmsg_result.fhopen(2)man page.
Timeline
- 2026-06-29 Discovered during automated file-by-file audit of
sys/kern/vfs_syscalls.c. - pending Reported to DragonFlyBSD security contact.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0002 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| fhopen_spur.c | trigger-source | User-space PoC: mount tmpfs, open a file, null its v_object via the KLD, then fhopen() the handle. | 3.4 KB | view raw |
| df0002_trigger.c | exploit-chain | KLD exposing debug.df0002.corrupt_fd β saves-and-nulls a vnode's v_object (restores on fd=-1 or unload). Recreates the FS-invariant violation that no in-tree FS produces. | 3.9 KB | view raw |
| Makefile | build-script | Builds df0002_trigger.ko against /usr/src/sys via bsd.kmod.mk. | 68 B | β download |
| build.sh | build-script | Builds fhopen_spur + df0002_trigger.ko. | 627 B | view raw |
| run.sh | run-script | kldload df0002_trigger.ko && ./fhopen_spur. | 484 B | view raw |
| VERDICT.md | verdict | Full narrative: mechanism, trigger, before/after, fix validation. | 5.8 KB | β raw |
| README.md | readme | How to build, run, and what to expect. | 2.4 KB | β raw |
| fix.diff | suggested-fix | git-apply-able one-line fix: add `error = EINVAL;` before goto bad_drop at vfs_syscalls.c:4937. Matches the finding markdown proposal exactly. | 268 B | view raw |
| run.baseline.log | run-log | Unpatched baseline #0 output: BUG fires (spurious fd 0). | 896 B | view raw |
| fix_build.log | build-log | Full 35512-line nativekernel build of the single-fix kernel (rc=0). | 5.6 MB | β download |
| env.txt | environment | guest uname, modules, HW-gate note | 629 B | view 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-0002 β PoC
sys_fhopen() at sys/kern/vfs_syscalls.c:4933-4938 has a missing
error = EINVAL before goto bad_drop when a VREG vnode has
v_object == NULL after VOP_OPEN. The function returns 0 (success)
while the reserved fd slot is freed and sysmsg_result is never written,
so the caller sees "fd 0" (its own stdin) β no real descriptor allocated.
Info-severity correctness bug. fhopen is gated by
SYSCAP_RESTRICTEDROOT (root-only) and the trigger requires an FS-invariant
violation (a VREG vnode without a VM object after VOP_OPEN), which no
in-tree filesystem produces. There is no security impact; reported for
correctness/hardening.
Files
| File | Purpose |
|---|---|
fhopen_spur.c |
User-space PoC: mount tmpfs, open a file, get its handle, ask the KLD to null v_object, then fhopen(2) the handle. |
df0002_trigger.c |
Tiny KLD exposing debug.df0002.corrupt_fd. Writing a positive fd saves-and-nulls that vnode's v_object; writing -1 restores. |
Makefile |
Builds df0002_trigger.ko against /usr/src/sys. |
build.sh / run.sh |
Exact build/run commands. |
VERDICT.md |
Full narrative + before/after evidence. |
fix.diff |
git apply-able one-line fix (matches finding proposal). |
manifest.json |
Site-renderable catalog. |
Build
./build.sh # builds fhopen_spur + df0002_trigger.ko (guest, root, /usr/src present)
Run
./run.sh # kldload df0002_trigger.ko && ./fhopen_spur
Expected output
Bug present (unpatched #0):
[*] v_object nullified on vnode for fd 3 (/tmp/df0002_tmpfs/file) fhopen returned 0 (success) BUG: fd 0 is stdin, no real descriptor was allocated
Fixed (single-fix #1):
[*] v_object nullified on vnode for fd 3 (/tmp/df0002_tmpfs/file) fhopen failed as expected: Invalid argument (errno=22)
Normal usage (no KLD, normal FS) is unaffected on either kernel: ./fhopen_spur
on a UFS file returns fhopen returned 3 (success) β a real descriptor.
Preconditions (realistic-threat model)
The KLD is root-only (kldload) and is just a tool to demonstrate the buggy
code path fires; it is not part of any escalation chain. The bug itself is
also gated by SYSCAP_RESTRICTEDROOT, so there is no privilege boundary being
crossed. The realistic in-tree trigger is the FS-invariant violation alone
(e.g. a buggy/out-of-tree/fuse filesystem) β without the KLD, the bug requires
that violation to manifest.
DF-0002 β REPRODUCED + FIX VALIDATED
Verdict: REPRODUCED (logic/correctness bug, root-only) β and FIX VALIDATED
(single-fix kernel boots, PoC now returns EINVAL instead of spurious success).
Mechanism
sys_fhopen at sys/kern/vfs_syscalls.c:4814 opens a vnode located via a file
handle. After VOP_OPEN succeeds, the defensive check at lines 4933β4938 fires
when the vnode is a regular file (VREG) that unexpectedly has no VM object
(vp->v_object == NULL):
4933: if (vp->v_type == VREG && vp->v_object == NULL) {
4934: kprintf("fhopen: regular file did not "
4935: "have VM object: %p\n",
4936: vp);
4937: goto bad_drop; /* <-- error stays 0 from VOP_OPEN */
4938: }
The goto bad_drop skips the only success-path assignment to sysmsg->sysmsg_result
at line 4981. The bad_drop label (4986β4988) clears the reserved fd slot via
fsetfd(fdp, NULL, indx) and fdrop()s fp, then falls into bad: (vput(vp))
β done: (mount_drop(mp)) β done2: return (error);. Because error is
still 0 from the successful VOP_OPEN, the syscall returns 0 (success), and
because sysmsg->sysmsg_result was never written, it retains the syscall-entry
default of 0 β i.e. the caller's stdin (fd 0).
The caller therefore sees fhopen() "succeed" with fd 0, but no descriptor was
actually allocated for the fhopen result. The reserved slot was freed, so the
caller is left operating on its own stdin as if it were the requested file.
Why this is reachable only via an FS-invariant violation
v_object is initialized by vinitvmio() during vnode creation in every
in-tree filesystem that supports fhopen (UFS, HAMMER, HAMMER2, msdosfs,
cd9660, udf, ext2, tmpfs, β¦). After VOP_OPEN, v_object is always non-NULL
for VREG vnodes. The buggy branch can therefore fire only on an out-of-tree
filesystem that violates the VREG-has-VM-object invariant.
Demonstration (the bug DOES fire when the invariant is violated)
We mount a tmpfs, create a regular file, get its handle, and then ask a small
KLD (df0002_trigger.ko) to null the vnode's v_object β recreating the
invariant violation. Then we call fhopen(2) on the handle. tmpfs_open() does
NOT re-create v_object (unlike ufs_open), so vp->v_object stays NULL when
the defensive check at vfs_syscalls.c:4933 fires.
The KLD is root-only (kldload) and the bug is itself gated by
SYSCAP_RESTRICTEDROOT (sys/kern/vfs_syscalls.c:4832). There is no privilege
boundary being crossed β root can already open any descriptor β so this is
reported as a correctness/hardening bug, Info severity, no security impact.
The KLD is just a tool to demonstrate the buggy code path fires; it is not part
of any escalation chain.
Unpatched baseline (#0) β single run:
$ ./fhopen_spur [*] v_object nullified on vnode for fd 3 (/tmp/df0002_tmpfs/file) fhopen returned 0 (success) BUG: fd 0 is stdin, no real descriptor was allocated
dmesg:
DF-0002: corrupted vnode 0xfffff80118539080 (cleared v_object, was 0xfffff8008fad4040, type=VREG)
fhopen: regular file did not have VM object: 0xfffff80118539080 <-- the buggy kprintf at vfs_syscalls.c:4934
<-- BUT no error is set; sysmsg_result retains the syscall-entry default of 0
Normal usage (no corruption, no KLD) is unaffected: ./fhopen_spur on a normal
UFS file returns fhopen returned 3 (success) β a real descriptor, no bug.
Impact
None, security-wise. fhopen is root-only (SYSCAP_RESTRICTEDROOT), and the
trigger requires an FS-invariant violation. A maintainer-facing concern only:
an FS invariant violation should not present itself to userspace as a silent
success returning the caller's stdin. Reported as Info for correctness.
Fix
fix.diff adds a single line β error = EINVAL; β before the goto bad_drop
at vfs_syscalls.c:4937. This matches the finding markdown's ## Recommended fix
proposal exactly (no divergence). The patch is git apply-able; it builds clean
with make -j6 nativekernel KERNCONF=X86_64_GENERIC (rc=0, no errors).
Fix validation (Phase 8)
Built the single-fix kernel from the with-src baseline by applying ONLY
fix.diff:
| Step | Result |
|---|---|
with-src baseline (unpatched) |
kern.version = 6.5-DEVELOPMENT #0 |
apply fix.diff to /usr/src |
Hunk #1 succeeded at 4934, PATCH_RC=0 |
make -j6 nativekernel |
=== NK_DONE rc=0 === (35512-line build log saved) |
cp kernel.stripped β /boot/kernel/kernel |
sha256 7e62d83b27d6f06663302ca7acd4894ee2dde3a6357fd14c1abe536a44ebc4fa |
| reboot | kern.version = 6.5-DEVELOPMENT #1 (today's ts) |
re-run ./fhopen_spur |
fhopen failed as expected: Invalid argument (errno=22) β |
Before/after contrast (same PoC, same KLD, same trigger condition):
BEFORE (unpatched #0):
fhopen returned 0 (success)
BUG: fd 0 is stdin, no real descriptor was allocated
dmesg: fhopen: regular file did not have VM object: 0xfffff80118539080
AFTER (single-fix #1, sha256 7e62d83bβ¦ebc4fa):
fhopen failed as expected: Invalid argument (errno=22)
dmesg: fhopen: regular file did not have VM object: 0xfffff80117251e80
(kprintf still fires β the path is hit β but the syscall now returns
EINVAL instead of spurious success.)
The kprintf at line 4934 still fires on the patched kernel (the defensive path
is still reached); the only change is that the syscall now correctly returns
EINVAL instead of leaking error == 0 to the caller.
Final guest state: reset to with-src (unpatched #0) so the next runner starts
clean.
Fix verification
fixedVALIDATED: baseline spurious success fd 0; patched EINVAL. Normal UFS unaffected.
BEFORE: fhopen 0 (success) BUG fd=stdin. AFTER: fhopen EINVAL. Normal: fd=3.
Confirmed kernel references
Detail
Exploit chain
none -- Info-level logic bug, root-only (SYSCAP_RESTRICTEDROOT). No escalation.
Evidence (decisive lines)
BEFORE: fhopen returned 0 (success), BUG fd 0 is stdin. AFTER: fhopen failed EINVAL (errno=22). Normal UFS fhopen still works (fd=3).
PoC changes
Authored: df0002_trigger.c+Makefile (KLD nulls v_object), fhopen_spur.c (fhopen PoC), fix.diff (error=EINVAL before goto bad_drop), VERDICT.md, manifest.json.
Verified recommended fix
Add error=EINVAL before goto bad_drop at vfs_syscalls.c:4937. Matches finding proposal exactly. Full diff in findings/poc/DF-0002/fix.diff.
Verdict
REPRODUCED (logic, root-only kld trigger). sys_fhopen vfs_syscalls.c:4933-4938 missing error=EINVAL before goto bad_drop -> error stays 0 from VOP_OPEN -> spurious success fd 0 (stdin). KLD nulls tmpfs v_object to reach defensive kprintf branch. No in-tree FS produces this condition.
No comments yet.