wg peer teardown races in-flight noise consumers: wg_peer freed via uncounted r_arg and r_index re-linked after handshake_clear (use-after-free)
Summary
noise_remote_disable() only unlinks a remote from the public-key hash; a noise_consume_initiation() consumer that already passed noise_remote_lookup() keeps running with its remote reference while wg_peer_destroy() completes: noise_remote_handshake_clear() removes r_index, then the peer is kfree()d unconditionally. If the consumer acquires r_handshake_lock after the clear, it re-inserts &r->r_index into l_index_hash and sets state=RESPONDER on the dead remote - when the last ref drops, noise_remote_put() explicit_bzero()s and kfrees the remote with the node still linked. Independently, wg_handshake() derefs the already-freed wg_peer via noise_remote_arg() (wg_peer_set_endpoint/wg_send_response/callout resets on freed memory) because r_arg is an uncounted back-pointer. The FreeBSD original defers both frees with NET_EPOCH_CALL + an r_cleanup callback executed at last reference; the DragonFly port dropped both. VERIFIED to the honest ceiling: built a userspace Noise_IKpsk2 forger from the kernel's own crypto (199 forged initiations kernel-accepted), raced ~5.3M peer-destroys against ~340k consumer critical sections over 28 min - no visible crash on the stock INVARIANTS kernel (same-type M_ZERO reallocation heals the peer deref; the zeroed dangling node silently truncates dead hash chains): verdict inconclusive, bug certain from code. Trigger: a configured peer sends handshakes while an admin churns peers - every 'wg syncconf' does REMOVE+re-add per peer, so ordinary reconfiguration under traffic enters the window. Root-escalation route documented (freed M_WG wg_peer re-occupied by different content -> wg_peer_set_endpoint/wg_send_response become controlled writes); not developed to uid0. Fix: port FreeBSD's deferred teardown (r_cleanup at last ref + r_dying re-checks) - fix.diff in pack.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3388 Β· 21 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 2.5 KB | β raw | |
| VERDICT.md | β | 6.7 KB | β raw | |
| wgforge.c | β | 8.5 KB | view raw | |
| wgchurn.c | β | 5.1 KB | view raw | |
| wgkeygen.c | β | 941 B | view raw | |
| chachapoly_user.c | β | 2.9 KB | view raw | |
| blake2s.c | β | 5.8 KB | view raw | |
| curve25519.c | β | 33.4 KB | view raw | |
| chacha.c | β | 7.5 KB | view raw | |
| poly1305.c | β | 6.9 KB | view raw | |
| crypto/ | β | 4.0 KB | β download | |
| shim/ | β | 4.0 KB | β download | |
| build.sh | β | 84 B | view raw | |
| run.sh | β | 90 B | view raw | |
| fix.diff | β | 4.5 KB | view raw | |
| verdict.json | β | 3.7 KB | view raw | |
| env.txt | β | 4.4 KB | view raw | |
| logs/tcpdump_sanity.log | β | 27.0 KB | view raw | |
| logs/churn_race.log | β | 14.3 KB | view raw | |
| logs/forge_a.log | β | 22.1 KB | view raw | |
| logs/forge_c.log | β | 22.1 KB | view raw |
DF-3388 β wg peer-destroy vs in-flight noise consumer lifetime race (UAF)
Unprivileged-impact statement: the racing packet source is a remote UDP peer
(needs the configured peer's private key, i.e. a malicious/compromised peer);
the peer-destruction side is routine root reconfiguration (wg syncconf,
peer replace, interface teardown) β the two coincide in normal operation
whenever peers are reconfigured under load.
What this pack contains
wgforge.cβ userspace WireGuard handshake-initiation forger built from the kernel's own portable crypto (blake2s.c,curve25519.c,chacha.c,poly1305.cvendored here, compiled againstshim/sys/*). It forges valid Noise_IKpsk2 initiations for N peer identities whose private keys we generated, at a paced rate.wgchurn.cβ SIOCSWG driver: loopsWG_INTERFACE_REPLACE_PEERSso the kernel runswg_peer_destroy_all()βwg_peer_destroy()βnoise_remote_free()for every peer and immediately re-adds them.wgkeygen.cβ writes/tmp/wgkeys.bin= resp_pub || priv0 || N peer privs.build.sh,run.shβ exact build/run.logs/β sanity tcpdump (199 accepted handshakes β 92-byte responses), churn/forge logs from the race runs.env.txtβ guest state after the race run.VERDICT.mdβ full narrative.fix.diffβ the deferred-free fix (not applied to sys/).
Run
# on the DragonFly guest, as root:
cd /tmp/df3388 && sh build.sh
./wgkeygen
ifconfig wg0 create && ifconfig wg0 up
./wgchurn /tmp/wgkeys.bin & # first ioctl sets private+port+32 peers
sleep 1; pkill wgchurn
# sanity: verify handshakes are accepted (expect 92-byte responses):
(tcpdump -i lo0 -c 2000 -nn udp port 51820 &) ; ./wgforge 127.0.0.1 51820 /tmp/wgkeys.bin &
sleep 5; pkill wgforge
# race:
./wgchurn /tmp/wgkeys.bin &
./wgforge 127.0.0.1 51820 /tmp/wgkeys.bin & # x3 processes
# watch for panic / hang / vmstat -m NOISE object-count growth
Expected (observed on the stock INVARIANTS guest)
- Sanity: forged initiations are ACCEPTED β kernel sends handshake responses (92-byte UDP) β logs/tcpdump_sanity.log shows 199.
- Race: no panic in ~28 minutes of ~130 destroy-all cycles/s (5.3M wg_peer_destroy executions) against ~660 forged initiations/s (~340k reaching noise_consume_initiation's critical section). See VERDICT.md for why the corruption stays silent on this kernel (M_ZERO same-type reuse heals the peer UAF; the dangling noise_index node is explicit_bzero()'d before the kfree so bucket chains truncate quietly).
DF-3388 VERDICT β wg peer teardown races in-flight noise consumers
Status: INCONCLUSIVE (race window code-proven; no externally visible manifestation produced on the stock INVARIANTS guest in ~28 minutes of aggressive racing; the quiet-ness of both manifestation paths on this kernel is understood and documented below).
Root cause (code-proven)
wg_peer_destroy() (sys/net/wg/if_wg.c:640-695) tears a peer down while
in-flight wg_handshake()/wg_deliver_*() paths still hold
struct noise_remote references for that peer. The comment at if_wg.c:646-649
("Disable remote and timers. This will prevent any new handshakes from
occuring") is the faulty assumption: noise_remote_disable()
(sys/net/wg/wg_noise.c:477-489) only unlinks the remote from the
public-key hash; a consumer that has already passed
noise_remote_lookup() keeps its reference and continues:
noise_consume_initiation()(wg_noise.c:1088-1169) β betweennoise_remote_lookup()(line 1121) andlockmgr(&r->r_handshake_lock)(line 1140) only unshared crypto runs; nothing excludes a concurrentnoise_remote_free()βnoise_remote_handshake_clear()(wg_noise.c:674-681) which removesr->r_indexfroml_index_hashunder the same handshake lock. If the consumer acquires the lock after the clear ran, line 1154noise_remote_index_insert()re-links&r->r_indexintol_index_hashand setsr_handshake_state = HANDSHAKE_RESPONDERon a remote whose owning peer is already gone.- The consumer then returns the remote;
wg_handshake()(if_wg.c:1648-1660) doespeer = noise_remote_arg(remote)βr_argis an uncounted back-pointer to thestruct wg_peerthatwg_peer_destroy()unconditionallykfree()s at if_wg.c:696. The subsequentwg_peer_set_endpoint(peer, e)/wg_send_response(peer)/wg_timers_event_session_derived(peer)(callout_reset on freed memory) are use-after-free. - When the consumer's reference is the last one,
noise_remote_put()(wg_noise.c:596-611)explicit_bzero()s andkfree()s the remote while&r->r_indexis still linked intol_index_hash(manifestation 1) β a dangling LIST node that every subsequentnoise_keypair_lookup()/noise_remote_index_lookup()(wg_noise.c:536-564, 730-750) walks.
Contrast with the FreeBSD original this file was ported from: FreeBSD's
noise_remote_put() frees through NET_EPOCH_CALL(noise_remote_smr_free)
and noise_remote_free(r, cleanup) defers the peer destruction to the
remote's last reference via r_cleanup (freebsd sys/dev/wg/wg_noise.c,
noise_remote_smr_free). The DragonFly port dropped both the epoch and the
cleanup callback β noise_remote_free() (wg_noise.c:613-620) has no
teardown synchronization and wg_peer_destroy() frees the peer inline.
Threat: a configured peer (holder of a peer private key β i.e. a malicious
or compromised peer) sending handshakes while an administrator churns peers
(wg syncconf does REMOVE+re-add per peer) races the window. This is a
kernel use-after-free (CWE-416/CWE-362).
What was run (attempts, honest)
Guest: DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC, INVARIANTS), 6 vCPUs,
QEMU/KVM. Built the userspace forger from the kernel's own crypto sources
(wgforge.c; blake2s/curve25519/chacha20/poly1305 compiled with a sys/
shim) and the SIOCSWG churn driver (wgchurn.c).
- Sanity (PROVEN): with 32 stable peers, forged initiations are
cryptographically accepted β tcpdump captured 199 92-byte handshake
responses in <2 s (logs/tcpdump_sanity.log). This proves the PoC
exercises
noise_consume_initiation()all the way into its critical section (the response is only generated after the index insert andnoise_remote_ref()). - Race: churner at ~130 destroy-all cycles/s (each cycle destroys and
re-adds all 32 peers β ~4160
wg_peer_destroy()executions/s; 165,815 cycles total) racing 3 forgers at ~660 initiations/s (~895k sent, est. ~340k reaching the consumer critical section) for ~28 minutes. Guest never panicked;vmstat -mNOISE object count stayed at 33 (32 remotes + 1 local β no orphaned keypairs, no leak).
Why no visible crash on this kernel (analysis)
- Manifestation (2) (peer UAF): the churn re-adds peers from the same
M_WG slab with
M_ZEROwithin the same ioctl, so the freed peer slot is immediately re-initialized as a new, valid peer β the consumer's writes land on a live same-type object (silent corruption of the new peer's endpoint/timers).lockuninit()(sys/kern/kern_lock.c:1325-1333) does not poison the lock, so locking stale bytes does not trip INVARIANTS. - Manifestation (1)/(3) (dangling index node):
noise_remote_put()explicit_bzero()s the remote beforekfree(); a zeroed LIST node yieldsle_next = NULL, so bucket-chain walks merely terminate early over dead entries β no wild pointers; M_NOISE slot reuse is always byM_ZEROremotes of the same size class, never attacker-shaped data. Net effect is silently dropped index lookups / permanently dead hash entries, not a crash. - A crash requires a compounded cascade (freed remote slot re-linked by a
new remote while an old bucket still points at it, then a
LIST_REMOVEcross-bucket write) β probabilistic and not observed in the run window.
Given the per-attempt window geometry (the handshake-lock arrival must land inside the few-Β΅s clear/critical-section of the peer's own destroy), the expected number of silent hits over the run is in the tens; the analysis above explains why none of them became visible.
Bottom line
The lifetime bug is real and code-proven (see root cause); on a
DEBUGSLABS/slab-poisoning or KASAN-style kernel it would fire immediately.
On the stock INVARIANTS guest the PoC demonstrated (a) full protocol-level
reachability of the racing consumer path (199 accepted handshakes) and
(b) 5.3M destroy events against ~340k consumer critical sections with no
visible crash. Classification: inconclusive, reproduced=0,
impact=none (observed); the finding itself remains High severity on the
strength of the code analysis.
Recommended fix
Port the FreeBSD deferred-free discipline: give struct noise_remote a
cleanup callback invoked at last-reference drop (which frees the wg_peer),
and have noise_consume_initiation()/noise_consume_response() re-check a
"dying" flag under r_handshake_lock before re-inserting the index.
See fix.diff.
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_testableConfirmed kernel references
Detail
Evidence (decisive lines)
['logs/tcpdump_sanity.log: 199 x 92-byte handshake responses = forged initiations accepted into noise_consume_initiation critical section', 'logs/churn_race.log + env.txt: 165,815 destroy-all cycles (5.3M wg_peer_destroy) vs ~895k forged initiations, guest stable, NOISE object count 33 (no orphans)', 'VERDICT.md: interleaving analysis with wg_noise.c:1121-1158, wg_noise.c:674-681, wg_noise.c:596-611, if_wg.c:640-696 citations']
PoC changes
Wrote the PoC from scratch (no seed): userspace Noise_IKpsk2 initiation forger using the kernel's own portable blake2s/curve25519/chacha20/poly1305 compiled with a sys/ shim; SIOCSWG churn driver with WG_INTERFACE_REPLACE_PEERS; keygen. Fixed during bring-up: i_port is host byte order (kernel htons()s it itself), peer public keys must be derived from the peer private keys (churner originally registered the private keys as publics), pace below the wg underload threshold (handshake queue >= 512 switches to cookie mode which bounces initiations before the racing path).
Verified recommended fix
Port FreeBSD's deferred teardown: add a last-reference cleanup callback to struct noise_remote that frees the wg_peer (instead of kfree in wg_peer_destroy), and re-check a r_dying flag under r_handshake_lock in noise_consume_initiation/consume_response/create_response before re-inserting the index.
Verdict
Race window code-proven: wg_peer_destroy() kfree()s the wg_peer and noise_remote_free() clears the handshake index while in-flight noise_consume_initiation() consumers (already past noise_remote_lookup(), holding a remote ref) can still re-insert &r->r_index into l_index_hash and dereference the freed peer via noise_remote_arg(); FreeBSD defers these frees via NET_EPOCH_CALL + r_cleanup, the DragonFly port dropped both. The userspace forger (built from the kernel's own crypto) provably reaches the racing consumer path - 199 accepted forged initiations with 92-byte kernel responses observed - and ~5.3M peer-destroy events were raced against ~340k consumer critical sections over ~28 minutes with no panic; both UAF manifestation paths are silent on the stock kernel because the churn's M_ZERO same-type reallocation heals the peer deref and explicit_bzero() turns the dangling index node into a null-terminated (chain-truncating) list element. Bug: certain; runtime manifestation on stock kernel: not observed.
No comments yet.