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

drm_clients_info walks dev->filelist under struct_mutex instead of filelist_mutex: count/copy TOCTOU heap overflow (write) and close-race UAF (read) via hw.dri.N.clients

Summary

Wrong lock: DRM_LOCK protects nothing on this path (all filelist mutators take filelist_mutex) and the copy loop is bounded by the LIVE list, not privcount - opens landing during the M_WAITOK sleep write whole 544B drm_file copies past the allocation (pointer-rich, semi-controlled); closes make the walk deref freed files and copy them out. World-readable trigger; verified IN KERNEL SPACE on stock guest: deterministic overflow every read with stretched window, 138/100k organic OOB hits unpriv, groomed exact-size alloc cross-object guard damage, leaked module pointer. filelist_mutex-across-both-walks fix validated (drm.ko rebuilt -Werror, blocked-mutator stays in bounds).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5059 Β· 14 files
FileTypeDescriptionSize
kernel/clients_race_harness.c β€” 20.7 KB view raw
kernel/Makefile β€” 493 B ↓ download
race_clients.c β€” 1.1 KB view raw
build.sh β€” 189 B view raw
run.sh β€” 262 B view raw
build.log β€” 6.0 KB view raw
run.log β€” 5.1 KB view raw
dmesg.txt β€” 29.6 KB view raw
status.txt β€” 340 B view raw
env.txt β€” 731 B view raw
fix.diff β€” 1.2 KB view raw
fix_build.log β€” 123.7 KB view raw
VERDICT.md β€” 6.3 KB ↓ raw
verdict.json β€” 8.0 KB view raw
VERDICT.md
↓ download raw

DF-5059 β€” VERDICT

Status: REPRODUCED (kernel-space primitive + unprivileged trigger, on the stock-kernel QEMU guest, DF-5008 methodology), High severity, certain confidence.

The bug, traced in sys/ (no guessing)

  • drm_sysctl.c:268 β€” the handler takes DRM_LOCK(dev).
  • drmP.h:114-115 β€” DRM_LOCK(dev) = lockmgr(&dev->struct_mutex, LK_EXCLUSIVE).
  • drm_device.h:88-89 / drm_file.h:243 β€” filelist is protected by filelist_mutex (a different lock).
  • drm_file.c:514-516 (drm_open) β€” list_add(&priv->lhead, &dev->filelist) under mutex_lock(&dev->filelist_mutex) only.
  • drm_file.c:380-382 (drm_close) β€” list_del(&file_priv->lhead) under filelist_mutex (+ drm_global_mutex) only; then drm_file.c:614 β†’ drm_file_free() β†’ kfree(file) at drm_file.c:267, no RCU/grace period.
  • drm_sysctl.c:271 β€” count walk sizes privcount; :274 β€” kmalloc(sizeof(struct drm_file) * privcount, M_DRM, M_WAITOK|M_NULLOK) (M_WAITOK sleeps β€” the attacker-stretchable window); :281-282 β€” copy walk tempprivs[i++] = *priv; bounded by the LIST.

Nothing serializes the two walks against open/close. Growth between the walks β‡’ heap overflow write (544 B/elem, pointer-rich, semi-controlled); close between/during the walks β‡’ UAF read of a freed drm_file (copied and partially printed).

Contrast: drm_vm_info's maplist walk IS correctly serialized β€” every maplist mutation takes struct_mutex (drm_bufs.c:347,464,561,574,611…), so only the known DF-2075 lock-leak applies there. amdgpu_gem.c:97-113 shows the correct in-tree precedent for walking filelist (under filelist_mutex).

What ran on the guest (stock X86_64_GENERIC, no custom kernel needed)

clirace.ko (sources in kernel/): the verbatim handler body with marked [ADAPT]ations (fake dev carrying only filelist; observation shadow behind the exact-size kmalloc; a sync window that models opens landing in the M_WAITOK sleep β€” an interleave the shipped code permits), plus a churn thread executing the drm_file.c:514-516 critical section verbatim under filelist_mutex. Because the mutator already takes the correct lock, the demonstration is airtight: the walker simply never takes it.

Decisive results (full text in run.log, raw console in dmesg.txt):

