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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5177 Β· 13 files
FileTypeDescriptionSize
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 KLD
  • build.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-5177
  • panic.serial.txt β€” an early PoC-harness crash (DECLARE_MODULE misuse), kept for transparency; not a kernel finding
  • env.txt, VERDICT.md, manifest.json, verdict.json
VERDICT.md
↓ download raw

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:

  1. cpu0 thread: hrtimer_start(1ms) β†’ fire β†’ __hrtimer_task runs the callback on taskqueue_thread[0]; callback sets cb_running and sleeps 333 ms.
  2. cpu1 thread: hrtimer_start(50ms) β€” legal re-arm while the callback is in flight (running=false lie, gd=cpu1).
  3. cpu2 thread: hrtimer_cancel() β†’ returns immediately; the PoC records cancel_saw_cb_done = cb_done at that moment.
  4. 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.
  5. 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_start no longer writes the running=false lie;
  • hrtimer_cancel loops until timer->active == false under the token, snapshots tq/gd under the token, keeps the token held across systimer_del (including the CPU migration) so a concurrent start cannot move gd mid-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):

  1. Passing the handler directly to DF's DECLARE_MODULE (which expects a moduledata_t variable) made the kernel strcmp() the handler's first machine-code bytes as a module name β†’ GPF in module_lookupbyname. panic.serial.txt records it.
  2. Migrating the kldload user thread with lwkt_setcpu_self wedged the guest (two CPUs spinning in acpi_hpet_early_get_timecount); v2 uses dedicated per-CPU kernel threads via lwkt_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

fixed
baseline reproduced→ patch + rebuild →patched clean

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

Confirmed kernel references

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).