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

output_poll_execute's self-rearm structurally survives drm_kms_helper_poll_disable/poll_fini on the DragonFly workqueue shim β€” poll work executes up to 10s AFTER cancel_delayed_work_sync returned (mid-suspend connector probe on a suspended GPU, or UAF on a torn-down drm_device)

Summary

output_poll_execute re-arms itself whenever any polled connector exists; the shim's cancel_delayed_work_sync cancels the callout BEFORE waiting out the running callback, its idle predicate ignores a pending re-armed timer, and the late queue_work CLEARS work->canceled - so cancel returns 'work dead' while it is guaranteed to run once more up to 10s later. Suspend callers get a full connector-detect cycle against a suspending GPU; teardown callers get the poll on a drm_device being freed (UAF of mode_config + connector walks; fixed code, crash/hang ceiling). VERIFIED in-kernel on guest via a KLD compiling the shim functions verbatim against the real primitives ('timer pending=1' at cancel return, then EXEC #2; FIXED build converges). Make-cancel-converge fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4625 Β· 11 files
FileTypeDescriptionSize
README.md β€” 3.7 KB ↓ raw
VERDICT.md β€” 4.9 KB ↓ raw
df4625.c β€” 8.2 KB view raw
build.sh β€” 66 B view raw
run.sh β€” 126 B view raw
fix.diff β€” 1.7 KB view raw
build.log β€” 10.4 KB view raw
run.log β€” 1.3 KB view raw
env.txt β€” 275 B view raw
df4625_extracted.inc β€” 3.9 KB ↓ download
verdict.json β€” 7.5 KB view raw

DF-4625 β€” poll worker's self-rearm survives cancel_delayed_work_sync β†’ post-cancel execution (mid-suspend MMIO / teardown UAF)

What

output_poll_execute() (sys/dev/drm/drm_probe_helper.c:570-655) re-arms itself at :653-654 (if (repoll) schedule_delayed_work(delayed_work, DRM_OUTPUT_POLL_PERIOD) = 10 s). Upstream Linux relies on cancel_delayed_work_sync() reliably killing self-rearming work. The DragonFly drm shim's cancel_delayed_work_sync() (sys/dev/drm/linux_workqueue.c:415-424) does not:

  1. :420-421 it sets canceled and runs callout_cancel(&dwork->timer) first. If the callback is currently executing, the timer is idle β€” callout_cancel (kern_timeout.c:1032) finds nothing, consumes/clears the CANCEL state and returns.
  2. _cancel_work (:340-388) then parks in lksleep waiting for running == false.
  3. The running callback (our poll worker) reaches :654 and calls schedule_delayed_work() β†’ callout_reset() arms a fresh timer (nothing blocks a reset; the CANCEL flag was consumed in step 1) β€” then returns.
  4. _cancel_work wakes, sees on_queue == 0 && running == 0 and breaks β€” a pending timer is invisible to it. cancel_delayed_work_sync returns with the timer armed.
  5. Up to 10 s later the timer fires β†’ _delayed_work_fn β†’ queue_work(), which clears work->canceled (linux_workqueue.c:135) and re-queues β€” the full poll cycle executes after its caller was promised the work is dead.

Victim contract: drm_kms_helper_poll_disable() (:695-700, cancel_delayed_work_sync at :699) and drm_kms_helper_poll_fini() (:735-739) β€” called from amdgpu suspend (amdgpu_device.c:985, :2813), radeon suspend (radeon_device.c:1233, :1620), i915 (i915_drv.c:1983), and teardown (i915_drv.c:1663, amdgpu dce_v8/10/11/virtual, radeon_display.c:1652, drm_modeset_helper.c:185).

Impact: a full connector-detect cycle racing device suspend (MMIO against a powered-down GPU β†’ hang/panic) or executing on a torn-down drm_device (use-after-free of dev->mode_config / freed connectors).

Build / run (DragonFly guest, fresh boot)

sh build.sh    # verbatim-extracts the shim from /usr/src, builds
               # df4625.ko (baseline) and fixed/df4625fix.ko