Scenario Result
S1 sync+shadow (root-driven) OOB WRITE copies=6 counted=4 (+2 elems x 544B) … pid=40002 magic=0x6d610002 … w3=fffff801179e7ed8 (kernel heap ptr in the spilled element)
S4 sync+fix pattern fixmode: copies=4 == counted=4 (churn blocked=1) -- IN BOUNDS
S3a unpriv deterministic (uid=1001 sysctl read) clients pass: copies=6 counted=4 <-- OOB WRITE β€” every read
S3b organic (uid=1001, 100k reads, free-running churn) rounds_oob=138 oob_max_elems=4; leaked kernel module pointer ffffffff831ded20 in an OOB element; hit rate varies 7–138/100k across boots
S2 groomed exact-size (2176 B) in guard hole GUARD DAMAGE … guard[9] word 0: expected a5a5000900000000 got 0000000000000000 β€” real cross-object kernel-heap corruption; guest survived (victim was a harness guard)

sizeof(struct drm_file) = 544 on this build.

Why this is a faithful model of the production path

  • The sysctl OID hw.dri.N.clients is world-readable (CTLFLAG_RD, drm_sysctl.c:90-99) β€” the trigger needs no privileges; S3a executed it as uid=1001.
  • The mutator side (open/close) requires only video-group membership on production systems (/dev/dri/cardN is root:video 0660, drm_drv.c:1178 + DRM_DEV_MODE in drmP.h).
  • The copy walk is bounded by the list, exactly as shipped β€” the harness changed no loop bounds.
  • The kmalloc is the real kmalloc(…, M_DRM, M_WAITOK|M_NULLOK); S2's 2176-byte allocation is byte-for-byte what the driver allocates for privcount=4.

Exploitability

The primitive is a repeatable, unprivileged, pointer-rich heap overflow write into an adjacent M_DRM object of the attacker's sizing class (steerable via privcount = number of open DRM clients). The UAF-read side additionally exposes stale pid/magic of freed clients. On the audit guest no DRM device exists (no GPU), so a full uid=0 chain on the live path could not be exercised end-to-end; the chain on real hardware is the standard one (open/close storm to size privcount β†’ groom M_DRM size class β†’ victim object adjacent to tempprivs β†’ overwrite with drm_file copies containing kernel pointers). Even unsophisticated use is a reliable local panic/DoS.

Fix validation

  • Compile: fix.diff (swap to mutex_lock(&dev->filelist_mutex) around both walks) applied to the guest's /usr/src; drm.ko rebuilt with -Werror β€” rc=0, 0 errors/warnings (fix_build.log, 105 compile lines, drm_sysctl.c recompiled at the -c …/drm_sysctl.c line); patched module loads (PATCHED_DRM_LOADED).
  • Behavioral: S4 executed the identical locking pattern with the real lockmgr locks in-kernel β€” mutator blocked (churn blocked=1), count == copies, zero out-of-bounds writes, while the same interleave without the lock overflows every time (S1/S3a/S3b/S2).

Honest notes / deviations

  • The guest has no DRM hardware, so the literal hw.dri.N.clients OID and real drm_open()/drm_close() could not be used; the harness reproduces the exact code shapes with the real allocators instead (same methodology as the accepted DF-5008 verification).
  • Harness iterations that did NOT count as reproduction: one self-inflicted NULL-deref panic in my first teardown (my bug, dmesg in early session), one livelock from an unbounded churn loop (my bug, redesigned to bounded bursts), a stale-module run and a missing-forward-decl build failure β€” all documented in the shell history; the decisive artifacts above are from the final clean sequence.
  • kldunload drm of an idle drm core module wedged the guest once (drm-core teardown, unrelated to this finding); the guest is reverted after evidence capture regardless.

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Fixed. Behavioral: the identical interleave with the fix locking (mutex_lock(&dev->filelist_mutex) across both walks, real lockmgr locks) stays in bounds with the mutator legitimately blocked (S4: 'copies=4 == counted=4 (churn blocked=1) -- IN BOUNDS') while the same interleave without the lock overflows deterministically (S1/S3a), organically (S3b: 138/100k) and corrupts a neighbor (S2). Compile: fix.diff applied to the guest's /usr/src, drm.ko rebuilt under -Werror rc=0 with 0 errors/warnings and the patched module loads (PATCHED_DRM_LOADED). The literal hw.dri.N.clients OID cannot exist on the GPU-less guest, so the patched sysctl path itself was exercised via the harness's identical code shape, not the production OID.

run.log S4 line; fix_build.log (MAKE_RC=0, 0 error: lines, '-c /usr/src/sys/dev/drm/drm/../drm_sysctl.c' recompiled, drm.ko relinked 780208 bytes); PATCHED_DRM_LOADED in the run transcript; fix.diff (lock swap at drm_sysctl.c:268-291)
↓ fix.diffsame stock X86_64_GENERIC guest; behavioral fix-pattern proof via harness S4 (real lockmgr locks) + patched drm_sysctl.c rebuilt into drm.ko on the guest's /usr/src copy

