# DF-3388 VERDICT — wg peer teardown races in-flight noise consumers

**Status: INCONCLUSIVE** (race window code-proven; no externally visible
manifestation produced on the stock INVARIANTS guest in ~28 minutes of
aggressive racing; the quiet-ness of both manifestation paths on this
kernel is understood and documented below).

## Root cause (code-proven)

`wg_peer_destroy()` (sys/net/wg/if_wg.c:640-695) tears a peer down while
in-flight `wg_handshake()`/`wg_deliver_*()` paths still hold
`struct noise_remote` references for that peer. The comment at if_wg.c:646-649
("Disable remote and timers. This will prevent any new handshakes from
occuring") is the faulty assumption: `noise_remote_disable()`
(sys/net/wg/wg_noise.c:477-489) only unlinks the remote from the
public-key hash; a consumer that has **already passed**
`noise_remote_lookup()` keeps its reference and continues:

1. `noise_consume_initiation()` (wg_noise.c:1088-1169) — between
   `noise_remote_lookup()` (line 1121) and `lockmgr(&r->r_handshake_lock)`
   (line 1140) only unshared crypto runs; nothing excludes a concurrent
   `noise_remote_free()` → `noise_remote_handshake_clear()`
   (wg_noise.c:674-681) which removes `r->r_index` from `l_index_hash`
   under the same handshake lock. If the consumer acquires the lock
   *after* the clear ran, line 1154 `noise_remote_index_insert()` re-links
   `&r->r_index` into `l_index_hash` and sets `r_handshake_state =
   HANDSHAKE_RESPONDER` on a remote whose owning peer is already gone.
2. The consumer then returns the remote; `wg_handshake()`
   (if_wg.c:1648-1660) does `peer = noise_remote_arg(remote)` —
   `r_arg` is an **uncounted** back-pointer to the `struct wg_peer` that
   `wg_peer_destroy()` unconditionally `kfree()`s at if_wg.c:696. The
   subsequent `wg_peer_set_endpoint(peer, e)` / `wg_send_response(peer)` /
   `wg_timers_event_session_derived(peer)` (callout_reset on freed memory)
   are use-after-free.
3. When the consumer's reference is the last one,
   `noise_remote_put()` (wg_noise.c:596-611) `explicit_bzero()`s and
   `kfree()`s the remote **while `&r->r_index` is still linked into
   `l_index_hash`** (manifestation 1) — a dangling LIST node that every
   subsequent `noise_keypair_lookup()`/`noise_remote_index_lookup()`
   (wg_noise.c:536-564, 730-750) walks.

Contrast with the FreeBSD original this file was ported from: FreeBSD's
`noise_remote_put()` frees through `NET_EPOCH_CALL(noise_remote_smr_free)`
and `noise_remote_free(r, cleanup)` defers the peer destruction to the
remote's last reference via `r_cleanup` (freebsd sys/dev/wg/wg_noise.c,
`noise_remote_smr_free`). The DragonFly port dropped both the epoch and the
cleanup callback — `noise_remote_free()` (wg_noise.c:613-620) has no
teardown synchronization and `wg_peer_destroy()` frees the peer inline.

Threat: a configured peer (holder of a peer private key — i.e. a malicious
or compromised peer) sending handshakes while an administrator churns peers
(`wg syncconf` does REMOVE+re-add per peer) races the window. This is a
kernel use-after-free (CWE-416/CWE-362).

## What was run (attempts, honest)

Guest: DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC, INVARIANTS), 6 vCPUs,
QEMU/KVM. Built the userspace forger from the kernel's own crypto sources
(`wgforge.c`; blake2s/curve25519/chacha20/poly1305 compiled with a sys/
shim) and the SIOCSWG churn driver (`wgchurn.c`).

1. **Sanity (PROVEN):** with 32 stable peers, forged initiations are
   cryptographically accepted — tcpdump captured 199 92-byte handshake
   responses in <2 s (logs/tcpdump_sanity.log). This proves the PoC
   exercises `noise_consume_initiation()` all the way into its critical
   section (the response is only generated after the index insert and
   `noise_remote_ref()`).
2. **Race:** churner at ~130 destroy-all cycles/s (each cycle destroys and
   re-adds all 32 peers ⇒ ~4160 `wg_peer_destroy()` executions/s; 165,815
   cycles total) racing 3 forgers at ~660 initiations/s (~895k sent,
   est. ~340k reaching the consumer critical section) for ~28 minutes.
   Guest never panicked; `vmstat -m` NOISE object count stayed at 33
   (32 remotes + 1 local — no orphaned keypairs, no leak).

## Why no visible crash on this kernel (analysis)

- Manifestation (2) (peer UAF): the churn re-adds peers from the same
  M_WG slab with `M_ZERO` within the same ioctl, so the freed peer slot is
  immediately re-initialized as a *new, valid* peer — the consumer's writes
  land on a live same-type object (silent corruption of the new peer's
  endpoint/timers). `lockuninit()` (sys/kern/kern_lock.c:1325-1333) does
  not poison the lock, so locking stale bytes does not trip INVARIANTS.
- Manifestation (1)/(3) (dangling index node): `noise_remote_put()`
  `explicit_bzero()`s the remote before `kfree()`; a zeroed LIST node
  yields `le_next = NULL`, so bucket-chain walks merely *terminate early*
  over dead entries — no wild pointers; M_NOISE slot reuse is always by
  `M_ZERO` remotes of the same size class, never attacker-shaped data.
  Net effect is silently dropped index lookups / permanently dead hash
  entries, not a crash.
- A crash requires a compounded cascade (freed remote slot re-linked by a
  *new* remote while an old bucket still points at it, then a
  `LIST_REMOVE` cross-bucket write) — probabilistic and not observed in
  the run window.

Given the per-attempt window geometry (the handshake-lock arrival must
land inside the few-µs clear/critical-section of the peer's own destroy),
the expected number of silent hits over the run is in the tens; the
analysis above explains why none of them became visible.

## Bottom line

The lifetime bug is real and code-proven (see root cause); on a
DEBUGSLABS/slab-poisoning or KASAN-style kernel it would fire immediately.
On the stock INVARIANTS guest the PoC demonstrated (a) full protocol-level
reachability of the racing consumer path (199 accepted handshakes) and
(b) 5.3M destroy events against ~340k consumer critical sections with no
visible crash. Classification: `inconclusive`, `reproduced=0`,
`impact=none (observed)`; the finding itself remains High severity on the
strength of the code analysis.

## Recommended fix

Port the FreeBSD deferred-free discipline: give `struct noise_remote` a
cleanup callback invoked at last-reference drop (which frees the wg_peer),
and have `noise_consume_initiation()`/`noise_consume_response()` re-check a
"dying" flag under `r_handshake_lock` before re-inserting the index.
See fix.diff.

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