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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3173 Β· 22 files
FileTypeDescriptionSize
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.

VERDICT.md
↓ download raw

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)

  1. 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 SIGKILLed stat blocked 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."
  2. 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 executes STAILQ_REMOVE of 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_next with curelm == NULL, offset 0.
  3. UAF path: if the home runner already dequeued the task (ta_pending=0, home tq_running == task), the wrong-queue taskqueue_drain_timeout compares the WRONG queue's tq_running (subr_taskqueue.c:493,526) and returns immediately, so objcache_put (autofs.c:400) frees the request while autofs_task (autofs.c:196-211) is still parked on sc_lock/executing on ar: post-free read (AUTOFS_WARN prints ar->ar_path of 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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Mon Sep 7 20:24:03 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

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.