Interface down/destroy tears down tasks/queues/softc while the receive upcall can still re-enqueue them (sockets closed last): late handshake/decrypt task fires on freed softc
Summary
wg_down() cancels+drains sc_handshake_task and the per-CPU encrypt/decrypt tasks and purges their queues while the UDP sockets are still open - they are closed only at the very end. A receive upcall already inside wg_input() can therefore enqueue a handshake packet and re-enqueue sc_handshake_task AFTER the cancel loop; the handshake enqueue path needs no valid crypto (WG_PKT_IS_* checks only the type word and exact length), so an unprivileged network peer drives it with garbage packets. In wg_clone_destroy the softc is then dismantled: task arrays kfree'd, queue locks lockuninit'd, softc kfree'd - the late task's callback argument is that freed softc. A decrypt-path variant re-enqueues sc_decrypt_tasks[cpu] after its kfree. Additionally wg_clone_destroy re-acquires sc_lock after wg_down released it, leaving a window for a concurrent SIOCSIFFLAGS wg_up to re-open sockets never closed before the free. OBSERVED once: fatal silent wedge (no dump) 45s into the first race run; 4 reruns (~8,000 churn iterations) and 3 controls clean -> verdict inconclusive (impact dos), defect certain. Unpriv input (raw UDP 148/92/64-byte type-1/2/3 packets on lo0) raced with root destroy loop. Distinct from DF-0315 (peer lifetime, not softc/task lifetime). Fix.diff authored (close the sockets before cancelling anything; airtight fix additionally needs a softc ref across wg_input).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3311 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 2.5 KB | β raw | |
| VERDICT.md | β | 7.1 KB | β raw | |
| wgtool.c | β | 14.4 KB | view raw | |
| spam.c | β | 1.9 KB | view raw | |
| raceA.sh | β | 1.5 KB | view raw | |
| raceA12.sh | β | 1.5 KB | view raw | |
| raceB2.sh | β | 1.1 KB | view raw | |
| build.sh | β | 114 B | view raw | |
| run.sh | β | 193 B | view raw | |
| run.log | β | 588 B | view raw | |
| run.2.log | β | 409 B | view raw | |
| run.3.log | β | 271 B | view raw | |
| env.txt | β | 249 B | view raw | |
| fix.diff | β | 883 B | view raw | |
| manifest.json | β | 1.2 KB | view raw | |
| verdict.json | β | 3.7 KB | view raw |
DF-3311 β wg interface teardown races the in-flight receive upcall: tasks/queues/softc used after cancel/free
What this is
Evidence pack for the pass-2 finding in sys/net/wg/if_wg.c:
wg_down()/wg_clone_destroy() cancel and drain the handshake and
encrypt/decrypt tasks and purge their queues while the UDP sockets are
still open, so a receive upcall already inside wg_input() can enqueue a
handshake packet and re-enqueue sc_handshake_task (or a decrypt dispatch)
after the cancel/drain/purge β leaving a task that later fires on a
softc whose task arrays were kfree'd (if_wg.c:2883-2884), whose queue
locks were lockuninit'd (if_wg.c:2885-2887), and which is ultimately
kfree'd (if_wg.c:2917).
verdict summary
- status: inconclusive.
- One hard system wedge (guest dead, ssh gone, no panic text, no crash dump, QEMU process alive at DDB/hang; required force-kill + snapshot revert) occurred ~45 s into the first race run (root churn + unpriv spam), i.e. exactly under the trigger conditions.
- 3 clean controls (spam only; churn only; spam + single destroy) and 4 subsequent race reruns (~8000 churn iterations total incl. a widened 12-peer variant) did not re-wedge within budget.
- See VERDICT.md for the interleaving analysis and why the wedge signature (silent hang, no dump) matches a lock/queue-lifetime corruption rather than an unrelated flake.
Contents
README.mdβ this fileVERDICT.mdβ full narrativewgtool.cβ SIOCSWG/SIOCGWG driver (no wg(8) on the guest)spam.cβ unprivileged "network peer": exact-length WG_PKT type-1/2/3 packets (no valid crypto needed β the enqueue path only checks the type word and length, if_wg.c:114-125, 2163-2172)raceA.shβ root driver: create/config(4 peers,pka=1)/up/destroy loopraceA12.shβ widened variant (12 peers, longer teardown per destroy)build.sh,run.shrun.logβ the wedge run (run #1) campaign transcriptrun.2.log,run.3.logβ reruns (clean)env.txtβ guest unamefix.diffβ close the sockets before cancelling/draining in wg_down()manifest.json,verdict.json
Reproduce
# on the DragonFly guest: # terminal 1 (root): build + churn loop kldload if_wg cc -O2 -o wgtool wgtool.c && cc -O2 -o spam spam.c sh raceA.sh 3000 # terminal 2 (unprivileged): su -m nobody -c './spam 127.0.0.1 51820 0 tag' # success criterion: guest dies (ssh stops answering, vm.sh status => # down, no reboot in serial log); expect low probability per run, # repeat the whole cycle.
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:
- cancel+drain
sc_handshake_task(:2763-2767), then the per-CPU encrypt and decrypt tasks (:2768-2779); - purge per-peer stage queues, disable timers (:2781-2784);
- purge the handshake queue (:2786);
- clear noise state (:2788-2791);
- 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):
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 latewg_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 latewg_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.
Fix verification
not_testableWedge occurred only in run #1 and never recurred on the unpatched kernel afterwards, so a baseline/patched A/B could not be driven; fix is by-inspection (reorders wg_socket_uninit() ahead of all cancels/purges).
findings/poc/DF-3311/fix.diff
Confirmed kernel references
Detail
Evidence (decisive lines)
['findings/poc/DF-3311/VERDICT.md (interleaving diagram + 8-run campaign table)', 'findings/poc/DF-3311/run.log (the wedge run #1: status down, boot.log silent, /var/crash empty)', 'findings/poc/DF-3311/run.2.log run.3.log (controls clean; reruns 1 wedge / 5 races)', 'findings/poc/DF-3311/fix.diff (close sockets before cancelling tasks in wg_down)']
PoC changes
No wg(8) on guest: wgtool.c issues SIOCSWG directly (byte-exact struct layout from if_wg.h); spam.c sends type-1/2/3 packets whose type word + exact length (148/92/64B) pass WG_PKT_IS_* with zero valid crypto - the enqueue path never validates MACs before queueing, which is what makes the unprivileged side of this race possible.
Verified recommended fix
In wg_down(), call wg_socket_uninit() FIRST (before any taskqueue_cancel/purge) so no new wg_input() can re-enqueue tasks/queues being torn down; a complete fix additionally needs a softc reference across wg_input() (see fix.diff).
Verdict
Code defect certain: wg_down() cancels/drains the handshake and encrypt/decrypt tasks and purges their queues (if_wg.c:2763-2786) while the UDP sockets are still open (closed only at :2795), so an unprivileged peer's packet driving wg_upcall()->wg_input() can enqueue a handshake packet and re-enqueue sc_handshake_task (if_wg.c:2166-2170; WG_PKT_IS_* checks need no valid crypto) AFTER the cancel; wg_clone_destroy() then kfree's the task arrays (:2883-2884), lockuninit's the queues (:2885-2887) and kfree's the softc (:2917) that the late task's callback argument points to. Dynamically: the FIRST race run (root create/up/destroy churn + nobody-spammed 148/92/64-byte packets on lo0) hard-wedged the guest within 45 s (ssh dead, vm down, QEMU alive, no panic text, no /var/crash dump - silent hang needing force-kill); 3 clean controls and 4 subsequent race reruns (~8000 iterations incl. a 12-peer widened variant) stayed up, so the wedge could not be repeated and full 'reproduced' is not honestly claimable. Realistic ceiling: fatal local DoS raced between unprivileged packet input and a privileged config op; no path to uid=0 pursued (write content not attacker-chosen, window is teardown-only).
No comments yet.