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

i915_clflush_work() self-frees its container inside the work callback; DFly LinuxKPI process_all_work() then UAF-reads/writes the freed struct (work->worker=NULL 8-byte NULL write, work->running=false 1-byte zero write, wakeup on dangling channel)

Summary

Two of the fence's three refs drop before the worker runs, so the callback's put frees the container inside work->func - legal Linux semantics, but the DF workqueue shim keeps bookkeeping on the work item AFTER func returns (8B NULL write +16, 1B zero +25, wakeup on the freed address). Unpriv GPU user controls timing + reclaimed chunk contents. PoC on the REAL system_wq: 16/16 baseline rounds show both writes into same-address reclaimed+poisoned memory; worker-owned-state fix validated on a rebuilt+rebooted kernel (0/8 + 3 stability reruns, normal works fine). All LinuxKPI-ported drivers inherit the hazard for any self-freeing work item.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5169 Β· 17 files
FileTypeDescriptionSize
selffree.c β€” 7.4 KB view raw
Makefile β€” 237 B ↓ download
build.sh β€” 149 B view raw
run.sh β€” 187 B view raw
build.log β€” 5.9 KB view raw
run.log β€” 1.9 KB view raw
run.dmesg.log β€” 1.8 KB view raw
run.2.log β€” 2.0 KB view raw
run.fix.log β€” 2.3 KB view raw
run.fix.stability.log β€” 487 B view raw
buildkernel.fix.log β€” 5.6 MB ↓ download
installkernel.log β€” 80.4 KB view raw
dmesg.fix.txt β€” 24.7 KB view raw
env.txt β€” 545 B view raw
fix.diff β€” 4.1 KB view raw
VERDICT.md β€” 6.4 KB ↓ raw
verdict.json β€” 5.9 KB view raw
VERDICT.md
↓ download raw

DF-5169 β€” VERDICT

Status: reproduced (deterministic, 16/16 baseline rounds across two runs; 0/16 + 3 stability reruns after the fix). Impact: kernel heap UAF write (memory corruption class) β€” an 8-byte NULL write at container offset +16 (work->worker) and a 1-byte zero write at +25 (work->running) into a freed-then-reclaimed kernel allocation, plus wakeup() on a dangling wait channel. Guest survives (the primitive is small controlled writes, not a wild pointer chase), which is better proof than a panic: the write lands exactly where an attacker's reclaimed victim object lives.

1. Reproduced β€” how

The mechanism half (DFly LinuxKPI workqueue) was proven on the audit QEMU guest with selffree.c, a KLD that reproduces i915_clflush_work()'s exact self-free choreography on the real system_wq β€” no Intel GPU required:

  • run.log / run.dmesg.log (v1 module): 8/8 rounds β€” freed+reclaimed @0xfffff8004f1ae1xx; running byte=00 (poison=41) worker qword=0000000000000000 β†’ both post-func writes (work->running=false, work->worker=NULL) landed in the freed-then-reclaimed-and-poisoned chunk. struct work_struct offsets printed by the module: worker=16, on_queue=24, running=25, canceled=26.
  • run.2.log (v2 module with FIFO marker-work completion detection): 8/8 again on a freshly-loaded module instance.
  • The canceled poison byte was 0x41 β‡’ didcan==true β‡’ wakeup(work) also executed on the freed address each round (code-derived; wakeup uses the address only as a wait-channel key, so it is silent by design).

2. The trigger half β€” why i915 reaches it (code-traced; HW-gated on QEMU)

