# DF-2679 — VERDICT

**status: reproduced** (baseline), **impact: dos** (kernel UAF read → panic / permanent hang),
**confidence: certain**.

## Root cause (path:line against the audited tree)

- `sys/kern/subr_bus.c:3879-3884` — `sysctl_devices()` walks the global
  `bus_data_devices` TAILQ (`TAILQ_FOREACH`) and then dereferences
  `dev->nameunit`, `dev->desc`, `dev->driver->name`, `dev->parent`,
  and dispatches `bus_child_pnpinfo_str`/`bus_child_location_str`
  (kobj dispatch reading `child->ivars`) with no lock held.
- `sys/kern/subr_bus.c:3871` — the only guard,
  `bus_data_generation_check(name[0])`, is checked *once* before the
  walk; `bus_data_generation` is a plain int incremented without
  atomics (`subr_bus.c:3922-3925`). It is advisory, not exclusion.
- `sys/kern/subr_bus.c:1302-1306` — `device_delete_child()` unpublishes
  the device with `TAILQ_REMOVE(&bus_data_devices, child, devlink)` and
  then frees it with `kobj_delete((kobj_t)child, M_BUS)` (kfree), with
  no synchronization against the readers above.
- `sys/kern/subr_bus.c:1164-1169` — `devclass_delete_device()` (called
  by both `device_delete_child` and `device_detach`) kfrees
  `dev->nameunit` before the device itself is freed — a second,
  earlier-freed pointer the readers copy out.
- `sys/kern/subr_bus.c:152-186` — `device_sysctl_handler()` has the
  same TOCTOU shape (`value = dev->desc ? dev->desc : ""` …
  `strlen(value)`), and its only protection (`oid_running` drain in
  `sysctl_ctx_free`, kern_sysctl.c) protects the *oid*, not the `dev`.

The sysctl node `hw.bus.devices` is CTLFLAG_RD and world-readable; the
walk is the documented userland ABI (`devinfo(8)`). The free side needs
a privileged topology mutation (kldload/kldunload, devd hotplug), which
is routine on hotplug buses.

## How it was reproduced

Guest: DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC, stock INVARIANTS
kernel), 6 vCPUs.

- `walker.c`: unprivileged loop — resolve `hw.bus.info` /
  `hw.bus.devices` MIBs via `sysctlnametomib(3)`, then fetch
  `hw.bus.devices.<gen>.<idx>` for idx = 0.. until error, forever
  (4 processes running as uid 1002 `testu`).
- `dfrace.c` (kld): MOD_LOAD adds 512 `dfrace` children to `root_bus`
  and registers a real driver on the root devclass so they probe,
  attach (`devadded` → devctl events) and later detach; MOD_UNLOAD
  deletes all 512 children (`device_delete_child` → kfree) and
  back-fills each freed `sizeof(struct bsd_device)` chunk with a
  0xAA-poisoned same-size `kmalloc` — modelling the kernel allocation
  that would recycle the chunk during the race window.
- Root churn loop: `while true; do kldload dfrace.ko; kldunload dfrace; done`.

### Baseline run 1 (panic — see panic.txt)

```
Fatal trap 9: general protection fault while in kernel mode
cpuid = 5; lapic id = 5
instruction pointer     = 0x8:0xffffffff8068c065
current process         = 42646
kernel: type 9 trap, code=0
Stopped at      sysctl_devices.part.15+0x35:    movq    0x18(%rbx),%rbx
```

`current process 42646` was verified beforehand to be one of the four
walkers (`ps -ax -o pid,uid,command` → `42646 1002 /tmp/df2679/walker`).
The faulting `movq 0x18(%rbx),%rbx` loads a field of the freed,
0xAA-filled `device_t` (non-canonical pointer → #GP).

### Baseline run 2 (hard wedge — see run.2.log)

Second fresh-boot run of the identical stress ended with the console
filling with `send_ipiq 5->0 tgt not draining (N)` — the walker thread
on CPU0 never returned from the walk (the poisoned `devlink` formed a
loop → unbounded in-kernel TAILQ walk) and the system became unusable;
ssh stopped answering and even `ps -ax` hung.

## Exploit chain (why impact is dos and not uid0_privesc)

The primitive is a UAF *read* of a freed `bsd_device` whose contents can
be groomed by racing kernel allocations. Chains toward escalation:

1. `strlcpy(udev.dv_name, dev->nameunit, 32)` /
   `strlcpy(udev.dv_desc, dev->desc, 32)` copy **up to 32 bytes** of
   whatever the freed chunk now contains out to the unprivileged reader
   (bounded kernel-heap disclosure), and
   `bus_child_pnpinfo_str(dev, udev.dv_pnpinfo, 128)` dispatches a kobj
   method on the *parent* with the freed device as `child`, whose
   `child->ivars` is read from freed memory — a fully groomable wild
   pointer deref inside driver pnpinfo formatters (128-byte copyout).
2. Getting from there to a controlled write requires winning the
   load-vs-free microsecond race with a kernel-side groom that places
   attacker-chosen bytes at the `nameunit`/`ivars` offsets of the
   recycled chunk; with only root-driven churn as the free side, the
   reliable outcome on this guest is the panic/hang demonstrated.

No uid=0 chain was developed; the honest ceiling shown is
unprivileged local DoS plus bounded heap-content disclosure.

## Fix validation

**fix_status: fix_failed (three iterations, all deadlocking against ACPI —
see fix_iterations.log / run.fixed.log).** The naive approach — a global
sleepable `bus_topo_lock` (shared in the readers, exclusive at
publish/unpublish/nameunit-free points) — was built and stress-tested
three times on in-guest `nativekernel` builds:

- v1 (kernel #1, 14:58:28): lock at make_device + all
  `devclass_delete_device` sites; SHARED across walk+dispatch+copyout →
  60 s ACPICA "indefinite wait" panic (`AcpiNsGetNode`).
- v2 (kernel #2, 15:16:12): publish unlocked, lock dropped before
  `SYSCTL_OUT` (strings snapshotted to locals) → same deadlock with the
  cycle pinned: walker holds topo SHARED inside `bus_child_pnpinfo_str`
  → `acpi_child_pnpinfo_str_method` → `AcpiGetObjectInfo` waiting on the
  ACPI namespace mutex, while an ACPI-side thread needs the topo lock.
- v3 (kernel #3, 15:30:16): probe-loop `device_set_devclass(0)` site
  unlocked, EXCL only at `device_delete_child`'s unpublish+free tail and
  `device_detach`'s `devclass_delete_device` → identical deadlock within
  ~4 minutes of identical stress.

Lesson (important for upstream): **any global lock held across the
`BUS_CHILD_PNPINFO_STR` / `BUS_CHILD_LOCATION_STR` kobj dispatches
inverts against ACPI-internal mutexes** reached from probe/attach
contexts. The correct fix needs per-device lifetime references:
refcount the `bsd_device` for sysctl/devctl readers (acquire before the
walk/dispatch, drop after), with `device_delete_child` unlinking first
and freeing only after the refcount drains. Short-term hardening can
snapshot `nameunit`/`desc` under a small lock while leaving the
dispatches unlocked-and-racy (narrows but does not close the hole).

The final `fix.diff` in this pack is v3 (the best-scoped variant); it
fixes the demonstrated UAF but introduces the ACPI deadlock under heavy
churn and must NOT be merged as-is.

