hrtimer_cancel() is not atomic against concurrent hrtimer_start(): releases timer_token before the delete/drain and start() falsifies running/gd β cancel returns while the callback is still executing, so the caller's kfree turns the __hrtimer_task epilogue into a deterministic use-after-free write
Summary
start() writes running=false (a lie) and moves gd; cancel drops the token before draining the (now wrong) cpu's queue and returns ret=1 with the callback still in flight - the standard i915 teardown-free pattern then gets the epilogue doing token ops + byte writes + a full systimer_init_oneshot of the freed struct (ending in a function call from reclaimed memory). Sleep-orchestrated PoC: cancel returned with cb_done=0, same-address realloc, 2 post-free writes proven with guest surviving; fixed drm.ko returns cb_done=1, zero writes. Organic trigger GPU-gated (i915 pmu/forcewake).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5177 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df5177.c | β | 6.4 KB | view raw | |
| Makefile | β | 151 B | β download | |
| build.sh | β | 155 B | view raw | |
| run.sh | β | 150 B | view raw | |
| build.log | β | 1.0 KB | view raw | |
| run.log | β | 1.4 KB | view raw | |
| run.fix.log | β | 728 B | view raw | |
| fix.diff | β | 2.8 KB | view raw | |
| panic.serial.txt | β | 13.4 KB | view raw | |
| env.txt | β | 354 B | view raw | |
| README.md | β | 3.1 KB | β raw | |
| VERDICT.md | β | 7.1 KB | β raw | |
| verdict.json | β | 6.9 KB | view raw |
DF-5177 β hrtimer_cancel() returns while the timer callback is still running (caller frees β deterministic use-after-free write)
What
sys/dev/drm/linux_hrtimer.c:121-150: hrtimer_cancel() snapshots
tq/running under timer->timer_token but releases the token before
doing any of the work (linux_hrtimer.c:133). A concurrent
hrtimer_start() β which the Linux hrtimer API allows from any CPU while
the callback is in flight (i915_pmu, intel_uncore, i915_perf all do this) β
then, still under the token (linux_hrtimer.c:109-118):
- falsifies
timer->running = false(linux_hrtimer.c:112) β the callback is running on another CPU's taskqueue thread, - reassigns
timer->gd(linux_hrtimer.c:114) and re-arms the systimer on the new CPU.
hrtimer_cancel() consequently cancels/drains taskqueue_thread[gd] of the
new CPU (linux_hrtimer.c:128) instead of the queue whose thread is
actually executing __hrtimer_task, finds the task neither pending nor
running there, and returns while timer->function is still executing.
Linux guarantees hrtimer_cancel() excludes against a running callback;
callers therefore free the timer right after (e.g. i915_pmu_unregister,
i915_pmu.c:1089), and the epilogue of __hrtimer_task
(linux_hrtimer.c:58-68) then operates on freed memory:
lwkt_gettoken(&timer->timer_token), timer->running = false,
timer->active = false, and on the RESTART path a full
systimer_init_oneshot(&timer->st, ...) β a bzero + insertion of the freed
systimer into a live per-CPU queue.
Reproduce (on the audit QEMU guest)
scp df5177.c Makefile dfbsd:/root/df5177/ ssh dfbsd 'cd /root/df5177 && make SYSDIR=/usr/src/sys && kldload drm' ssh dfbsd 'kldload /root/df5177/df5177.ko; dmesg | tail'
Expected (baseline, unpatched drm.ko)
[df5177] hrtimer_cancel() returned ret=1, cb_done=0 (Linux API requires cb_done==1 here!) [df5177] timer freed and reallocated: victim=0xfffff8008bb481f0 same=1; ... [df5177] UAF-WRITE victim[152]=0x00 (was 0x5A: epilogue cleared active) [df5177] UAF-WRITE victim[153]=0x00 (was 0x5A: epilogue cleared running) [df5177] EXPLOIT-PRIMITIVE PROVEN: cancel returned before callback + 2 post-free writes into a reallocated object
The freed chunk is immediately re-allocated at the same address and the in-flight epilogue writes into it β a deterministic cross-object use-after-free write. (Guest stays up; the corruption is contained only because the PoC re-initializes the victim's token slot and the callback returns NORESTART β see VERDICT.md for the RESTART-path escalation.)
(fix-validated: with fix.diff, the same PoC shows
hrtimer_cancel() returned ret=1, cb_done=1 and changed=0 β see
run.fix.log.)
Files
df5177.c/Makefileβ the PoC KLDbuild.log/run.logβ baseline evidence (deterministic UAF write)run.fix.logβ fixed-kernel rerun (no early return, no writes)fix.diffβ one coherent fix for DF-5176 + DF-5177panic.serial.txtβ an early PoC-harness crash (DECLARE_MODULE misuse), kept for transparency; not a kernel findingenv.txt,VERDICT.md,manifest.json,verdict.json
DF-5177 VERDICT β REPRODUCED (deterministic use-after-free write)
1. The bug
hrtimer_cancel (sys/dev/drm/linux_hrtimer.c:121-150):
lwkt_gettoken(&timer->timer_token);
if (timer->active) {
struct taskqueue *tq = taskqueue_thread[timer->gd->gd_cpuid]; /* :128 */
bool running = timer->running; /* :129 */
ret = 1;
timer->cancel = true;
lwkt_reltoken(&timer->timer_token); /* :133 ! */
if (running) {
/* Nothing */
} else if (timer->gd == mycpu) { ... systimer_del ... }
...
while (taskqueue_cancel(tq, &timer->task, NULL)) /* :144 */
taskqueue_drain(tq, &timer->task);
}
Everything after the token release operates on unlocked, re-readable state.
hrtimer_start_range_ns (linux_hrtimer.c:109-118), called concurrently from
another CPU while the callback executes (the token is deliberately dropped
around timer->function(timer), linux_hrtimer.c:55), then sets:
timer->cancel = false;
timer->running = false; /* :112 LIE β __hrtimer_task is mid-callback */
timer->active = true;
timer->gd = mycpu; /* :114 moved to the new CPU */
So cancel():
1. snapshots tq = the NEW cpu's queue (or a stale one),
2. reads running == false (the lie),
3. deletes the systimer on the (correct) new cpu β fine,
4. taskqueue_cancel(tq_new, &timer->task): the task is executing on the
OLD cpu's queue thread (ta_pending == 0 there, tq_running on the new
queue is not this task) β returns 0 β no drain β
5. returns ret=1 ("was active, cancelled") while timer->function is
still running.
The Linux API contract (hrtimer_cancel() waits for a running callback) is
what every teardown caller relies on: i915_pmu_unregister (i915_pmu.c:1089),
intel_uncore teardown (intel_uncore.c:1390), i915_perf stream teardown
(i915_perf.c:1952) β they free the surrounding object right after cancel.
Once the caller frees the struct hrtimer, the in-flight
__hrtimer_task epilogue (linux_hrtimer.c:58-68) touches freed memory:
lwkt_gettoken(&timer->timer_token); /* token ops on freed memory */
timer->running = false; /* UAF write */
timer->active = false; /* UAF write */
if (!timer->cancel && restart == HRTIMER_RESTART) {
timer->active = true;
systimer_init_oneshot(&timer->st, __hrtimer_function,
timer, timer->timeout_us); /* bzero of freed memory +
insertion into a LIVE per-cpu
systimer queue */
}
Distinct from the known pair: DF-2174 is the epilogue's unconditional
active=false clobber (un-cancellable timer β UAF on next fire);
DF-2175 is hrtimer_start re-initializing an ARMED systimer. This finding
is the cancel-side break: non-atomic cancel + the running=false lie β
early return β UAF of the epilogue.
2. PoC and result (deterministic β sleep-orchestrated, no race)
df5177.c (KLD, MODULE_DEPEND(drm)), all operations on dedicated
per-CPU kernel threads:
- cpu0 thread:
hrtimer_start(1ms)β fire β__hrtimer_taskruns the callback ontaskqueue_thread[0]; callback setscb_runningand sleeps 333 ms. - cpu1 thread:
hrtimer_start(50ms)β legal re-arm while the callback is in flight (running=falselie,gd=cpu1). - cpu2 thread:
hrtimer_cancel()β returns immediately; the PoC recordscancel_saw_cb_done = cb_doneat that moment. - Caller-side teardown exactly like i915:
kfree(timer); immediately reallocate the same-size chunk (same address,same=1), poison the state bytes (running/active/cancel/is_rel = 0x5A) and re-init the token slot so the demonstration converges instead of hanging. - After the callback's sleep expires, the epilogue writes into the freed chunk β scan the reallocated victim for changes.
Baseline run (run.log):
[df5177] hrtimer_cancel() returned ret=1, cb_done=0 (Linux API requires cb_done==1 here!) [df5177] timer freed and reallocated: victim=0xfffff8008bb481f0 same=1 [df5177] UAF-WRITE victim[152]=0x00 (was 0x5A: epilogue cleared active) [df5177] UAF-WRITE victim[153]=0x00 (was 0x5A: epilogue cleared running) [df5177] EXPLOIT-PRIMITIVE PROVEN: cancel returned before callback + 2 post-free writes into a reallocated object
Guest survives β this is a controllable corruption primitive, not merely a panic.
3. Escalation path (why this is more than a DoS)
The demonstrated writes are the epilogue's fixed fields, but the same
interleave with a callback returning HRTIMER_RESTART (and
timer->cancel == false in the freed chunk) reaches
systimer_init_oneshot(&timer->st, __hrtimer_function, timer, ...) on the
freed memory: that bzeros ~120 bytes of the freed chunk and inserts the
stale st into the current CPU's live systimer queue; on expiry the
kernel calls __hrtimer_function with info->data read from reallocated
memory, and __hrtimer_task then calls timer->function(timer) β an
attacker-reclaimed chunk yields a controlled function pointer call with a
controlled first argument. (Not chained to uid=0 in this run: on the audit
guest drm's hrtimer users are not hardware-attached, so the organic trigger
path is GPU-gated exactly like DF-5169.)
4. Fix validation
fix.diff (shared with DF-5176):
hrtimer_startno longer writes therunning=falselie;hrtimer_cancelloops untiltimer->active == falseunder the token, snapshotstq/gdunder the token, keeps the token held acrosssystimer_del(including the CPU migration) so a concurrent start cannot movegdmid-delete, and re-checks after each drain.
Applied in the guest /usr/src, drm.ko rebuilt + installed, reboot, PoC
rebuilt against the patched header, re-run (run.fix.log):
[df5177] hrtimer_cancel() returned ret=1, cb_done=1 (Linux API requires cb_done==1 here!) [df5177] victim[152] still 0x5A (no write) [df5177] victim[153] still 0x5A (no write) [df5177] changed=0 cancel_saw_cb_done=1
Cancel now waits out the in-flight callback and returns only when the timer is quiescent; zero post-free writes.
5. Harness incidents (transparency)
Two early crashes were PoC-harness bugs, not kernel findings (both documented, eliminated):
- Passing the handler directly to DF's
DECLARE_MODULE(which expects amoduledata_tvariable) made the kernelstrcmp()the handler's first machine-code bytes as a module name β GPF inmodule_lookupbyname.panic.serial.txtrecords it. - Migrating the kldload user thread with
lwkt_setcpu_selfwedged the guest (two CPUs spinning inacpi_hpet_early_get_timecount); v2 uses dedicated per-CPU kernel threads vialwkt_create(..., cpuid).
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
fixedfix.diff applied inside guest /usr/src (patch -p1 clean), drm.ko rebuilt from patched source, installed to /boot/kernel, guest rebooted, PoC rebuilt against the patched header and re-run: hrtimer_cancel() now returns only after the callback completed ('cb_done=1'), the freed chunk reallocation shows both poison bytes intact ('still 0x5A (no write)', changed=0) β the early-return UAF is eliminated while normal timer operation (start/fire/callback) is preserved
run.fix.log ('[df5177] hrtimer_cancel() returned ret=1, cb_done=1 ... victim[152] still 0x5A (no write) ... victim[153] still 0x5A (no write) ... changed=0 cancel_saw_cb_done=1') vs run.log (baseline: cb_done=0 + two UAF writes + EXPLOIT-PRIMITIVE PROVEN)
Confirmed kernel references
- sys/dev/drm/linux_hrtimer.c:121-150
- sys/dev/drm/linux_hrtimer.c:112
- sys/dev/drm/linux_hrtimer.c:114
- sys/dev/drm/linux_hrtimer.c:128
- sys/dev/drm/linux_hrtimer.c:133-145
- sys/dev/drm/linux_hrtimer.c:42-69
- sys/dev/drm/i915/i915_pmu.c:1089
- sys/dev/drm/i915/i915_pmu.c:130-135
- sys/dev/drm/i915/intel_uncore.c:393
- sys/dev/drm/i915/i915_perf.c:1952
Detail
Exploit chain
unpriv GPU user triggers a legal cross-cpu hrtimer restart while a drm hrtimer callback is in flight (i915 pmu gt-unpark on cpu B during a sampling callback on cpu A; or intel_uncore forcewake re-arm racing forcewake_reset's unlocked cancel loop) -> hrtimer_cancel returns early -> driver teardown frees the containing object (i915_pmu_unregister pattern) -> in-flight __hrtimer_task epilogue performs lwkt-token ops and flag writes on freed memory -> same-size reclaim (demonstrated same-address) places a victim object under the writes: cleared refcounts/flags give premature-free/state-confusion, and the RESTART variant inserts the freed systimer into a live per-cpu queue yielding a timer->function call from reclaimed memory. Deterministic primitive proven on QEMU; uid=0 not chained (organic trigger needs i915/amdgpu hardware, like DF-5169).
Evidence (decisive lines)
run.log: '[df5177] hrtimer_cancel() returned ret=1, cb_done=0 (Linux API requires cb_done==1 here!)' + 'timer freed and reallocated: victim=0xfffff8008bb481f0 same=1' + 'UAF-WRITE victim[152]=0x00 (was 0x5A: epilogue cleared active)' + 'UAF-WRITE victim[153]=0x00 (was 0x5A: epilogue cleared running)' + 'EXPLOIT-PRIMITIVE PROVEN ... 2 post-free writes'; run.fix.log: 'cb_done=1' + 'victim[152]/[153] still 0x5A (no write)' + 'changed=0'; VERDICT.md sections 1-4 trace every cited line
PoC changes
PoC written from scratch (no seed). v1 migrated the kldload user thread via lwkt_setcpu_self (wedged the guest scheduler; two cpus spinning in acpi_hpet_early_get_timecount) and misused DF's DECLARE_MODULE by passing the handler instead of a moduledata_t variable (kernel strcmp'd the handler's machine-code bytes as the module name -> GPF in module_lookupbyname; panic.serial.txt) β both harness bugs diagnosed via QEMU-monitor pmemsave + page-table walk + offline symbolization, then eliminated. v2 uses dedicated per-cpu kernel threads (lwkt_create(..., TDF_NOSTART, cpuid) + lwkt_schedule) and the moduledata_t pattern; the freed chunk is re-inited (token) + poisoned (0x5A state bytes) so the deterministic epilogue writes are observable without hanging or panicking, isolating the corruption primitive
Verified recommended fix
Make hrtimer_cancel atomic against hrtimer_start: drop the running=false write from hrtimer_start_range_ns, snapshot tq/gd under the token, keep the token held across systimer_del (including cpu migration), and loop {cancel+del+drain} until timer->active is false under the token (see fix.diff)
Verdict
hrtimer_cancel() (linux_hrtimer.c:121-150) snapshots tq/running under timer_token and then RELEASES the token before systimer_del/taskqueue_cancel/taskqueue_drain. A concurrent hrtimer_start() β legal from any cpu while the callback runs, and required to work by the Linux API (i915_pmu.c:130-135 start vs i915_pmu.c:1089 cancel; intel_uncore.c:70-78 arm vs :393 cancel outside uncore.lock) β sets running=false (a lie, linux_hrtimer.c:112) and moves timer->gd (:114), so cancel() drains the NEW cpu's taskqueue (linux_hrtimer.c:128) instead of the one whose thread is executing __hrtimer_task, finds the task neither pending nor running there, and returns ret=1 with the callback still in flight. Reproduced deterministically on the guest with a sleep-orchestrated KLD (start on cpu0 -> fire -> callback sleeps 333ms -> start on cpu1 -> cancel on cpu2): 'hrtimer_cancel() returned ret=1, cb_done=0', then kfree + same-address realloc + poison shows the epilogue writing into the reallocated object ('UAF-WRITE victim[152]=0x00 (was 0x5A: epilogue cleared active)', 'UAF-WRITE victim[153]=0x00 ... running', changed=2), guest up. fix.diff (drop the running lie; loop cancel until active==false under the token; hold the token across systimer_del) rebuilt as drm.ko in the guest and re-run: 'cb_done=1' at cancel return and zero post-free writes. Distinct from known DF-2174 (epilogue active-clobber) and DF-2175 (re-init of armed systimer).
No comments yet.