sh run.sh      # kldload both; verdicts land in dmesg

The KLD compiles process_all_work, wq_worker_thread, queue_work, _delayed_work_fn, queue_delayed_work, _cancel_work, and the cancel_* trio verbatim against the real kernel primitives (lockmgr / lksleep / callout_reset / callout_cancel / lwkt), then runs a poll-worker-shaped self-rearming delayed work and cancels it mid-callback. The modules deliberately refuse to unload (the verbatim worker thread loops forever β€” the already-filed DF-1977 wart); reboot after use.

Expected output (dmesg)

baseline: df4625: canceller: cancel_delayed_work_sync() RETURN (timer pending=1 on_queue=0 running=0) df4625: EXEC #2 entered df4625: DF-4625 REPRODUCED: work executed AFTER cancel_delayed_work_sync() returned (exec_count 1 -> 2) fixed: df4625: canceller: cancel_delayed_work_sync() RETURN (timer pending=0 on_queue=0 running=0) df4625: FIX VALIDATED: no execution after cancel_delayed_work_sync() returned within the window (exec_count=1)

The timer pending=1 at cancel-return is the structural miss made directly observable; EXEC #2 is the post-cancel execution.

Files

df4625.c KLD: environment + scenario (fixed variant via -DDF4625_FIXED) build.sh / run.sh exact build/run fix.diff converging cancel_delayed_work_sync (linux_workqueue.c) df4625_extracted.inc the verbatim shim extraction build.log / run.log / env.txt full untrimmed evidence

VERDICT.md
↓ download raw

DF-4625 VERDICT β€” REPRODUCED (in-kernel KLD, verbatim shim + real primitives)

Status: reproduced / impact: dos (post-cancel execution β†’ mid-suspend probe or teardown UAF) / confidence: certain.

Root cause (traced line by line)

  • drm_probe_helper.c:653-654 β€” output_poll_execute() unconditionally re-arms itself (repoll is true whenever any polled connector exists).
  • drm_probe_helper.c:695-700 / :735-739 β€” drm_kms_helper_poll_disable()/ poll_fini() delegate the kill to cancel_delayed_work_sync().
  • linux_workqueue.c:415-424 β€” the shim cancels the callout before waiting out the running callback; when the callback then re-arms (callout_reset arms a fresh timer; DFBSD callouts only refuse resets while a CANCEL request is still queued β€” kern_timeout.c:293 checks CALLOUT_CANCEL at wheel-processing time, and _callout_cancel_or_stop on an idle timer consumes/clears the request immediately at kern_timeout.c:1032), nothing re-cancels afterwards.
  • linux_workqueue.c:348-385 β€” _cancel_work() breaks as soon as on_queue == 0 && running == 0; a pending timer is not part of its idle predicate.
  • linux_workqueue.c:135 β€” when the surviving timer fires, queue_work() clears work->canceled, so the full callback re-executes.

Net effect: cancel_delayed_work_sync() returns while the work is guaranteed to run once more (up to DRM_OUTPUT_POLL_PERIOD = 10 s later, drm_probe_helper.c:206). Consumers:

  • suspend: amdgpu_device.c:985/:2813, radeon_device.c:1233/:1620, i915_drv.c:1983 β†’ full connector detect (i2c/aux reads + MMIO) against a suspending/suspended GPU β†’ hang/panic.
  • teardown: i915_drv.c:1663, amdgpu dce_v8/10/11/virtual fini, radeon_display.c:1652, drm_modeset_helper.c:185 β†’ the work executes on a drm_device being freed β†’ UAF (dev->mode_config reads, connector walks via kref-protected iters on freed memory).

How it was proven (guest, DragonFly 6.5-DEVELOPMENT, real kernel)

