mirror of
https://github.com/wweir/sower.git
synced 2026-10-19 16:14:33 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user