# DF-0195 — Unlocked devstat list: UAF read (panic / heap-info-leak)

**File:** `sys/kern/subr_devstat.c`
**Severity:** High · **Verdict:** REPRODUCED (panic + heap-address leak) · **Fix:** VALIDATED

## The bug

`device_statq` is a singly-linked tail queue (`STAILQ`) of every device's
`struct devstat`. It is mutated by `devstat_add_entry()` (attach) and
`devstat_remove_entry()` (detach) and traversed by the `sysctl_devstat()`
handler that backs the **world-readable** `kern.devstat.all` sysctl.
**None of the three paths take any lock.**

The sysctl walker caches the next pointer then copies each entry out:

```c
for (i = 0, nds = STAILQ_FIRST(devstat_head);
    (nds != NULL) && (i < devstat_num_devs) && (error == 0);
     nds = STAILQ_NEXT(nds, dev_links), i++)        /* :291 caches next  */
    error = SYSCTL_OUT(req, nds, sizeof(struct devstat));  /* :292 copies out */
```

If a device is detached concurrently (`devstat_remove_entry()` unlinks the
node, then the driver `kfree()`s the softc that embeds it), the walker either
(1) dereferences the freed node's `dev_links.stqe_next` — which on this slab
allocator is overwritten with the **slab free-list pointer** (a valid kernel
heap address) during the kfree window, or (2) during the
`debug.use_weird_array` poison window is `0xdeadc0de…` (a non-canonical
address). Outcome:

* **panic** — `Fatal trap 9: general protection fault … sysctl_devstat+0xa4:
  movq (%rbx),%rbx` (the `STAILQ_NEXT` load on a freed/poisoned node), and
* **kernel-heap-address info leak** — the freed chunk's first 8 bytes (the
  slab free-list pointer) plus stale slab residue are copied back to the
  unprivileged reader.

`sizeof(struct devstat)` = 200 → the objects live in the `kmalloc-256` slab
bucket.

## Threat model / realism

* **Victim (impact) side is fully unprivileged.** `kern.devstat.all` is
  `CTLFLAG_RD` — any local user can read it in a tight loop. Confirmed: `maxx`
  (uid 1001, not in wheel) reads it with no error.
* **Detach side** is a privileged/hardware event in a real deployment: a USB
  mass-storage hot-unplug, a CAM LUN going away, or a privileged
  `mdconfig -d` / `camcontrol` action. None of those are attacker-help — they
  are ordinary device-management events. The unprivileged reader is simply the
  process that happens to be walking the list when one occurs.
* **This is a read-only primitive (UAF read).** Per the audit's escalation
  policy, a read-only primitive has no `uid=0` chain — the impact ceiling is
  **DoS (kernel panic) + kernel-heap-address disclosure (KASLR-defeat)**.

## Reproduction (evidence in this folder)

Because no runtime device can be detached by an unprivileged user on this
guest (md is in-kernel, ccd/dm need root), the detach side is driven by a
small **root-loaded harness KLD** (`ds195_harness.c`) that rapidly adds /
removes / frees devstat-sized objects on the real, unlocked
`device_statq`. This is only a *detach trigger*; the victim is the
unprivileged `reader` doing `sysctl kern.devstat.all` in a loop.

Steps (see `run.sh`):

```
# root: load harness + make freed chunks observable
kldload /root/df195/ds195.ko
sysctl -w debug.use_weird_array=1     # poison freed chunks (debug knob)
# maxx: hammer the world-readable sysctl
./reader 20
```

### Before (unpatched `#0` kernel) — BAD

Reader prints leaked freed-memory entries (`0xdeadc0de` poison + a leaked
kernel heap pointer in the first 8 bytes), then the kernel panics:

```
=== ANOMALY POISON-0xdeadc0de (iter=21000) ===
  0000: 40 66 3b 17 01 f8 ff ff de c0 ad de de c0 ad de   <- 0xfffff801173b6640 + poison
  0010: de c0 ad de de c0 ad de de c0 ad de de c0 ad de
  ...

Fatal trap 9: general protection fault while in kernel mode
Stopped at      sysctl_devstat+0xa4:    movq (%rbx),%rbx
```

Reproduced 3× on the `#0` baseline (run.log / panic.txt / leak_sample.txt).

### After (single-fix kernel `#1`) — FIXED

Under identical churn, the reader reports **0 anomalies** across two 18 s
runs, exits on alarm (`RC=142`), and **the guest stays up** (no panic).
`fix_run.log`.

## The fix (`fix.diff`)

Add a `struct lock devstat_lock` (initialized via `SYSINIT` at
`SI_SUB_CREATE_INIT`, before any device attaches). `devstat_add_entry()` and
`devstat_remove_entry()` take it **exclusively**; `sysctl_devstat()` takes it
**shared** across the whole walk (held across `SYSCTL_OUT`, which is a
sleepable copyout — a sleep lock is the correct primitive). This serializes
the detach against the walk, closing the race. Minimal, targeted change; no
behaviour change for the per-device counter updates (`devstat_*_transaction`,
which use atomics and don't touch the list).

Validated: applied to in-guest `/usr/src`, built
`make -j6 nativekernel KERNCONF=X86_64_GENERIC`, installed via
`make installkernel`, booted `#1` kernel, re-ran the PoC → no panic, no leak.
`fix_status = fixed`.

## Files

| file | purpose |
|---|---|
| `reader.c` | unprivileged victim: hammers `kern.devstat.all`, detects UAF (poison / leaked ptr) |
| `ds195_harness.c` / `Makefile` | root-loaded KLD: churns `device_statq` (detach-side trigger only) |
| `build.sh` / `run.sh` | exact build & run |
| `run.log` | unpatched run: poison leak dump + panic |
| `leak_sample.txt` | leaked freed-chunk hex (2 samples, distinct heap ptrs) |
| `panic.txt` | `sysctl_devstat+0xa4` GPF signature |
| `env.txt` | guest uname / cc / sysctls |
| `fix.diff` | git-apply-able fix (add `devstat_lock`) |
| `fix_build.log` / `fix_run.log` | single-fix kernel build + before/after PoC result |

---
**2026-09-04 pass-2 re-verification (GLM 5.3):** re-run end-to-end from a
fresh `vm.sh reset with-src`. Stock kernel: 6 leak anomalies in 45s (freelist
ptrs 0xffff810117e2xxxx + poison at struct offset 0), then Fatal trap 9 panic
at `sysctl_devstat+0xa4` from the unprivileged reader (proc 1118). Fixed
kernel (#1 2026-09-04, fix.diff applied in-guest, nativekernel rc=0): two
45s races under live churn (generation >121k) -> 0 anomalies, no panic.
Details in `VERDICT.md`; run matrix in `run_summary.txt`.