A KLD (df4625.c) that sed-extracts the shim functions verbatim from /usr/src/sys/dev/drm/linux_workqueue.c (ranges in build.sh) and compiles them against the real lockmgr/lksleep/callout_reset/ callout_cancel/lwkt-thread kernel primitives. The scenario emulates the poll worker: a callback that re-arms itself with schedule_delayed_work() while a canceller thread runs cancel_delayed_work_sync() mid-callback β€” the exact interleaving a suspend or poll_fini() hits when it races the 10 s poll cycle (probe cycles run for hundreds of ms, so the overlap is a per-event probability that compounds over suspend/resume cycles).

Decisive dmesg (run.log):

BASELINE:
  canceller: cancel_delayed_work_sync() RETURN (timer pending=1 on_queue=0 running=0)
  EXEC #2 entered
  DF-4625 REPRODUCED: work executed AFTER cancel_delayed_work_sync() returned
FIXED (fix.diff):
  canceller: cancel_delayed_work_sync() RETURN (timer pending=0 on_queue=0 running=0)
  FIX VALIDATED: no execution after cancel_delayed_work_sync() returned

timer pending=1 at cancel-return is the structural miss observed directly in-kernel, not inferred. (The four "interface/module already present" lines in run.log are earlier failed-load attempts of the fixed module before its module id was renamed β€” kept for honesty.)

Why dos-grade and not higher

The post-cancel execution runs fixed kernel code (the poll worker) on a freed/suspended object; there is no attacker-controlled write primitive. Realistic ceiling: repeatable suspend/resume or driver-teardown crash / hang (needs a drm-driven GPU and timing overlap). The guest's X86_64_GENERIC has no drm compiled in (optional drm), so the real poll path could not be exercised on it β€” hence the KLD reproduces the exact code and primitives rather than the driver itself.

Fix validation

fix.diff replaces cancel_delayed_work_sync()'s one-shot sequence with a converging loop (_cancel_work until callout_cancel() returns 0 and the work is idle). Convergence argument: any re-arm happens before the callback returns (i.e., before running == false is observed), so the loop's callout_cancel always observes the last-armed timer; a timer firing mid-loop is covered by callout_cancel's synchronous wait on CALLOUT_INPROG (kern_timeout.c). Validated on the same kernel: fixed module returns with pending=0 and no post-cancel execution.

Filed against drm_probe_helper.c (the victim whose drm_kms_helper_poll_disable/poll_fini contract is violated); the causal patch lands in linux_workqueue.c β€” a file whose pass-1 findings (DF-1976..1980) did not cover this self-rearm cancel miss.

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 replaces cancel_delayed_work_sync's one-shot sequence with a converging loop (_cancel_work until callout_cancel returns 0 AND work idle). On the fixed KLD run: cancel returns with timer pending=0 on_queue=0 running=0 and no execution occurs in the post-cancel window, vs the baseline's pending=1 + post-cancel EXEC. The loop converges because any re-arm happens before the callback returns (before running==false is observed), and callout_cancel's sync wait (kern_timeout.c CALLOUT_INPROG) covers a timer firing mid-loop.

