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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5689 Β· 8 files
FileTypeDescriptionSize
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.c the race harness (includes the real linux_idr.c)
  • linux_idr.stock.c pristine copy of the audited source
  • shim/ userland stub headers (tokens = recursive pthread mutexes; kfree poisons)
  • run.*.log baseline and fix-validation outputs
  • fix.diff validated fix (same diff as DF-5688's β€” shared file)
VERDICT.md
↓ download raw

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_find for the already-serialized idr_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() under rcu_read_lock() only (i915_gem.c:4532-4533, 4971-4972). DF kfree() has no RCU grace period; the stale drm_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

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

fixed
baseline reproduced→ patch + rebuild →patched clean

with 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)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Sat Oct 10 01:43:01 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC (fixed kernel)

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.