# DF-3311 VERDICT — teardown (down/destroy) vs in-flight receive upcall

## Bottom line

**inconclusive**: one hard, fatal system wedge occurred under exactly the
trigger conditions ~45 s into the first race run; it never recurred in
four subsequent race runs (~8,000 churn iterations) nor in three control
runs. The code-level defect is certain; the dynamic race is real (it
killed the guest once) but is low-probability per attempt in this
environment.

## The defect (cited path)

`wg_down()` (sys/net/wg/if_wg.c:2747-2798) tears an interface down in this
order:

1. cancel+drain `sc_handshake_task` (:2763-2767), then the per-CPU encrypt
   and decrypt tasks (:2768-2779);
2. purge per-peer stage queues, disable timers (:2781-2784);
3. purge the handshake queue (:2786);
4. clear noise state (:2788-2791);
5. **only now** close the sockets (`wg_socket_uninit`, :2795).

Until step 5, the UDP sockets are still open, so inbound packets keep
triggering `wg_upcall()` → `wg_input()`. A thread already inside
`wg_input()` — or one that gets there between step 1 and step 5 — executes
the handshake enqueue path (if_wg.c:2163-2172):

```c
2163  if (WG_PKT_IS_INITIATION(m) || WG_PKT_IS_RESPONSE(m) || WG_PKT_IS_COOKIE(m)) {
2166      if (!wg_queue_enqueue_handshake(&sc->sc_handshake_queue, pkt)) { ... }
2170      taskqueue_enqueue(sc->sc_handshake_taskqueue,
2171                        &sc->sc_handshake_task);
2172      return;
```

`WG_PKT_IS_INITIATION(m)` checks only the type word and exact length
(if_wg.c:114-116) — no crypto validation happens before the enqueue (MACs
are checked later inside `wg_handshake()` by
`cookie_checker_validate_macs()`), so **any** unprivileged network peer
can drive this path with garbage packets. The `taskqueue_enqueue()` at
:2170 happens *after* `wg_down()`'s cancel loop completed, so the task
stays queued.

In the plain-down case the late task merely runs against a live softc.
The fatal case is `wg_clone_destroy()` (if_wg.c:2874-2920), which calls
`wg_down()` and then, under `sc_lock`:

- `kfree(sc->sc_encrypt_tasks/sc->sc_decrypt_tasks)` — the task structs a
  late `wg_decrypt_dispatch()` (if_wg.c:2189-2193) would enqueue
  (:2883-2884);
- `wg_queue_deinit(&sc->sc_handshake_queue)` and the parallel queues —
  purge + **lockuninit** (:2885-2887), so a late
  `wg_queue_enqueue_handshake()` operates on an uninitialized/freed lock
  and list head;
- ...and finally `kfree(sc, M_WG)` (:2917), while the late task's callback
  argument is that same softc pointer.

Additionally `wg_clone_destroy()` re-acquires `sc_lock` (:2881) after
`wg_down()` released it (:2797), leaving a window in which a concurrent
`SIOCSIFFLAGS` can run `wg_up()` and re-open sockets that are then never
closed before the softc is freed.

Primary interleaving (unpriv peer + root `ifconfig wgX destroy`):

```
CPU0 (netisr / upcall)                CPU1 (root ioctl thread)
-----------------------               ----------------------------
packet arrives on open socket
wg_input(): type+len OK
                                      wg_down(): cancel+drain tasks
                                      wg_down(): purge queues
wg_queue_enqueue_handshake(pkt)
taskqueue_enqueue(sc_handshake_task)
                                      wg_socket_uninit(): sockets closed
                                      wg_clone_destroy(): kfree(tasks)
                                      wg_queue_deinit() [lockuninit]
                                      ...
                                      kfree(sc)
(task fires) wg_handshake_worker(sc)  →  UAF read of freed softc /
                                          uninit lock / freed queue walk
```

## Campaign log

Guest: DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC, INVARIANTS), 6 vCPU
QEMU/KVM. Root side: `raceA.sh` (create + private key + 4 peers with
pka=1 + up + destroy, looped). Unpriv side: `spam` as `nobody` sending
148/92/64-byte type-1/2/3 packets to 127.0.0.1:51820.

| # | run | duration / iters | outcome |
|---|-----|------------------|---------|
| 1 | race run #1 (fresh boot, 1 spammer) | wedge ≤ 45 s (≤ ~400 iters) | **GUEST DEAD**: ssh timeout, `vm.sh status` = down, QEMU pid alive, no reboot/panic text in serial log, **no crash dump** in /var/crash after forced revert → hard hang (silent wedge), not a clean panic |
| 2 | control A (spam only, 60 s) | — | alive |
| 3 | control A2 (iface up + spam 45 s + one destroy) | — | alive, destroy clean |
| 4 | control B (churn 400 iters, no spam) | 400 | alive |
| 5 | race rerun #2 (2000 iters, multi-spammer) | 2000 | alive |
| 6 | race rerun #3 (3000 iters) | 3000 | alive |
| 7 | fresh-boot rerun (module+kldload+race, 2000 iters) | 2000 | alive |
| 8 | widened race (12 peers, 2 spammers, 800 iters) | 800 | alive |

## Why "inconclusive" and not "reproduced"

The single fatal observation satisfies the trigger conditions exactly
(root churn + unprivileged packet flood) and this guest had been stable
across every prior campaign (dozens of boots, all controls), so an
unrelated flake is unlikely — but with 4 failed attempts to repeat it, an
honest `reproduced=1` is not supportable. The silent wedge (no panic text,
no dump) is consistent with: lockmgr spin/deadlock on an uninitialized
queue lock, a taskqueue walking freed task memory, or objcache corruption
from a double-queue insertion — all plausible manifestations of this
interleaving family, none captured on tape.

## Exploitation assessment

The corrupted state (a wg_packet/queue linkage written into freed or
recycled softc memory, or a task callback argument pointing at freed
memory) is reachable only in the narrow teardown window and the write
content is largely not attacker-chosen (kernel pointers/small ints).
No path toward `uid=0` was pursued; the realistic ceiling of this finding
is a reliable-looking local DoS (fatal hang of a multi-user system,
requiring force-reset) triggered by unprivileged packet input racing a
privileged config operation. The peer-lifetime half of the teardown
problem is DF-0315 (known, unfixed); this finding is the *softc/task*
half and needs its own fix.

## Fix

`fix.diff`: in `wg_down()`, close the sockets **first** so no new upcall
can enter `wg_input()` while the tasks/queues are cancelled, purged and
(after `wg_clone_destroy()`) freed. This removes the systematic re-enqueue
window (the upcall loop and soreceive are quiesced by `soclose()` before
any cancel). The residual single-in-flight-`wg_input()` window (a thread
already past soreceive) would additionally need a softc reference count;
noted in the diff commentary, out of scope for a minimal fix.
`fix_status: not_testable` — the race did not recur reliably enough after
run #1 to drive a baseline/patched A/B comparison.

## Artifacts
- run.log / run.2.log / run.3.log — campaign transcripts
- wgtool.c / spam.c / raceA.sh / raceA12.sh — tools
- env.txt — guest uname
- fix.diff, manifest.json, verdict.json

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