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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4196 Β· 11 files
FileTypeDescriptionSize
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).

VERDICT.md
↓ download raw

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 after spin_unlock_irqrestore(&dev->event_lock) (:1165) and long after lockmgr(&dev->vbl_lock, LK_RELEASE) (:1150). No lock is held.
  • sys/dev/drm/drm_vblank.c:618 β€” sentinel check if (mode->crtc_clock == 0) return false;
  • sys/dev/drm/drm_vblank.c:637-641 β€” opaque external dev->driver->get_scanout_position(dev, pipe, in_vblank_irq, &vpos, &hpos, &stime, &etime, mode) β€” receives mode, so the compiler must re-load mode->crtc_clock after 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_release drm_file.c:184-204 + drm_send_event_locked drm_file.c:925-953): pending events are unlinked with file_priv = NULL and stay on dev->vblank_event_list; later freed under event_lock by drm_send_event_locked (kfree on !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_vblank from 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_64 64-bit arithmetic β€” sound.
  • HZ conversions (mod_timer offdelay 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

inconclusive
baseline reproduced→ patch + rebuild →patched clean

fix.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
↓ fix.diffper-fix-DF-4196

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.