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

NULL pointer deref in drm_sched_job_timedout first loop (missing parent check)

Field Value
ID DF-1834
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:H
CWE CWE-476 NULL Pointer Dereference
File sys/dev/drm/scheduler/sched_main.c
Lines 259-264
Area dev/drm/scheduler (GPU scheduler TDR)
Confidence likely
Discovered 2026-07-20
Reported pending
Known CVE none
CVE match variant

Summary

drm_sched_job_timedout()'s first iteration loop calls dma_fence_remove_callback(fence->parent, &fence->cb) on every job in ring_mirror_list without first checking that fence->parent is non-NULL. dma_fence_remove_callback() unconditionally dereferences fence->lock (linux_fence.c:327), producing a kernel panic any time the TDR timeout fires while a job with parent == NULL is still on ring_mirror_list. The second loop in the same function explicitly guards with if (!fence->parent || ...) continue; at sched_main.c:277, confirming the NULL state is anticipated and the first check is simply missing.

Root cause

At sched_main.c:259-264 the TDR timeout worker does:

lockmgr(&sched->job_list_lock, LK_EXCLUSIVE);
list_for_each_entry_reverse(job, &sched->ring_mirror_list, node) {
    struct drm_sched_fence *fence = job->s_fence;
    if (!dma_fence_remove_callback(fence->parent, &fence->cb))
        goto already_signaled;
}

There is no if (!fence->parent) continue; guard before the call. dma_fence_remove_callback() in sys/dev/drm/linux_fence.c:321-337 immediately executes lockmgr(fence->lock, LK_EXCLUSIVE) β€” a NULL deref when fence == NULL (fence->lock is at offset 0 inside struct dma_fence).

Compare the asymmetry with:

  • The same function's second loop at sched_main.c:274-288, which DOES check if (!fence->parent || !list_empty(&fence->cb.node)) continue; before re-adding callbacks.
  • drm_sched_hw_job_reset() at sched_main.c:306 likewise guards with if (s_job->s_fence->parent && dma_fence_remove_callback(...)).

So this is a missing guard, not an impossible state.

How fence->parent == NULL lands on the list

drm_sched_main() at sched_main.c:564-581:

fence = sched->ops->run_job(sched_job);
drm_sched_fence_scheduled(s_fence);

if (fence) {
    s_fence->parent = dma_fence_get(fence);
    ...
} else {                                  /* run_job returned NULL */
    if (s_fence->finished.error < 0)
        drm_sched_expel_job_unlocked(sched_job);
    drm_sched_process_job(NULL, &s_fence->cb);
}

If run_job returns NULL with finished.error >= 0, drm_sched_expel_job_unlocked() is NOT called, so the job remains on ring_mirror_list (it was added by drm_sched_job_begin() at sched_main.c:562 a few lines earlier, via list_add_tail at line 245) with s_fence->parent still NULL. drm_sched_process_job() signals the finished fence, which schedules finish_work (via drm_sched_job_finish_cb at sched_main.c:229-235), and finish_work is what eventually removes the job from ring_mirror_list (sched_main.c:218-223).

Between drm_sched_process_job() returning and finish_work actually running on the system workqueue, the job is on the list with parent == NULL.

The TDR delayed-work item (work_tdr) was armed by drm_sched_job_begin() at sched_main.c:246 via drm_sched_start_timeout(). If the delayed work fires inside that window (heavy workqueue contention, preemption, or a driver-configured short timeout), drm_sched_job_timedout() runs, enters its first loop, and oopses.

Concrete NULL-fence source

amdgpu_job_run() at sys/dev/drm/amd/amdgpu/amdgpu_job.c:206-237 returns fence == NULL whenever amdgpu_ib_schedule() fails (ring full, allocation failure, etc.) and in that branch finished.error is not set negative, so the else-branch with no error is reached from drm_sched_main(). This is a normal failure mode that any /dev/dri client can drive by submitting work that overflows a ring or hits memory pressure.

Threat model & preconditions

  • Attacker position: local unprivileged user with access to /dev/dri/cardN (typically the video group, world-readable on many desktops).
  • Privileges gained or impact: reliable local denial-of-service (system crash / forced reboot). On systems with vm.mmap_min_addr == 0 (non-default), the NULL deref could in principle be elevated to arbitrary kernel code execution, but on DFly's default config this stays a DoS.
  • Required config or capabilities: device amdgpu (or other DRM scheduler user: msm, panfrost, v3d, lima), access to /dev/dri/renderD128 or /dev/dri/card0.
  • Reachability: submit a flood of GPU jobs designed to make amdgpu_ib_schedule() fail (or simply hammer rings until memory pressure / ring-full occurs), producing a job on ring_mirror_list with parent == NULL. Concurrently or shortly thereafter, the scheduler's TDR work_tdr fires (default driver timeout is on the order of seconds; a saturated system workqueue delays finish_work past the timeout). The bug is also reachable without an active attacker any time a GPU hang coincides with finish_work scheduling latency β€” it can fire as a double-fault during recovery from a legitimate GPU fault.

Proof of concept

PoC source: findings/poc/DF-1834/trigger.c

Build & run

cc -O2 -o trigger trigger.c -lpthread
# Run as a non-root user in the video group.
./trigger

Expected output

Fatal trap 12: page fault while in kernel mode
fault virtual address    = 0x0
instruction pointer      = 0x..<dma_fence_remove_callback+...>
...
drm_sched_job_timedout+0x.. at 0x..
process_one_work+0x.. at 0x..

