autofs request timeout task is armed on the creator CPU's taskqueue but cancelled/drained on the last releaser CPU's queue β foreign-list STAILQ_REMOVE NULL-deref panic and wrong-queue-drain use-after-free (read+write after objcache_put)
Summary
autofs_trigger_one() binds both the arming and the teardown of each request's timeout task to taskqueue_thread[mycpuid] (:303): armed at creation on the CREATOR's per-cpu queue (:357-360) but cancelled+drained on the LAST RELEASER's (:398-400). Requests are shared across CPUs via the path+key dedup (:324-339) and the creator can exit early on EINTR (:366-377 - e.g. a SIGKILLed stat blocked in the trigger), so the last releaser routinely runs on a different CPU - exactly the 'do NOT SHARE the task between CPUs' prohibition in subr_taskqueue.c:184-189. When a non-timeout completion (AUTOFSDONE ioctl) races the timeout boundary: (a) with the callout already transitioned onto the home queue (ta_pending=1), taskqueue_cancel_locked STAILQ_REMOVEs a task not on the passed queue's list - the walk runs off the end and derefs NULL => kernel panic; (b) with the home runner having dequeued the task (ta_pending=0), the wrong-queue taskqueue_drain_timeout compares the WRONG queue's tq_running and returns without waiting, so objcache_put frees the request while autofs_task still executes on it - post-free read (AUTOFS_WARN %s of ar_path from freed memory, console leak) and post-free writes (ar_error/ar_wildcards/ar_done/ar_in_progress) into a reusable autofs_request object. VERIFIED stock INVARIANTS guest: deterministic round-1 panic (cpuid=0 releaser freeing a task homed on cpu4; module samples ref=2 dedup then ta_pending=1 immediately before the trap), 4x reproduced; cross-cpu frees and post-free field changes on freed objects observed via same-magazine re-get. Trigger side is the real unpriv path (stat -> VOP_NRESOLVE -> autofs_trigger, EINTR early exit); requires autofs mount + automountd answering near the timeout frontier. Ceiling: local DoS panic + corruption of an unrelated in-flight autofs request (writes confined to the dedicated objcache, fixed values/offsets) - not a general arbitrary-write or unpriv->root chain. Fix validated (remember ar_taskqueue in the request, use it for teardown; 22+ clean rounds on patched kernel vs round-1 panic baseline).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3173 Β· 22 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.7 KB | β raw | |
| VERDICT.md | β | 7.4 KB | β raw | |
| df3173_race.c | β | 14.9 KB | view raw | |
| fake_daemon3173.c | β | 2.2 KB | view raw | |
| pending_stat.c | β | 1.7 KB | view raw | |
| autofs_ioctl.h | β | 3.0 KB | view raw | |
| Makefile | β | 76 B | β download | |
| build.sh | β | 585 B | view raw | |
| run.sh | β | 111 B | view raw | |
| runB.sh | β | 2.1 KB | view raw | |
| debug1.sh | β | 1.4 KB | view raw | |
| redeploy.sh | β | 700 B | view raw | |
| deploy_only.sh | β | 682 B | view raw | |
| build.log | β | 6.2 KB | view raw | |
| run.log | β | 1.6 KB | view raw | |
| panic.txt | β | 671 B | view raw | |
| runB_markers.log | β | 1.2 KB | view raw | |
| fix_run_dmesg.txt | β | 81.1 KB | view raw | |
| env.txt | β | 258 B | view raw | |
| fix.diff | β | 2.2 KB | view raw | |
| manifest.json | β | 2.6 KB | view raw | |
| verdict.json | β | 6.0 KB | view raw |
DF-3173 β autofs timeout task torn down on the wrong per-CPU taskqueue
Finding: autofs_trigger_one() (sys/vfs/autofs/autofs.c) arms each
request's timeout task on the CREATOR's per-cpu taskqueue
(#define _taskqueue_thread (taskqueue_thread[mycpuid]), autofs.c:303,
arming at :357-360) but tears it down on the LAST RELEASER's per-cpu
queue (:398-399). Requests are shared across CPUs (dedup at :324-339)
and the creator can exit early via EINTR (:366-377, e.g. SIGKILL of a
blocked stat), so the last releaser routinely runs on a different
CPU than the creator β the teardown then operates on the WRONG
taskqueue, violating subr_taskqueue.c:184-189 ("do NOT SHARE the task
between CPUs").
Two memory-safety outcomes:
- PANIC (reproduced, deterministic in round 1): with the timeout
callout fired and the task queued on its home queue (ta_pending=1),
taskqueue_cancel_timeout() β taskqueue_cancel_locked() runs
STAILQ_REMOVE against the wrong queue's list β the walk in
sys/sys/queue.h:319-331 runs off the end and dereferences NULL.
- UAF (code-proven; cross-cpu frees + post-free writes observed):
if the home runner already dequeued the task (ta_pending=0), the
wrong-queue taskqueue_drain_timeout() compares the WRONG queue's
tq_running (subr_taskqueue.c:493) and returns without waiting, so
objcache_put(ar) (autofs.c:400) frees the request while
autofs_task() (:196-211) is still executing on it: AUTOFS_WARN
prints ar->ar_path from freed memory (info leak to console/dmesg)
and writes ar_error=ETIMEDOUT / ar_wildcards / ar_done /
ar_in_progress into the freed, reusable object.
Reproduce (root on the DragonFly guest)
sh /root/poc/df3173/build.sh # kmod + fake_daemon3173 + pending_stat sh /root/poc/df3173/run.sh # variant A: EXPECT PANIC in round 1
run.sh choreographs, per round (vfs.autofs.timeout=1):
- T0 a real user stat (pending_stat: self-pending blocked
SIGTERM fires at +850ms) creates the request on its cpu;
- T0+0.1 a long-lived releaser helper on another cpu stats the same
key (dedup, refcount 2);
- T0+0.6 the module's SLEEPER task occupies the home queue's runner
(busy-spinning would starve this kernel's per-cpu softclock
thread β kern_timeout.c:423 β and the callout would never
fire);
- T0+0.85 the pending SIGTERM interrupts the creator's cv wait ->
EINTR exit, refcount 2->1;
- T0+1.0 the timeout callout fires; the timeout task is queued
behind the sleeper on the HOME queue (ta_pending=1);
- T0+1.2 the fake automountd's AUTOFSDONE wakes the releaser, which
frees the request on ITS cpu -> cancel/drain on the WRONG
queue -> foreign-list STAILQ_REMOVE -> NULL deref.
Expected (stock kernel): after DF3173: R01 ... ta_pending=1,
Fatal trap 12: page fault while in kernel mode fault virtual address = 0x0 Stopped at taskqueue_cancel_locked+0x45: movq (%rcx),%rax
(see run.log / panic.txt; reproduced 4x total, including twice spontaneously during variant-B timing iterations.)
Expected (kernel + fix.diff): RUN-A-DONE-NO-PANIC, ~22+ rounds of
R%02d complete (request freed=yes), zero traps (fix_run_dmesg.txt).
Variant B (runB.sh) hunts the UAF marker: the module re-gets the
freed request from the freeing cpu's objcache magazine (SAME-BLOCK
prints in runB_markers.log prove the cross-cpu free) and observes
ar_error/done written after the free.
Fix
fix.diff: remember the arming queue in the request
(ar->ar_taskqueue = taskqueue_thread[mycpuid] at creation) and use it
for taskqueue_cancel_timeout/taskqueue_drain_timeout at teardown.
Validated: baseline panic gone, normal autofs behavior preserved.
DF-3173 VERDICT β autofs timeout task torn down on the wrong per-CPU taskqueue
Status: REPRODUCED (deterministic kernel panic in round 1 on the
stock INVARIANTS kernel; reproduced 4x). Impact: kernel memory-safety
defect β NULL-deref panic (local DoS) on the primary path, plus a
use-after-free (read+write) variant on the adjacent interleaving.
Bucket: memcorrupt. Not a standalone unprivβroot chain: the write
primitive is confined to the dedicated autofs_request objcache with
fixed values at fixed offsets (ar_error=ETIMEDOUT(60), ar_wildcards=1,
ar_done=1, ar_in_progress=0 at the head of a ~5 KB object), and the
read primitive (AUTOFS_WARN %s of ar->ar_path from freed memory,
autofs.c:202-203) leaks freed autofs-request contents (paths/keys,
largely attacker-known) to console/dmesg. The realistic ceiling is
local DoS (panic) plus spurious corruption of an unrelated in-flight
autofs request when the freed object is reused.
Root cause (path:line)
sys/vfs/autofs/autofs.c:303β#define _taskqueue_thread (taskqueue_thread[mycpuid]): the arming (autofs.c:357-360,TIMEOUT_TASK_INIT+taskqueue_enqueue_timeout) uses the CREATOR's per-cpu thread taskqueue, while the teardown (autofs.c:398-400,taskqueue_cancel_timeout+taskqueue_drain_timeout+objcache_put) uses the LAST RELEASER's. Requests are shared across CPUs (dedup at autofs.c:324-339 acquires a refcount from any waiter), and the creator can drop out early on EINTR (autofs.c:366-377) β e.g. a SIGKILLedstatblocked in the trigger β leaving a waiter on another CPU as the last releaser. DF's own taskqueue layer forbids exactly this: sys/kern/subr_taskqueue.c:184-189 "If using the per-cpu taskqueues ... be sure NOT TO SHARE the task between CPUs."- Panic path: when the request is completed by something other
than its own timeout (AUTOFSDONE ioctl, autofs.c:506-520) racing
the timeout boundary, the callout has already moved the task onto
its home queue (
ta_pending=1) while the home runner is busy; the wrong-queue cancel then executesSTAILQ_REMOVEof a task that is not on the passed queue's list (subr_taskqueue.c:424-425), and the list walk in sys/sys/queue.h:319-331 runs off the end:curelm->ta_link.stqe_nextwithcurelm == NULL, offset 0. - UAF path: if the home runner already dequeued the task
(
ta_pending=0, hometq_running == task), the wrong-queuetaskqueue_drain_timeoutcompares the WRONG queue'stq_running(subr_taskqueue.c:493,526) and returns immediately, soobjcache_put(autofs.c:400) frees the request whileautofs_task(autofs.c:196-211) is still parked onsc_lock/executing onar: post-free read (AUTOFS_WARN printsar->ar_pathof freed memory) and post-free writes into the freed object (observed via same-magazine re-get; see runB_markers.log).
Production shape: an autofs mount + automountd; any unprivileged user
triggering lookups (two users hitting the same uncached path, one
Ctrl-C'ing/killed while blocked) while automountd answers near the
vfs.autofs.timeout (default 30 s) frontier β e.g. a lagging daemon
draining a request backlog. The harness reproduces this boundary
deterministically (fake automountd answering at +1.2 s with
vfs.autofs.timeout=1).
Reproduction β baseline (run.log, panic.txt; decisive lines)
Stock X86_64_GENERIC (INVARIANTS) DragonFly 6.5-DEVELOPMENT #0,
6-cpu guest. Module narration for the decisive round:
DF3173: R01 T0 creator=userstat id=0 ar=0xfffff80118896200 home_tq=cpu4-queue key=k3173_r001 DF3173: R01 w req=alive id=0 ref=2 done=0 inprog=1 ta_pending=0 tq=cpu4 cflags=1 <- dedup'd, callout armed DF3173: R01 w req=alive id=0 ref=2 done=0 inprog=1 ta_pending=1 tq=cpu4 cflags=0 <- callout fired, task QUEUED on home tq[cpu4] Fatal trap 12: page fault while in kernel mode cpuid = 0; lapic id = 0 fault virtual address = 0x0 instruction pointer = 0x8:0xffffffff806a3895 current process = 1089 <- the releaser `stat` (user), freeing on cpu0 Stopped at taskqueue_cancel_locked+0x45: movq (%rcx),%rax
cpuid=0 + home_tq=cpu4 + current process = <the releaser stat>
is direct proof that the cancel ran on the releaser's cpu for a task
living on the creator's cpu4 queue, and ta_pending=1 (sampled under
sc_lock immediately before) proves the task was still queued β the
foreign-list STAILQ_REMOVE walk is the only possible fault at
taskqueue_cancel_locked+0x45 reading address 0x0. The module's
per-cpu getter kthreads additionally re-got the freed request from the
freeing cpu's objcache magazine (SAME-BLOCK prints in
runB_markers.log), independently confirming cross-cpu frees.
Variant B runs additionally produced the same
taskqueue_cancel_locked+0x45 panic twice spontaneously when the
free raced ahead of the home runner's dequeue, and same-block markers
whose ar_error/ar_done changed to ETIMEDOUT/1 in the freed object
(post-free writes; the e0=0βe1=60 clean-cut marker requires the DONE
to land inside the hold window and was out-raced by the panic-prone
interleaving in the final runs β the drain-side defect is nevertheless
fully determined by code: subr_taskqueue.c:493 compares the passed
queue's tq_running).
Fix validation (fix.diff, fix_run_dmesg.txt)
fix.diff stores the arming queue in the request
(ar->ar_taskqueue = taskqueue_thread[mycpuid] at creation,
autofs.c:364-370 patched) and uses it for both
taskqueue_cancel_timeout and taskqueue_drain_timeout at teardown
(autofs.c:406-408 patched).
- Baseline (stock kernel #0): panic in round 1, deterministic.
- Patched (
make nativekernel+installkernel, kernel #1 of 2026-09-07 20:24, /boot/kernel/autofs.ko 2026-09-07 20:28): the exact same PoC ran 22+ rounds to completion ("R%02d complete (request freed=yes)"), zero traps, guest stayed up, including rounds where the module's getters still observed cross-cpu frees ("freed-object resurfaced on cpu3 magazine") β i.e. the racy situation still occurs but the teardown now cancel/drains against the correct queue and is harmless. Normal autofs behavior preserved (requests complete, daemon handshake works, no hangs).
Harness notes (why the choreography looks the way it does)
- The trigger side is the REAL production path: user
stat()-> VOP_NRESOLVE -> autofs_trigger; the early creator exit is the REAL EINTR path driven by a self-pending blocked SIGTERM (pending_stat.c), exactly like a killed lookup. - The releaser must run on a different cpu than the creator: DF's dfly scheduler co-locates forks with their parent on an idle box, so the harness keeps long-lived releaser helper processes plus one usermode spinner for migration pressure.
- The runner-occupying task must SLEEP, not spin: this kernel runs softclock as a per-cpu thread (kern_timeout.c:423), and a busy taskqueue daemon starves it, so the timeout callout would never fire (empirically confirmed: ta_pending never left 0 with a spinning occupier).
- KLD unload is refused (EBUSY): harness kthreads live in the module text; reset the guest between runs.
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 (ar_taskqueue remembered; teardown targets it) applied to in-guest /usr/src, nativekernel+installkernel, reboot into #1: exact baseline PoC ran 22+ rounds with zero traps and normal autofs operation; baseline panicked deterministically in round 1. Cross-cpu frees still occur post-fix (observed via magazine getters) but teardown is now correct and harmless.
fix_run_dmesg.txt
Confirmed kernel references
- sys/vfs/autofs/autofs.c:303
- sys/vfs/autofs/autofs.c:324
- sys/vfs/autofs/autofs.c:338
- sys/vfs/autofs/autofs.c:357
- sys/vfs/autofs/autofs.c:359
- sys/vfs/autofs/autofs.c:366
- sys/vfs/autofs/autofs.c:395
- sys/vfs/autofs/autofs.c:398
- sys/vfs/autofs/autofs.c:399
- sys/vfs/autofs/autofs.c:400
- sys/vfs/autofs/autofs.c:196
- sys/vfs/autofs/autofs.c:202
- sys/vfs/autofs/autofs.c:506
- sys/kern/subr_taskqueue.c:184
- sys/kern/subr_taskqueue.c:424
- sys/kern/subr_taskqueue.c:493
- sys/kern/subr_taskqueue.c:522
- sys/sys/queue.h:319
- sys/sys/taskqueue.h:169
Detail
Evidence (decisive lines)
['run.log β module narration: R01 ref=2 (dedup by cross-cpu waiter) then ta_pending=1 tq=cpu4 (timeout task queued on HOME queue) immediately before the trap', 'panic.txt β Fatal trap 12, fault virtual address 0x0, cpuid=0 (releaser cpu) vs home_tq=cpu4, current process = the releaser stat, Stopped at taskqueue_cancel_locked+0x45 movq (%rcx),%rax', "runB_markers.log β MARKER[n] obj==request SAME-BLOCK (freed request re-gotten from the FREEING cpu's magazine) and readback e=60/done=1 write-after-free markers", "fix_run_dmesg.txt β patched kernel: 22+ 'complete (request freed=yes)' rounds, zero Fatal traps, guest up (baseline panics in round 1)", 'VERDICT.md β full path:line trace, harness design rationale, fix validation']
PoC changes
Complete rewrite vs any seed: kthread-driven waiter abandoned (autofs_set_sigmask derefs td_lwp==NULL for kthreads under vfs.autofs.interruptible=1 β NULL+offset fault); BOTH waiters must be real user processes. Creator = pending_stat (self-pending blocked SIGTERM via thread+kill(getpid) β POSIX timers absent from DF libc, ITIMER_REAL delivers SIGALRM which autofs does not unmask) so the EINTR early exit lands at a precise moment. Releaser = long-lived helper processes on other cpus (fork co-location on idle box) + one usermode spinner for scheduler migration pressure. The runner-occupying task must SLEEP not spin: this kernel's softclock is a per-cpu thread (kern_timeout.c:423) that a busy taskq daemon starves, preventing the very callout the race needs (empirically: ta_pending never left 0 with a spinner). Module: publishes round/key/stage sysctls, samples request state (ref/done/inprog/ta_pending/ta_queue) under sc_lock, per-cpu getter kthreads identify the freeing cpu via objcache magazine locality.
Verified recommended fix
Store the arming per-cpu taskqueue in the request (ar_taskqueue) at creation and use it for taskqueue_cancel_timeout/taskqueue_drain_timeout in the last-releaser free path
Verdict
REPRODUCED on stock INVARIANTS X86_64_GENERIC (6-cpu guest): deterministic round-1 kernel panic 'Fatal trap 12, fault virtual address = 0x0, Stopped at taskqueue_cancel_locked+0x45' β the wrong-per-cpu-taskqueue cancel in autofs_trigger_one() walking a foreign STAILQ to NULL. The adjacent interleaving (task dequeued on home queue) yields a UAF: wrong-queue taskqueue_drain_timeout returns without waiting, objcache_put frees the request while autofs_task still reads (AUTOFS_WARN %s of ar_path from freed memory) and writes (ar_error=ETIMEDOUT/ar_wildcards/ar_done/ar_in_progress) into the freed, reusable autofs_request object β cross-cpu frees and post-free field changes observed via same-magazine re-get; the write primitive is confined to the dedicated objcache with fixed values/offsets, so the realistic ceiling is local DoS (panic) plus corruption of an unrelated in-flight request, not a general arbitrary-write or unpriv->root chain. Trigger side is the real unprivileged path (stat -> VOP_NRESOLVE -> autofs_trigger with an EINTR early creator exit); the boundary completion is the automountd AUTOFSDONE ioctl racing the timeout frontier. fix.diff (remember arming queue in ar_taskqueue; cancel/drain against it) validated by in-guest kernel rebuild: baseline panics round 1, patched runs 22+ clean rounds with zero traps.
No comments yet.