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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3311 Β· 16 files
FileTypeDescriptionSize
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 file
  • VERDICT.md β€” full narrative
  • wgtool.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 loop
  • raceA12.sh β€” widened variant (12 peers, longer teardown per destroy)
  • build.sh, run.sh
  • run.log β€” the wedge run (run #1) campaign transcript
  • run.2.log, run.3.log β€” reruns (clean)
  • env.txt β€” guest uname
  • fix.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.
VERDICT.md
↓ download raw

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:

  1. cancel+drain sc_handshake_task (:2763-2767), then the per-CPU encrypt and decrypt tasks (:2768-2779);
  2. purge per-peer stage queues, disable timers (:2781-2784);
  3. purge the handshake queue (:2786);
  4. clear noise state (:2788-2791);
  5. 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 late wg_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 late wg_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_testable
baseline no→ patch + rebuild →patched clean

Wedge 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
↓ fix.diffper-fix-DF-3311

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