run.log FIXED build section: 'RETURN (timer pending=0 on_queue=0 running=0)' + 'FIX VALIDATED: no execution after cancel_delayed_work_sync() returned within the window (exec_count=1)'
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0 x86_64 (same-kernel KLD-level validation: verbatim shim vs shim-with-fixed-cancel_delayed_work_sync, real lockmgr/lksleep/callout primitives; full kernel rebuild hardware-gated: guest's X86_64_GENERIC has no drm)

Confirmed kernel references

Detail

Exploit chain

cancel_delayed_work_sync(dwork) while dwork's callback is executing -> :421 callout_cancel runs on an idle timer (consumed/cleared, kern_timeout.c _callout_cancel_or_stop) -> _cancel_work parks in lksleep waiting for running==false -> callback tail schedule_delayed_work(dwork, 10s) (drm_probe_helper.c:654) arms a FRESH timer via callout_reset -> callback returns, _cancel_work sees on_queue==0/running==0 and breaks -> cancel_delayed_work_sync returns with the timer pending -> 10s later timer fires, _delayed_work_fn -> queue_work clears canceled (linux_workqueue.c:135) and re-queues -> output_poll_execute runs full detect cycle after the caller believed it dead -> mid-suspend MMIO (panic/hang) or post-teardown UAF of drm_device/mode_config. No attacker-controlled write primitive; crash/DoS-grade with UAF flavor.

Evidence (decisive lines)

run.log lines: 'canceller: cancel_delayed_work_sync() RETURN (timer pending=1 on_queue=0 running=0)' (baseline) then 'EXEC #2 entered' and 'DF-4625 REPRODUCED: work executed AFTER cancel_delayed_work_sync() returned (exec_count 1 -> 2)'; fixed section: 'RETURN (timer pending=0 on_queue=0 running=0)' + 'FIX VALIDATED: no execution...'. build.log shows the verbatim extraction ranges; df4625.c contains the exact scenario (poll_emul_fn re-arms exactly like drm_probe_helper.c:653-654).

PoC changes

no seed PoC; KLD written from scratch: verbatim sed-extraction of the shim compiled against real kernel primitives; the scenario forces the deterministic interleaving (callback holds in lksleep until the canceller has passed callout_cancel and parked inside _cancel_work) that suspend/teardown hits statistically; fixed variant needed distinct module id (df4625fx) because DECLARE_MODULE/moduledata-string/MODULE_VERSION names collide

Verified recommended fix

Converging cancel_delayed_work_sync loop in linux_workqueue.c (see fix.diff); optionally also make drm_kms_helper_poll_disable/fini clear poll_enabled before cancelling.

Verdict

REPRODUCED on the DragonFly guest with a KLD that compiles the drm workqueue shim functions VERBATIM (build.sh seds process_all_work/wq_worker_thread/queue_work/_delayed_work_fn/queue_delayed_work/_cancel_work/cancel_delayed_work* out of the guest's /usr/src/sys/dev/drm/linux_workqueue.c) against the REAL kernel primitives (lockmgr, lksleep, callout_reset/callout_cancel, lwkt threads), then races a self-rearming delayed_work exactly the way output_poll_execute does (drm_probe_helper.c:653-654). Decisive dmesg lines (run.log): BASELINE -- 'canceller: cancel_delayed_work_sync() RETURN (timer pending=1 on_queue=0 running=0)' proving cancel_sync returns while the callback-armed timer is STILL PENDING and the work is idle (the structural miss: _cancel_work linux_workqueue.c:348-385 breaks on idle and never sees the timer; the initial callout_cancel at :421 ran before the callback re-armed), followed 4 ticks later by 'EXEC #2 entered' -> 'DF-4625 REPRODUCED: work executed AFTER cancel_delayed_work_sync() returned (exec_count 1 -> 2)'. When the surviving timer fires, queue_work (linux_workqueue.c:135) clears work->canceled, so the full poll cycle re-executes. In the drm context this means drm_kms_helper_poll_disable()/poll_fini() (drm_probe_helper.c:695-739, cancel_delayed_work_sync at :699/:737) return while output_poll_execute will run once more up to DRM_OUTPUT_POLL_PERIOD=10s later: on the amdgpu/radeon/i915 suspend paths (amdgpu_device.c:985/:2813, radeon_device.c:1233/:1620, i915_drv.c:1983) that is a full connector-detect cycle against a suspended GPU (MMIO on powered-down hardware -> hang/panic), and on the teardown paths (i915_drv.c:1663, amdgpu dce_v8/10/11/virtual, radeon_display.c:1652, drm_modeset_helper.c:185) it is a use-after-free on the freed drm_device. FIXED build (only cancel_delayed_work_sync replaced by the converging loop of fix.diff): 'RETURN (timer pending=0 on_queue=0 running=0)' and 'FIX VALIDATED: no execution after cancel_delayed_work_sync() returned'. Realistic ceiling on stock systems is a suspend/resume or driver-teardown crash/DoS (needs a drm-driven GPU and the poll worker mid-cycle during cancel; poll period 10s, probe cycles run hundreds of ms, so the overlap is a per-event probability that compounds over suspend/resume cycles).