idr_find()/idr_for_each() read the node array without the idr token while idr_grow() kfree()s the old array β use-after-free read returns a stale kernel object pointer (and idr_for_each walks OOB past the freed array)
Summary
Mutators serialize on idr_token but the two readers are lockless while idr_grow swaps idr_nodes and kfrees the old array: a lockless reader straddling the swap returns a stale kernel object pointer read from freed M_IDR slab (kernel-pointer disclosure; object confusion with grooming). idr_for_each additionally caches the node pointer once but re-reads idr_count each iteration, walking past the freed array. Unguarded in-tree consumer: i915_gem_object_lookup_rcu under rcu_read_lock only - DF kfree has no RCU grace period so the Linux RCU argument does not transfer. Race harness with poisoned-kfree canary: UAF-READ hit 3/3 rounds within seconds (30k-10M finds per hit); foreach variant SIGSEGVs; fixed source zero hits across 81.8M racing finds. take-the-token fix validated.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5689 Β· 8 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df5689_race.c | β | 5.5 KB | view raw | |
| shim/ | β | 4.0 KB | β download | |
| build.sh | β | 66 B | view raw | |
| run.race.log | β | 1.1 KB | view raw | |
| fix.diff | β | 2.7 KB | view raw | |
| env.txt | β | 1.5 KB | view raw | |
| README.md | β | 3.2 KB | β raw | |
| VERDICT.md | β | 4.3 KB | β raw |
DF-5689 β idr_find()/idr_for_each() are lockless against idr_grow()'s kfree of the old node array (UAF read)
File: sys/libkern/linux_idr.c (pass 2)
Class: kernel use-after-free read (memcorrupt / kernleak)
Evidence class (per ID reservation): same-source userland race harness with a poisoned kfree canary (the guest has no ASAN), stock vs fixed source from /usr/src.
Build (on the DragonFly guest, as root)
sh build.sh # builds df5689_race_stock (pristine linux_idr.stock.c,
# md5 2d66ecf755e254d38a5c9e9e63992eaa)
# and df5689_race (/usr/src/sys/libkern/linux_idr.c β
# stock for the baseline phase, fixed after fix.diff is
# applied for the validation phase)
Run
./df5689_race_stock 15 # baseline: 2 find-threads + writer cycling
# grow 255->511->1023->2047 (3 kfrees/cycle)
./df5689_race_stock 10 foreach # adds the idr_for_each reader (usually
# SIGSEGVs: OOB read past the freed old
# array β for_each caches nodes but re-reads
# idr_count each iteration)
./df5689_race 15 # fixed source: same load, expect zero hits
Expected
- stock: within seconds β
UAF-READ: idr_find(<id>) returned data from a FREED idr_nodes array (0xdddddddddddddddd)(the shim allocator poisons every kfree'd chunk; that pointer value can only be observed by reading freed memory). Observed on every baseline round; the for_each variant additionally crashes with SIGSEGV (out-of-bounds read past the freed chunk). - fixed (fix.diff: idr_find takes idr_token; idr_for_each takes idr_token and tests leaf state): zero hits across 37.4M racing idr_find calls in 3 rounds (14.6M/13.3M/9.5M) against 102k poisoned grow-kfrees.
In-kernel meaning
idr_find() returns nodes[id].data from the freed array β i.e. a
stale kernel object pointer read from freed slab memory is handed back
to the caller. In-tree unguarded consumer:
i915_gem_object_lookup_rcu() (sys/dev/drm/i915/i915_gem_object.h:307-314)
calls idr_find() under rcu_read_lock() only
(sys/dev/drm/i915/i915_gem.c:4532-4533, 4971-4972); DragonFly kfree()
has no RCU grace period, so the Linux RCU argument does not transfer.
The stale struct drm_i915_gem_object * then flows into
i915_gem_object_get_rcu()/use β a UAF-deref of attacker-influenceable
slab content (object confusion), plus a kernel-pointer disclosure
primitive (freed-chunk contents returned through the ioctl).
The guest has no GPU (/dev/dri absent), so the i915/ioctl-level chain
cannot be driven there; the same-source race harness is the reserved
evidence class for this file and establishes the UAF read conclusively
(poison canary + OOB-SEGV manifestation).
Files
df5689_race.cthe race harness (includes the real linux_idr.c)linux_idr.stock.cpristine copy of the audited sourceshim/userland stub headers (tokens = recursive pthread mutexes; kfree poisons)run.*.logbaseline and fix-validation outputsfix.diffvalidated fix (same diff as DF-5688's β shared file)
DF-5689 VERDICT
Bottom line
REPRODUCED β certain. idr_find() (sys/libkern/linux_idr.c:515-528) and
idr_for_each() (linux_idr.c:530-546) read idp->idr_count /
idp->idr_nodes / the node array without taking idp->idr_token, while
idr_grow() β invoked under the token from idr_pre_get() (linux_idr.c:275)
and idr_alloc() (linux_idr.c:375) β swaps idp->idr_nodes and
kfree()s the OLD array (linux_idr.c:449-459). A concurrent lockless reader
that has loaded the old array pointer dereferences freed memory:
idr_find() returns whatever the freed slab chunk contains as a kernel
object pointer (UAF read), and idr_for_each() β which caches the nodes
pointer once (linux_idr.c:537) but re-reads idp->idr_count every iteration
(linux_idr.c:538) β walks past the end of the freed old array (OOB read;
the rig's for_each variant dies with SIGSEGV against the stock source).
Evidence (same-source rig, kfree-poison canary):
- baseline (pristine source, md5 2d66ecf755e254d38a5c9e9e63992eaa):
UAF-READ: idr_find(N) returned data from a FREED idr_nodes array (0xdddddddddddddddd)β 3/3 rounds, hit after 30kβ10M finds; the value 0xDDDDβ¦ can only be observed by reading a chunk after its kfree. for_each variant: SIGSEGV (OOB read past the freed chunk). - fixed source (fix.diff: token-held
idr_find/idr_for_each+__idr_findfor the already-serializedidr_get_next): zero hits in 3 rounds totaling 37.4M racing finds against 102k grow-kfrees. (fix-race.log)
In-tree exposure (lock discipline vs the drm consumers)
- Unguarded:
i915_gem_object_lookup_rcu()(sys/dev/drm/i915/i915_gem_object.h:307-314) βidr_find()underrcu_read_lock()only (i915_gem.c:4532-4533, 4971-4972). DFkfree()has no RCU grace period; the staledrm_i915_gem_object *flows into refcount/use β object confusion on freed slab content, and a kernel-pointer disclosure through the ioctl path. - Guarded by external locks (safe today, fragile by design): drm_syncobj find (syncobj_table_lock, drm_syncobj.c:88-91), drm_gem lookup (table_lock, drm_gem.c:625-628), mode objects (idr_mutex, drm_mode_object.c:132-133), dp aux (aux_idr_mutex). The internal-token design invites the Linux-style "rcu-lookup" pattern that i915 already uses β the API must either take the token itself or document/enforce external locking.
Exploitation ceiling
The primitive is a read of freed kernel heap returned as an object
pointer: (a) info-leak of freed-slab contents (stale object pointers +
whatever the groomer left there) through any ioctl that reports the
looked-up object; (b) object confusion β attacker grooms the freed
4KB/8KB chunk (M_IDR) so the stale data field points at attacker-chosen
kernel memory, then the i915 path operates on a fake object. Full uid=0
was not pursued (no /dev/dri on the audit guest; the same-source rig is
the reserved evidence class). The race window is a single
load-to-dereference gap but the attacker controls both the allocation
cadence (grow rate) and the lookup rate, and the rig shows reliable hits
within seconds of userland-grade threading.
Fix
fix.diff (shared with DF-5688, same file): idr_find() takes
idp->idr_token around an internal __idr_find(); idr_for_each() takes
the token around the scan; idr_get_next() (the drm shim that already
holds the token) is switched to __idr_find(). Validated: 37.4M racing
finds, zero hits (vs 3/3 baseline hits).
Key references
- sys/libkern/linux_idr.c:515-528 (lockless idr_find)
- sys/libkern/linux_idr.c:530-546 (lockless idr_for_each; :537 cached pointer, :538 fresh count)
- sys/libkern/linux_idr.c:421-461 (idr_grow), :449-459 (swap + kfree of old array)
- sys/libkern/linux_idr.c:275, 375 (grow call sites under the token)
- sys/dev/drm/i915/i915_gem_object.h:307-314; sys/dev/drm/i915/i915_gem.c:4532-4533, 4971-4972 (unguarded consumer)
- sys/dev/drm/include/linux/idr.h:104-121 (idr_get_next, token-held)
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
fixedwith fix.diff applied to /usr/src/sys/libkern/linux_idr.c, the identical race load produced zero UAF observations across 3 rounds totaling 81.8M idr_find calls racing 338k kfree-poisoned grow events (baseline: 3/3 hits within seconds); the fix is the token-acquisition hunk shared with DF-5688's diff.
run.race.log (baseline vs fixed sections)
Confirmed kernel references
Detail
Exploit chain
multithreaded unprivileged DRI client: thread A hammers lockless GEM-object lookups (i915 rcu-lookup path) while thread B forces idr_grow (handle allocations crossing 2^n-1 boundaries) -> B's kfree of the old nodes array races A's in-flight idr_find -> A receives a pointer read from freed M_IDR slab -> attacker grooms the freed chunk so nodes[id].data points at chosen memory -> fake drm_i915_gem_object consumption (info leak + object confusion).
Evidence (decisive lines)
run.race.log (3/3 baseline UAF-READ hits with poison pointer 0xdddddddddddddddd; for_each SIGSEGV; 3/3 fixed rounds with 81.8M finds and 338k grow-kfrees, zero hits); README.md/VERDICT.md (protocol, canary design, consumer analysis)
PoC changes
authored the whole harness: same-source compile via shim headers, poisoned-kfree canary (guest lacks ASAN), two-sided reader barrier so the caller-contract destroy path cannot masquerade as the grow race, stock-vs-fixed binaries for attribution
Verified recommended fix
take idp->idr_token in idr_find() and idr_for_each() (unlocked __idr_find for the already-serialized idr_get_next in the drm shim)
Verdict
REPRODUCED with certainty via the same-source race harness (kfree-poison canary; the guest has no ASAN): idr_find() (linux_idr.c:515-528) and idr_for_each() (linux_idr.c:530-546) take no idr_token while idr_grow() β under the token from idr_pre_get()/idr_alloc() (linux_idr.c:275, 375) β swaps idp->idr_nodes and kfree()s the old array (linux_idr.c:449-459). Every baseline round against the pristine source observed 'UAF-READ: idr_find(N) returned data from a FREED idr_nodes array (0xdddddddddddddddd)' β a value that can only be read out of kfree'd memory β within seconds (hits after 30k-10M finds); the idr_for_each variant, which caches the nodes pointer (linux_idr.c:537) but re-reads idr_count per iteration (linux_idr.c:538), walks past the end of the freed old array and SIGSEGVs (OOB read). In-kernel meaning: a stale kernel object pointer read from freed slab memory is returned to the caller; the unguarded in-tree consumer is i915_gem_object_lookup_rcu() (i915_gem_object.h:307-314) called under rcu_read_lock() only (i915_gem.c:4532-4533, 4971-4972) β DF kfree has no RCU grace period, so the stale drm_i915_gem_object pointer flows into refcount/use (object confusion on freed-slab content, plus kernel-pointer disclosure through the ioctl). Impact labeled 'leak' (stale-pointer disclosure) with the object-confusion ceiling noted; uid=0 not pursued because the guest has no /dev/dri β the same-source harness is the reserved evidence class for this file.
No comments yet.