β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-0195

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0195 Β· 17 files
FileTypeDescriptionSize
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
README.md readme human-readable reproduction + mechanism
↓ download 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 (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.

VERDICT.md verdict full root-cause + exploitability-ceiling + fix-validation narrative (pass-2 re-verification)
↓ download raw

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_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 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_OUTs; 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

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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Fri Sep 4 04:11:33 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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.