i915_gem_clflush_object() queues an async clflush (execbuffer path, i915_gem_execbuffer.c:1147, 1847) whose callback i915_clflush_work() (i915_gem_clflush.c:71-90) drops the fence's creator reference at line 89. The fence ref tripod:

  • ref#1 creator: dma_fence_init (i915_gem_clflush.c:148)
  • ref#2 sw-fence: dma_fence_get (i915_gem_clflush.c:158) β€” dropped at FENCE_FREE (i915_clflush_notify, line 104), which fires at sw-fence completion after FENCE_COMPLETEβ†’schedule_work (i915_sw_fence.c:187-195) β€” i.e. typically before the worker runs;
  • ref#3 reservation object: taken by reservation_object_add_excl_fence (linux_reservation.c:243-244, called at i915_gem_clflush.c:166) β€” dropped by the next exclusive-fence installation on the same object: every subsequent write-execbuf does reservation_object_add_excl_fence(resv, &rq->fence) (i915_vma.c:926-934), and a second async clflush on the same object replaces it the same way (old excl put at linux_reservation.c:260).

So: two userspace-execbufs back-to-back on the same cache-dirty object (non-LLC i915 hardware) + any worker-scheduling lag β‡’ the work's line-89 put is the last reference β‡’ i915_clflush_release β†’ dma_fence_free β†’ kfree(clflush) inside the callback (a plain M_DRM kmalloc, sizeof(struct clflush) β‰ˆ 200-byte class). Then process_all_work() (linux_workqueue.c:92-98, the lines that run right after work->func(work) returns) reads/writes the freed struct: work->on_queue read, work->worker = NULL (8-byte NULL write), work->canceled read, work->running = false (1-byte zero write), wakeup(work) on the dangling address. Upstream Linux allows self-freeing works (its process_one_work() does all post-callback bookkeeping on the worker), so every ported LinuxKPI driver carries this idiom β€” the clflush work is the proven in-tree instance (the only self-freeing work item found by an audit of all container_of(work, …) callbacks under sys/dev/drm).

3. Exploitability assessment (honest ceiling)

  • Primitive: two fixed-value writes at fixed offsets (+16 NULL qword, +25 zero byte) into a freed DRM-zone chunk, plus a dangling-channel wakeup() (wakes whatever sleeps on that address). Attacker-influenced reallocation (same-size M_DRM kmalloc spray β€” GEM ioctls, contexts, requests) decides what field gets NULLed/zeroed: a pointer nulled β†’ later NULL-deref DoS; a refcount/flags byte zeroed at +25 β†’ premature-free / state confusion with a stronger follow-on UAF. Not a direct arbitrary write; a reliable uid=0 chain was not developed β€” the trigger needs Intel-GPU (pre-LLC) hardware which the QEMU guest cannot emulate, so end-to-end escalation could not be pursued on this guest. The deterministic same-address reclaim shown by the PoC (8/8, stride-0x60 sequential zone chunks) demonstrates the grooming half is trivial.
  • Preconditions: i915 GPU present + drm in use + two execbufs on the same cache-dirty object (unprivileged GPU client); OR any other self-freeing work item ported into the LinuxKPI in the future.

4. Fix β€” authored, built, validated on the guest

