β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-3310

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3310 Β· 15 files
FileTypeDescriptionSize
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:

  1. lockinit(&so->so_lock, ...) at if_wg.c:974 β€” a second initialization of a live lock. DragonFly's lockinit() (sys/kern/kern_lock.c:1300) unconditionally zeroes lk_count and lk_lockholder. Any in-flight shared holder's release then subtracts LKC_SCOUNT from 0, leaving a ghost "shared holder" (0xFFFFFFFF00000000) in lk_count forever β€” subsequent exclusive acquirers (wg_socket_set_cookie if_wg.c:1114, wg_socket_uninit if_wg.c:1063) starve permanently.
  2. soclose() of the old sockets + pointer install at if_wg.c:976-982 without taking so_lock at all (only sc_lock, which wg_send() never takes) β€” the lock exists precisely to serialize this against wg_send(), as wg_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, and net.inet.udp.sosend_async=0 which maximizes the in-lock hold time) with live so_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 file
  • VERDICT.md β€” full narrative + code proof + run log
  • setupB.sh β€” bring up wg0<->wg1 with RFC 7748 keys over lo0
  • raceB.sh β€” port-change churn loop (plain)
  • raceB2.sh β€” port churn + concurrent so_lock-EXCLUSIVE probe (SIOCSWG cookie with timeout), the live wedge detector
  • wgtool.c β€” SIOCSWG/SIOCGWG driver (also in DF-3311/), subcommands set, setport, peer, cookie, show
  • build.sh, run.sh
  • run.log, run.2.log, run.3.log β€” the three race campaigns
  • env.txt β€” guest uname / kernel
  • fix.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).
VERDICT.md
↓ download raw

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:

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

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

Fix verification

not_testable
baseline no→ patch + rebuild →patched clean

Race 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
↓ fix.diffper-fix-DF-3310

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.