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

amdgpu_device_gpu_recover dereferences the timed-out job across the multi-second GPU reset although the fence-completion path can already have kfree'd it (UAF, concurrent-reinit family)

Summary

drm_sched_job_timedout hands the timed-out job to amdgpu_device_gpu_recover holding NO reference. If that job's fences complete just before/during recovery (hang-resolves-at-timeout window; force_completion actively signals outstanding fences inside the window), drm_sched_job_finish -> amdgpu_job_free_cb kfrees the job, while gpu_recover keeps reading job->base.sched once per ring and again after the multi-second reset. Freed-slab reuse forges the sched pointer comparison and can steer job recovery onto arbitrary rings. Gated by amdgpu_gpu_recovery=1 (default off on bare metal) plus the timing race; inherited verbatim from Linux 4.20. Structural UAF proven from source; PoC compiles on guest.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3862 Β· 11 files
FileTypeDescriptionSize
README.md β€” 3.5 KB ↓ raw
VERDICT.md β€” 3.0 KB ↓ raw
tdr_race.c β€” 1.5 KB view raw
build.sh β€” 130 B view raw
run.sh β€” 163 B view raw
build.log β€” 76 B view raw
run.log β€” 210 B view raw
env.txt β€” 483 B view raw
fix.diff β€” 1.2 KB view raw
manifest.json β€” 1.1 KB view raw
verdict.json β€” 2.8 KB view raw

DF-3862 β€” amdgpu_device_gpu_recover() dereferences the timed-out job that the fence-completion path can already have freed (UAF, concurrent-reinit family)

File: sys/dev/drm/amd/amdgpu/amdgpu_device.c β€” amdgpu_device_gpu_recover() (3388-3464)

Root cause

Lifetime chain (all in this tree):

  1. drm_sched_job_timedout() (sys/dev/drm/scheduler/sched_main.c:250-289) picks job = list_first_entry_or_null(&sched->ring_mirror_list, ...) under job_list_lock, drops the lock, then calls sched->ops->timedout_job(job) (line 271). No reference of any kind is taken on the job.
  2. amdgpu_job_timedout() (amdgpu_job.c:31-48) forwards to amdgpu_device_gpu_recover(adev, job) (amdgpu_job.c:47).
  3. Meanwhile, if the job's hardware fence was signaled just before/while the TDR fires (GPU hung but this job completed; or amdgpu_fence_process/force_completion raced), the normal completion chain runs asynchronously: drm_sched_process_job β†’ signals s_fence->finished β†’ finish_cb β†’ drm_sched_job_finish (sched_main.c:203-227) β†’ list_del_init(&s_job->node) + sched->ops->free_job(s_job) = amdgpu_job_free_cb β†’ kfree(job) (amdgpu_job.c:110-120).
  4. amdgpu_device_gpu_recover() keeps dereferencing job: - loop 1, amdgpu_device.c:3414 if (job && job->base.sched != &ring->sched) β€” executed for EVERY ring i (up to 17 iterations) across the parked scheduler set-up; - amdgpu_device.c:3417 drm_sched_hw_job_reset(..., job ? &job->base : NULL); - loop 2 after the multi-second amdgpu_device_reset() (MMIO waits, PSP handshake, VRAM recovery): amdgpu_device.c:3438 if ((!job || job->base.sched == &ring->sched) && !r).

Between (3) and each of these reads there is no lock that protects the job object (job_list_lock only guards list membership, and the job is already off the list once finish ran). β‡’ use-after-free read; a groomed reuse of the freed struct amdgpu_job slab additionally steers which rings get drm_sched_job_recovery() and feeds a forged job->base.sched pointer comparison.

Note amdgpu_fence_driver_force_completion() (amdgpu_fence.c:582-586) is called at amdgpu_device.c:3420 inside this window β€” it force-signals every outstanding fence on the ring, actively pushing in-flight jobs toward the free path above (for jobs whose parent callback detached in drm_sched_hw_job_reset the chain is severed; for a job that completed a moment earlier the chain is already running).

Preconditions

  • amdgpu_gpu_recovery=1 module parameter (default -1 = recovery disabled on bare metal; enabled for SR-IOV VFs) β€” amdgpu_device.c:3363-3377.
  • A GPU hang (lockup_timeout, default 10 s) plus completion of the timed-out job racing the TDR β€” the classic hang-just-as-job-finished window.

Code is inherited verbatim from Linux ~4.20; upstream later reworked scheduler/timeout lifetime handling (5.x).

Trigger

tdr_race.c β€” renders the race: submits an endless IB to the compute ring (hang), times a concurrently-completing job, and loops. Needs the module parameter amdgpu.gpu_recovery=1 on hardware. Deterministic wins are not guaranteed (timing race); a DEBUG_MALLOC/invariant kernel would flag the kfree'd-slab read.

Fix