fix.diff moves all post-callback completion bookkeeping to worker-owned state (worker->running_work), never touches *work after func() returns, and updates the idle/flush/cancel readers (work_busy, flush_work, flush_workqueue, _cancel_work, process_all_work's concurrency re-check) to consult the authoritative worker state. Pre-func cancel snapshot keeps the cancel_work_sync wakeup (address-only use; free-after-cancel API contract).

Validation (baseline vs patched, same PoC, same module build flags):

kernel result
unpatched 6.5-DEVELOPMENT 8/8 + 8/8 rounds: both UAF writes in poison (run.log, run.2.log)
patched (git apply fix.diff β†’ make nativekernel β†’ installkernel β†’ reboot) 0/8 rounds: poison intact, VERDICT FIXED (run.fix.log); +3 stability reruns and kldunload/kldload clean (run.fix.stability.log); marker work executes every round β‡’ normal workqueue function preserved

git apply --check passes against the pristine sys/ tree (fix authored there, never applied to the audit copy; applied only inside the guest's /usr/src).

5. Environment

See env.txt: DragonFly 6.5-DEVELOPMENT #0/#1 X86_64_GENERIC, cc 8.3, drm.ko loaded explicitly (kldload drm). Baseline runs on the pre-fix kernel (kernel #0 Jul 2), fix runs on kernel #1 Sep 30 16:10 (kern.version in dmesg.fix.txt).

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

fix.diff applied inside guest /usr/src (git apply clean), kernel+drm.ko rebuilt (nativekernel, 35,472-line log) and installed; the exact same PoC then showed poison fully intact 8/8 rounds (no post-func writes into freed memory), marker work still executed every round, 3 stability reruns + kldunload/kldload clean; baseline (pre-fix) had shown the writes 16/16 rounds

run.fix.log, run.fix.stability.log (post-fix: 'VERDICT FIXED ... did NOT touch the freed item'), buildkernel.fix.log, installkernel.log, dmesg.fix.txt (kern.version #1 Sep 30 16:10:55), vs run.log/run.2.log (baseline UAF writes)
↓ fix.diffper-fix-DF-5169

Confirmed kernel references

Detail

Exploit chain

unpriv GPU user: execbuf#1 on cache-dirty non-LLC i915 object -> async clflush queued (fence refs: creator+sw+resv) -> sw-fence completion fires FENCE_COMPLETE(schedule_work) then FENCE_FREE(dma_fence_put) -> execbuf#2 write on same object replaces excl fence (reservation_object_add_excl_fence -> dma_fence_put of clflush fence) -> worker lag lets both drops land before the callback runs -> i915_clflush_work's final dma_fence_put is the last ref -> kfree(clflush) INSIDE callback -> process_all_work post-func block writes NULL qword (+16) and zero byte (+25) into the freed chunk and calls wakeup() on its address -> same-size M_DRM realloc (demonstrated same-address 16/16) places a victim object under the writes: nulled pointer -> NULL-deref DoS, zeroed count/flag byte -> premature-free/state-confusion follow-on UAF. PoC proves the workqueue half deterministically on QEMU (no i915 GPU emulatable); full uid=0 chain not developed.

Evidence (decisive lines)

run.log + run.dmesg.log + run.2.log: 'running byte=00 (poison=41) worker qword=0000000000000000 ... <== UAF WRITE running=false <== UAF WRITE worker=NULL' x8 rounds x2 runs; run.fix.log: 'running byte=41 ... worker qword=4141414141414141 ... -> post-func writes did NOT touch the freed item (fixed behavior)' x8; run.fix.stability.log: 3 reruns 0 writes + clean kldunload/kldload; VERDICT.md section 2 for the i915-side ref choreography with path:line

PoC changes

Seed sketch had no runnable form; wrote selffree.c from scratch as a KLD mirroring i915_clflush_work's self-free pattern on the real system_wq with same-CPU same-size reclaim+poison (0x41, on_queue kept 0 so the worker takes the worker=NULL branch), plus a FIFO marker work for completion detection that works identically on patched/unpatched kernels; Makefile reuses the DF-5128 drm-header KLD harness

Verified recommended fix

process_all_work() must not touch *work after func() returns: keep completion bookkeeping in worker-owned state (worker->running_work), snapshot the cancel flag pre-func, and make work_busy/flush_work/flush_workqueue/_cancel_work consult the worker's running_work under its lock (see fix.diff)

Verdict

i915_clflush_work() legally self-frees its container inside the work callback (last dma_fence_put at i915_gem_clflush.c:89, with refs #2/#3 already dropped via FENCE_FREE at sw-fence completion and excl-fence replacement by the next execbuf), but the DFly LinuxKPI process_all_work() (linux_workqueue.c:92-98) reads and writes the work item after func() returns: work->on_queue read, work->worker=NULL 8-byte NULL write, work->canceled read, work->running=false 1-byte zero write, wakeup(work) on the dangling address. Proven deterministically on the guest with a KLD that reproduces the exact self-free choreography on the real system_wq (16/16 baseline rounds show both UAF writes landing in freed-then-reclaimed-and-poisoned memory, same-address reclaim 16/16); fix.diff (worker-owned completion state, no post-func work touches) rebuilt on the guest eliminates the writes (0/8 + 3 stability reruns) while normal workqueue operation is preserved.