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)
PoC verification
Evidence pack
findings/poc/DF-5059 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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 takesDRM_LOCK(dev).drmP.h:114-115βDRM_LOCK(dev)=lockmgr(&dev->struct_mutex, LK_EXCLUSIVE).drm_device.h:88-89/drm_file.h:243βfilelistis protected byfilelist_mutex(a different lock).drm_file.c:514-516(drm_open) βlist_add(&priv->lhead, &dev->filelist)undermutex_lock(&dev->filelist_mutex)only.drm_file.c:380-382(drm_close) βlist_del(&file_priv->lhead)underfilelist_mutex(+drm_global_mutex) only; thendrm_file.c:614βdrm_file_free()βkfree(file)atdrm_file.c:267, no RCU/grace period.drm_sysctl.c:271β count walk sizesprivcount;:274βkmalloc(sizeof(struct drm_file) * privcount, M_DRM, M_WAITOK|M_NULLOK)(M_WAITOK sleeps β the attacker-stretchable window);:281-282β copy walktempprivs[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.clientsis 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/cardNisroot:video 0660,drm_drv.c:1178+DRM_DEV_MODEindrmP.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 tomutex_lock(&dev->filelist_mutex)around both walks) applied to the guest's/usr/src;drm.korebuilt with-Werrorβ rc=0, 0 errors/warnings (fix_build.log, 105 compile lines, drm_sysctl.c recompiled at the-c β¦/drm_sysctl.cline); 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.clientsOID and realdrm_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,
dmesgin 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 drmof 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
fixedFixed. 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)
Confirmed kernel references
- sys/dev/drm/drm_sysctl.c:268
- sys/dev/drm/drm_sysctl.c:271-272
- sys/dev/drm/drm_sysctl.c:274-275
- sys/dev/drm/drm_sysctl.c:281-282
- sys/dev/drm/include/drm/drmP.h:114-115
- sys/dev/drm/include/drm/drm_device.h:88-89
- sys/dev/drm/include/drm/drm_file.h:243
- sys/dev/drm/drm_file.c:514-516
- sys/dev/drm/drm_file.c:380-382
- sys/dev/drm/drm_file.c:614
- sys/dev/drm/drm_file.c:221-267
- sys/dev/drm/drm_drv.c:1178-1179
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.
No comments yet.