Confirmed kernel references

Detail

Exploit chain

unpriv local user (video group) opens/closes /dev/dri/cardN in a loop (drm_file.c:514/380 mutate filelist under filelist_mutex) while reading hw.dri.N.clients (CTLFLAG_RD, world-readable, drm_sysctl.c:90-99) -> count walk (:271) sizes tempprivs=kmalloc(544*privcount) (:274, M_WAITOK window stretchable under memory pressure) -> open lands between walks -> copy walk (:281, list-bounded) writes 544B drm_file structs past the buffer into the adjacent M_DRM object (steerable size class via privcount; contents include kernel pointers, attacker's pid/magic) -> slab-groomed victim object -> corruption; close-side variant: list_del+kfree between walks -> list_for_each_entry derefs freed drm_file -> UAF read printed as pid/magic. uid0 chain not exercised end-to-end: guest has no DRM hardware.

Evidence (decisive lines)

run.log: S1 'OOB WRITE copies=6 counted=4 (+2 elems x 544B) ... pid=40002 magic=0x6d610002 ... w3=fffff801179e7ed8'; S3a 'clients pass: copies=6 counted=4 <-- OOB WRITE' (uid=1001, every read); S3b 'rounds_oob=138 oob_max_elems=4' + leaked module pointer ffffffff831ded20; S2 'GUARD DAMAGE ... guard[9] word 0: expected a5a5000900000000 got 0000000000000000' (1 damaged guard, groomed exact-size 2176B alloc); S4 'fixmode: copies=4 == counted=4 (churn blocked=1) -- IN BOUNDS'; fix_build.log (patched drm_sysctl.c -> drm.ko, -Werror, MAKE_RC=0, 0 errors) + PATCHED_DRM_LOADED; dmesg.txt (139 OOB WRITE lines); status.txt; env.txt

PoC changes

No seed PoC existed (finding new in pass 2). The harness embeds the verbatim handler body; marked [ADAPT]ations: fake dev struct carrying only filelist + the two real locks; DRM_SYSCTL_PRINT compressed to kprintf; observation shadow behind the exact-size kmalloc (loop bounds untouched); a sync window modeling opens landing in the M_WAITOK sleep (with a documented inline fallback using the exact drm_file.c:514-516 critical section when the churn thread misses the 1s handshake); guard spray/punch for the groomed run. Harness-side iterations that were MY bugs, not the target's: initial teardown NULL-deref (uninitialized list, panicked guest), unbounded churn loop livelock (redesigned to bounded 6ms bursts + 10s deadline), stale-module/missing-forward-decl build failure, cur_len desync after organic runs (added resync_len).

Verified recommended fix

drm_sysctl.c drm_clients_info: hold mutex_lock(&dev->filelist_mutex) across BOTH walks (count + kmalloc + copy) instead of DRM_LOCK(dev) - see fix.diff

Verdict

REPRODUCED at the primitive level in kernel space, with the unprivileged trigger demonstrated. drm_clients_info() walks dev->filelist under DRM_LOCK(dev)=dev->struct_mutex (drm_sysctl.c:268, drmP.h:114) but the list is protected by dev->filelist_mutex (drm_device.h:88, drm_file.h:243) and its only mutators - drm_open (drm_file.c:514-516 list_add) and drm_close (drm_file.c:380-382 list_del, then drm_file_free->kfree at :267 with no RCU) - take filelist_mutex exclusively, so the handler's two walks run unserialized against open/close. The copy walk is bounded by the list, not by privcount, so growth between the walks overflows kmalloc(sizeof(struct drm_file)*privcount) (drm_sysctl.c:274) with pointer-rich drm_file copies; a close mid-walk yields a UAF read that is copied out and partially printed. On the stock-kernel guest (no DRM device possible), a kld harness ran the verbatim handler body against the real allocators: deterministic interleave -> copies=6 vs counted=4, +2x544B past the array containing pid/magic and a kernel heap pointer; uid=1001 sysctl reads fired it on every read and organically 138/100k times vs a free-running open/close churn; a groomed exact-size 2176B allocation clobbered the adjacent M_DRM object (guard[9] marker overwritten). The identical interleave under the fix pattern (filelist_mutex held across both walks) stayed in bounds with the mutator blocked, and the patched drm_sysctl.c rebuilt into drm.ko with -Werror rc=0 and loaded. Full uid0 escalation is the expected class for this primitive on any machine with a DRM device (world-readable sysctl + video-group opens) but was not demonstrable on the GPU-less guest.