Unlocked devstat list: concurrent device detach vs sysctl walk yields UAF (world-readable sysctl)
Summary
device_statq STAILQ mutated by devstat_add/remove(no lock) traversed by sysctl_devstat(no lock,world-readable). STAILQ_NEXT cached(:291) then SYSCTL_OUT(:292). Concurrent detach+kfree -> SYSCTL_OUT reads freed memory. Unpriv user sysctl kern.devstat.all vs USB/CAM/md detach. Panic or heap info leak.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0195 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| reader.c | trigger-source | unprivileged victim: hammers world-readable kern.devstat.all, detects UAF reads (poison runs / leaked freelist ptr / bad entries) | 3.8 KB | view raw |
| ds195_harness.c | trigger-source | root-loaded KLD detach accelerator: churns device_statq exactly like device detach (remove + kfree + re-add); not part of any privilege chain | 2.8 KB | view raw |
| Makefile | build-file | bsd.kmod.mk Makefile for the harness KLD | 108 B | β download |
| build.sh | build-file | exact build commands | 586 B | view raw |
| run.sh | run-file | exact race procedure (root side + unpriv side) | 1.5 KB | view raw |
| run.log | run-log | THIS RUN, stock kernel: 6 anomalies in 45s -- kernel heap ptrs 0xffff810117e22550/e227c0/e22890 at struct offset 0 (walker walked the slab freelist), poison, stale residue | 4.5 KB | view raw |
| run_summary.txt | run-log | run matrix: baseline 0 / stock A 6 leaks / stock B panic / fixed C+D 0 anomalies | 421 B | view raw |
| leak_sample.txt | leak-sample | extracted hexdumps of freed-chunk contents disclosed to the unprivileged reader | 1.3 KB | view raw |
| panic.txt | panic-signature | THIS RUN's serial console: Fatal trap 9 GPF, Stopped at sysctl_devstat+0xa4 movq (%rbx),%rbx, current process 1118 (unpriv reader) | 581 B | view raw |
| env.txt | environment | fixed-kernel uname, MD5, sysctls | 301 B | view raw |
| fix.diff | suggested-fix | git-apply-able: struct lock devstat_lock -- LK_EXCLUSIVE in devstat_add_entry/devstat_remove_entry, LK_SHARED around the sysctl_devstat walk | 2.1 KB | view raw |
| build.log | build-log | this run's build outputs (reader, harness, patched kernel rc=0) | 531 B | view raw |
| fix_build.log | build-log | fixed-kernel nativekernel build rc=0 + installkernel rc=0 + booted #1 | 271 B | view raw |
| fix_run.log | run-log | FIXED kernel: two 45s races under live churn (generation 121374): 0 anomalies, RC=142, guest up | 138 B | view raw |
| README.md | readme | human-readable reproduction + mechanism | 5.9 KB | β raw |
| VERDICT.md | verdict | full root-cause + exploitability-ceiling + fix-validation narrative (pass-2 re-verification) | 5.1 KB | β raw |
| verdict.json | verdict | machine verdict consumed by audit/persist_poc.py / persist_fix.py | 5.2 KB | view raw |
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:
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(theSTAILQ_NEXTload 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.allisCTLFLAG_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/camcontrolaction. 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=0chain β 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.
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_statqis a global STAILQ with no lock anywhere in the file.sys/kern/subr_devstat.c:269-295βsysctl_devstat()(backing world-readablekern.devstat.all) walks the list andSYSCTL_OUTs 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 freelistc_Nextat chunk offset 0. A freedstruct devstattherefore hasdev_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_devstatonlySYSCTL_OUTs; there is no attacker-influenced write through this path. Corruption is confined to chasing a recycledstqe_nextand 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)indevstat_add_entry/devstat_remove_entry;LK_SHAREDaround thesysctl_devstatwalk.- 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).
Fix verification
fixedfix.diff applied cleanly (8/8 hunks), nativekernel rc=0 under -Werror, installed and booted (kernel #1). Exact PoC re-run twice (45s each) with harness loaded and churning (kern.devstat.generation=121374, ds195.ko in kldstat) and debug.use_weird_array=1: zero anomalies, zero panics, guest stayed up -- vs 6 leak anomalies + Fatal trap 9 panic on stock. SYSINIT lock-init ordering verified: SI_SUB_CREATE_INIT (0x2300000) precedes first devstat_add_entry at SI_SUB_DRIVERS (0x2400000) per sys/sys/kernel.h:183-187.
fix_build.log (build rc=0, install rc=0); fix_run.log (0 anomalies x2, RC=142); env.txt (fixed-kernel uname + MD5)
Confirmed kernel references
Detail
Exploit chain
unpriv reader sysctl(kern.devstat.all) races root/hotplug detach: kfree poisons chunk[0..63]=0xdeadc0de then writes zone freelist c_Next at offset 0 over dev_links.stqe_next; sysctl_devstat walk (subr_devstat.c:289-292) chases that pointer through freed chunks and SYSCTL_OUTs each 200-byte chunk -> kernel heap pointers + stale data disclosed to unprivileged userspace; when sampled in the poison-only window the chased value is non-canonical 0xdeadc0dedeadc0de -> GPF panic attributed to the unprivileged reader's process. uid0 BLOCKED: handler is copy-out only (no write primitive) and no unprivileged detach trigger exists on a stock system (mdconfig/camcontrol/dm/ccd/vn/kldload are privileged; USB/CAM hotplug is physical).
Evidence (decisive lines)
['run.log: full unpriv reader log, 6 ANOMALY hexdumps incl. consecutive chunks 0xffff810117e22550/e227c0/e22890 (walker walked the freelist)', "panic.txt: Fatal trap 9 GPF 'Stopped at sysctl_devstat+0xa4: movq (%rbx),%rbx', current process 1118 (unpriv reader), from this run's serial console", 'leak_sample.txt: extracted hexdumps showing heap ptr at offset 0 + 0xdeadc0de runs at 8..63', 'fix_run.log: fixed kernel, two 45s races under live churn (generation 121374): 0 anomalies, RC=142, guest up', 'fix_build.log: patch 8/8 hunks + make -j6 nativekernel rc=0, booted kernel #1 2026-09-04T04:11:33Z', 'run_summary.txt: run matrix baseline/A/B/C/D']
PoC changes
none to sources (seed reader.c/ds195_harness.c built and ran as-is); procedure only: reader must run in a blocking ssh session -- nohup-backgrounded readers get SIGHUP'd when the ssh session drops
Verified recommended fix
Add a global lock (lockmgr) taken exclusive in devstat_add_entry/devstat_remove_entry and shared around the sysctl_devstat walk, exactly as in fix.diff
Verdict
Re-verified end-to-end on the stock INVARIANTS guest: an unprivileged user hammering the world-readable kern.devstat.all sysctl while a (root-simulated) device detach churns the unlocked device_statq receives freed-slab contents copied out verbatim -- live kernel heap freelist pointers at struct offset 0, 0xdeadc0de poison runs, stale residue (6 anomalies/45s) -- and a second run panicked the kernel from the unprivileged reader (Fatal trap 9, sysctl_devstat+0xa4 movq (%rbx),%rbx, proc 1118). Read-only primitive: no write path exists in the handler and every detach trigger on a stock system is privileged/physical, so the ceiling is heap-address disclosure + unpriv-triggerable panic during legit admin/hotplug activity -- High as filed, not a self-service uid0 chain. fix.diff (lockmgr EXCL on add/remove, SHARED around the sysctl walk) rebuilt in-guest (nativekernel rc=0) and re-running the exact PoC twice under live churn (generation>121k) yields 0 anomalies, no panic, guest up.
No comments yet.