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

2. **`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`).

3. **`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.

4. **`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`.

2. **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.