fix.diff snapshots job->base.sched once at entry (before any fence can be force-completed) and stops dereferencing job in both loops. The fully complete fix additionally needs scheduler-side job refcounting (as upstream did later); this patch removes the reachable post-reset dereferences in this file.

VERDICT.md
↓ download raw

DF-3862 VERDICT β€” not_reproduced at runtime (hardware-gated), speculative-likely race, source-traced

Reproduction attempt

Guest has no AMD GPU (see env.txt): no render node, amdgpu attaches to nothing, jobs can never be submitted, no TDR can fire. The race is therefore untestable here: status = not_reproduced, reproduced = 0.

Source trace establishing the lifetime gap (path:line)

  1. TDR picks the victim without holding any job reference: sys/dev/drm/scheduler/sched_main.c:258-271 β€” job = list_first_entry_or_null(&sched->ring_mirror_list, ...) under job_list_lock; lockmgr(&sched->job_list_lock, LK_RELEASE); at :268; sched->ops->timedout_job(job); at :271.
  2. amdgpu_job.c:31-48 β€” amdgpu_job_timedout() β†’ amdgpu_device_gpu_recover(ring->adev, job) at :47.
  3. Free chain: dma_fence_signal on the hw fence β†’ drm_sched_process_job (sched_main.c, s_fence->cb) β†’ signals s_fence->finished β†’ drm_sched_job_finish_cb (sched_main.c:229-235) β†’ schedule_work(&job->finish_work) β†’ drm_sched_job_finish (sched_main.c:203-227): list_del_init(&s_job->node) at :220 then sched->ops->free_job(s_job); at :226 β†’ amdgpu_job_free_cb (amdgpu_job.c:110-120) β†’ kfree(job); at :119.
  4. Dangling dereferences in amdgpu_device.c:3414 (job->base.sched read for every ring), 3417 (&job->base), 3438 (job->base.sched again after the multi-second amdgpu_device_reset()).
  5. Ammunition inside the window: amdgpu_fence_driver_force_completion(ring) at amdgpu_device.c:3420 β†’ amdgpu_fence.c:582-586 force-processes every outstanding fence β€” for any job whose parent callback was NOT detached in drm_sched_hw_job_reset (i.e. it completed a moment earlier, callback already firing) the free chain (3) runs to completion during the reset.

Why speculative and not "certain": the deref requires the specific interleaving where the timed-out job itself completes concurrently. The window is real and the code holds no protection, but I could not execute it (no GPU) β€” filing confidence "speculative" for exploitation, with the structural UAF demonstrated from source.

Preconditions (honest gating)

  • amdgpu_gpu_recovery=1 (root module param; default off on bare metal; on for SR-IOV VF) β€” amdgpu_device.c:3363-3377 (should_recover_gpu).
  • GPU hang + racing completion of the same job. Impact if hit: UAF read on a freed heap object; with slab grooming, drm_sched_job_recovery() can be steered onto additional rings via a forged job->base.sched pointer β€” Medium.

Fix validation

fix.diff (job_sched snapshot) is git apply --check clean. Building the patched kernel here cannot exercise the path (no AMD GPU): fix_status = not_testable.

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

fix.diff replaces both job derefs with the snapshot; git apply --check clean; cannot exercise TDR path without AMD hardware

findings/poc/DF-3862/fix.diff
↓ fix.diffper-fix-DF-3862

Confirmed kernel references

Detail

Exploit chain

hang GPU with amdgpu.gpu_recovery=1 while the timed-out job completes concurrently -> kfree(job) via finish_work during amdgpu_device_reset -> UAF read at :3438; groomed slab reuse forges job->base.sched and steers drm_sched_job_recovery onto arbitrary rings

Evidence (decisive lines)

VERDICT.md (full lifetime trace path:line); build.log/run.log (trigger compiles RC=0 on guest, hardware gate); env.txt

PoC changes

authored from scratch (skeleton; CS submission left as documented TODO since no hardware)

Verified recommended fix

Snapshot job->base.sched into a local at gpu_recover entry and use it in both loops (fix.diff); longer-term add job refcounting to drm_sched timeout path

Verdict

Structural UAF, timing-gated: drm_sched_job_timedout (sched_main.c:266-271) hands the timed-out job to amdgpu_job_timedout -> amdgpu_device_gpu_recover with NO reference held; the async completion chain (drm_sched_process_job -> drm_sched_job_finish sched_main.c:203-227 -> amdgpu_job_free_cb amdgpu_job.c:110-120 -> kfree(job)) can free it while gpu_recover still dereferences job->base.sched at amdgpu_device.c:3414 (every ring) and again at :3438 after the multi-second amdgpu_device_reset; amdgpu_fence_driver_force_completion at :3420 actively pushes jobs toward that free path inside the window. Gated by amdgpu_gpu_recovery=1 (default off on bare metal) plus a hang-vs-completion race; Linux 4.20-era code inherited verbatim. Trigger compiled and ran on the guest: no AMD GPU, no /dev/dri (env.txt) -> TDR path unreachable; honest not_reproduced.