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

UAF/OOB-read race in amdgpu_bo_list_get(): DragonFly's idr_find() is lockless while idr_grow() kfree()s the node array synchronously (Linux RCU-safe-idr assumption does not hold)

Summary

amdgpu_bo_list_get wraps bare idr_find in rcu_read_lock only, but the DF linux_idr is a flat array: idr_find takes NO lock and idr_grow (synchronous from CREATE's idr_alloc once >254 lists exist - an unpriv render user creates them at will) swaps and kfree()s the node array with no RCU deferral. Preemption window -> kref_get_unless_zero on a garbage pointer (refcount corruption/premature free); load-reorder variant -> OOB read 2320B past the freed chunk. Oracle deterministically executes both interleavings from a byte-identical generated copy of linux_idr.c (V1 returns the planted 0xDDDD... as the data pointer; fixed variant clean); 400-thread race runs 0 false positives. In-kernel run impossible (no AMD GPU). bo_list_lock fix or systemic idr_find-token fix (protects all DRM idr_find callers).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5033 Β· 19 files
FileTypeDescriptionSize
README.md β€” 5.6 KB ↓ raw
VERDICT.md β€” 6.0 KB ↓ raw
gen_core.py β€” 1.2 KB view raw
idr_test_core.c β€” 11.5 KB view raw
harness_prelude.h β€” 2.2 KB view raw
inc/sys/idr.h β€” 1000 B view raw
idr_race.c β€” 6.1 KB view raw
oracle.c β€” 6.0 KB view raw
build.sh β€” 66 B view raw
run.sh β€” 90 B view raw
build.log β€” 1.1 KB view raw
oracle_baseline.log β€” 471 B view raw
oracle_fixed.log β€” 277 B view raw
race_baseline.log β€” 459 B view raw
race_fixed.log β€” 459 B view raw
env.txt β€” 197 B view raw
fix.diff β€” 872 B view raw
verdict.json β€” 6.0 KB view raw
manifest.json β€” 1.6 KB view raw

DF-5033 β€” UAF/OOB-read race: amdgpu_bo_list_get() vs idr_grow() (DF idr is not RCU-safe for lookups)

What

amdgpu_bo_list_get() (sys/dev/drm/amd/amdgpu/amdgpu_bo_list.c:164-177) uses the Linux idiom for RCU-safe IDR lookups:

rcu_read_lock();
*result = idr_find(&fpriv->bo_list_handles, id);
if (*result && kref_get_unless_zero(&(*result)->refcount)) ...

On Linux this is correct: idr is built on the RCU-safe xarray. On DragonFly the DRM-compat idr (sys/libkern/linux_idr.c) is a flat array guarded by an lwkt_token, and:

  • idr_find() (linux_idr.c:516-529) takes no lock at all β€” it reads idp->idr_count and idp->idr_nodes[id] bare;
  • idr_grow() (linux_idr.c:421-459, called under the token from idr_alloc(), linux_idr.c:358) swaps in a new node array (idp->idr_nodes = newnodes, :454) and immediately kfree()s the old array (:458) β€” no RCU grace period, no deferral.

So a CS ioctl thread (amdgpu_cs.c:591 calls amdgpu_bo_list_get() with no bo_list_lock) races a concurrent AMDGPU_BO_LIST_OP_CREATE ioctl (amdgpu_bo_list.c:283 idr_alloc() β†’ idr_grow() β†’ kfree(oldnodes)). The per-fd idr grows when the 255-node initial array (IDR_DEFAULT_SIZE, linux_idr.c:132) overflows β€” i.e. after an unprivileged render-node user creates >254 BO lists in one fd β€” and again at 511, 1023, … entries.

Two unsynchronized interleavings of the unlocked two-step read in idr_find() (bound-check idr_count; load idr_nodes; dereference):

  • V1 (preemption in window) β€” reader loads idp->idr_nodes (old array), is preempted; grower swaps + frees; reader resumes and dereferences the freed 4080-byte chunk β†’ use-after-free read. The garbage/foreign data pointer it returns is then handed to kref_get_unless_zero() (amdgpu_bo_list.c:170) = an atomic RMW on freed/garbage memory (refcount corruption; M_IDR-zone reuse is limited to other idr arrays, so the read value is a foreign-but-real amdgpu_bo_list* or a stale pointer to an already-freed list β€” the latter gives the atomic increment on freed memory, and a later balanced amdgpu_bo_list_put() double-drop/UAF chain).
  • V2 (load reorder) β€” nothing in idr_find() forbids the compiler from loading idp->idr_nodes before idp->idr_count (independent loads, no barriers, no READ_ONCE). Reader caches the old 255-entry pointer, observes the new idr_count (e.g. 1023), passes the bound check with id=400 and reads nodes[400] β†’ OOB read 2320 bytes past the freed 4080-byte chunk plus UAF.

Note: the object half of the pattern is safe on DF β€” amdgpu_bo_list_free() releases via call_rcu(), and DF's call_rcu (sys/dev/drm/linux_rcu.c) defers callbacks β‰₯1 s β€” but the idr array half is not.

Files in this pack

  • idr_test_core.c β€” GENERATED from sys/libkern/linux_idr.c by gen_core.py: only the leading #ifdef USERLAND_TEST/#else/#endif header block is replaced with #include "harness_prelude.h"; every algorithm line is byte-identical to the kernel source (verified by diff at packaging time; detected line numbers map 1:1).
  • harness_prelude.h, inc/sys/idr.h β€” build-time stubs (same role the file's own USERLAND_TEST block plays) + instrumentation hooks. IDR_FIXED_BUILD maps lwkt_gettoken/reltoken onto a real mutex.
  • idr_race.c β€” concurrent harness: N reader threads call idr_find() with no lock (exactly amdgpu_bo_list_get()'s semantics) while a grower drives idr_alloc() growth phases (the CREATE ioctl's semantics). kfree is instrumented to poison freed chunks (0xDD) and quarantine them, so any read of a freed idr_nodes[] is detectable. Readers park on a gate while the idr is destroyed/re-initialized, so every observed free during a grow phase comes from idr_grow() β€” the kernel-reachable event (idr_destroy runs at fd close where drm core excludes in-flight ioctls).
  • oracle.c β€” deterministic interleaving proof of V1 and V2 against the same byte-identical core.
  • build.sh, run.sh β€” exact commands.
  • oracle_baseline.log, oracle_fixed.log, race_baseline.log, race_fixed.log, env.txt β€” full untrimmed guest output.
  • fix.diff β€” call-site fix (serialize with bo_list_lock).

Build / run (on the DragonFly guest)

sh build.sh          # 4 binaries: oracle, oracle_fixed, idr_race, idr_race_fixed
sh run.sh            # runs all four, tee'ing logs

Expected output

oracle (baseline, idr_find unlocked exactly as in the kernel):

V1 preemption-in-window: allocated=dddddddd data=dddddddddddddddd -> *** USE-AFTER-FREE READ of freed idr_nodes[] ... ***
V2 load-reorder:          allocated=dddddddd data=dddddddddddddddd -> *** OOB READ past freed 255-entry chunk ... ***
BASELINE RESULT: both interleavings dereference freed idr_nodes[] memory

oracle_fixed (idr_find takes the token β€” the fix, algorithm level):

V1 call-after-grow idr_find(4) = 5100000000000003 -> clean (valid tag / NULL)
V2 call idr_find(400)            = 510000000000012b -> clean (valid tag / NULL)
FIXED RESULT: no unsynchronized read possible; both variants clean

idr_race (concurrent, 400 grow phases = 1600 grow-kfree events): no spontaneous hit was observed β€” consistent with the ~2-cycle window vs microsecond-scale free; the in-kernel harm window is preemption/SMT-stall inside those 2 cycles or the compiler-reorder variant, which the oracle covers deterministically. idr_race_fixed completes the same workload with zero anomalies (regression check for the fix).

VERDICT.md
↓ download raw

DF-5033 VERDICT β€” UAF/OOB-read race in amdgpu_bo_list_get() (DF idr not RCU-safe for lookups)

Overall status: inconclusive at the kernel-ioctl level (no AMD GPU on the audit guest), certain at the code level β€” the defect is statically provable and both bad interleavings were demonstrated deterministically on the DragonFly guest against a byte-identical copy of the kernel algorithm.

Root cause (path:line)

  • amdgpu_bo_list.c:167-176 β€” amdgpu_bo_list_get() wraps a bare idr_find() in rcu_read_lock() only. Called from amdgpu_cs.c:591 with no fpriv->bo_list_lock (that mutex is taken only in amdgpu_bo_list.c itself, amdgpu_bo_list.c:157/282/304, and in amdgpu_kms.c init/destroy).
  • sys/libkern/linux_idr.c:516-529 β€” idr_find() takes no lock; it performs two unsynchronized memory steps: bound-check idp->idr_count, then load idp->idr_nodes and dereference nodes[id].allocated / nodes[id].data.
  • sys/libkern/linux_idr.c:421-459 β€” idr_grow() (under the token, from idr_alloc() at :358) does idp->idr_nodes = newnodes (:454) and kfree(oldnodes, M_IDR) (:458) β€” synchronously, no RCU deferral. DF's rcu_read_lock() cannot protect this (it is a preemption/critical section marker; only call_rcu() callbacks, deferred β‰₯1 s in sys/dev/drm/linux_rcu.c, are). The bo-list object lifetime is safe (amdgpu_bo_list_free() β†’ call_rcu, amdgpu_bo_list.c:58); the idr array lifetime is not.
  • Trigger: per-fd idr grows past 255/511/1023… live BO lists (IDR_DEFAULT_SIZE 255, linux_idr.c:132). An unprivileged render-node user creates 255+ BO lists (amdgpu_bo_list.c:276-291) in one fd while hammering CS ioctls referencing a list handle in the same fd.

In-kernel consequences

  1. V1 β€” UAF read, preemption in window: reader between nodes load and nodes[id] deref is preempted; grow frees the array; reader resumes β†’ reads freed memory β†’ returns garbage/foreign amdgpu_bo_list* β†’ kref_get_unless_zero() (amdgpu_bo_list.c:170) atomically increments freed/garbage memory. M_IDR-zone reuse is limited to other idr arrays (idr_init/idr_grow), so the value read is either NULL, a foreign live list pointer (balanced get/put β€” mostly benign cross-fd confusion), or β€” after the owning fd closed inside the window β€” a pointer to an already-kvfree'd bo list: an atomic increment write on freed memory, with a matched amdgpu_bo_list_put() later dropping a refcount that was never counted β†’ premature/double free of a bo list and its embedded entries array (which pins ttm_bo references) β†’ classic UAF chain material (panic β†’ potential privesc with grooming; narrow).
  2. V2 β€” compiler load reorder: idr_find()'s two independent loads may be reordered (no barriers/READ_ONCE); (new count, old nodes) tear β†’ bound check passes for id beyond the old array's 255 entries β†’ OOB read past the freed 4080-byte chunk β†’ same garbage-pointer sink.

Realistic impact ceiling on real hardware: kernel panic (garbage pointer kref_get) or, with substantial grooming, refcount corruption of freed kernel objects. Classification: Medium (AV:L/AC:H, requires amdgpu render node access + 255+ lists + winning a ~2-cycle window or the reorder).

Verification performed (this run)

  1. Guest: DragonFly 6.5-DEVELOPMENT #0 Thu Jul 2 2026, X86_64_GENERIC, 6 vCPU, cc 8.3. amdgpu.ko is present in /boot/kernel but the guest has no AMD GPU (vgapci 0:2:0 unattached, /dev/dri does not exist) β†’ the ioctl path cannot be exercised end-to-end. This caps the verdict at inconclusive for the in-kernel chain.
  2. Algorithm-level proof on the guest (byte-identical copy of sys/libkern/linux_idr.c β€” only the USERLAND_TEST header block replaced, verified by diff): * oracle baseline: V1 and V2 both deterministically dereference the freed idr_nodes[] array (poison 0xDD… returned as the data pointer β€” the exact value the kernel would pass to kref_get_unless_zero). See oracle_baseline.log, exit 1. * oracle_fixed (idr_find serialized by the token): both variants clean. See oracle_fixed.log, exit 0. This validates the fix concept at the algorithm level. * idr_race: 400 concurrent grow phases = 1600 grow-kfree events, 0 spontaneous hits β€” expected: the unsynchronized window is ~2 cycles while the poison write is Β΅s-scale, so a free can only land inside a reader's window if the reader is preempted exactly there (the real-world path) or via the reorder variant; both are what the oracle executes deterministically. idr_race_fixed: same workload, 0 anomalies.
  3. Fix: fix.diff serializes amdgpu_bo_list_get() with fpriv->bo_list_lock (the mutex already guarding every other bo_list_handles idr operation, amdgpu_bo_list.c:157-159/282-284/304-306), which closes the race for this path; the systemic alternative (make DF idr_find() take the lwkt token) is validated by oracle_fixed but touches every idr user and is left to maintainers. Kernel-build-level fix validation is not possible on this guest (no GPU to bind amdgpu); fix_status = not_testable, with the algorithm-level validation above.

Honest limits

  • No end-to-end kernel reproduction (hardware absent) β€” hence not reproduced.
  • The concurrent harness never caught the race "naturally"; the oracle forces the two legal interleavings instead. The static unsynchronized access is, however, indisputable from source.
  • Exploitability beyond panic is assessed, not demonstrated: it requires the M_IDR-zone reuse dance + a preempted-in-window reader and is rated AC:H accordingly.

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

not_testable
baseline no→ patch + rebuild →patched clean

Kernel-build fix validation impossible on this guest (no AMD GPU; amdgpu never binds, /dev/dri absent). Algorithm-level validation: baseline oracle reproduces both UAF/OOB interleavings (oracle_baseline.log, exit 1); with idr_find() serialized (IDR_FIXED_BUILD) the identical interleavings are clean (oracle_fixed.log, exit 0), and the serialized concurrent harness completes the same 1600-free workload with zero anomalies (race_fixed.log). fix.diff applies the equivalent serialization at the call site.

oracle_baseline.log vs oracle_fixed.log (exit 1 vs exit 0); race_baseline.log vs race_fixed.log (hits=0/0 with same workload, fix regression-free); fix.diff
↓ fix.diffper-fix-DF-5033

Confirmed kernel references

Detail

Exploit chain

unprivileged render-node user -> fd A: create 255+ BO lists (AMDGPU_BO_LIST_OP_CREATE, amdgpu_bo_list.c:276-291) -> idr grows per-fd bo_list_handles past 255 -> idr_grow() kfree(old nodes array); concurrently fd A thread B: AMDGPU_CS ioctl with a bo-list handle (amdgpu_cs.c:591 -> amdgpu_bo_list_get) -> idr_find() reads the freed/reused array (V1 preemption-in-window, or V2 count/nodes load reorder) -> stale/garbage amdgpu_bo_list* returned -> kref_get_unless_zero() atomically increments freed or foreign memory -> balanced amdgpu_bo_list_put() later drops an uncounted reference -> premature free of bo list + entries array (holds ttm_bo refs) -> UAF; realistic near-term outcome: panic. Not executed in-kernel (no GPU); demonstrated at algorithm level on the guest (oracle).

Evidence (decisive lines)

oracle_baseline.log: V1 and V2 lines show allocated=dddddddd data=dddddddddddddddd -> USE-AFTER-FREE READ / OOB READ past freed 255-entry chunk (exit 1); oracle_fixed.log: same interleavings clean, exit 0; race_baseline.log / race_fixed.log: 400 grow phases x 4 idr_grow kfree events, hits=0 both (natural catch needs preemption in the ~2-cycle window; no false positives); idr_test_core.c is byte-identical to sys/libkern/linux_idr.c below the USERLAND_TEST header (diff-verified at packaging); env.txt: guest uname + cc 8.3 + hw.ncpu=6

PoC changes

No PoC existed (pass-2 finding, authored fresh). Built: gen_core.py generator (replaces only the USERLAND_TEST header block of sys/libkern/linux_idr.c; algorithm byte-identical), poison-on-free instrumented allocators, 4-thread concurrent harness mirroring amdgpu_bo_list_get vs CREATE-ioctl growth, and a deterministic interleaving oracle for the two bad interleavings; IDR_FIXED_BUILD variant validates serializing idr_find.

Verified recommended fix

Take fpriv->bo_list_lock around idr_find()+kref_get_unless_zero in amdgpu_bo_list_get() (see fix.diff); systemic alternative: make DragonFly idr_find() acquire the idr lwkt_token (validated by oracle_fixed).

Verdict

Kernel-path end-to-end reproduction was impossible on the audit guest (amdgpu.ko present but no AMD GPU; /dev/dri absent), so the in-kernel chain stays unproven at runtime. The defect itself is statically certain: amdgpu_bo_list_get() calls idr_find() under rcu_read_lock() only (amdgpu_bo_list.c:167-176, reached from amdgpu_cs.c:591 without bo_list_lock), while DragonFly's idr_find() takes no lock (sys/libkern/linux_idr.c:516-529) and idr_grow(), invoked synchronously from the CREATE ioctl's idr_alloc() (amdgpu_bo_list.c:283 -> linux_idr.c:358 -> :421), swaps idp->idr_nodes (:454) and kfree()s the old array (:458) with no RCU deferral - DF rcu_read_lock() cannot protect it. On the guest, against a byte-identical copy of linux_idr.c, a deterministic oracle reproduced both bad interleavings: (V1) preemption between the nodes-pointer load and the nodes[id] dereference returns the freed chunk's contents as a data pointer - the exact value the kernel hands to kref_get_unless_zero() (amdgpu_bo_list.c:170), an atomic RMW on freed/garbage memory; (V2) compiler-permitted load reorder (new idr_count, old nodes) yields an OOB read up to 2320 bytes past the freed 255-entry (4080-byte) chunk. Serializing idr_find (fix) makes both variants clean. Ceiling: kernel panic from a garbage kref_get, or with grooming a refcount corruption -> premature/double free of an amdgpu_bo_list; requires amdgpu render-node access, 255+ live BO lists to force the first growth, and winning a ~2-cycle window (or the reorder) - rated Medium, AV:L/AC:H.