fix(transport,pkg): authenticate full sower frame, validate DHCP offers, harden handshakes

- The sower frame checksum now covers the command and port in addition to
  the target, using HMAC-SHA256 (length-extension resistant) so a
  man-in-the-middle on a plaintext link cannot retarget a captured frame
  to an arbitrary port.
- Unwrap rejects empty or control-character target hosts.
- The SOCKS5 server writes the RFC 1928 METHOD=0xFF failure response
  instead of leaving the peer hanging on an unusable negotiation.
- DHCP discovery validates the reply (matching xid, BOOTREPLY, message
  type OFFER) and skips unrelated packets instead of trusting the first
  datagram, closing a LAN-side DNS-hijack vector; the socket also sets
  SO_BROADCAST explicitly (Go 1.26 removed UDPConn.SetBroadcast).
- The uTLS handshake gets a bounded default deadline when the dialer has
  no timeout, so it cannot hang open forever.
- The suffix tree trims every trailing dot so dirty rule files cannot
  build unreachable empty-label subtrees.
This commit is contained in:
wweir
2026-08-12 17:26:26 +08:00
parent 3286851fdf
commit 3ea411e5fd
10 changed files with 108 additions and 30 deletions
+1 -1
View File
@@ -20,6 +20,7 @@ require (
go.opentelemetry.io/otel/metric v1.45.0
go.opentelemetry.io/otel/sdk/metric v1.45.0
golang.org/x/crypto v0.54.0
golang.org/x/sys v0.47.0
)
require (
@@ -47,7 +48,6 @@ require (
golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.48.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
-2
View File
@@ -67,8 +67,6 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sower-proxy/conns v0.1.1 h1:9VPVW+sFc/y/IvXbUQTbXNSPF3I+1IG5mbrQTt5bdTE=
github.com/sower-proxy/conns v0.1.1/go.mod h1:p3ZM/8MolzW4TN0LaiPJnwnhhif3y9sBi2vQEWHvwhg=
github.com/sower-proxy/deferlog/v2 v2.1.4 h1:Vo/iVWGw3Su625RP2RIy4mE5FlfRdfrgP7kwOWNOyec=
github.com/sower-proxy/deferlog/v2 v2.1.4/go.mod h1:mdLrL/bxiTNhxeRLtRgKAecQddugI0iJ0ArC3yDwVSw=
github.com/sower-proxy/deferlog/v2 v2.1.5 h1:3MF21JmCbIOI7ROWqwfBDDX4Y6g3EkDp4p7TZxJFyyg=
github.com/sower-proxy/deferlog/v2 v2.1.5/go.mod h1:mdLrL/bxiTNhxeRLtRgKAecQddugI0iJ0ArC3yDwVSw=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+27 -2
View File
@@ -1,6 +1,7 @@
package dhcp
import (
"bytes"
"crypto/rand"
"fmt"
"net"
@@ -40,19 +41,43 @@ func GetDNSServer() ([]string, error) {
}
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(3 * time.Second))
// Broadcasting to 255.255.255.255 requires SO_BROADCAST on most stacks.
if c, ok := conn.(*net.UDPConn); ok {
if raw, err := c.SyscallConn(); err == nil {
_ = raw.Control(func(fd uintptr) {
_ = setBroadcast(int(fd))
})
}
}
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
if _, err := conn.WriteTo([]byte(pack), broadcastAddr); err != nil {
return nil, fmt.Errorf("write broadcast: %w", err)
}
// The socket may receive packets from other DHCP transactions (the
// system dhclient shares port 68 via SO_REUSEPORT) or spoofed LAN
// traffic; accept only the OFFER that belongs to this transaction.
buf := make([]byte, 1500 /*MTU*/)
for {
n, _, err := conn.ReadFrom(buf)
if err != nil {
return nil, fmt.Errorf("read dhcp offer: %w", err)
}
pack = dhcp4.Packet(buf[:n])
if !bytes.Equal(pack.XId(), xid) {
continue // another transaction's packet
}
if pack.OpCode() != dhcp4.BootReply {
continue
}
opts := pack.ParseOptions()
if mt := opts[dhcp4.OptionDHCPMessageType]; len(mt) == 0 || mt[0] != byte(dhcp4.Offer) {
continue
}
break
}
dnsBytes := pack.ParseOptions()[dhcp4.OptionDomainNameServer]
if len(dnsBytes) < 4 || len(dnsBytes)%4 != 0 {
return nil, errors.New("invalid DNS setting in upstream network device")
+11
View File
@@ -0,0 +1,11 @@
//go:build !windows
package dhcp
import "golang.org/x/sys/unix"
// setBroadcast enables SO_BROADCAST so the DHCP discover broadcast to
// 255.255.255.255 is delivered on stacks that require the explicit option.
func setBroadcast(fd int) error {
return unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_BROADCAST, 1)
}
+10
View File
@@ -0,0 +1,10 @@
//go:build windows
package dhcp
import "golang.org/x/sys/windows"
// setBroadcast enables SO_BROADCAST; Winsock requires it for broadcasts.
func setBroadcast(fd int) error {
return windows.SetsockoptInt(windows.Handle(fd), windows.SOL_SOCKET, windows.SO_BROADCAST, 1)
}
+3 -1
View File
@@ -57,7 +57,9 @@ func (n *node) string(prefix, indent string) (out string) {
}
func (n *Node) trim(item string) string {
return strings.ToLower(strings.TrimSuffix(item, n.sep))
// Trim every trailing separator so a rule with repeated dots (dirty
// remote rule files) cannot build an unreachable empty-label subtree.
return strings.ToLower(strings.TrimRight(item, n.sep))
}
func (n *Node) Add(item string) {
+10 -2
View File
@@ -16,6 +16,10 @@ type Options struct {
InsecureSkipVerify bool
}
// defaultHandshakeTimeout bounds the uTLS handshake when the dialer carries
// no timeout of its own.
const defaultHandshakeTimeout = 10 * time.Second
func ValidateClientHello(value string) error {
_, err := clientHelloID(value)
return err
@@ -39,13 +43,17 @@ func Dial(dialer *net.Dialer, network, addr string, options Options) (net.Conn,
return nil, err
}
if timeout := dialer.Timeout; timeout > 0 {
timeout := dialer.Timeout
if timeout <= 0 {
// A zero-timeout dialer must not handshake forever; fall back to a
// bounded default so the connection cannot hang open indefinitely.
timeout = defaultHandshakeTimeout
}
if err := rawConn.SetDeadline(time.Now().Add(timeout)); err != nil {
rawConn.Close()
return nil, fmt.Errorf("set tls deadline: %w", err)
}
defer rawConn.SetDeadline(time.Time{})
}
serverName := options.ServerName
if serverName == "" {
+5
View File
@@ -57,6 +57,11 @@ func (s *Socks5) ReadRequest(conn net.Conn) (net.Addr, error) {
return nil, fmt.Errorf("read auth request: %w", err)
}
if !auth.IsValid() {
// RFC 1928 requires a METHOD=0xFF failure response so the peer
// does not hang waiting for a selection it will never get.
if err := binary.Write(conn, binary.BigEndian, authResp{VER: 5, METHOD: 0xFF}); err != nil {
return nil, fmt.Errorf("write auth failure: %w", err)
}
return nil, errors.New("no acceptable auth method")
}
+33 -14
View File
@@ -2,6 +2,7 @@ package sower
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"fmt"
@@ -62,10 +63,25 @@ func (s *Sower) Unwrap(conn net.Conn) (net.Addr, error) {
return nil, fmt.Errorf("invalid command: %d", h.Cmd)
}
if h.Checksum != sumChecksum(h.TgtAddr, s.password) {
if h.Checksum != sumChecksum(h.Cmd, h.Port, h.TgtAddr, s.password) {
return nil, errors.New("auth fail")
}
// Reject hosts that could corrupt dialing or logging: empty targets and
// control characters are never legitimate.
idx := bytes.IndexByte(h.TgtAddr[:], 0)
if idx < 0 {
idx = len(h.TgtAddr)
}
if idx == 0 {
return nil, errors.New("empty target host")
}
for _, c := range h.TgtAddr[:idx] {
if c < 0x20 || c == 0x7f {
return nil, errors.New("target host contains control characters")
}
}
return h, nil
}
@@ -76,11 +92,12 @@ func (s *Sower) Wrap(conn net.Conn, tgtHost string, tgtPort uint16) error {
tgtAddr := [maxDomainLength]byte{}
copy(tgtAddr[:len(tgtHost)], tgtHost)
cmd := byte(0x80)
buf := bytes.NewBuffer(make([]byte, 0, headSize))
if err := binary.Write(buf, binary.BigEndian, &Head{
Cmd: 0x80,
Checksum: sumChecksum(tgtAddr, s.password),
Cmd: cmd,
Checksum: sumChecksum(cmd, tgtPort, tgtAddr, s.password),
Port: tgtPort,
TgtAddr: tgtAddr,
}); err != nil {
@@ -92,15 +109,17 @@ func (s *Sower) Wrap(conn net.Conn, tgtHost string, tgtPort uint16) error {
return nil
}
func sumChecksum(target [maxDomainLength]byte, password []byte) uint64 {
h := sha256.New()
h.Write(target[:])
h.Write(password)
var checksum [sha256.Size]byte
h.Sum(checksum[:0])
// Take a fixed 64-bit window of the digest. binary.Uvarint would only
// consume bytes until the first continuation bit is clear (typically the
// first byte, ~50% of the time), collapsing the checksum to 7 bits of
// entropy and making the auth check brute-forceable.
return binary.BigEndian.Uint64(checksum[:8])
// sumChecksum authenticates the whole frame: command, port, and target are
// all covered, so a man-in-the-middle on a plaintext link cannot retarget a
// captured frame to a different port without breaking the check. HMAC-SHA256
// keeps the password out of the hashed message (length-extension resistant)
// and off the wire in a non-invertible form.
func sumChecksum(cmd byte, port uint16, target [maxDomainLength]byte, password []byte) uint64 {
mac := hmac.New(sha256.New, password)
mac.Write([]byte{cmd})
var portBuf [2]byte
binary.BigEndian.PutUint16(portBuf[:], port)
mac.Write(portBuf[:])
mac.Write(target[:])
return binary.BigEndian.Uint64(mac.Sum(nil)[:8])
}
+1 -1
View File
@@ -47,7 +47,7 @@ func TestUnwrapReadsFullHeader(t *testing.T) {
buf := bytes.NewBuffer(nil)
if err := binary.Write(buf, binary.BigEndian, &Head{
Cmd: 0x80,
Checksum: sumChecksum(target, []byte("123")),
Checksum: sumChecksum(0x80, 443, target, []byte("123")),
Port: 443,
TgtAddr: target,
}); err != nil {