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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0898 Β· 14 files
FileTypeDescriptionSize
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
README.md readme summary, mechanism, impact, build/run, fix
↓ download 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.

VERDICT.md verdict full narrative: mechanism, panic proof, privilege boundary, fix validation
↓ download raw

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)

  1. devfs_rule_alloc() (sys/vfs/devfs/devfs_rules.c:78-139) processes an ioctl template. The rule->name / rule->namlen fields are only initialized when the DEVFS_RULE_NAME flag is present in rule_type:
  • Line 85: memset(rule, 0, sizeof(struct devfs_rule)) β€” rule->name starts as NULL, rule->namlen starts 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(...); } β€” linkname is processed independently of name. 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.

  1. DEVFS_RULE_APPLY ioctl calls devfs_apply_rules(mntpoint) which asynchronously iterates every devfs node on the matched mount and invokes devfs_rule_check_apply() (sys/vfs/devfs/devfs_rules.c:256).

  2. devfs_rule_check_apply() walks the rule list. With rule_type == 0: - Line 273: no DEVFS_RULE_JAIL filter β€” passes. - Line 281: no DEVFS_RULE_JAIL flag β€” passes (assuming non-jailed mount). - Line 289: mntpoint matches /dev β€” passes. - Line 297: no DEVFS_RULE_TYPE filter β€” passes. - Line 306: no DEVFS_RULE_NAME filter β€” passes (so the rule matches every node on the mount). - Line 331: else if (rule->rule_cmd & DEVFS_RULE_LINK) β€” dispatches to devfs_rule_create_link(node, rule) regardless of whether rule->name was ever set.

  3. devfs_rule_create_link() (sys/vfs/devfs/devfs_rules.c:236-253):

  • Line 243: if (rule->name[rule->namlen-1] == '*') β€” unconditionally dereferences rule->name. With rule->name == NULL and rule->namlen being u_char (zero-extended to int via 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:

  1. Opens /dev/devfs as root (O_RDWR).
  2. Builds a struct devfs_rule_ioctl with rule_type = 0 (no NAME), rule_cmd = DEVFS_RULE_LINK, mntpoint = "/dev", linkname = "x". The name field is left zero-filled (it is never consulted by alloc when NAME is unset).
  3. Issues DEVFS_RULE_ADD (succeeds on unpatched kernel β€” rule stored with NULL name and zero namlen).
  4. Issues DEVFS_RULE_APPLY with mntpoint = "/dev" β€” queues async devfs rule application; the syscall returns 0 immediately, then the Idle thread panics in devfs_rule_create_link on the first matching node.

Fix

Authored fix.diff β€” two-part minimal fix in sys/vfs/devfs/devfs_rules.c:

  1. Root-cause fix (devfs_rule_alloc, line 113): reject at the input boundary any LINK rule that lacks NAME. Since devfs_rule_create_link unconditionally dereferences rule->name to 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.

  1. 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 bypasses alloc:

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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED 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.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sun Jul 12 01:15:57 UTC 2026 (sha256 /boot/kernel/kernel = 0e56ec2a2135ab0da7a6e53f3e4a92e93325aac6a593a9bbeffca0665b748ebc)

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).