iounmap() walks the global iomap_list without iomap_lock, racing concurrent inserters/removers into UAF read, double-free and NULL-deref panic
Summary
iounmap() does unlocked SLIST_FOREACH_MUTABLE lookup over process-global iomap_list then later still without lock calls pmap_unmapdev() and only acquires iomap_lock for SLIST_REMOVE/kfree tail. List is global (not per-device) every DRM driver shares it. Any two concurrent iounmap() calls (cross-device DRM_IOCTL_RM_MAP DRM_AUTH-only vs driver teardown or double-cleanup) race. Loser either: (a) dereferences kfree()-d saved-next pointer (UAF read kernel heap info leak/wild walk); (b) double-calls pmap_unmapdev/kmem_free on same VA range (double-free); (c) passes entry not present in list to SLIST_REMOVE which walks to NULL derefs NULL panic. SLIST_FOREACH_MUTABLE stashes tmp_imp=SLIST_NEXT(imp) read unlocked and dereferenced next iteration. pmap_unmapdev called BEFORE entry unlinked. Between unlocked lookup (line 68) and locked remove (line 99) entry can be removed+freed by concurrent iounmap. Same-handle double-call: both pass found-check both pmap_unmapdev same VA (double kmem_free) second SLIST_REMOVE walks past list end SLIST_NEXT(NULL) deref panic. List is global across ALL DRM devices so per-device dev->struct_mutex does NOT serialize cross-device iounmap. Impact UAF read freed heap info leak/wild pointer double-free kernel_map VA memory corruption often escalable to arbitrary kernel write via slab grooming or unambiguous panic DoS.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2206 Β· 8 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | readme | claim, source-trace pointers, fix summary | 3.6 KB | β raw |
| VERDICT.md | verdict | full narrative: race window, outcomes, fix | 3.2 KB | β raw |
| fix.diff | suggested-fix | hold iomap_lock from iounmap entry through kfree | 1.4 KB | view raw |
| build.sh | build-script | applies fix.diff + rebuilds drm.ko in guest | 812 B | view raw |
| run.sh | run-script | HW-gated no-op runner | 432 B | view raw |
| build_baseline.log | build-log | unpatched drm.ko build, rc=0, -Werror | 130.8 KB | view raw |
| build_patched.log | build-log | patched drm.ko rebuild, only linux_iomapping.o recompiled, rc=0, -Werror | 2.9 KB | view raw |
| env.txt | environment | uname, cc, kern.version | 376 B | view raw |
DF-2206 β iounmap() walks global iomap_list without lock (High)
Claim
iounmap() in sys/dev/drm/linux_iomapping.c performs an unlocked
SLIST_FOREACH_MUTABLE lookup over the process-global iomap_list, then
calls pmap_unmapdev() and only later (still without holding the lock during
the unmap) acquires iomap_lock purely for the SLIST_REMOVE / kfree tail.
Because the list is shared across every DRM device (not per-device), two
concurrent iounmap() callers can race and either dereference a kfree()-ed
saved-next pointer (UAF read), double-call pmap_unmapdev / SLIST_REMOVE
on the same VA (double free), or walk past end-of-list (NULL deref panic).
Verification approach
HW-gated / source-only. iounmap() is exercised only by DRM drivers
(radeon, amdgpu, i915) during MMIO teardown, which on this audit guest has no
backing GPU. Per the task brief, source-only confirmation is acceptable for
HW/module-gated findings. The race is fully visible at the source level and
the structural fix is mechanical, so the verification is:
- Source-trace the cited path line-by-line in
sys/dev/drm/linux_iomapping.cand confirm the lock-acquire / release pattern matches the claim. - Confirm the list is process-global (single
SLIST_HEADat file scope). - Author
fix.diff(holdiomap_lockfor the entire op). - Phase 8 β apply all 5 batched fixes and rebuild
drm.kowith-Werrorto confirm the patched source compiles cleanly.
Source trace (confirmed)
sys/dev/drm/linux_iomapping.c:39βSLIST_HEAD(iomap_list_head, iomap) iomap_listis a single global list, file-scope.sys/dev/drm/linux_iomapping.c:37βiomap_lockexists but is only taken selectively.sys/dev/drm/linux_iomapping.c:68βSLIST_FOREACH_MUTABLE(imp, &iomap_list, ...)runs withoutiomap_lockheld.sys/dev/drm/linux_iomapping.c:80βpaddr_endis computed fromimp(still unlocked).sys/dev/drm/linux_iomapping.c:90βpmap_change_attr(imp->paddr, ...)(still unlocked).sys/dev/drm/linux_iomapping.c:96βpmap_unmapdev((vm_offset_t)imp->pmap_addr, ...)is called before the entry is removed (still unlocked).sys/dev/drm/linux_iomapping.c:98-100βiomap_lockis finally acquired here, only forSLIST_REMOVE.sys/dev/drm/linux_iomapping.c:102βkfree(imp)runs after the lock is released.
Race window
Between the unlocked lookup (line 68) and the locked remove (line 99) the
entry pointed at by imp (and the tmp_imp = SLIST_NEXT(imp) saved by
SLIST_FOREACH_MUTABLE) can be removed and kfree()-ed by a concurrent
iounmap() on the same or any other DRM device, because the list is global.
Worst observable outcomes:
* UAF read of tmp_imp next iteration (kernel heap info leak / wild walk).
* Same-handle double iounmap: both pass found, both call
pmap_unmapdev on the same VA (double kmem_free of kernel_map),
second SLIST_REMOVE walks SLIST_NEXT(NULL) β panic.
Files
VERDICT.mdβ full narrative.fix.diffβ git-apply-able fix: holdiomap_lockfrom function entry throughkfree.iomap_lockisLK_CANRECURSEand the called helpers (pmap_unmapdev,pmap_change_attr,kfree) do not take it, so holding it across teardown is safe.build_baseline.logβ full unpatcheddrm.kobuild, rc=0,-Werror.build_patched.logβ full patcheddrm.korebuild (onlylinux_iomapping.orecompiled), rc=0,-Werror.env.txtβ guest environment.
Reproduce
./build.sh # applies fix.diff into in-guest /usr/src and rebuilds drm.ko ./run.sh # re-runs (here: only a source-level repro is possible)
DF-2206 β VERDICT
Verdict: REPRODUCED (source-only, HW-gated).
Class: kernel race / UAF / double-free / NULL deref (memory corruption).
Mechanism
iounmap() in sys/dev/drm/linux_iomapping.c tears down a previously
ioremap()-ed MMIO mapping. The list of live mappings (iomap_list,
declared at linux_iomapping.c:39) is a process-global singly-linked
SLIST shared by every DRM driver in the system; it is not per-device.
The lookup of the matching entry is performed by SLIST_FOREACH_MUTABLE
without holding the list lock:
/* linux_iomapping.c:68 */
SLIST_FOREACH_MUTABLE(imp, &iomap_list, im_iomaps, tmp_imp) {
if (imp->pmap_addr == ptr) { found = 1; break; }
}
After the unlocked lookup succeeds, the code uses imp (and computes
paddr_end from imp->npages * PAGE_SIZE) at line 80, optionally calls
pmap_change_attr at line 90, calls pmap_unmapdev at line 96 β and only
then, at lines 98-100, acquires iomap_lock to do SLIST_REMOVE, releases
the lock, and kfree(imp) at line 102.
Because the list is global, per-device dev->struct_mutex does not
serialize cross-device iounmap(). Two concurrent callers therefore race
in the window between the unlocked lookup (line 68) and the locked remove
(line 99):
| Outcome | How |
|---|---|
| UAF read / wild walk | caller A's tmp_imp = SLIST_NEXT(imp) saved by SLIST_FOREACH_MUTABLE points at an entry caller B already kfree()-ed. |
double free of kernel_map VA |
both callers pass the unlocked found check for the same pmap_addr, both call pmap_unmapdev on that VA. |
| NULL deref panic | second SLIST_REMOVE for the same handle walks SLIST_NEXT(NULL) past end of list. |
Each of these is a kernel memory-corruption primitive (slab-groommable to arbitrary kernel write in principle; unambiguous panic DoS in practice).
Trigger surface / reachability
iounmap() is reached from every DRM driver's MMIO teardown path
(amdgpu_device_fini, radeon/i915 fini, etc.) and from
DRM_IOCTL_RM_MAP (drm_bufs.c) which is DRM_AUTH-only. Two
concurrent teardowns (driver reset + ioctl, or cross-device) race.
On this audit guest there is no GPU, so the race cannot be exercised live; it is confirmed at the source level. The structural fix is mechanical and the patched module compiles cleanly (Phase 8).
Fix
fix.diff holds iomap_lock from function entry through the final
kfree. iomap_lock is initialised LK_CANRECURSE
(linux_iomapping.c:37) and none of the helpers called during teardown
(pmap_unmapdev, pmap_change_attr, kfree) acquire iomap_lock, so
holding it across the teardown cannot deadlock. An early-return path
(!found) is updated to release the lock before returning.
Phase 8 build validation
Applied fix.diff (plus the four other batched DRM fixes) to the in-guest
/usr/src, rebuilt drm.ko with -Werror:
* baseline (unpatched) drm.ko: rc=0 (build_baseline.log).
* patched drm.ko: rc=0, only linux_iomapping.o recompiled
(build_patched.log); no warnings, no errors.
Verdict
REPRODUCED at source level (HW-gated; no live repro possible on guest).
The race is real, the primitive is memory corruption, and the fix compiles
cleanly under -Werror.
Fix verification
fixeddrm.ko baseline+patched rc=0 -Werror
drm.ko baseline+patched rc=0 -Werror
Confirmed kernel references
β
Detail
Exploit chain
none (HW-gated)
Evidence (decisive lines)
HW-gated (no GPU). Source-confirmed: iounmap walks global iomap_list without lock -> UAF/double-free race.
Verified recommended fix
HW-gated (no GPU). Source-confirmed: iounmap walks global iomap_list without lock -> UAF/double-free race.
Verdict
HW-gated (no GPU). Source-confirmed: iounmap walks global iomap_list without lock -> UAF/double-free race.
No comments yet.