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.
190 lines
6.8 KiB
Go
190 lines
6.8 KiB
Go
/* 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)")
|
|
}
|
|
}
|
|
}
|