DF-5059 / verdict.json
{ "finding_id": "DF-5059", "status": "reproduced", "reproduced": true, "impact": "heap-overflow-write + UAF-read (kernel-space primitive reproduced with the verbatim drm_sysctl.c:260-301 walk/copy body, real M_DRM allocator and real struct drm_file[544B]: opens landing between the count walk (:271) and copy walk (:281) made the copy write 544B-elem structs past the exact-size kmalloc; 138 organic hits per 100k unprivileged sysctl reads with no forced interleave; a groomed exact-size allocation clobbered an adjacent kernel heap object (guard[9]) - real cross-object corruption; end-to-end hw.dri path hardware-gated: QEMU guest has no GPU/device, so no real drm clients exist and a uid0 chain on the live path was not exercisable)", "confidence": "certain", "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 handlers 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.", "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, attackers 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": "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", "kernel_refs": [ "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" ], "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 targets: 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).", "attempts": 6, "guest_uname": "DragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (stock kernel; drm.ko + clirace.ko kldloaded)", "runtime_sec": 210, "guest_dirty": 1, "build_cmd": "guest# cd /root/clirace && make (bsd.kmod.mk, -Werror); guest# cc -O2 -Wall -o race_clients race_clients.c", "run_cmd": "guest# kldload /root/clirace/clirace.ko; sysctl hw.clirace.mode=1 hw.clirace.n0=4 hw.clirace.m=2 hw.clirace.shadow=32 hw.clirace.groom=0; sysctl hw.clirace.go=1 (S1) | fixmode=1 (S4) | run_user: sysctl -n hw.clirace.clients (S3a) / ./race_clients 100000 (S3b) | mode=1 shadow=0 groom=1; sysctl go=1 (S2); dmesg | grep CLIRACE", "code_hash": "05e8062206ec0fa661696c8d72e3f3738bda9189cffadb6ef3df96761c5a98c7", "notes": "Sibling handlers re-checked: drm_vm_infos maplist walk IS serialized (all maplist mutators take struct_mutex - drm_bufs.c:347,464,561,574,611); only the known DF-2075 unlock-leak applies there; drm_bufs_info NULL-write is DF-2076 (known). Device perms: /dev/dri/cardN root:video 0660 (drmP.h DRM_DEV_MODE/GID_VIDEO; drm_drv.c:1178) - trigger side needs no privileges (world-readable sysctl), mutator side needs video-group membership. Organic hit-rate variance across boots: 7/64/138 per 100k reads. sizeof(struct drm_file)=544. Guest must be reverted (S2 corrupted a guard by design; kldunload refused).", "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", "fix_status": "fixed", "fix_baseline_reproduced": 1, "fix_patched_reproduced": 0, "fix_kernel_uname": "same 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 guests /usr/src copy", "fix_verdict": "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 guests /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 harnesss identical code shape, not the production OID.", "fix_evidence": "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)" } |