lx: gate reserved-byte clear on receive so AmneziaWG magic survives
The Cloudflare "reserved" bytes (1-3) were zeroed unconditionally on every received datagram across all StdNetBind/WinRingBind receive paths. AmneziaWG reads its magic header as LittleEndian.Uint32(packet[padding:]) where padding is s1/s2/s4; with small padding (0-3) the magic overlaps bytes 1-3, so clearing them collapses it out of the ranged h1-h4 window and every packet is dropped (handshake included) — the AWG endpoint never comes up. Plain WG (types 1-4, bytes 1-3 already zero) and large padding are unaffected, which is why it went unnoticed. Gate all five receive clears (bind_std receiveIP, msgx_darwin receiveSingle + makeReceiveMsgX, bind_windows receiveIPv4/v6) behind a new hasReserved() so bytes 1-3 are only touched when a WARP reserved value is actually configured. Send paths already gate on a per-endpoint loaded/non-zero check, so they are left unchanged. The reserved map is populated before the receive goroutines start and never mutated after, so the lock-free read is safe. Tests: awg_stdnetbind_reserved_lx_test.go brings up two Devices over StdNetBind with zero padding (magic in bytes 0-3) and asserts delivery (red before the fix, green after); reserved_gate_lx_test.go pins the hasReserved() gate.
This commit is contained in:
parent
ee7ff1b77f
commit
1e787bb3e0
7 changed files with 283 additions and 11 deletions
190
device/awg_stdnetbind_reserved_lx_test.go
Normal file
190
device/awg_stdnetbind_reserved_lx_test.go
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
/* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* lx: e2e regression for the reserved-clear vs AWG magic-header collision,
|
||||
* exercised over the StdNetBind path (no detour) with real loopback UDP.
|
||||
*
|
||||
* Bug model. On receive, StdNetBind.receiveIP unconditionally zeroed bytes
|
||||
* 1-3 of every datagram >3 bytes (the Cloudflare WARP "reserved" field).
|
||||
* AmneziaWG reads its magic header as LittleEndian.Uint32(packet[padding:]),
|
||||
* where padding is s1/s2 (handshake) or s4 (transport). With small padding
|
||||
* (0..3) the 4-byte magic overlaps bytes 1-3, so the unconditional clear
|
||||
* corrupts it: the value falls outside the ranged h1-h4 window, the packet is
|
||||
* classified MessageUnknownType and dropped. WARP was never configured on
|
||||
* these binds (no SetReservedForEndpoint), so the clear was pure collateral.
|
||||
*
|
||||
* The fix gates the clear behind StdNetBind.hasReserved(): bytes 1-3 are only
|
||||
* zeroed when a non-zero reserved value is actually set for some endpoint.
|
||||
* With no reserved value the magic survives and the handshake completes.
|
||||
*
|
||||
* This test provokes the worst case: padding = 0 (no s1/s2/s4 at all), so the
|
||||
* h1 initiation magic sits in bytes [0..3] and its high bytes (1-3) are the
|
||||
* ones the clear would destroy. The h1-h4 ranges are chosen entirely above
|
||||
* 0x10000000, so after zeroing bytes 1-3 the surviving value is <= 255 and can
|
||||
* never land back inside any range -> guaranteed drop on the buggy tree.
|
||||
*
|
||||
* GREEN on the fixed tree. To see RED, temporarily restore the unconditional
|
||||
* clear in conn/bind_std.go receiveIP:
|
||||
* if msg.N > 3 {
|
||||
* common.ClearArray(bufs[i][1:4])
|
||||
* }
|
||||
* and the handshake times out (init magic zeroed in bytes 1-3).
|
||||
*/
|
||||
|
||||
package device
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/wireguard-go/conn"
|
||||
)
|
||||
|
||||
// magic header ranges kept entirely above 0x10000000 (268435456). Any value
|
||||
// the sender picks therefore has a non-zero byte among positions 1-3; zeroing
|
||||
// those bytes collapses the value to <= 0xFF, which is below every range start,
|
||||
// so a corrupted magic can never validate. Distinct windows per message type.
|
||||
const (
|
||||
lxH1Lo, lxH1Hi = 268500000, 268600000 // init
|
||||
lxH2Lo, lxH2Hi = 300000000, 300100000 // response
|
||||
lxH3Lo, lxH3Hi = 400000000, 400100000 // cookie
|
||||
lxH4Lo, lxH4Hi = 500000000, 500100000 // transport
|
||||
)
|
||||
|
||||
// lxReadListenPort parses listen_port=<n> out of a device's IpcGet dump.
|
||||
func lxReadListenPort(t *testing.T, dev *Device) uint16 {
|
||||
t.Helper()
|
||||
dump, err := dev.IpcGet()
|
||||
if err != nil {
|
||||
t.Fatalf("IpcGet: %v", err)
|
||||
}
|
||||
scanner := bufio.NewScanner(strings.NewReader(dump))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if v, ok := strings.CutPrefix(line, "listen_port="); ok {
|
||||
p, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
t.Fatalf("parse listen_port %q: %v", v, err)
|
||||
}
|
||||
return uint16(p)
|
||||
}
|
||||
}
|
||||
t.Fatalf("listen_port not found in dump:\n%s", dump)
|
||||
return 0
|
||||
}
|
||||
|
||||
// newStdNetPaddedPair builds two Up()'d Devices peered over real loopback UDP
|
||||
// (NewStdNetBind), configured with ranged h1-h4 + junk and *no* s1/s2/s4
|
||||
// (padding = 0). Endpoints are wired after Up, once the ephemeral ports are
|
||||
// known. No reserved value is ever set, so hasReserved() is false.
|
||||
func newStdNetPaddedPair(t *testing.T) (devA, devB *Device, tunA, tunB *chanTun) {
|
||||
t.Helper()
|
||||
|
||||
skA, err := newPrivateKey()
|
||||
if err != nil {
|
||||
t.Fatalf("newPrivateKey A: %v", err)
|
||||
}
|
||||
skB, err := newPrivateKey()
|
||||
if err != nil {
|
||||
t.Fatalf("newPrivateKey B: %v", err)
|
||||
}
|
||||
pkA := skA.publicKey()
|
||||
pkB := skB.publicKey()
|
||||
|
||||
tunA = newChanTun()
|
||||
tunB = newChanTun()
|
||||
|
||||
devA = NewDevice(context.Background(), tunA, conn.NewStdNetBind(nil), NewLogger(LogLevelError, "devA: "), 1)
|
||||
devB = NewDevice(context.Background(), tunB, conn.NewStdNetBind(nil), NewLogger(LogLevelError, "devB: "), 1)
|
||||
t.Cleanup(devA.Close)
|
||||
t.Cleanup(devB.Close)
|
||||
|
||||
// obfuscation shared by both ends. Ranged magic headers, junk packets,
|
||||
// and deliberately no s1/s2/s4 so padding stays 0 for every message type.
|
||||
obf := fmt.Sprintf(
|
||||
"jc=3\njmin=8\njmax=16\n"+
|
||||
"h1=%d-%d\nh2=%d-%d\nh3=%d-%d\nh4=%d-%d\n",
|
||||
lxH1Lo, lxH1Hi, lxH2Lo, lxH2Hi, lxH3Lo, lxH3Hi, lxH4Lo, lxH4Hi)
|
||||
|
||||
// Bring both up on an ephemeral port (listen_port=0), no endpoint yet.
|
||||
cfgA := fmt.Sprintf("private_key=%s\nlisten_port=0\n%sreplace_peers=true\npublic_key=%s\nallowed_ip=%s/32\n",
|
||||
hex.EncodeToString(skA[:]), obf, hex.EncodeToString(pkB[:]), testIPB)
|
||||
cfgB := fmt.Sprintf("private_key=%s\nlisten_port=0\n%sreplace_peers=true\npublic_key=%s\nallowed_ip=%s/32\n",
|
||||
hex.EncodeToString(skB[:]), obf, hex.EncodeToString(pkA[:]), testIPA)
|
||||
|
||||
if err := devA.IpcSet(cfgA); err != nil {
|
||||
t.Fatalf("IpcSet A: %v", err)
|
||||
}
|
||||
if err := devB.IpcSet(cfgB); err != nil {
|
||||
t.Fatalf("IpcSet B: %v", err)
|
||||
}
|
||||
if devA.paddings.init != 0 || devA.paddings.response != 0 || devA.paddings.transport != 0 {
|
||||
t.Fatalf("padding must be 0 for this test: init=%d resp=%d transport=%d",
|
||||
devA.paddings.init, devA.paddings.response, devA.paddings.transport)
|
||||
}
|
||||
|
||||
if err := devA.Up(); err != nil {
|
||||
t.Fatalf("Up A: %v", err)
|
||||
}
|
||||
if err := devB.Up(); err != nil {
|
||||
t.Fatalf("Up B: %v", err)
|
||||
}
|
||||
|
||||
portA := lxReadListenPort(t, devA)
|
||||
portB := lxReadListenPort(t, devB)
|
||||
if portA == 0 || portB == 0 {
|
||||
t.Fatalf("ephemeral ports not assigned: A=%d B=%d", portA, portB)
|
||||
}
|
||||
|
||||
// Now that ports are known, point each peer at the other over loopback.
|
||||
if err := devA.IpcSet(fmt.Sprintf("public_key=%s\nupdate_only=true\nendpoint=127.0.0.1:%d\n",
|
||||
hex.EncodeToString(pkB[:]), portB)); err != nil {
|
||||
t.Fatalf("set endpoint A->B: %v", err)
|
||||
}
|
||||
if err := devB.IpcSet(fmt.Sprintf("public_key=%s\nupdate_only=true\nendpoint=127.0.0.1:%d\n",
|
||||
hex.EncodeToString(pkA[:]), portA)); err != nil {
|
||||
t.Fatalf("set endpoint B->A: %v", err)
|
||||
}
|
||||
|
||||
return devA, devB, tunA, tunB
|
||||
}
|
||||
|
||||
// TestStdNetBindReservedClearVsMagic_ZeroPadding drives a real handshake and a
|
||||
// data packet A->B over loopback UDP through StdNetBind, with padding=0 so the
|
||||
// h1 magic overlaps the reserved bytes 1-3. It passes only when receive does
|
||||
// not blindly clear those bytes (the fix).
|
||||
func TestStdNetBindReservedClearVsMagic_ZeroPadding(t *testing.T) {
|
||||
devA, _, tunA, tunB := newStdNetPaddedPair(t)
|
||||
_ = devA
|
||||
|
||||
pkt := buildIPv4Packet(testIPA, testIPB, 8)
|
||||
|
||||
// Re-inject periodically: the first packet triggers the handshake and may
|
||||
// be dropped until keys are established.
|
||||
send := func() { tunA.toDevice <- pkt }
|
||||
send()
|
||||
|
||||
deadline := time.After(15 * time.Second)
|
||||
retry := time.NewTicker(500 * time.Millisecond)
|
||||
defer retry.Stop()
|
||||
for {
|
||||
select {
|
||||
case got := <-tunB.fromDevice:
|
||||
if bytes.Equal(got, pkt) {
|
||||
return // delivered end to end: magic survived, handshake ok
|
||||
}
|
||||
t.Logf("ignoring unexpected packet len=%d", len(got))
|
||||
case <-retry.C:
|
||||
send()
|
||||
case <-deadline:
|
||||
t.Fatal("timed out waiting for packet on peer tun " +
|
||||
"(handshake never completed: reserved-clear likely corrupted the h1 magic)")
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue