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)
PoC verification
Evidence pack
findings/poc/DF-5169 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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_structoffsets 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
canceledpoison 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 doesreservation_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-sizeM_DRMkmalloc 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
fixedfix.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)
Confirmed kernel references
- sys/dev/drm/i915/i915_gem_clflush.c:89
- sys/dev/drm/i915/i915_gem_clflush.c:104
- sys/dev/drm/i915/i915_gem_clflush.c:148-166
- sys/dev/drm/linux_workqueue.c:92-98
- sys/dev/drm/linux_workqueue.c:87-89
- sys/dev/drm/i915/i915_sw_fence.c:187-195
- sys/dev/drm/i915/i915_vma.c:926-934
- sys/dev/drm/linux_reservation.c:243-244
- sys/dev/drm/linux_reservation.c:260
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.
No comments yet.