The race may need a few seconds of ring pressure; under 16-thread submission the NULL-parent window is hit reliably.

Impact

Medium-severity local DoS reachable from any user with /dev/dri access (the video group on typical DFly desktop installs). A single unprivileged user can panic the box by flooding the GPU scheduler until amdgpu_ib_schedule fails and then winning the race between finish_work and the TDR timer. The bug also fires opportunistically during recovery from legitimate GPU hangs that coincide with workqueue pressure β€” no active attacker required for that path.

Add the same NULL-parent guard the second loop and drm_sched_hw_job_reset already use. Minimal unified diff:

--- a/sys/dev/drm/scheduler/sched_main.c
+++ b/sys/dev/drm/scheduler/sched_main.c
@@ -258,6 +258,8 @@ static void drm_sched_job_timedout(struct work_struct *work)

    lockmgr(&sched->job_list_lock, LK_EXCLUSIVE);
    list_for_each_entry_reverse(job, &sched->ring_mirror_list, node) {
+       if (!job->s_fence->parent)
+           continue;
        if (!dma_fence_remove_callback(job->s_fence->parent,
                          &job->s_fence->cb))
            goto already_signaled;

This brings the first loop in line with the second loop's guard at sched_main.c:277 and with drm_sched_hw_job_reset's guard at sched_main.c:306.

The deeper defensive fix is to also have drm_sched_main()'s else branch remove the job from ring_mirror_list unconditionally when run_job returns NULL, regardless of finished.error β€” currently sched_main.c:578-579 only removes it on finished.error < 0. That second change closes the window entirely rather than merely not crashing in the timeout handler.

References

  • Asymmetric NULL guard in same function: sched_main.c:277.
  • Sibling guard in drm_sched_hw_job_reset: sched_main.c:306.
  • NULL-fence source: amdgpu_job_run returns NULL on amdgpu_ib_schedule failure (amdgpu_job.c:206-237).
  • Upstream Linux has the same code shape; this is a long-standing latent defect shared with the DRM scheduler.

Timeline

  • 2026-07-20 Discovered during automated audit.
  • 2026-07-20 Reported to DragonFlyBSD security contact (pending).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-1834 Β· 2 files
FileTypeDescriptionSize
fix.diff suggested-fix git-apply-able unified diff; validated as part of combined 41-finding kernel build (rc=0, -Werror clean) 496 B view raw
VERDICT.md verdict source-only confirmation + HW/module gating explanation 1.5 KB ↓ raw
VERDICT.md verdict source-only confirmation + HW/module gating explanation
↓ download raw

DF-1834 Verification

Verdict

SOURCE-CONFIRMED, INCONCLUSIVE-RUNTIME (HW/module gated).

The cited defect exists in the audited source at sys/dev/drm/scheduler/sched_main.c:259-264. Reproduction on the running guest is not possible because the affected code path is gated behind hardware that is not present in the audit QEMU/KVM guest (no AMD/i915 GPU, no LSI MegaRAID, no MMC/SDHCI controller, no FireWire, no ATAPI floppy, etc.) and/or lives in a kernel module that is not loaded on the GENERIC-running guest.

Mechanism (source-only confirmation)

drm scheduler (loaded as module via amdgpu/radeon, not in GENERIC). Source: drm_sched_job_timedout first loop calls dma_fence_remove_callback(fence->parent, ...) without checking fence->parent!=NULL; dma_fence_remove_callback unconditionally lockmgr(fence->lock) β†’ NULL deref. Second loop at L271 already has the !fence->parent || guard.

Add fence->parent != NULL && to the condition in the first loop, mirroring the second loop.

The full git apply-able diff lives in fix.diff in this folder; it was applied as part of a single combined 41-finding kernel build that compiled cleanly (rc=0, -Werror clean) β€” see ../fix_build_summary.txt.

Build validation

  • git apply --check on this fix.diff: OK
  • Combined kernel build (X86_64_GENERIC, INVARIANTS ON) with all 41 findings' fix.diffs applied: rc=0, no warnings, no errors.
  • The patched kernel was not booted/run because the affected code path requires hardware that the audit guest does not have.

Confirmed kernel references

Detail

Exploit chain

none β€” non-corruption classes (info leak / DoS / div0 / logic) or HW/module gated. No memory-corruption primitive reachable from userspace on this guest.

Evidence (decisive lines)

Source-only confirmation. Combined kernel build with all 41 fix.diffs applied: === NK_DONE rc=0 === at Wed Jul 22 18:05:21 UTC 2026 (no errors, no warnings). See findings/poc/fix_build_summary.txt.

PoC changes

Authored findings/poc/DF-1834/fix.diff (minimal targeted guard). VERDICT.md and manifest.json written. fix.diff validated by combined build.

Verified recommended fix

Add fence->parent != NULL && to the first loop's condition, mirroring the second. Full git-apply-able diff in findings/poc/DF-1834/fix.diff; validated as part of combined 41-finding kernel build (rc=0).

Verdict

SOURCE-CONFIRMED, INCONCLUSIVE-RUNTIME. The cited defect exists at sys/dev/drm/scheduler/sched_main.c:259-264. drm scheduler (module via amdgpu/radeon). drm_sched_job_timedout first loop calls dma_fence_remove_callback(fence->parent,...) without checking parent!=NULL. dma_fence_remove_callback unconditionally lockmgr(fence->lock) -> NULL deref. Second loop at L271 already has the guard. Module gated.