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