# DF-3310 VERDICT — port-roam `lockinit()` on a live, held lock + unsynchronized socket close/swap

## Bottom line

**not_reproduced dynamically; the defect itself is certain by source
inspection.** The port-change-on-running-interface path in
`wg_socket_init()` re-runs `lockinit()` on a lock that may be concurrently
held, which is undefined behavior for DragonFly lockmgr, and closes/installs
the UDP sockets without holding the lock that exists to protect them. Three
racing campaigns totaling ~8,900 successful port changes against sustained
tunnel traffic (including `sosend_async=0`, the configuration that maximizes
the time `wg_send()` holds the lock) produced no observable corruption
within the verification budget. The race window is a sub-1% duty-cycle
overlap per iteration; absence of manifestation in this budget is not
evidence of absence of the defect.

## Code proof (the cited path, line by line)

Trigger: root issues `SIOCSWG` with `WG_INTERFACE_HAS_PORT` and a port
different from the current one while the interface is `IFF_RUNNING`
(`wg_ioctl_set()`, sys/net/wg/if_wg.c:2555-2564):

```c
2557  if (sc->sc_ifp->if_flags & IFF_RUNNING) {
2558      ret = wg_socket_init(sc, iface_o.i_port);
```

`wg_socket_init()` (if_wg.c:932-992) opens the two *new* sockets, then:

```c
972  KKASSERT(lockstatus(&sc->sc_lock, curthread) == LK_EXCLUSIVE);
974  lockinit(&so->so_lock, "wg socket lock", 0, 0);
976  if (so->so_so4 != NULL)
977      soclose(so->so_so4, 0);          /* old socket closed */
979  if (so->so_so6 != NULL)
980      soclose(so->so_so6, 0);
980  so->so_so4 = so4;                    /* pointers swapped */
981  so->so_so6 = so6;
982  so->so_port = bound_port;
```

Problems:

1. **`lockinit()` on a live lock (if_wg.c:974).** `so_lock` was already
   initialized by the previous `wg_socket_init()` (first `wg_up()` or first
   port set). DragonFly `lockinit()` (sys/kern/kern_lock.c:1300-1307)
   unconditionally sets `lkp->lk_count = 0; lkp->lk_lockholder = NULL;`.
   Meanwhile the send fast path holds the lock shared:

   ```c
   1145  lockmgr(&so->so_lock, LK_SHARED);        /* wg_send() */
   1146  if (sa->sa_family == AF_INET && so->so_so4 != NULL) {
   1147      ret = so_pru_sosend(so->so_so4, ...);
   ...
   1160  lockmgr(&so->so_lock, LK_RELEASE);
   ```

   With a shared holder in flight, `lockinit()` zeroes `lk_count`; the
   holder's release then does `fetchadd(-LKC_SCOUNT)` on 0, leaving
   `lk_count == 0xFFFFFFFF00000000` — a permanent *ghost shared holder*
   (flag layout in sys/sys/lock.h:156-162). Every later
   `LK_EXCLUSIVE` acquirer (`wg_socket_set_cookie` if_wg.c:1114,
   `wg_socket_uninit` if_wg.c:1063) sees `LKC_SMASK != 0`, requests
   `LKC_EXREQ`, and sleeps forever: all subsequent SIOCSWG-cookie ioctls and
   `ifconfig wgX destroy` wedge permanently (local DoS), and under
   INVARIANTS/DEBUG_LOCKS builds the count underflow trips assertions
   (panic).

2. **Unsynchronized close/swap of live socket pointers (if_wg.c:976-982).**
   The only synchronization is `sc_lock` (exclusive), but `wg_send()` never
   takes `sc_lock` — it takes `so_lock` shared. The file's own convention
   shows `so_lock` is meant to serialize exactly this:
   `wg_socket_uninit()` (if_wg.c:1063-1074) takes `so_lock` LK_EXCLUSIVE
   before closing both sockets. A `wg_send()` that has already loaded
   `so->so_so4` can be inside `so_pru_sosend()` on the old socket while
   `soclose()` executes concurrently, racing socket teardown with an
   in-flight transmit.

Note the trigger requires privilege (`SIOCSWG` is gated by
`caps_priv_check(cred, SYSCAP_RESTRICTEDROOT)` at if_wg.c:2671), but the
*concurrency partner* is attacker-influenceable packet traffic — which is
why this is Medium, not High.

## Dynamic campaign (what was run)

Guest: DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC, INVARIANTS),
6 vCPU QEMU/KVM. Tools built in-guest (`build.sh`); tunnel wg0<->wg1 with
RFC 7748 §6.1 keypairs over lo0 (the kernel performs all DH — the guest has
no wg(8), so a custom SIOCSWG driver `wgtool.c` was written; the tunnel
coming up proves the ioctl layout is byte-exact).

| run | config | port changes | traffic | live probe | result |
|-----|--------|--------------|---------|-----------|--------|
| 1   | async UDP (`sosend_async=1`) | 8000 | 1×`ping -f` | none | clean; `ifconfig wg0 destroy` after: instant (0s) |
| 2   | async UDP | 600 | 3×`ping -f` | `wgtool cookie` every ~75ms, `timeout 3` | clean, 203 probes |
| 3   | **sync UDP (`sosend_async=0`)** | 300 | 2×`ping -f` (one 1400B) | `wgtool cookie`, `timeout 3` | clean, 98 probes |

All port changes succeeded (0 `FAIL_ITER` entries; `wgtool show` confirmed
the port actually moved, e.g. 40001 → 36563 → 33534). After every run,
`ifconfig wg0 destroy` completed instantly — i.e. `so_lock` was never
wedged — and the EXCLUSIVE-cookie probes never timed out.

## Why it did not reproduce (and why that does not clear the code)

The corruption requires a `wg_send()` shared hold to straddle the exact
`lockinit()` instant. The in-lock duration is dominated by
`so_pru_sosend()` on a loopback UDP datagram — microseconds even in sync
mode. At the observed ping-flood rates this is well under a few percent
duty cycle per ~25 ms port-change iteration. Expected hits over 8,900
iterations were single-digit to low-double-digit; zero observed is within
bad luck for the thinnest part of the window (the exact instruction that
zeroes `lk_count`). The code remains wrong independent of luck: no caller
may `lockinit()` a lock that other threads may hold, and the socket
close/swap must be under `so_lock`.

## Exploitation assessment (if the race is won)

The primitive is lock-state corruption (ghost shared count) → permanent
exclusive starvation (wedge of any thread taking `so_lock` exclusive,
including `wg_socket_uninit` inside `ifconfig wgX destroy`), or, on debug
builds, an INVARIANTS panic. The unsynchronized `soclose()` half races
socket teardown against an in-flight `so_pru_sosend()` — a struct socket
use-after-free is theoretically possible but its outcome is mediated by the
generic socket layer's own lifetime machinery, which we could not defeat in
budget. No path to `uid=0` was pursued further given the trigger requires a
privileged ioctl; the realistic impact ceiling is local DoS (wedge/panic)
with root cooperation on the trigger.

## Fix

See `fix.diff` (authored against the read-only `sys/` tree, never applied
there): initialize `so_lock` exactly once at softc creation, take it
exclusive around the close/swap in `wg_socket_init()`, tear it down with
the softc. `fix_status: not_testable` — the race that the fix removes never
manifested dynamically, so a patched-kernel A/B comparison cannot be
driven by this PoC.

## Artifacts
- run.log / run.2.log / run.3.log — campaign transcripts
- env.txt — guest uname
- wgtool.c, setupB.sh, raceB.sh, raceB2.sh — tools
- 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.
