Lock-free vblank->hwmode.crtc_clock = 0 store in drm_crtc_vblank_off() races the reloaded divisor in drm_calc_vbltimestamp_from_scanoutpos() β TOCTOU integer divide-by-zero (#DE kernel panic)
Summary
drm_crtc_vblank_off clears the mode-timing sentinel AFTER releasing event_lock and vbl_lock - completely lock-free. The calc path checks the sentinel, then calls the opaque get_scanout_position (which forces the compiler to RE-LOAD mode->crtc_clock), then divides by the re-loaded field. If the store lands in that window the divisor is 0 -> #DE panic. The racing reader path (drm_vblank_get -> enable -> update_count) holds neither event_lock nor any lock the store takes. Unprivileged half: WAIT_VBLANK / CRTC_GET_SEQUENCE (both DRM_UNLOCKED, no AUTH) run into the multi-microsecond window; other half: ordinary display-server DPMS/modeset CRTC-offs. Model harness: deterministic SIGFPE 3/3 (verbatim interleaving), fixed semantics survive. Cache-timings-into-locals fix.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-4196 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 4.1 KB | β raw | |
| VERDICT.md | β | 6.4 KB | β raw | |
| vblank_toctou.c | β | 5.2 KB | view raw | |
| build.sh | β | 109 B | view raw | |
| run.sh | β | 130 B | view raw | |
| build.log | β | 414 B | view raw | |
| run.log | β | 1.9 KB | view raw | |
| env.txt | β | 404 B | view raw | |
| fix.diff | β | 1.5 KB | view raw | |
| manifest.json | β | 992 B | view raw | |
| verdict.json | β | 3.8 KB | view raw |
DF-4196 β lock-free hwmode.crtc_clock = 0 store vs. reloaded divisor in drm_calc_vbltimestamp_from_scanoutpos (TOCTOU divide-by-zero / kernel #DE)
File under audit: sys/dev/drm/drm_vblank.c (pass 2)
What
drm_crtc_vblank_off() clears the mode-timing sentinel
vblank->hwmode.crtc_clock = 0 at drm_vblank.c:1169 β after both
dev->event_lock (:1165) and dev->vbl_lock (:1150) have been released,
i.e. completely lock-free.
drm_calc_vbltimestamp_from_scanoutpos() checks the sentinel at
drm_vblank.c:618 (if (mode->crtc_clock == 0) return false;) and then,
after the opaque external call dev->driver->get_scanout_position()
(:637-641 β an MMIO loop up to DRM_TIMESTAMP_MAXRETRIES long, which forces
the compiler to re-load mode->crtc_clock), divides by it at
drm_vblank.c:671-672:
delta_ns = div_s64(1000000LL * (vpos * mode->crtc_htotal + hpos),
mode->crtc_clock);
If the :1169 store lands between :618 and :671, the re-loaded divisor is 0 β integer divide exception (#DE) β kernel panic.
Why the existing locks do NOT close the window
The vblank-query reader path is:
drm_wait_vblank_ioctl / drm_crtc_get_sequence_ioctl (both DRM_UNLOCKED,
no DRM_AUTH:
drm_ioctl.c:639,699)
β drm_vblank_get() drm_vblank.c:988 takes vbl_lock
β drm_vblank_enable() drm_vblank.c:946 takes vblank_time_lock
β drm_update_vblank_count() drm_vblank.c:961
β drm_get_last_vbltimestamp β drm_calc_vbltimestamp_from_scanoutpos
That path holds vbl_lock + vblank_time_lock but never event_lock,
and the racing store at :1169 executes after the modeset thread has
released all locks. Every other caller of the calc helper holds
event_lock (irq handler :1695, drm_crtc_arm_vblank_event) and is
serialized against the :1129-1165 event_lock section β but the store itself
is outside that section. CPU A inside the checkβMMIOβdivide window and
CPU B executing :1165β:1169 conflict on no lock.
Attacker model (unprivileged half of the race): any local user who can open
the DRM device node hammers WAIT_VBLANK/CRTC_GET_SEQUENCE get/put cycles;
the other half (a CRTC-off modeset) is performed by the display server
(DRM master) during ordinary activity (DPMS off, screen lock, mode change,
lid switch). Both DRM vblank query ioctls are DRM_UNLOCKED with no auth
requirement (drm_ioctl.c:639,699).
Impact: kernel #DE β panic (local DoS). No memory corruption (uid=0 chain N/A for this class).
How it is proven here
vblank_toctou.c reproduces the exact interleaving on the DragonFly guest
with two threads + a barrier (deterministic, 3/3 runs):
- thread A (vblank query): passes the :618 check on the live value, then
blocks inside the stand-in
get_scanout_position()(the MMIO window); - thread B (modeset tail): prints that event_lock is released and executes
the verbatim
vblank->hwmode.crtc_clock = 0;(:1169); - thread A resumes and executes the verbatim :671-672 division on the re-loaded field β SIGFPE (userland analog of the kernel #DE).
./vblank_toctou race_fixed runs the same interleaving with the fix
(divisor cached into a local before the check, exactly what fix.diff does to
sys/dev/drm/drm_vblank.c) and survives cleanly.
Reproduce
cc -O2 -Wall -pthread -o vblank_toctou vblank_toctou.c ./vblank_toctou race # expect: "Floating point exception", rc=136 ./vblank_toctou race_fixed # expect: "SURVIVED ... cached-nonzero", rc=0
Full untrimmed output: run.log (baseline + fixed + 3x determinism loop).
Hardware gating (honest scope)
The guest has no DRM-capable GPU (ls /dev/dri β ENOENT; only ehci/xhci
modules loaded), so the in-kernel panic could not be exercised directly.
The interleaving itself, the lock-freedom of the store, the forced reload,
and the #DE are all proven at source level + deterministic harness. This
mirrors the DF-3869 verification convention (hardware-gated #DE family).
DF-4196 VERDICT
Status: inconclusive (hardware-gated) β defect proven deterministically in a faithful model harness on the DragonFly guest; in-kernel manifestation requires DRM hardware the guest does not have. Impact class if it fires in-kernel: integer divide exception (#DE) β kernel panic = local DoS. Not memory corruption; a uid=0 chain is not applicable to this bug class.
Root cause (path:line)
sys/dev/drm/drm_vblank.c:1169βvblank->hwmode.crtc_clock = 0;executes afterspin_unlock_irqrestore(&dev->event_lock)(:1165) and long afterlockmgr(&dev->vbl_lock, LK_RELEASE)(:1150). No lock is held.sys/dev/drm/drm_vblank.c:618β sentinel checkif (mode->crtc_clock == 0) return false;sys/dev/drm/drm_vblank.c:637-641β opaque externaldev->driver->get_scanout_position(dev, pipe, in_vblank_irq, &vpos, &hpos, &stime, &etime, mode)β receivesmode, so the compiler must re-loadmode->crtc_clockafter the call.sys/dev/drm/drm_vblank.c:671-672βdiv_s64(..., mode->crtc_clock)with the re-loaded value β 0 β #DE.
Why the race window is real (full lock analysis)
Store side (modeset thread, drm_crtc_vblank_off):
| line | action | locks held |
|---|---|---|
| 1129 | event_lock | event |
| 1131 | vbl_lock | event+vbl |
| 1138 | drm_vblank_disable_and_save (vblank_time_lock inside; enabled=false) | event+vbl+vblank_time |
| 1150 | vbl release | event |
| 1155-1164 | flush queued vblank events | event |
| 1165 | event release | β |
| 1169 | vblank->hwmode.crtc_clock = 0 | β (LOCK-FREE) |
Reader side β the only calc caller that holds no event_lock:
drm_vblank_get (:988 vbl_lock) β drm_vblank_enable (:946 vblank_time_lock,
all while vbl_lock held) β drm_update_vblank_count (:961) β
drm_get_last_vbltimestamp β drm_calc_vbltimestamp_from_scanoutpos
(inside the :618β:671 window for the duration of up to 3 MMIO
scanout-position queries β microseconds).
Other calc callers are safe by construction and were checked and killed:
* vblank IRQ handler drm_handle_vblank (:1695 event_lock, :1704 re-checks
vblank->enabled under the lock) β serialized against :1129-1165, and if
it arrives after :1165 it sees enabled == false and bails before the calc.
* drm_crtc_arm_vblank_event β asserts event_lock (callers hold it:
e.g. amdgpu_dm.c:295 pflip handler holds adev->ddev->event_lock).
* drm_crtc_accurate_vblank_count callers: i915 CRC irq (i915_irq.c:1772)
and amdgpu CRC work (amdgpu_dm_crc.c:150) hold no event_lock β these are
also exposed to the same window, but are debugfs-gated (root arms CRC
capture); the ioctl path above is the unprivileged-relevant one.
The drm_vblank_get reader can enter while the pipe is being shut down
because driver ->enable_vblank hooks (e.g. i915's) merely unmask the
interrupt and can succeed during the disable window; if the pipe is fully
dead, get_scanout_position usually fails and the function returns before
the division β which is exactly why this needs the timing overlap, not a
logic guarantee.
Reproduction (guest, DragonFly 6.5-DEVELOPMENT, cc 8.3)
$ cc -O2 -Wall -pthread -o vblank_toctou vblank_toctou.c $ ./vblank_toctou race [main] mode=race (initial hwmode.crtc_clock=148500 kHz, htotal=2200) [vblank_off] event_lock released; executing lock-free vblank->hwmode.crtc_clock = 0 (drm_vblank.c:1169) Floating point exception (core dumped) # rc=136 = SIGFPE = userland #DE $ ./vblank_toctou race_fixed [main] SURVIVED: delta_ns=1481818 (check passed BEFORE zero store, division used a cached-nonzero value) # rc=0
Determinism: 3/3 race runs SIGFPE (rc=136); 3/3 race_fixed survive
(rc=0). See run.log.
The harness uses the verbatim expressions from :618/:671-672 (the division
reads the shared field, not a local) and the verbatim :1169 store; the MMIO
window is modeled by a barrier + spin inside a noinline opaque callee that
receives the mode pointer (preserving the forced reload property).
Why not full kernel verification
The guest QEMU VM has no GPU: ls /dev/dri β No such file or directory;
kldstat shows only ehci/xhci. i915/radeon/amdgpu (the only DF drm drivers)
cannot bind, so neither the modeset side nor the query side of the race can
run in-kernel here. Model-harness + source-level proof is the maximum
achievable on this guest (same convention as DF-3869, the dce_calcs #DE
family).
Fix validation
fix.diff (against the read-only sys/ tree) caches crtc_clock/crtc_htotal
into locals before the zero check and divides by the cached local β the
reloaded-field race becomes impossible; a store landing mid-window either
happens before the snapshot (calc sees 0 and fails gracefully) or after the
division. Validated in the harness: race_fixed survives 3/3 with a sane
delta_ns. A full DRM kernel build/boot test is not possible on this guest
(no GPU), so fix validation is harness-level: fix_status: inconclusive.
Killed negatives (pass-2 sweep of the same file)
- Event lifecycle vs. file close (
drm_events_releasedrm_file.c:184-204 +drm_send_event_lockeddrm_file.c:925-953): pending events are unlinked withfile_priv = NULLand stay ondev->vblank_event_list; later freed under event_lock bydrm_send_event_locked(kfreeon!file_priv). Sound (modern dual-link design; the 2013-era vblank event UAFs are not present). - vblank get/put refcount pairing incl. error paths (:959, :994, :1359, :1459/:1476, :1666, :1897/:1911) β balanced; lock order event_lock β vbl_lock β vblank_time_lock is consistent everywhere.
drm_handle_vblankfrom the IRQ thread: lockmgr from DF ithread context is legal in the drm port (spin_lock_irqsave shim = local_irq_save + lockmgr, include/linux/spinlock.h:61-88); instant-off path (:1721-1730) matches upstream.vblank_passed/widen_32_to_6464-bit arithmetic β sound.- HZ conversions (
mod_timeroffdelay math :1037,msecs_to_jiffies:1082,DRM_WAIT_ON 3*HZ:1626) β int math, admin-only module params, no user-triggerable overflow. - crc debugfs hooks: no drm_debugfs_crc.c exists in this tree β surface absent.
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
inconclusivefix.diff caches the divisor into a local (exactly the race_fixed harness path): baseline harness SIGFPE 3/3 vs fixed harness survival 3/3. Full DRM kernel build/boot cannot be exercised on this guest (no GPU), so kernel-level fix validation is not possible; harness-level validation passed cleanly.
run.log determinism loop (race#1-3 rc=136 / fixed#1-3 rc=0); fix.diff
Confirmed kernel references
Detail
Evidence (decisive lines)
run.log: race -> 'Floating point exception' rc=136 3/3 and race_fixed -> 'SURVIVED ... cached-nonzero' rc=0 3/3; VERDICT.md lock table for drm_crtc_vblank_off (:1129-1169) and the reader path analysis showing no common lock; build.log
PoC changes
harness written fresh (no seed): verbatim :618 check / :671-672 division (dividing the shared field, not a local) and verbatim :1169 store, orchestrated deterministically with a barrier inside a noinline opaque callee that receives the mode pointer (preserving the forced-reload property)
Verified recommended fix
Cache crtc_clock/crtc_htotal into locals before the zero-check in drm_calc_vbltimestamp_from_scanoutpos and divide by the cached local (see fix.diff); alternatively zero hwmode.crtc_clock while still holding vbl_lock+vblank_time_lock in drm_crtc_vblank_off
Verdict
Hardware-gated on this guest (no DRM/GPU device: /dev/dri absent, only ehci+xhci modules), so the in-kernel panic could not be exercised directly; the defect itself is proven deterministically on the DragonFly guest with a faithful two-thread interleaving harness: drm_crtc_vblank_off() stores vblank->hwmode.crtc_clock = 0 at drm_vblank.c:1169 completely lock-free (event_lock released at :1165, vbl_lock at :1150), while the no-event_lock reader path drm_vblank_get->drm_vblank_enable->drm_update_vblank_count->drm_calc_vbltimestamp_from_scanoutpos passes the crtc_clock==0 check at :618, spends microseconds in the opaque get_scanout_position() call (:637, which forces a compiler re-load of mode->crtc_clock), and then divides by the re-loaded field at :671-672 -> integer #DE -> kernel panic. Harness: 3/3 SIGFPE (rc=136) baseline, 3/3 survival with the cached-divisor fix. Unprivileged half of the race is any local user with the DRM node open hammering DRM_UNLOCKED WAIT_VBLANK/CRTC_GET_SEQUENCE (drm_ioctl.c:639,699); the modeset half is ordinary display-server activity (DPMS/screen-lock/mode change). Not memory corruption; uid=0 chain N/A for this class.
No comments yet.