# DF-0195 — Unlocked devstat list: UAF read → kernel heap leak + unpriv panic

**Re-verified (pass 2, GLM 5.3, 2026-09-04)** on the audit guest by a fresh
end-to-end run (baseline → race → panic → fixed-kernel validation). Prior
seeded logs replaced with this run's evidence.

**Status: REPRODUCED** (leak + panic, from an unprivileged reader) · **Fix: VALIDATED**

## Guest

DragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (stock
INVARIANTS `X86_64_GENERIC`), cc 8.3, `kern.devstat.version=4`, 6 registered
devstat devices at boot. Unprivileged user `maxx`; root only builds/loads the
detach-simulation KLD (`ds195.ko`) and sets `debug.use_weird_array=1`.

## Root cause (unchanged, re-confirmed against this tree)

- `sys/kern/subr_devstat.c:45` — `device_statq` is a global STAILQ with **no
  lock anywhere in the file**.
- `sys/kern/subr_devstat.c:269-295` — `sysctl_devstat()` (backing
  **world-readable** `kern.devstat.all`) walks the list and `SYSCTL_OUT`s each
  node while concurrent detach mutates/frees nodes.
- Free path on this INVARIANTS kernel (`sys/kern/kern_slaballoc.c:1557-1586`):
  `chunk_mark_free` → poison bytes 0-63 with 0xdeadc0de → **then** write the
  zone freelist `c_Next` at chunk offset 0. A freed `struct devstat` therefore
  has `dev_links.stqe_next` = (a) live kernel heap pointer (post-freelist
  write), (b) 0xdeadc0dedeadc0de non-canonical (poison-only window), or
  (c) contents of whichever object re-allocates the chunk.

## This run's observations

| Run | Kernel | Churn | Result |
|-----|--------|-------|--------|
| baseline 8s | stock | none | 0 anomalies, RC=142 (`base.log` lost to guest reset; recorded in transcript) |
| A 45s | stock | ds195.ko + poison | **6 anomalies**: kernel heap ptrs `0xffff810117e2xxxx` at struct offset 0, 0xdeadc0de poison runs, stale residue; walker walked the zone freelist across 3 consecutive freed chunks (`run.log`, `leak_sample.txt`) |
| B 45s | stock | ds195.ko + poison | **Fatal trap 9** GPF, `Stopped at sysctl_devstat+0xa4: movq (%rbx),%rbx`, `current process = 1118` (the **unprivileged** reader); guest dead (`panic.txt`, serial log) |
| C 45s | fixed (#1 2026-09-04) | ds195.ko + poison | **0 anomalies**, RC=142, guest up (`fix_run.log`) |
| D 45s | fixed | ds195.ko + poison | **0 anomalies**, RC=142, guest up; `kern.devstat.generation=121374` (race provably live while harmless) |

The panic is the walker sampling a chunk inside the poison-write →
freelist-write window inside `kfree()` (two adjacent stores under
`crit_enter()`, readable from the other CPU in between): RBX loads
`0xdeadc0dedeadc0de`, next loop iteration faults. The leak is the steady
state: freed chunk offset 0 carries the freelist pointer which is copied
**verbatim into the unprivileged reader's buffer** along with poison and
stale bytes 64-199.

## Exploitability ceiling (uid0 assessment)

- **Primitive: read-only.** `sysctl_devstat` only `SYSCTL_OUT`s; there is no
  attacker-influenced write through this path. Corruption is confined to
  *chasing* a recycled `stqe_next` and *copying from* freed memory.
- **Disclosure value:** kernel heap addresses (slab freelist pointers) +
  stale chunk residue → defeats heap-address randomization for a *different*
  bug; on this guest (no KASLR) of limited standalone value.
- **Hard blocker for uid0:** (1) no write primitive in this handler;
  (2) every detach/free trigger on a stock system is privileged
  (`mdconfig`, `camcontrol`, `dm`, `ccdconfig`, `vnconfig`, `kldload`) or
  physical (USB/CAM hotplug). The unprivileged party owns only the reader
  side, so the realistic impact is **info-leak + panic during legitimate
  admin/hotplug activity** — matching the filed High severity, not a
  self-service root chain.

## Fix validation

`fix.diff` (unchanged from seed; lockinit via
`SYSINIT(..., SI_SUB_CREATE_INIT, ...)` verified to precede first
`devstat_add_entry` at `SI_SUB_DRIVERS=0x2400000` — `sys/sys/kernel.h:183-187`):

- `lockmgr(&devstat_lock, LK_EXCLUSIVE)` in `devstat_add_entry` /
  `devstat_remove_entry`; `LK_SHARED` around the `sysctl_devstat` walk.
- Applied to in-guest `/usr/src` (8/8 hunks), `make -j6 nativekernel
  KERNCONF=X86_64_GENERIC` → rc=0; installed; booted as kernel #1.
- Exact PoC re-run twice under live churn (generation >121k): **0 anomalies,
  no panic, guest stays up.** Baseline vs patched recorded above.

## Kernel references

- `sys/kern/subr_devstat.c:45` (unlocked global list)
- `sys/kern/subr_devstat.c:284-292` (unlocked walk + full-struct copyout)
- `sys/kern/subr_devstat.c:147-153` (unlocked remove)
- `sys/kern/subr_devstat.c:67-82` (unlocked add/sort walk)
- `sys/kern/kern_slaballoc.c:1557-1586` (free-path poison/freelist mechanics)
- `sys/sys/queue.h:319-331` (STAILQ_REMOVE walk-off semantics)

## Procedure notes (what changed vs the seed)

No source changes were needed — seed `reader.c`/`ds195_harness.c` compiled
and ran as-is. Operational fix only: the reader must run in a **blocking**
ssh session (`vm.sh run_user '/home/maxx/reader 45'`); backgrounding it with
`nohup ... &` inside `su -c` gets the reader SIGHUP'd when the session drops
(observed: banner-only log, no iterations).
