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:
Leadaxe 2026-07-17 01:32:40 +02:00
parent ee7ff1b77f
commit 1e787bb3e0
7 changed files with 283 additions and 11 deletions

View file

@ -359,7 +359,7 @@ func (s *StdNetBind) receiveIP(
if sizes[i] == 0 {
continue
}
if msg.N > 3 {
if msg.N > 3 && s.hasReserved() { // lx: only strip reserved bytes for WARP (see hasReserved)
common.ClearArray(bufs[i][1:4])
}
ep := &StdNetEndpoint{AddrPort: M.AddrPortFromNet(msg.Addr)} // TODO: remove allocation
@ -539,6 +539,20 @@ func (s *StdNetBind) SetReservedForEndpoint(destination netip.AddrPort, reserved
s.reservedForEndpoint[destination] = reserved
}
// lx: hasReserved reports whether any Cloudflare "reserved" value is set. The
// receive path must only zero bytes 1-3 when a reserved value exists (WARP);
// otherwise an AmneziaWG magic header that lands in bytes 1-3 (small s1/s2/s4
// padding) would be corrupted and the packet dropped. The send path already
// gates its stamp on a per-endpoint `loaded` check, so no change is needed there.
func (s *StdNetBind) hasReserved() bool {
for _, reserved := range s.reservedForEndpoint {
if reserved != [3]uint8{} {
return true
}
}
return false
}
func (s *StdNetBind) send(conn *net.UDPConn, pc batchWriter, msgs []ipv6.Message) error {
var (
n int

View file

@ -461,7 +461,7 @@ func (bind *WinRingBind) receiveIPv4(bufs [][]byte, sizes []int, eps []Endpoint)
bind.mu.RLock()
defer bind.mu.RUnlock()
n, ep, err := bind.v4.Receive(bufs[0], &bind.isOpen)
if n > 3 {
if n > 3 && bind.hasReserved() { // lx: only strip reserved bytes for WARP (see hasReserved)
common.ClearArray(bufs[0][1:4])
}
sizes[0] = n
@ -473,7 +473,7 @@ func (bind *WinRingBind) receiveIPv6(bufs [][]byte, sizes []int, eps []Endpoint)
bind.mu.RLock()
defer bind.mu.RUnlock()
n, ep, err := bind.v6.Receive(bufs[0], &bind.isOpen)
if n > 3 {
if n > 3 && bind.hasReserved() { // lx: only strip reserved bytes for WARP (see hasReserved)
common.ClearArray(bufs[0][1:4])
}
sizes[0] = n
@ -576,6 +576,18 @@ func (bind *WinRingBind) SetReservedForEndpoint(destination netip.AddrPort, rese
bind.reservedForEndpoint[*endpoint.(*WinRingEndpoint)] = reserved
}
// lx: hasReserved reports whether any Cloudflare "reserved" value is set. See
// the StdNetBind.hasReserved comment — the unconditional receive clear would
// corrupt an AmneziaWG magic header sitting in bytes 1-3 (small padding).
func (bind *WinRingBind) hasReserved() bool {
for _, reserved := range bind.reservedForEndpoint {
if reserved != [3]uint8{} {
return true
}
}
return false
}
func (s *StdNetBind) BindSocketToInterface4(interfaceIndex uint32, blackhole bool) error {
s.mu.Lock()
defer s.mu.Unlock()

View file

@ -234,7 +234,7 @@ func (s *StdNetBind) receiveSingle(conn *net.UDPConn, bufs [][]byte, sizes []int
return 0, err
}
sizes[0] = n
if n > 3 {
if n > 3 && s.hasReserved() { // lx: only strip reserved bytes for WARP (see hasReserved)
bufs[0][1] = 0
bufs[0][2] = 0
bufs[0][3] = 0
@ -299,7 +299,7 @@ func (s *StdNetBind) makeReceiveMsgX(conn *net.UDPConn, isV6 bool) (ReceiveFunc,
numMsgs := int(n)
for i := 0; i < numMsgs; i++ {
sizes[i] = int(state.hdrs[i].DataLen)
if sizes[i] > 3 {
if sizes[i] > 3 && s.hasReserved() { // lx: only strip reserved bytes for WARP (see hasReserved)
bufs[i][1] = 0
bufs[i][2] = 0
bufs[i][3] = 0

View file

@ -0,0 +1,56 @@
/* SPDX-License-Identifier: MIT
*
* lx: unit coverage for the StdNetBind.hasReserved() gate that guards the
* receive-side reserved-clear. receiveIP zeroes bytes 1-3 (Cloudflare WARP
* "reserved") only when a non-zero reserved value is set for some endpoint;
* otherwise an AmneziaWG magic header landing in bytes 1-3 (small s1/s2/s4
* padding) would be corrupted and the packet dropped. This test pins the gate
* itself; the end-to-end handshake proof lives in the device package.
*/
package conn
import (
"net/netip"
"testing"
)
func stdNetBindForTest(t *testing.T) *StdNetBind {
t.Helper()
b, ok := NewStdNetBind(nil).(*StdNetBind)
if !ok {
t.Fatalf("NewStdNetBind did not return *StdNetBind")
}
return b
}
func TestStdNetBindHasReserved(t *testing.T) {
b := stdNetBindForTest(t)
if b.hasReserved() {
t.Fatal("fresh bind must report no reserved value")
}
ep := netip.MustParseAddrPort("127.0.0.1:51820")
// An all-zero reserved value is indistinguishable from "unset" and must
// not arm the clear.
b.SetReservedForEndpoint(ep, [3]byte{0, 0, 0})
if b.hasReserved() {
t.Fatal("all-zero reserved must not count as reserved")
}
// Any non-zero byte (WARP anycast tag) arms the clear.
b.SetReservedForEndpoint(ep, [3]byte{0, 0, 1})
if !b.hasReserved() {
t.Fatal("non-zero reserved (byte 3) must count as reserved")
}
// A second endpoint's non-zero value must also be seen.
b2 := stdNetBindForTest(t)
ep2 := netip.MustParseAddrPort("192.0.2.1:2408")
b2.SetReservedForEndpoint(ep, [3]byte{0, 0, 0})
b2.SetReservedForEndpoint(ep2, [3]byte{0xAB, 0, 0})
if !b2.hasReserved() {
t.Fatal("non-zero reserved on any endpoint must count as reserved")
}
}