NULL pointer deref when DEVFS_RULE_LINK is used without DEVFS_RULE_NAME
Summary
devfs_rules.c:99 rule->name only set when DEVFS_RULE_NAME in rule_type. :331 devfs_rule_check_apply dispatches to devfs_rule_create_link whenever rule_cmd&DEVFS_RULE_LINK regardless of rule_type. :243 devfs_rule_create_link derefs rule->name[rule->namlen-1] = NULL[0-1] = 0x-1 page fault. LINK-without-NAME rule matches entire mount (no NAME/TYPE filter). Fix: require NAME flag for LINK or defensive NULL check.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0898 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| trigger.c | trigger-source | PoC: DEVFS_RULE_LINK rule without DEVFS_RULE_NAME -> kernel NULL deref panic | 4.5 KB | view raw |
| build.sh | build-script | cc -O0 -o trigger trigger.c | 115 B | view raw |
| run.sh | run-script | runs trigger as root | 113 B | view raw |
| README.md | readme | summary, mechanism, impact, build/run, fix | 3.0 KB | β raw |
| fix.diff | suggested-fix | two-part fix: alloc rejects LINK-without-NAME + defensive NULL guard in devfs_rule_create_link | 1.4 KB | view raw |
| build.log | build-log | trigger build output (cc) | 203 B | view raw |
| run.log | run-log | decisive baseline run: ADD succeeds, APPLY queues, async panic in devfs_rule_create_link+0x27 (fault va 0xffffffffffffffff) | 2.0 KB | view raw |
| fix_build.log | build-log | single-fix nativekernel build output (rc=0) | 5.6 MB | β download |
| fix_run.log | run-log | patched kernel run: DEVFS_RULE_ADD returns EINVAL, no panic (3 runs deterministic) | 948 B | view raw |
| panic.txt | panic-signature | Fatal trap 12 page fault, devfs_rule_create_link+0x27, fault va 0xffffffffffffffff, current process Idle | 247 B | view raw |
| env.txt | environment | uname, cc version, /dev/devfs perms (0600 root:wheel) | 548 B | view raw |
| VERDICT.md | verdict | full narrative: mechanism, panic proof, privilege boundary, fix validation | 8.4 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-0898 β NULL pointer deref when DEVFS_RULE_LINK is used without DEVFS_RULE_NAME
Summary
devfs_rule_create_link() in sys/vfs/devfs/devfs_rules.c:243 unconditionally
dereferences rule->name[rule->namlen-1]. However, rule->name is only set by
devfs_rule_alloc() when the DEVFS_RULE_NAME flag is present in
rule_type (line 99). A rule that sets rule_cmd = DEVFS_RULE_LINK without
rule_type = DEVFS_RULE_NAME is accepted by devfs_rule_alloc() (because
linkname is valid on its own), and is dispatched to devfs_rule_create_link()
by devfs_rule_check_apply() (line 331) whenever rule_cmd & DEVFS_RULE_LINK,
regardless of rule_type. The result is a NULL-pointer (+wrap) dereference:
rule->name[rule->namlen-1] == NULL[(u_char)0 - 1]
== *((char*)NULL + (ptrdiff_t)-1)
== *(char*)0xFFFFFFFFFFFFFFFF
==> page fault ==> kernel panic
Furthermore, because rule_type == 0, the rule has no JAIL / TYPE / NAME
filter β it matches every node on the matched mount, so the very first node
encountered during DEVFS_RULE_APPLY triggers the panic.
Impact
panic (kernel NULL-deref β DoS) on the default GENERIC kernel. The fault
address (0xffffffffffffffff) is fixed and uncontrolled, so this is pure
DoS, not an escalation primitive. No memory corruption, no info leak.
Reachability / privilege boundary
/dev/devfs is 0600 root:wheel, so the direct trigger requires root. On a
default non-jailed GENERIC this is a rootβkernel hardening gap (root already
has kldload / /dev/mem, so no privilege boundary is crossed). The realistic
escalation-relevant angle is jail escape: if a host admin exposes
/dev/devfs inside a jail's devfs ruleset (non-default but plausible), jailed
root (cr_uid=0) can trigger this and panic the host kernel from inside the
jail. That crosses the jail isolation boundary.
Build / Run
./build.sh # cc -O0 -o trigger trigger.c sudo ./run.sh # must be root (opens /dev/devfs)
Expected on unpatched (6.5-DEVELOPMENT #0):
- DEVFS_RULE_ADD returns 0 (rule accepted, name left NULL).
- DEVFS_RULE_APPLY triggers Fatal trap 12 (page fault) at
devfs_rule_create_link+0x.. with fault virtual address = 0xffffffffffffffff.
- Guest panics, SSH dies.
Expected on patched (#1):
- DEVFS_RULE_ADD returns EINVAL (alloc rejects LINK-without-NAME).
- DEVFS_RULE_APPLY never runs (no rule to apply).
- Guest stays up; program exits cleanly.
Fix
fix.diff β two-part minimal fix:
1. devfs_rule_alloc(): reject at input boundary any LINK rule that lacks NAME
(root-cause fix β closes the bug for new rules).
2. devfs_rule_create_link(): defensive NULL guard before the dereference
(defense in depth β protects against any pre-existing rule or future
code path that bypasses alloc).
Supersedes the finding proposal ("require NAME flag for LINK or defensive NULL check") β implements BOTH.
DF-0898 β NULL pointer deref when DEVFS_RULE_LINK is used without DEVFS_RULE_NAME
Verdict: REPRODUCED (panic confirmed, fix validated)
Impact: panic (kernel NULL-deref β host DoS). The fault address
(0xffffffffffffffff) is fixed and uncontrolled, so this is pure DoS β no
escalation primitive, no info leak, no memory corruption beyond the faulting
read. Phase 6 hard blocker: the primitive is a fixed-address NULL-pointer
read fault, which by definition cannot be turned into a write or control-flow
hijack. The realistic-reachability angle is jail escape: if a host admin
exposes /dev/devfs inside a jail's devfs ruleset (non-default but plausible),
jailed root (cr_uid=0) can panic the host kernel from inside the jail.
Mechanism (confirmed, path:line at each hop)
devfs_rule_alloc()(sys/vfs/devfs/devfs_rules.c:78-139) processes an ioctl template. Therule->name/rule->namlenfields are only initialized when theDEVFS_RULE_NAMEflag is present inrule_type:
- Line 85:
memset(rule, 0, sizeof(struct devfs_rule))βrule->namestarts as NULL,rule->namlenstarts as 0. - Lines 99-111:
if (templ->rule_type & DEVFS_RULE_NAME) { ... rule->name = kstrdup(...); rule->namlen = len; } - Lines 113-125:
if (templ->rule_cmd & DEVFS_RULE_LINK) { ... rule->linkname = kstrdup(...); }βlinknameis processed independently ofname. There is no requirement that LINK imply NAME.
A rule with rule_cmd = DEVFS_RULE_LINK and rule_type = 0 (no NAME) is
therefore accepted by devfs_rule_alloc() (alloc returns the rule), and
rule->name is left NULL, rule->namlen left 0.
-
DEVFS_RULE_APPLYioctl callsdevfs_apply_rules(mntpoint)which asynchronously iterates every devfs node on the matched mount and invokesdevfs_rule_check_apply()(sys/vfs/devfs/devfs_rules.c:256). -
devfs_rule_check_apply()walks the rule list. Withrule_type == 0: - Line 273: noDEVFS_RULE_JAILfilter β passes. - Line 281: noDEVFS_RULE_JAILflag β passes (assuming non-jailed mount). - Line 289: mntpoint matches/devβ passes. - Line 297: noDEVFS_RULE_TYPEfilter β passes. - Line 306: noDEVFS_RULE_NAMEfilter β passes (so the rule matches every node on the mount). - Line 331:else if (rule->rule_cmd & DEVFS_RULE_LINK)β dispatches todevfs_rule_create_link(node, rule)regardless of whetherrule->namewas ever set. -
devfs_rule_create_link()(sys/vfs/devfs/devfs_rules.c:236-253):
-
Line 243:
if (rule->name[rule->namlen-1] == '*')β unconditionally dereferencesrule->name. Withrule->name == NULLandrule->namlenbeingu_char(zero-extended tointvia integer promotion):rule->name[rule->namlen-1] == NULL[(u_char)0 - 1]==NULL[(int)-1]==*((char*)NULL + (ptrdiff_t)-1)==*(char*)0xFFFFFFFFFFFFFFFF
The kernel page-faults reading address 0xFFFFFFFFFFFFFFFF (non-canonical
on x86_64) β Fatal trap 12, supervisor read data, page not present.
Reproduction evidence (decisive)
On 6.5-DEVELOPMENT #0 (unpatched baseline), running the trigger as root:
[*] Sending DEVFS_RULE_ADD (LINK without NAME)... [*] DEVFS_RULE_ADD returned 0 (rule accepted β name stays NULL) [*] Sending DEVFS_RULE_APPLY (triggers NULL deref)... [*] DEVFS_RULE_APPLY returned 0 (apply queued async; syscall returns)
(ssh then times out β guest is in DDB. The panic fires in Idle thread
context, because devfs_apply_rules queues the rule application asynchronously.
This is why the trigger program exits normally before the panic.)
Panic signature captured from dfbsd-qemu/boot.log:
Fatal trap 12: page fault while in kernel mode cpuid = 1; lapic id = 1 fault virtual address = 0xffffffffffffffff fault code = supervisor read data, page not present instruction pointer = 0x8:0xffffffff809243d7 current process = Idle current thread = pri 12 kernel: type 12 trap, code=0 Stopped at devfs_rule_create_link+0x27: cmpb $0x2a,-0x1(%rdi,%rax,1)
The disassembly is the smoking gun: cmpb $0x2a,-0x1(%rdi,%rax,1) is exactly
the comparison rule->name[rule->namlen-1] == '*' (0x2a = ASCII '*'),
with %rdi = rule->name = NULL and %rax = rule->namlen = 0. The
effective address computes to NULL + 0 - 1 = 0xFFFFFFFFFFFFFFFF β page fault.
Privilege boundary (Phase 6)
| Path | Reachable by | Boundary crossed? |
|---|---|---|
/dev/devfs direct (DEVFS_RULE_ADD/APPLY) |
root only (uid 0) β /dev/devfs is 0600 root:wheel |
None on default system (rootβkernel; root already has kldload//dev/mem) |
Jailed root opening /dev/devfs |
jailed root, IF exposed in jail devfs ruleset | Jailβhost DoS (non-default but plausible admin config) |
Unprivileged user (maxx, uid 1001) |
EPERM β confirmed for DF-0897 (same device) | No path |
Valid hard blocker for uid0 escalation: the primitive is a fixed-address
NULL-pointer read fault β there is no write, no corruption, no info leak, no
control-flow primitive derivable. This is a pure DoS by construction. The
escalation-relevant angle is jail escape (DoS the host kernel from inside a
jail), which crosses the jail isolation boundary but was not demonstrated on
this guest (no jail environment).
PoC changes
Authored trigger.c from scratch (no prior PoC existed for DF-0898; the
finding markdown was also absent β only the DB row existed). The trigger:
- Opens
/dev/devfsas root (O_RDWR). - Builds a
struct devfs_rule_ioctlwithrule_type = 0(no NAME),rule_cmd = DEVFS_RULE_LINK,mntpoint = "/dev",linkname = "x". Thenamefield is left zero-filled (it is never consulted byallocwhen NAME is unset). - Issues
DEVFS_RULE_ADD(succeeds on unpatched kernel β rule stored with NULL name and zero namlen). - Issues
DEVFS_RULE_APPLYwithmntpoint = "/dev"β queues async devfs rule application; the syscall returns 0 immediately, then the Idle thread panics indevfs_rule_create_linkon the first matching node.
Fix
Authored fix.diff β two-part minimal fix in sys/vfs/devfs/devfs_rules.c:
- Root-cause fix (
devfs_rule_alloc, line 113): reject at the input boundary any LINK rule that lacks NAME. Sincedevfs_rule_create_linkunconditionally dereferencesrule->nameto detect a trailing'*'wildcard, a LINK rule fundamentally requires a NAME. The check is:
c
if (templ->rule_cmd & DEVFS_RULE_LINK) {
if (!(templ->rule_type & DEVFS_RULE_NAME))
goto error_out; /* LINK requires NAME */
...
}
This makes DEVFS_RULE_ADD return EINVAL for the malformed template,
so the rule is never stored and never reaches devfs_rule_create_link.
- Defense in depth (
devfs_rule_create_link, line 243): guard the dereference against any pre-existing rule (e.g., one added before this fix was deployed) or any future code path that bypassesalloc:
c
if ((rule->name != NULL) && (rule->namlen > 0) &&
(rule->name[rule->namlen-1] == '*')) {
...wildcard handling...
} else {
devfs_alias_create(rule->linkname, node, 1);
}
Without a name, there is no wildcard suffix to strip, so we just create
the alias verbatim from linkname β which is exactly the existing else
branch.
Supersedes the finding proposal ("require NAME flag for LINK or defensive NULL check") β implements BOTH for defense in depth.
Fix validation (Phase 8)
Built and booted a single-fix kernel (6.5-DEVELOPMENT #1):
| Kernel | DEVFS_RULE_ADD rc |
Panic? | Guest after run |
|---|---|---|---|
#0 unpatched baseline |
0 (rule accepted) |
YES β Fatal trap 12 at devfs_rule_create_link+0x27, fault va = 0xffffffffffffffff, current process = Idle |
down (DDB) |
#1 single-fix (this fix.diff) |
-1, errno=22 (EINVAL) |
NO | up |
Reproduced 3Γ deterministically on both kernels. The fix closes the bug
completely: the malformed rule is rejected at the input boundary, so the
async apply never sees a NULL-name LINK rule, and the defensive guard in
devfs_rule_create_link provides belt-and-suspenders protection.
Fix verification
fixedVALIDATED the fix: trigger.c on the unpatched 6.5-DEVELOPMENT #0 baseline accepts the malformed LINK-without-NAME rule (DEVFS_RULE_ADD rc=0) and panics asynchronously in devfs_rule_create_link+0x27 (Fatal trap 12, fault va 0xffffffffffffffff, current process Idle). On the single-fix kernel #1 (this fix.diff applied, nativekernel rebuilt rc=0, kernel.stripped installed over /boot/kernel/kernel), DEVFS_RULE_ADD returns EINVAL (errno=22) -- the rule is never stored, APPLY never runs, no panic, guest stays up. Deterministic across 3 runs on each kernel. Fix closes the bug.
BASELINE #0 (unpatched):
[*] DEVFS_RULE_ADD returned 0 (errno=0) <- rule accepted, name=NULL
[*] DEVFS_RULE_APPLY returned 0 (errno=0) <- apply queued async
-> Fatal trap 12: page fault, va=0xffffffffffffffff
Stopped at devfs_rule_create_link+0x27: cmpb $0x2a,-0x1(%rdi,%rax,1)
current process = Idle ; guest down (DDB)
PATCHED #1 (single-fix):
[*] DEVFS_RULE_ADD returned -1 (errno=22: Invalid argument)
[!] ADD failed; cannot proceed to APPLY
-> no panic, no 'fatal trap' in boot.log, guest stays up (3/3 runs)
=> fix closes the bug.
Confirmed kernel references
Detail
Exploit chain
Not applicable for escalation: the primitive is a fixed-address NULL-pointer READ fault (va 0xffffffffffffffff), which by construction cannot be turned into a write, control-flow hijack, or info leak. This is a pure kernel DoS / panic -- Phase 6 valid hard blocker (read-only / fixed-address fault primitive). The realistic-reachability angle is jail escape: /dev/devfs is 0600 root:wheel, so direct trigger requires root (no privilege boundary on default GENERIC -- root already has kldload/mem); however, if a host admin exposes /dev/devfs inside a jail's devfs ruleset (non-default but plausible), jailed root (cr_uid=0) can panic the HOST kernel from inside the jail, crossing the jail isolation boundary. No uid0 chain developed because the primitive class definitively permits none. Trigger file: trigger.c; no exploit.c/chain.c needed.
Evidence (decisive lines)
Baseline (#0): DEVFS_RULE_ADD returned 0; DEVFS_RULE_APPLY returned 0 (async); guest then panicked: Fatal trap 12: page fault while in kernel mode cpuid = 1; lapic id = 1 fault virtual address = 0xffffffffffffffff fault code = supervisor read data, page not present current process = Idle kernel: type 12 trap, code=0 Stopped at devfs_rule_create_link+0x27: cmpb $0x2a,-0x1(%rdi,%rax,1) Patched (#1): DEVFS_RULE_ADD returned -1 (errno=22: Invalid argument); no panic; guest stays up. Deterministic across 3 runs on each kernel.
PoC changes
Authored trigger.c, build.sh, run.sh, README.md, VERDICT.md, manifest.json, fix.diff from scratch (no prior PoC directory or finding markdown existed -- only the DB row). The trigger issues DEVFS_RULE_ADD with rule_type=0/rule_cmd=DEVFS_RULE_LINK (linkname='x', mntpoint='/dev'), then DEVFS_RULE_APPLY to trigger the async panic.
Verified recommended fix
fix.diff (sys/vfs/devfs/devfs_rules.c only, 2 hunks, applies cleanly with git apply): (1) root-cause fix in devfs_rule_alloc() -- reject LINK rules that lack NAME at the input boundary (goto error_out -> DEVFS_RULE_ADD returns EINVAL), since devfs_rule_create_link() unconditionally dereferences rule->name; (2) defense-in-depth NULL guard in devfs_rule_create_link() -- only enter the wildcard branch when (rule->name != NULL && rule->namlen > 0), otherwise fall through to the existing else branch that uses just linkname. Supersedes the finding proposal (which suggested 'require NAME flag for LINK or defensive NULL check') -- implements BOTH.
Verdict
REPRODUCED. The bug is real because devfs_rule_alloc() (sys/vfs/devfs/devfs_rules.c:99) only initializes rule->name when (rule_type & DEVFS_RULE_NAME), while devfs_rule_check_apply() (devfs_rules.c:331) dispatches to devfs_rule_create_link() whenever (rule_cmd & DEVFS_RULE_LINK) regardless of rule_type, and devfs_rule_create_link() (devfs_rules.c:243) then unconditionally evaluates rule->name[rule->namlen-1]. With rule_type=0/rule_cmd=LINK, rule->name stays NULL and rule->namlen stays 0 (u_char), so the access computes (char)(NULL + (ptrdiff_t)(0-1)) = (char)0xFFFFFFFFFFFFFFFF -> page fault. Confirmed by panic signature 'Stopped at devfs_rule_create_link+0x27: cmpb $0x2a,-0x1(%rdi,%rax,1)' (0x2a == '*') with fault virtual address 0xffffffffffffffff, current process = Idle (devfs_apply_rules runs the iteration asynchronously).
No comments yet.