Port change on a running wg interface re-runs lockinit() on the live so_lock and closes/swaps the UDP sockets without holding it
Summary
wg_socket_init() is called on the listen-port-change path of a RUNNING interface and (a) executes lockinit(&so->so_lock) a second time on a lock that concurrent wg_send() readers may hold SHARED - lockinit() unconditionally zeroes lk_count/lk_lockholder, so an in-flight holder's release leaves a permanent ghost shared count and every later exclusive acquirer starves forever; (b) soclose()es the old sockets and installs new pointers without taking so_lock at all - only sc_lock, which the wg_send fast path never takes, racing an in-flight sosend on the socket being closed. The file's own wg_socket_uninit shows so_lock is meant to serialize exactly this. Root changes the wg listen port while the tunnel carries traffic (attacker-influenceable rate): permanent so_lock corruption wedges all later SIOCSWG-cookie ioctls and destroy (INVARIANTS panic); the soclose-vs-sosend half races teardown with an in-flight transmit. Ran 3 campaigns (~8,900 port changes): zero manifestations - sub-1%-duty overlap not hit in budget; verdict not_reproduced, defect certain by code proof. Ceiling local DoS/panic with root cooperation on the trigger. Fix.diff authored (lockinit once at softc creation + hold so_lock across the swap), git-apply-checked.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3310 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.0 KB | β raw | |
| VERDICT.md | β | 7.2 KB | β raw | |
| wgtool.c | β | 14.4 KB | view raw | |
| setupB.sh | β | 995 B | view raw | |
| raceB.sh | β | 943 B | view raw | |
| raceB2.sh | β | 1.1 KB | view raw | |
| build.sh | β | 114 B | view raw | |
| run.sh | β | 137 B | view raw | |
| run.log | β | 526 B | view raw | |
| run.2.log | β | 370 B | view raw | |
| run.3.log | β | 479 B | view raw | |
| env.txt | β | 249 B | view raw | |
| fix.diff | β | 1.4 KB | view raw | |
| manifest.json | β | 1.1 KB | view raw | |
| verdict.json | β | 3.1 KB | view raw |
DF-3310 β wg_socket_init() port-roam corrupts a live lock and swaps sockets unsynchronized
What this is
Evidence pack for the pass-2 finding in sys/net/wg/if_wg.c:
wg_socket_init() (called from wg_ioctl_set() when the listen port changes
on a running interface, if_wg.c:2557-2560) does, with old sockets still
live and the send fast path wg_send() (if_wg.c:1145-1160) concurrently
holding sc_socket.so_lock SHARED and reading so_so4/so_so6:
lockinit(&so->so_lock, ...)at if_wg.c:974 β a second initialization of a live lock. DragonFly'slockinit()(sys/kern/kern_lock.c:1300) unconditionally zeroeslk_countandlk_lockholder. Any in-flight shared holder's release then subtractsLKC_SCOUNTfrom 0, leaving a ghost "shared holder" (0xFFFFFFFF00000000) inlk_countforever β subsequent exclusive acquirers (wg_socket_set_cookieif_wg.c:1114,wg_socket_uninitif_wg.c:1063) starve permanently.soclose()of the old sockets + pointer install at if_wg.c:976-982 without taking so_lock at all (onlysc_lock, whichwg_send()never takes) β the lock exists precisely to serialize this againstwg_send(), aswg_socket_uninit()(if_wg.c:1063-1074) demonstrates.
verdict summary
- status: not_reproduced (dynamic race not hit; the code defect is certain β see VERDICT.md for the line-by-line proof).
- ~8,900 successful port changes raced against sustained encrypted traffic
(
ping -f, incl. 1400-byte floods, andnet.inet.udp.sosend_async=0which maximizes the in-lock hold time) with liveso_lock-EXCLUSIVE probes (wgtool cookie, SIOCSWG w/ WG_INTERFACE_HAS_COOKIE) every iteration β no wedge, no panic in the allotted budget.
Contents
README.mdβ this fileVERDICT.mdβ full narrative + code proof + run logsetupB.shβ bring up wg0<->wg1 with RFC 7748 keys over lo0raceB.shβ port-change churn loop (plain)raceB2.shβ port churn + concurrent so_lock-EXCLUSIVE probe (SIOCSWG cookie withtimeout), the live wedge detectorwgtool.cβ SIOCSWG/SIOCGWG driver (also in DF-3311/), subcommandsset,setport,peer,cookie,showbuild.sh,run.shrun.log,run.2.log,run.3.logβ the three race campaignsenv.txtβ guest uname / kernelfix.diffβ the verified-by-inspection fix (lock once at softc creation; take so_lock EXCLUSIVE around close/swap)manifest.json,verdict.json
Reproduce
# on the DragonFly guest (root): kldload if_wg cc -O2 -o wgtool wgtool.c ./setupB.sh # prints TUNNEL_UP ping -f 10.7.0.2 >/dev/null 2>&1 & sysctl net.inet.udp.sosend_async=0 # maximize wg_send() hold time sh raceB2.sh 300 0 # churn + live EXCLUSIVE probes # WEDGE_DETECTED on stdout (or a hang of `ifconfig wgX destroy`) is a # positive; repeated CLEAN runs were observed in this campaign (race is # sub-1% overlap per iteration; see VERDICT.md).
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):
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:
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:
lockinit()on a live lock (if_wg.c:974).so_lockwas already initialized by the previouswg_socket_init()(firstwg_up()or first port set). DragonFlylockinit()(sys/kern/kern_lock.c:1300-1307) unconditionally setslkp->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).
- Unsynchronized close/swap of live socket pointers (if_wg.c:976-982).
The only synchronization is
sc_lock(exclusive), butwg_send()never takessc_lockβ it takesso_lockshared. The file's own convention showsso_lockis meant to serialize exactly this:wg_socket_uninit()(if_wg.c:1063-1074) takesso_lockLK_EXCLUSIVE before closing both sockets. Awg_send()that has already loadedso->so_so4can be insideso_pru_sosend()on the old socket whilesoclose()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.
Fix verification
not_testableRace never manifested dynamically in budget, so no baseline/patched A/B was possible; fix is by-inspection correctness (single lockinit lifetime + exclusive serialization matches wg_socket_uninit's existing protocol).
findings/poc/DF-3310/fix.diff
Confirmed kernel references
Detail
Evidence (decisive lines)
['findings/poc/DF-3310/VERDICT.md (code proof: if_wg.c:974 vs kern_lock.c:1300 lockinit semantics)', 'findings/poc/DF-3310/run.log/.2/.3 (3 campaigns: 8000 async / 600+probes / 300 sync+probes, all clean)', 'findings/poc/DF-3310/fix.diff (init so_lock once at clone_create; EXCLUSIVE around close/swap)']
PoC changes
No wg(8) on guest: wrote wgtool.c (SIOCSWG/SIOCGWG ioctl driver, struct layout copied byte-exact from sys/net/wg/if_wg.h; includes embedded X25519 that turned out buggy and was bypassed by using RFC 7748 6.1 fixed keypairs - kernel does all DH). raceB2.sh adds a live so_lock-EXCLUSIVE detector (SIOCSWG cookie under timeout(1)) that the seed sketch lacked.
Verified recommended fix
Initialize sc_socket.so_lock once in wg_clone_create(); in wg_socket_init() take it LK_EXCLUSIVE around closing old sockets and installing new pointers; never re-lockinit a live lock (see fix.diff).
Verdict
Code defect certain by source proof: wg_socket_init() (if_wg.c:974) re-runs lockinit() on the live sc_socket.so_lock while wg_send() (if_wg.c:1145) may hold it shared (lockinit zeroes lk_count -> ghost shared holder -> permanent exclusive starvation of wg_socket_set_cookie/wg_socket_uninit), and closes/installs the UDP sockets (if_wg.c:976-982) without taking so_lock at all. Dynamic campaign: ~8900 successful port changes raced against sustained encrypted ping -f traffic across async-UDP and sosend_async=0 (sync) configs with live so_lock-EXCLUSIVE probes (SIOCSWG cookie, timeout-guarded) - no wedge, panic, or probe timeout observed; sub-1%-duty-cycle overlap window not hit within budget. Realistic ceiling if hit: permanent wg ioctl/destroy wedge (local DoS) or INVARIANTS panic; trigger itself requires a privileged SIOCSWG, so no unpriv->root path.
No comments yet.