Add TLS spoof support

This commit is contained in:
世界
2026-04-15 17:59:18 +08:00
parent 6ef38042f3
commit 08ce083b9b
45 changed files with 4227 additions and 6 deletions
+3
View File
@@ -155,6 +155,9 @@ func ValidateAppleTLSOptions(ctx context.Context, options option.OutboundTLSOpti
if options.KernelTx || options.KernelRx {
return AppleTLSValidated{}, E.New("ktls is unsupported in ", engineName)
}
if options.Spoof != "" || options.SpoofMethod != "" {
return AppleTLSValidated{}, E.New("spoof is unsupported in ", engineName)
}
if len(options.CertificatePublicKeySHA256) > 0 && (len(options.Certificate) > 0 || options.CertificatePath != "") {
return AppleTLSValidated{}, E.New("certificate_public_key_sha256 is conflict with certificate or certificate_path")
}
+32
View File
@@ -8,6 +8,7 @@ import (
"os"
"github.com/sagernet/sing-box/common/badtls"
"github.com/sagernet/sing-box/common/tlsspoof"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
@@ -19,6 +20,37 @@ import (
var errMissingServerName = E.New("missing server_name or insecure=true")
func parseTLSSpoofOptions(serverName string, options option.OutboundTLSOptions) (string, tlsspoof.Method, error) {
if options.Spoof == "" {
if options.SpoofMethod != "" {
return "", 0, E.New("`spoof_method` requires `spoof`")
}
return "", 0, nil
}
if !tlsspoof.PlatformSupported {
return "", 0, E.New("`spoof` is not supported on this platform")
}
if options.DisableSNI || serverName == "" {
return "", 0, E.New("`spoof` requires TLS ClientHello with SNI")
}
method, err := tlsspoof.ParseMethod(options.SpoofMethod)
if err != nil {
return "", 0, err
}
return options.Spoof, method, nil
}
func applyTLSSpoof(conn net.Conn, spoof string, method tlsspoof.Method) (net.Conn, error) {
if spoof == "" {
return conn, nil
}
spoofer, err := tlsspoof.NewSpoofer(conn, method)
if err != nil {
return nil, err
}
return tlsspoof.NewConn(conn, spoofer, spoof), nil
}
func NewDialerFromOptions(ctx context.Context, logger logger.ContextLogger, dialer N.Dialer, serverAddress string, options option.OutboundTLSOptions) (N.Dialer, error) {
if !options.Enabled {
return dialer, nil
+3
View File
@@ -59,6 +59,9 @@ func newRealityClient(ctx context.Context, logger logger.ContextLogger, serverAd
if options.UTLS == nil || !options.UTLS.Enabled {
return nil, E.New("uTLS is required by reality client")
}
if options.Spoof != "" || options.SpoofMethod != "" {
return nil, E.New("spoof is unsupported in reality")
}
uClient, err := newUTLSClient(ctx, logger, serverAddress, options, allowEmptyServerName)
if err != nil {
+15
View File
@@ -14,6 +14,7 @@ import (
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/tlsfragment"
"github.com/sagernet/sing-box/common/tlsspoof"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
@@ -31,6 +32,8 @@ type STDClientConfig struct {
fragment bool
fragmentFallbackDelay time.Duration
recordFragment bool
spoof string
spoofMethod tlsspoof.Method
}
func (c *STDClientConfig) ServerName() string {
@@ -75,6 +78,10 @@ func (c *STDClientConfig) Client(conn net.Conn) (Conn, error) {
if c.recordFragment {
conn = tf.NewConn(conn, c.ctx, c.fragment, c.recordFragment, c.fragmentFallbackDelay)
}
conn, err := applyTLSSpoof(conn, c.spoof, c.spoofMethod)
if err != nil {
return nil, err
}
return tls.Client(conn, c.config), nil
}
@@ -89,6 +96,8 @@ func (c *STDClientConfig) Clone() Config {
fragment: c.fragment,
fragmentFallbackDelay: c.fragmentFallbackDelay,
recordFragment: c.recordFragment,
spoof: c.spoof,
spoofMethod: c.spoofMethod,
}
cloned.SetServerName(cloned.serverName)
return cloned
@@ -218,6 +227,10 @@ func newSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres
} else {
handshakeTimeout = C.TCPTimeout
}
spoof, spoofMethod, err := parseTLSSpoofOptions(serverName, options)
if err != nil {
return nil, err
}
var config Config = &STDClientConfig{
ctx: ctx,
config: &tlsConfig,
@@ -228,6 +241,8 @@ func newSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres
fragment: options.Fragment,
fragmentFallbackDelay: time.Duration(options.FragmentFallbackDelay),
recordFragment: options.RecordFragment,
spoof: spoof,
spoofMethod: spoofMethod,
}
config.SetServerName(serverName)
if options.ECH != nil && options.ECH.Enabled {
+15
View File
@@ -14,6 +14,7 @@ import (
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/tlsfragment"
"github.com/sagernet/sing-box/common/tlsspoof"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common"
@@ -36,6 +37,8 @@ type UTLSClientConfig struct {
fragment bool
fragmentFallbackDelay time.Duration
recordFragment bool
spoof string
spoofMethod tlsspoof.Method
}
func (c *UTLSClientConfig) ServerName() string {
@@ -83,6 +86,10 @@ func (c *UTLSClientConfig) Client(conn net.Conn) (Conn, error) {
if c.recordFragment {
conn = tf.NewConn(conn, c.ctx, c.fragment, c.recordFragment, c.fragmentFallbackDelay)
}
conn, err := applyTLSSpoof(conn, c.spoof, c.spoofMethod)
if err != nil {
return nil, err
}
return &utlsALPNWrapper{utlsConnWrapper{utls.UClient(conn, c.config.Clone(), c.id)}, c.config.NextProtos}, nil
}
@@ -102,6 +109,8 @@ func (c *UTLSClientConfig) Clone() Config {
fragment: c.fragment,
fragmentFallbackDelay: c.fragmentFallbackDelay,
recordFragment: c.recordFragment,
spoof: c.spoof,
spoofMethod: c.spoofMethod,
}
cloned.SetServerName(cloned.serverName)
return cloned
@@ -290,6 +299,10 @@ func newUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddre
} else {
handshakeTimeout = C.TCPTimeout
}
spoof, spoofMethod, err := parseTLSSpoofOptions(serverName, options)
if err != nil {
return nil, err
}
id, err := uTLSClientHelloID(options.UTLS.Fingerprint)
if err != nil {
return nil, err
@@ -305,6 +318,8 @@ func newUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddre
fragment: options.Fragment,
fragmentFallbackDelay: time.Duration(options.FragmentFallbackDelay),
recordFragment: options.RecordFragment,
spoof: spoof,
spoofMethod: spoofMethod,
}
config.SetServerName(serverName)
if options.ECH != nil && options.ECH.Enabled {
+6 -3
View File
@@ -23,9 +23,10 @@ const (
)
type MyServerName struct {
Index int
Length int
ServerName string
Index int
Length int
ServerName string
ExtensionsListLengthIndex int
}
func IndexTLSServerName(payload []byte) *MyServerName {
@@ -41,6 +42,7 @@ func IndexTLSServerName(payload []byte) *MyServerName {
return nil
}
serverName.Index += recordLayerHeaderLen
serverName.ExtensionsListLengthIndex += recordLayerHeaderLen
return serverName
}
@@ -82,6 +84,7 @@ func indexTLSServerNameFromHandshake(handshake []byte) *MyServerName {
return nil
}
serverName.Index += currentIndex
serverName.ExtensionsListLengthIndex = currentIndex
return serverName
}
+86
View File
@@ -0,0 +1,86 @@
package tlsspoof
import (
"encoding/binary"
tf "github.com/sagernet/sing-box/common/tlsfragment"
E "github.com/sagernet/sing/common/exceptions"
)
const (
recordLengthOffset = 3
handshakeLengthOffset = 6
)
// server_name extension layout (RFC 6066 §3). Offsets are relative to the
// SNI host name (index returned by the parser):
//
// ... uint16 extension_type = 0x0000 (host_name - 9)
// ... uint16 extension_data_length (host_name - 7)
// ... uint16 server_name_list_length (host_name - 5)
// ... uint8 name_type = host_name (host_name - 3)
// ... uint16 host_name_length (host_name - 2)
// sni host_name (host_name)
const (
extensionDataLengthOffsetFromSNI = -7
listLengthOffsetFromSNI = -5
hostNameLengthOffsetFromSNI = -2
)
func rewriteSNI(record []byte, fakeSNI string) ([]byte, error) {
if len(fakeSNI) > 0xFFFF {
return nil, E.New("fake SNI too long: ", len(fakeSNI), " bytes")
}
serverName := tf.IndexTLSServerName(record)
if serverName == nil {
return nil, E.New("not a ClientHello with SNI")
}
delta := len(fakeSNI) - serverName.Length
out := make([]byte, len(record)+delta)
copy(out, record[:serverName.Index])
copy(out[serverName.Index:], fakeSNI)
copy(out[serverName.Index+len(fakeSNI):], record[serverName.Index+serverName.Length:])
err := patchUint16(out, recordLengthOffset, delta)
if err != nil {
return nil, E.Cause(err, "patch record length")
}
err = patchUint24(out, handshakeLengthOffset, delta)
if err != nil {
return nil, E.Cause(err, "patch handshake length")
}
for _, off := range []int{
serverName.ExtensionsListLengthIndex,
serverName.Index + extensionDataLengthOffsetFromSNI,
serverName.Index + listLengthOffsetFromSNI,
serverName.Index + hostNameLengthOffsetFromSNI,
} {
err = patchUint16(out, off, delta)
if err != nil {
return nil, E.Cause(err, "patch length at offset ", off)
}
}
return out, nil
}
func patchUint16(data []byte, offset, delta int) error {
patched := int(binary.BigEndian.Uint16(data[offset:])) + delta
if patched < 0 || patched > 0xFFFF {
return E.New("uint16 out of range: ", patched)
}
binary.BigEndian.PutUint16(data[offset:], uint16(patched))
return nil
}
func patchUint24(data []byte, offset, delta int) error {
original := int(data[offset])<<16 | int(data[offset+1])<<8 | int(data[offset+2])
patched := original + delta
if patched < 0 || patched > 0xFFFFFF {
return E.New("uint24 out of range: ", patched)
}
data[offset] = byte(patched >> 16)
data[offset+1] = byte(patched >> 8)
data[offset+2] = byte(patched)
return nil
}
+79
View File
@@ -0,0 +1,79 @@
package tlsspoof
import (
"encoding/binary"
"encoding/hex"
"testing"
tf "github.com/sagernet/sing-box/common/tlsfragment"
"github.com/stretchr/testify/require"
)
// realClientHello is a captured Chrome ClientHello for github.com,
// reused from common/tlsfragment/index_test.go.
const realClientHello = "16030105f8010005f403036e35de7389a679c54029cf452611f2211c70d9ac3897271de589ab6155f8e4ab20637d225f1ef969ad87ed78bfb9d171300bcb1703b6f314ccefb964f79b7d0961002a0a0a130213031301c02cc02bcca9c030c02fcca8c00ac009c014c013009d009c0035002fc008c012000a01000581baba00000000000f000d00000a6769746875622e636f6d00170000ff01000100000a000e000c3a3a11ec001d001700180019000b000201000010000e000c02683208687474702f312e31000500050100000000000d00160014040308040401050308050805050108060601020100120000003304ef04ed3a3a00010011ec04c0aeb2250c092a3463161cccb29d9183331a424964248579507ed23a180b0ceab2a5f5d9ce41547e497a89055471ea572867ba3a1fc3c9e45025274a20f60c6b60e62476b6afed0403af59ab83660ef4112ae20386a602010d0a5d454c0ed34c84ed4423e750213e6a2baab1bf9c4367a6007ab40a33d95220c2dcaa44f257024a5626b545db0510f4311b1a60714154909c6a61fdfca011fb2626d657aeb6070bf078508babe3b584555013e34acc56198ed4663742b3155a664a9901794c4586820a7dc162c01827291f3792e1237f801a8d1ef096013c181c4a58d2f6859ba75022d18cc4418bd4f351d5c18f83a58857d05af860c4b9ac018a5b63f17184e591532c6bc2cf2215d4a282c8a8a4f6f7aee110422c8bc9ebd3b1d609c568523aaae555db320e6c269473d87af38c256cbb9febc20aea6380c32a8916f7a373c8b1e37554e3260bf6621f6b804ee80b3c516b1d01985bf4c603b6daa9a5991de6a7a29f3a7122b8afb843a7660110fce62b43c615f5bcc2db688ba012649c0952b0a2c031e732d2b454c6b2968683cb8d244be2c9a7fa163222979eaf92722b92b862d81a3d94450c2b60c318421ebb4307c42d1f0473592a5c30e42039cc68cda9721e61aa63f49def17c15221680ed444896340133bbee67556f56b9f9d78a4df715f926a12add0cc9c862e46ea8b7316ae468282c18601b2771c9c9322f982228cf93effaacd3f80cbd12bce5fc36f56e2a3caf91e578a5fae00c9b23a8ed1a66764f4433c3628a70b8f0a6196adc60a4cb4226f07ba4c6b363fe9065563bfc1347452946386bab488686e837ab979c64f9047417fca635fe1bb4f074f256cc8af837c7b455e280426547755af90a61640169ef180aea3a77e662bb6dac1b6c3696027129b1a5edf495314e9c7f4b6110e16378ec893fa24642330a40aba1a85326101acb97c620fd8d71389e69eaed7bdb01bbe1fd428d66191150c7b2cd1ad4257391676a82ba8ce07fb2667c3b289f159003a7c7bc31d361b7b7f49a802961739d950dfcc0fa1c7abce5abdd2245101da391151490862028110465950b9e9c03d08a90998ab83267838d2e74a0593bc81f74cdf734519a05b351c0e5488c68dd810e6e9142ccc1e2f4a7f464297eb340e27acc6b9d64e12e38cce8492b3d939140b5a9e149a75597f10a23874c84323a07cdd657274378f887c85c4259b9c04cd33ba58ed630ef2a744f8e19dd34843dff331d2a6be7e2332c599289cd248a611c73d7481cd4a9bd43449a3836f14b2af18a1739e17999e4c67e85cc5bcecabb14185e5bcaff3c96098f03dc5aba819f29587758f49f940585354a2a780830528d68ccd166920dadcaa25cab5fc1907272a826aba3f08bc6b88757776812ecb6c7cec69a223ec0a13a7b62a2349a0f63ed7a27a3b15ba21d71fe6864ec6e089ae17cadd433fa3138f7ee24353c11365818f8fc34f43a05542d18efaac24bfccc1f748a0cc1a67ad379468b76fd34973dba785f5c91d618333cd810fe0700d1bbc8422029782628070a624c52c5309a4a64d625b11f8033ab28df34a1add297517fcc06b92b6817b3c5144438cf260867c57bde68c8c4b82e6a135ef676a52fbae5708002a404e6189a60e2836de565ad1b29e3819e5ed49f6810bcb28e1bd6de57306f94b79d9dae1cc4624d2a068499beef81cd5fe4b76dcbfff2a2008001d002001976128c6d5a934533f28b9914d2480aab2a8c1ab03d212529ce8b27640a716002d00020101002b000706caca03040303001b00030200015a5a000100"
func decodeClientHello(t *testing.T) []byte {
t.Helper()
payload, err := hex.DecodeString(realClientHello)
require.NoError(t, err)
return payload
}
func assertConsistent(t *testing.T, payload []byte, expectedSNI string) {
t.Helper()
serverName := tf.IndexTLSServerName(payload)
require.NotNil(t, serverName, "parser should find SNI in rewritten payload")
require.Equal(t, expectedSNI, serverName.ServerName)
require.Equal(t, expectedSNI, string(payload[serverName.Index:serverName.Index+serverName.Length]))
// Record length must equal len(payload) - 5.
recordLen := binary.BigEndian.Uint16(payload[3:5])
require.Equal(t, len(payload)-5, int(recordLen), "record length must equal payload - 5")
// Handshake length must equal len(payload) - 5 - 4.
handshakeLen := int(payload[6])<<16 | int(payload[7])<<8 | int(payload[8])
require.Equal(t, len(payload)-5-4, handshakeLen, "handshake length must equal payload - 9")
}
func TestRewriteSNI_ShorterReplacement(t *testing.T) {
t.Parallel()
payload := decodeClientHello(t)
out, err := rewriteSNI(payload, "a.io")
require.NoError(t, err)
require.Len(t, out, len(payload)-6) // original "github.com" is 10 bytes, "a.io" is 4 bytes.
assertConsistent(t, out, "a.io")
}
func TestRewriteSNI_SameLengthReplacement(t *testing.T) {
t.Parallel()
payload := decodeClientHello(t)
out, err := rewriteSNI(payload, "example.co")
require.NoError(t, err)
require.Len(t, out, len(payload))
assertConsistent(t, out, "example.co")
}
func TestRewriteSNI_LongerReplacement(t *testing.T) {
t.Parallel()
payload := decodeClientHello(t)
out, err := rewriteSNI(payload, "letsencrypt.org")
require.NoError(t, err)
require.Len(t, out, len(payload)+5) // "letsencrypt.org" is 15, original 10, delta 5.
assertConsistent(t, out, "letsencrypt.org")
}
func TestRewriteSNI_NoSNIReturnsError(t *testing.T) {
t.Parallel()
// Truncated payload — not a valid ClientHello.
_, err := rewriteSNI([]byte{0x16, 0x03, 0x01, 0x00, 0x01, 0x01}, "x.com")
require.Error(t, err)
}
func TestRewriteSNI_DoesNotMutateInput(t *testing.T) {
t.Parallel()
payload := decodeClientHello(t)
original := append([]byte(nil), payload...)
_, err := rewriteSNI(payload, "letsencrypt.org")
require.NoError(t, err)
require.Equal(t, original, payload, "input payload must not be mutated")
}
+126
View File
@@ -0,0 +1,126 @@
package tlsspoof
import (
"encoding/hex"
"io"
"net"
"testing"
tf "github.com/sagernet/sing-box/common/tlsfragment"
"github.com/stretchr/testify/require"
)
type fakeSpoofer struct {
injected [][]byte
err error
}
func (f *fakeSpoofer) Inject(payload []byte) error {
if f.err != nil {
return f.err
}
f.injected = append(f.injected, append([]byte(nil), payload...))
return nil
}
func (f *fakeSpoofer) Close() error {
return nil
}
func readAll(t *testing.T, conn net.Conn) []byte {
t.Helper()
data, err := io.ReadAll(conn)
require.NoError(t, err)
return data
}
func TestConn_Write_InjectsThenForwards(t *testing.T) {
t.Parallel()
payload, err := hex.DecodeString(realClientHello)
require.NoError(t, err)
client, server := net.Pipe()
spoofer := &fakeSpoofer{}
wrapped := NewConn(client, spoofer, "letsencrypt.org")
serverRead := make(chan []byte, 1)
go func() {
serverRead <- readAll(t, server)
}()
n, err := wrapped.Write(payload)
require.NoError(t, err)
require.Equal(t, len(payload), n)
require.NoError(t, wrapped.Close())
forwarded := <-serverRead
require.Equal(t, payload, forwarded, "underlying conn must receive the real ClientHello unchanged")
require.Len(t, spoofer.injected, 1)
injected := spoofer.injected[0]
serverName := tf.IndexTLSServerName(injected)
require.NotNil(t, serverName, "injected payload must parse as ClientHello")
require.Equal(t, "letsencrypt.org", serverName.ServerName)
}
func TestConn_Write_SecondWriteDoesNotInject(t *testing.T) {
t.Parallel()
payload, err := hex.DecodeString(realClientHello)
require.NoError(t, err)
client, server := net.Pipe()
spoofer := &fakeSpoofer{}
wrapped := NewConn(client, spoofer, "letsencrypt.org")
serverRead := make(chan []byte, 1)
go func() {
serverRead <- readAll(t, server)
}()
_, err = wrapped.Write(payload)
require.NoError(t, err)
_, err = wrapped.Write([]byte("second"))
require.NoError(t, err)
require.NoError(t, wrapped.Close())
forwarded := <-serverRead
require.Equal(t, append(append([]byte(nil), payload...), []byte("second")...), forwarded)
require.Len(t, spoofer.injected, 1)
}
func TestConn_Write_NonClientHelloReturnsError(t *testing.T) {
t.Parallel()
client, server := net.Pipe()
defer client.Close()
defer server.Close()
spoofer := &fakeSpoofer{}
wrapped := NewConn(client, spoofer, "letsencrypt.org")
_, err := wrapped.Write([]byte("not a ClientHello"))
require.Error(t, err)
require.Empty(t, spoofer.injected)
}
func TestParseMethod(t *testing.T) {
t.Parallel()
cases := map[string]struct {
want Method
ok bool
}{
"": {MethodWrongSequence, true},
"wrong-sequence": {MethodWrongSequence, true},
"wrong-checksum": {MethodWrongChecksum, true},
"nonsense": {0, false},
}
for input, expected := range cases {
m, err := ParseMethod(input)
if !expected.ok {
require.Error(t, err, "input=%q", input)
continue
}
require.NoError(t, err, "input=%q", input)
require.Equal(t, expected.want, m, "input=%q", input)
}
}
+29
View File
@@ -0,0 +1,29 @@
package tlsspoof
import (
"net"
"net/netip"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
)
// The returned addresses are v4-unmapped and share the same family.
func tcpEndpoints(conn net.Conn) (*net.TCPConn, netip.AddrPort, netip.AddrPort, error) {
tcpConn, isTCP := common.Cast[*net.TCPConn](conn)
if !isTCP {
return nil, netip.AddrPort{}, netip.AddrPort{}, E.New("tls_spoof: underlying conn is not *net.TCPConn")
}
local := M.AddrPortFromNet(tcpConn.LocalAddr())
remote := M.AddrPortFromNet(tcpConn.RemoteAddr())
if !local.IsValid() || !remote.IsValid() {
return nil, netip.AddrPort{}, netip.AddrPort{}, E.New("tls_spoof: invalid conn address")
}
local = netip.AddrPortFrom(local.Addr().Unmap(), local.Port())
remote = netip.AddrPortFrom(remote.Addr().Unmap(), remote.Port())
if local.Addr().Is4() != remote.Addr().Is4() {
return nil, netip.AddrPort{}, netip.AddrPort{}, E.New("tls_spoof: local/remote address family mismatch")
}
return tcpConn, local, remote, nil
}
@@ -0,0 +1,5 @@
//go:build darwin
package tlsspoof
const loopbackInterface = "lo0"
@@ -0,0 +1,5 @@
//go:build linux
package tlsspoof
const loopbackInterface = "lo"
+112
View File
@@ -0,0 +1,112 @@
//go:build linux || darwin
package tlsspoof
import (
"bufio"
"context"
"fmt"
"io"
"net"
"os"
"os/exec"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func requireRoot(t *testing.T) {
t.Helper()
if os.Geteuid() != 0 {
t.Fatal("integration test requires root")
}
}
func tcpdumpObserver(t *testing.T, iface string, port uint16, needle string, do func(), wait time.Duration) bool {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), wait)
defer cancel()
cmd := exec.CommandContext(ctx, "tcpdump", "-i", iface, "-n", "-A", "-l",
"-s", "4096", fmt.Sprintf("tcp and port %d", port))
cmd.Cancel = func() error {
return cmd.Process.Signal(os.Interrupt)
}
stdout, err := cmd.StdoutPipe()
require.NoError(t, err)
stderr, err := cmd.StderrPipe()
require.NoError(t, err)
require.NoError(t, cmd.Start())
t.Cleanup(func() {
_ = cmd.Process.Signal(os.Interrupt)
_ = cmd.Wait()
})
ready := make(chan struct{})
go func() {
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
if strings.Contains(scanner.Text(), "listening on") {
close(ready)
io.Copy(io.Discard, stderr)
return
}
}
}()
select {
case <-ready:
case <-time.After(2 * time.Second):
t.Fatal("tcpdump did not attach within 2s")
}
var found atomic.Bool
readerDone := make(chan struct{})
go func() {
defer close(readerDone)
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
if strings.Contains(scanner.Text(), needle) {
found.Store(true)
}
}
}()
do()
time.Sleep(200 * time.Millisecond)
_ = cmd.Process.Signal(os.Interrupt)
<-readerDone
return found.Load()
}
func dialLocalEchoServer(t *testing.T) (client net.Conn, serverPort uint16) {
t.Helper()
listener, err := net.Listen("tcp4", "127.0.0.1:0")
require.NoError(t, err)
accepted := make(chan net.Conn, 1)
go func() {
c, err := listener.Accept()
if err == nil {
accepted <- c
}
close(accepted)
}()
addr := listener.Addr().(*net.TCPAddr)
client, err = net.Dial("tcp4", addr.String())
require.NoError(t, err)
server := <-accepted
require.NotNil(t, server)
go io.Copy(io.Discard, server)
t.Cleanup(func() {
client.Close()
server.Close()
listener.Close()
})
return client, uint16(addr.Port)
}
+100
View File
@@ -0,0 +1,100 @@
//go:build linux || darwin
package tlsspoof
import (
"encoding/hex"
"io"
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestIntegrationSpoofer_WrongChecksum(t *testing.T) {
requireRoot(t)
client, serverPort := dialLocalEchoServer(t)
spoofer, err := NewSpoofer(client, MethodWrongChecksum)
require.NoError(t, err)
defer spoofer.Close()
payload, err := hex.DecodeString(realClientHello)
require.NoError(t, err)
fake, err := rewriteSNI(payload, "letsencrypt.org")
require.NoError(t, err)
captured := tcpdumpObserver(t, loopbackInterface, serverPort, "letsencrypt.org", func() {
require.NoError(t, spoofer.Inject(fake))
}, 3*time.Second)
require.True(t, captured, "injected fake ClientHello must be observable on loopback")
}
func TestIntegrationSpoofer_WrongSequence(t *testing.T) {
requireRoot(t)
client, serverPort := dialLocalEchoServer(t)
spoofer, err := NewSpoofer(client, MethodWrongSequence)
require.NoError(t, err)
defer spoofer.Close()
payload, err := hex.DecodeString(realClientHello)
require.NoError(t, err)
fake, err := rewriteSNI(payload, "letsencrypt.org")
require.NoError(t, err)
captured := tcpdumpObserver(t, loopbackInterface, serverPort, "letsencrypt.org", func() {
require.NoError(t, spoofer.Inject(fake))
}, 3*time.Second)
require.True(t, captured, "injected fake ClientHello must be observable on loopback")
}
// Loopback bypasses TCP checksum validation, so wrong-sequence is used instead.
func TestIntegrationConn_InjectsThenForwardsRealCH(t *testing.T) {
requireRoot(t)
listener, err := net.Listen("tcp4", "127.0.0.1:0")
require.NoError(t, err)
serverReceived := make(chan []byte, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
got, _ := io.ReadAll(conn)
serverReceived <- got
}()
addr := listener.Addr().(*net.TCPAddr)
serverPort := uint16(addr.Port)
client, err := net.Dial("tcp4", addr.String())
require.NoError(t, err)
t.Cleanup(func() {
client.Close()
listener.Close()
})
spoofer, err := NewSpoofer(client, MethodWrongSequence)
require.NoError(t, err)
wrapped := NewConn(client, spoofer, "letsencrypt.org")
payload, err := hex.DecodeString(realClientHello)
require.NoError(t, err)
captured := tcpdumpObserver(t, loopbackInterface, serverPort, "letsencrypt.org", func() {
n, err := wrapped.Write(payload)
require.NoError(t, err)
require.Equal(t, len(payload), n)
}, 3*time.Second)
require.True(t, captured, "fake ClientHello with letsencrypt.org SNI must be on the wire")
_ = wrapped.Close()
select {
case got := <-serverReceived:
require.Equal(t, payload, got, "server must receive real ClientHello unchanged (wrong-sequence fake must be dropped)")
case <-time.After(2 * time.Second):
t.Fatal("echo server did not receive real ClientHello")
}
}
+139
View File
@@ -0,0 +1,139 @@
//go:build windows && (amd64 || 386)
package tlsspoof
import (
"encoding/hex"
"io"
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func newSpoofer(t *testing.T, conn net.Conn, method Method) Spoofer {
t.Helper()
spoofer, err := NewSpoofer(conn, method)
require.NoError(t, err)
return spoofer
}
// Basic lifecycle: opening a spoofer against a live TCP conn installs
// the driver, spawns run(), then shuts down cleanly without ever
// injecting. Exercises the close path that cancels an in-flight Recv.
func TestIntegrationSpooferOpenClose(t *testing.T) {
listener, err := net.Listen("tcp4", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { listener.Close() })
accepted := make(chan net.Conn, 1)
go func() {
c, _ := listener.Accept()
accepted <- c
}()
client, err := net.Dial("tcp4", listener.Addr().String())
require.NoError(t, err)
t.Cleanup(func() { client.Close() })
server := <-accepted
t.Cleanup(func() {
if server != nil {
server.Close()
}
})
spoofer := newSpoofer(t, client, MethodWrongSequence)
require.NoError(t, spoofer.Close())
}
// End-to-end: Conn.Write injects a fake ClientHello with a rewritten
// SNI, then forwards the real ClientHello. With wrong-sequence, the
// fake lands before the connection's send-next sequence — the peer TCP
// stack treats it as already-received and only surfaces the real bytes
// to the echo server.
func TestIntegrationConnInjectsThenForwardsRealCH(t *testing.T) {
listener, err := net.Listen("tcp4", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { listener.Close() })
serverReceived := make(chan []byte, 1)
go func() {
conn, acceptErr := listener.Accept()
if acceptErr != nil {
return
}
defer conn.Close()
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
got, _ := io.ReadAll(conn)
serverReceived <- got
}()
client, err := net.Dial("tcp4", listener.Addr().String())
require.NoError(t, err)
t.Cleanup(func() { client.Close() })
spoofer := newSpoofer(t, client, MethodWrongSequence)
wrapped := NewConn(client, spoofer, "letsencrypt.org")
payload, err := hex.DecodeString(realClientHello)
require.NoError(t, err)
n, err := wrapped.Write(payload)
require.NoError(t, err)
require.Equal(t, len(payload), n)
_ = wrapped.Close()
select {
case got := <-serverReceived:
require.Equal(t, payload, got,
"server must receive real ClientHello unchanged (wrong-sequence fake must be dropped)")
case <-time.After(5 * time.Second):
t.Fatal("echo server did not receive real ClientHello within 5s")
}
}
// Inject before any kernel payload: stages the fake, then Write flushes
// the real CH. Same terminal expectation as the Conn variant but via the
// Spoofer primitive directly.
func TestIntegrationSpooferInjectThenWrite(t *testing.T) {
listener, err := net.Listen("tcp4", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { listener.Close() })
serverReceived := make(chan []byte, 1)
go func() {
conn, acceptErr := listener.Accept()
if acceptErr != nil {
return
}
defer conn.Close()
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
got, _ := io.ReadAll(conn)
serverReceived <- got
}()
client, err := net.Dial("tcp4", listener.Addr().String())
require.NoError(t, err)
t.Cleanup(func() { client.Close() })
spoofer := newSpoofer(t, client, MethodWrongSequence)
t.Cleanup(func() { spoofer.Close() })
payload, err := hex.DecodeString(realClientHello)
require.NoError(t, err)
fake, err := rewriteSNI(payload, "letsencrypt.org")
require.NoError(t, err)
require.NoError(t, spoofer.Inject(fake))
n, err := client.Write(payload)
require.NoError(t, err)
require.Equal(t, len(payload), n)
_ = client.Close()
select {
case got := <-serverReceived:
require.Equal(t, payload, got)
case <-time.After(5 * time.Second):
t.Fatal("echo server did not receive real ClientHello within 5s")
}
}
+100
View File
@@ -0,0 +1,100 @@
package tlsspoof
import (
"net/netip"
"github.com/sagernet/sing-tun/gtcpip/checksum"
"github.com/sagernet/sing-tun/gtcpip/header"
E "github.com/sagernet/sing/common/exceptions"
)
const (
defaultTTL uint8 = 64
defaultWindowSize uint16 = 0xFFFF
tcpHeaderLen = header.TCPMinimumSize
)
func buildTCPSegment(
src netip.AddrPort,
dst netip.AddrPort,
seqNum uint32,
ackNum uint32,
payload []byte,
corruptChecksum bool,
) []byte {
if src.Addr().Is4() != dst.Addr().Is4() {
panic("tlsspoof: mixed IPv4/IPv6 address family")
}
var (
frame []byte
ipHeaderLen int
)
if src.Addr().Is4() {
ipHeaderLen = header.IPv4MinimumSize
frame = make([]byte, ipHeaderLen+tcpHeaderLen+len(payload))
ip := header.IPv4(frame[:ipHeaderLen])
ip.Encode(&header.IPv4Fields{
TotalLength: uint16(len(frame)),
ID: 0,
TTL: defaultTTL,
Protocol: uint8(header.TCPProtocolNumber),
SrcAddr: src.Addr(),
DstAddr: dst.Addr(),
})
ip.SetChecksum(^ip.CalculateChecksum())
} else {
ipHeaderLen = header.IPv6MinimumSize
frame = make([]byte, ipHeaderLen+tcpHeaderLen+len(payload))
ip := header.IPv6(frame[:ipHeaderLen])
ip.Encode(&header.IPv6Fields{
PayloadLength: uint16(tcpHeaderLen + len(payload)),
TransportProtocol: header.TCPProtocolNumber,
HopLimit: defaultTTL,
SrcAddr: src.Addr(),
DstAddr: dst.Addr(),
})
}
encodeTCP(frame, ipHeaderLen, src, dst, seqNum, ackNum, payload, corruptChecksum)
return frame
}
func encodeTCP(frame []byte, ipHeaderLen int, src, dst netip.AddrPort, seqNum, ackNum uint32, payload []byte, corruptChecksum bool) {
tcp := header.TCP(frame[ipHeaderLen:])
copy(frame[ipHeaderLen+tcpHeaderLen:], payload)
tcp.Encode(&header.TCPFields{
SrcPort: src.Port(),
DstPort: dst.Port(),
SeqNum: seqNum,
AckNum: ackNum,
DataOffset: tcpHeaderLen,
Flags: header.TCPFlagAck | header.TCPFlagPsh,
WindowSize: defaultWindowSize,
})
applyTCPChecksum(tcp, src.Addr(), dst.Addr(), payload, corruptChecksum)
}
func buildSpoofFrame(method Method, src, dst netip.AddrPort, sendNext, receiveNext uint32, payload []byte) ([]byte, error) {
var sequence uint32
corrupt := false
switch method {
case MethodWrongSequence:
sequence = sendNext - uint32(len(payload))
case MethodWrongChecksum:
sequence = sendNext
corrupt = true
default:
return nil, E.New("tls_spoof: unknown method ", method)
}
return buildTCPSegment(src, dst, sequence, receiveNext, payload, corrupt), nil
}
func applyTCPChecksum(tcp header.TCP, srcAddr, dstAddr netip.Addr, payload []byte, corrupt bool) {
tcpLen := tcpHeaderLen + len(payload)
pseudo := header.PseudoHeaderChecksum(header.TCPProtocolNumber, srcAddr.AsSlice(), dstAddr.AsSlice(), uint16(tcpLen))
payloadChecksum := checksum.Checksum(payload, 0)
tcpChecksum := ^tcp.CalculateChecksum(checksum.Combine(pseudo, payloadChecksum))
if corrupt {
tcpChecksum ^= 0xFFFF
}
tcp.SetChecksum(tcpChecksum)
}
+77
View File
@@ -0,0 +1,77 @@
package tlsspoof
import (
"net/netip"
"testing"
"github.com/sagernet/sing-tun/gtcpip"
"github.com/sagernet/sing-tun/gtcpip/checksum"
"github.com/sagernet/sing-tun/gtcpip/header"
"github.com/stretchr/testify/require"
)
func TestBuildTCPSegment_IPv4_ValidChecksum(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("10.0.0.1:54321")
dst := netip.MustParseAddrPort("1.2.3.4:443")
payload := []byte("fake-client-hello")
frame := buildTCPSegment(src, dst, 100_000, 200_000, payload, false)
ip := header.IPv4(frame[:header.IPv4MinimumSize])
require.True(t, ip.IsChecksumValid())
tcp := header.TCP(frame[header.IPv4MinimumSize:])
payloadChecksum := checksum.Checksum(payload, 0)
require.True(t, tcp.IsChecksumValid(
tcpip.AddrFrom4(src.Addr().As4()),
tcpip.AddrFrom4(dst.Addr().As4()),
payloadChecksum,
uint16(len(payload)),
))
}
func TestBuildTCPSegment_IPv4_CorruptChecksum(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("10.0.0.1:54321")
dst := netip.MustParseAddrPort("1.2.3.4:443")
payload := []byte("fake-client-hello")
frame := buildTCPSegment(src, dst, 100_000, 200_000, payload, true)
tcp := header.TCP(frame[header.IPv4MinimumSize:])
payloadChecksum := checksum.Checksum(payload, 0)
require.False(t, tcp.IsChecksumValid(
tcpip.AddrFrom4(src.Addr().As4()),
tcpip.AddrFrom4(dst.Addr().As4()),
payloadChecksum,
uint16(len(payload)),
))
// IP checksum must still be valid so the router forwards the packet.
require.True(t, header.IPv4(frame[:header.IPv4MinimumSize]).IsChecksumValid())
}
func TestBuildTCPSegment_IPv6_ValidChecksum(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("[fe80::1]:54321")
dst := netip.MustParseAddrPort("[2606:4700::1]:443")
payload := []byte("fake-client-hello")
frame := buildTCPSegment(src, dst, 0xDEADBEEF, 0x12345678, payload, false)
tcp := header.TCP(frame[header.IPv6MinimumSize:])
payloadChecksum := checksum.Checksum(payload, 0)
require.True(t, tcp.IsChecksumValid(
tcpip.AddrFrom16(src.Addr().As16()),
tcpip.AddrFrom16(dst.Addr().As16()),
payloadChecksum,
uint16(len(payload)),
))
}
func TestBuildTCPSegment_MixedFamilyPanics(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("10.0.0.1:54321")
dst := netip.MustParseAddrPort("[2606:4700::1]:443")
require.Panics(t, func() {
buildTCPSegment(src, dst, 0, 0, nil, false)
})
}
+161
View File
@@ -0,0 +1,161 @@
package tlsspoof
import (
"encoding/binary"
"net"
"net/netip"
"strconv"
"strings"
"sync"
"syscall"
E "github.com/sagernet/sing/common/exceptions"
"golang.org/x/sys/unix"
)
const PlatformSupported = true
// Offsets into xinpcb_n within each net.inet.tcp.pcblist_n record, identical
// to the values used by common/process/searcher_darwin_shared.go.
const (
darwinXinpgenSize = 24
darwinXsocketOffset = 104
darwinXinpcbForeignPort = 16
darwinXinpcbLocalPort = 18
darwinXinpcbVFlag = 44
darwinXinpcbForeignAddr = 48
darwinXinpcbLocalAddr = 64
darwinXinpcbIPv4Offset = 12
darwinTCPExtraSize = 208
darwinXtcpcbSndNxtOffset = 56
darwinXtcpcbRcvNxtOffset = 80
)
var darwinStructSize = sync.OnceValue(func() int {
value, _ := syscall.Sysctl("kern.osrelease")
major, _, _ := strings.Cut(value, ".")
n, _ := strconv.ParseInt(major, 10, 64)
if n >= 22 {
return 408
}
return 384
})
type darwinSpoofer struct {
method Method
src netip.AddrPort
dst netip.AddrPort
rawFD int
rawSockAddr unix.Sockaddr
sendNext uint32
receiveNext uint32
}
func newRawSpoofer(conn net.Conn, method Method) (Spoofer, error) {
_, src, dst, err := tcpEndpoints(conn)
if err != nil {
return nil, err
}
fd, sockaddr, err := openDarwinRawSocket(dst)
if err != nil {
return nil, err
}
sendNext, receiveNext, err := readDarwinTCPSequence(src, dst)
if err != nil {
unix.Close(fd)
return nil, err
}
return &darwinSpoofer{
method: method,
src: src,
dst: dst,
rawFD: fd,
rawSockAddr: sockaddr,
sendNext: sendNext,
receiveNext: receiveNext,
}, nil
}
// readDarwinTCPSequence scans net.inet.tcp.pcblist_n for the PCB that matches
// src -> dst and returns (snd_nxt, rcv_nxt). These live in xtcpcb_n at the end
// of each record; see darwin-xnu bsd/netinet/in_pcblist.c:get_pcblist_n.
func readDarwinTCPSequence(src, dst netip.AddrPort) (uint32, uint32, error) {
buffer, err := unix.SysctlRaw("net.inet.tcp.pcblist_n")
if err != nil {
return 0, 0, E.Cause(err, "sysctl net.inet.tcp.pcblist_n")
}
structSize := darwinStructSize()
itemSize := structSize + darwinTCPExtraSize
for i := darwinXinpgenSize; i+itemSize <= len(buffer); i += itemSize {
inpcb := buffer[i : i+darwinXsocketOffset]
xtcpcb := buffer[i+structSize : i+itemSize]
localPort := binary.BigEndian.Uint16(inpcb[darwinXinpcbLocalPort : darwinXinpcbLocalPort+2])
remotePort := binary.BigEndian.Uint16(inpcb[darwinXinpcbForeignPort : darwinXinpcbForeignPort+2])
if localPort != src.Port() || remotePort != dst.Port() {
continue
}
versionFlag := inpcb[darwinXinpcbVFlag]
var localAddr, remoteAddr netip.Addr
switch {
case versionFlag&0x1 != 0:
localAddr = netip.AddrFrom4([4]byte(inpcb[darwinXinpcbLocalAddr+darwinXinpcbIPv4Offset : darwinXinpcbLocalAddr+darwinXinpcbIPv4Offset+4]))
remoteAddr = netip.AddrFrom4([4]byte(inpcb[darwinXinpcbForeignAddr+darwinXinpcbIPv4Offset : darwinXinpcbForeignAddr+darwinXinpcbIPv4Offset+4]))
case versionFlag&0x2 != 0:
localAddr = netip.AddrFrom16([16]byte(inpcb[darwinXinpcbLocalAddr : darwinXinpcbLocalAddr+16]))
remoteAddr = netip.AddrFrom16([16]byte(inpcb[darwinXinpcbForeignAddr : darwinXinpcbForeignAddr+16]))
default:
continue
}
if localAddr.Unmap() != src.Addr() || remoteAddr.Unmap() != dst.Addr() {
continue
}
sendNext := binary.NativeEndian.Uint32(xtcpcb[darwinXtcpcbSndNxtOffset : darwinXtcpcbSndNxtOffset+4])
receiveNext := binary.NativeEndian.Uint32(xtcpcb[darwinXtcpcbRcvNxtOffset : darwinXtcpcbRcvNxtOffset+4])
return sendNext, receiveNext, nil
}
return 0, 0, E.New("tls_spoof: connection ", src, "->", dst, " not found in pcblist_n")
}
func openDarwinRawSocket(dst netip.AddrPort) (int, unix.Sockaddr, error) {
if !dst.Addr().Is4() {
// macOS does not expose IPV6_HDRINCL; raw AF_INET6 injection would
// require either BPF link-layer writes or kernel-side IPv6 header
// synthesis, neither of which is implemented here.
return -1, nil, E.New("tls_spoof: IPv6 not supported on darwin")
}
return openIPv4RawSocket(dst)
}
func (s *darwinSpoofer) Inject(payload []byte) error {
frame, err := buildSpoofFrame(s.method, s.src, s.dst, s.sendNext, s.receiveNext, payload)
if err != nil {
return err
}
// Darwin inherits the historical BSD quirk: with IP_HDRINCL the kernel
// expects ip_len and ip_off in host byte order, not network byte order.
// Apple's rip_output swaps them back before transmission. This does not
// apply to IPv6.
if s.src.Addr().Is4() {
totalLen := binary.BigEndian.Uint16(frame[2:4])
binary.NativeEndian.PutUint16(frame[2:4], totalLen)
fragOff := binary.BigEndian.Uint16(frame[6:8])
binary.NativeEndian.PutUint16(frame[6:8], fragOff)
}
err = unix.Sendto(s.rawFD, frame, 0, s.rawSockAddr)
if err != nil {
return E.Cause(err, "sendto raw socket")
}
return nil
}
func (s *darwinSpoofer) Close() error {
if s.rawFD < 0 {
return nil
}
err := unix.Close(s.rawFD)
s.rawFD = -1
return err
}
+127
View File
@@ -0,0 +1,127 @@
package tlsspoof
import (
"net"
"net/netip"
"github.com/sagernet/sing/common/control"
E "github.com/sagernet/sing/common/exceptions"
"golang.org/x/sys/unix"
)
const PlatformSupported = true
const (
// Values of enum { TCP_NO_QUEUE, TCP_RECV_QUEUE, TCP_SEND_QUEUE } from
// include/net/tcp.h; not exported by golang.org/x/sys/unix.
tcpRecvQueue = 1
tcpSendQueue = 2
)
type linuxSpoofer struct {
method Method
src netip.AddrPort
dst netip.AddrPort
rawFD int
rawSockAddr unix.Sockaddr
sendNext uint32
receiveNext uint32
}
func newRawSpoofer(conn net.Conn, method Method) (Spoofer, error) {
tcpConn, src, dst, err := tcpEndpoints(conn)
if err != nil {
return nil, err
}
fd, sockaddr, err := openLinuxRawSocket(dst)
if err != nil {
return nil, err
}
spoofer := &linuxSpoofer{
method: method,
src: src,
dst: dst,
rawFD: fd,
rawSockAddr: sockaddr,
}
err = spoofer.loadSequenceNumbers(tcpConn)
if err != nil {
unix.Close(fd)
return nil, err
}
return spoofer, nil
}
func openLinuxRawSocket(dst netip.AddrPort) (int, unix.Sockaddr, error) {
if dst.Addr().Is4() {
return openIPv4RawSocket(dst)
}
fd, err := unix.Socket(unix.AF_INET6, unix.SOCK_RAW, unix.IPPROTO_TCP)
if err != nil {
return -1, nil, E.Cause(err, "open AF_INET6 SOCK_RAW")
}
err = unix.SetsockoptInt(fd, unix.IPPROTO_IPV6, unix.IPV6_HDRINCL, 1)
if err != nil {
unix.Close(fd)
return -1, nil, E.Cause(err, "set IPV6_HDRINCL")
}
sockaddr := &unix.SockaddrInet6{Port: int(dst.Port())}
sockaddr.Addr = dst.Addr().As16()
return fd, sockaddr, nil
}
// loadSequenceNumbers puts the socket briefly into TCP_REPAIR mode to read
// snd_nxt and rcv_nxt from the kernel. TCP_REPAIR requires CAP_NET_ADMIN;
// callers must run as root or grant both CAP_NET_RAW and CAP_NET_ADMIN.
func (s *linuxSpoofer) loadSequenceNumbers(tcpConn *net.TCPConn) error {
return control.Conn(tcpConn, func(raw uintptr) error {
fd := int(raw)
err := unix.SetsockoptInt(fd, unix.IPPROTO_TCP, unix.TCP_REPAIR, unix.TCP_REPAIR_ON)
if err != nil {
return E.Cause(err, "enter TCP_REPAIR (need CAP_NET_ADMIN)")
}
defer unix.SetsockoptInt(fd, unix.IPPROTO_TCP, unix.TCP_REPAIR, unix.TCP_REPAIR_OFF)
err = unix.SetsockoptInt(fd, unix.IPPROTO_TCP, unix.TCP_REPAIR_QUEUE, tcpSendQueue)
if err != nil {
return E.Cause(err, "select TCP_SEND_QUEUE")
}
sendSequence, err := unix.GetsockoptInt(fd, unix.IPPROTO_TCP, unix.TCP_QUEUE_SEQ)
if err != nil {
return E.Cause(err, "read send queue sequence")
}
err = unix.SetsockoptInt(fd, unix.IPPROTO_TCP, unix.TCP_REPAIR_QUEUE, tcpRecvQueue)
if err != nil {
return E.Cause(err, "select TCP_RECV_QUEUE")
}
receiveSequence, err := unix.GetsockoptInt(fd, unix.IPPROTO_TCP, unix.TCP_QUEUE_SEQ)
if err != nil {
return E.Cause(err, "read recv queue sequence")
}
s.sendNext = uint32(sendSequence)
s.receiveNext = uint32(receiveSequence)
return nil
})
}
func (s *linuxSpoofer) Inject(payload []byte) error {
frame, err := buildSpoofFrame(s.method, s.src, s.dst, s.sendNext, s.receiveNext, payload)
if err != nil {
return err
}
err = unix.Sendto(s.rawFD, frame, 0, s.rawSockAddr)
if err != nil {
return E.Cause(err, "sendto raw socket")
}
return nil
}
func (s *linuxSpoofer) Close() error {
if s.rawFD < 0 {
return nil
}
err := unix.Close(s.rawFD)
s.rawFD = -1
return err
}
+15
View File
@@ -0,0 +1,15 @@
//go:build !linux && !darwin && !(windows && (amd64 || 386))
package tlsspoof
import (
"net"
E "github.com/sagernet/sing/common/exceptions"
)
const PlatformSupported = false
func newRawSpoofer(conn net.Conn, method Method) (Spoofer, error) {
return nil, E.New("tls_spoof: unsupported platform")
}
+26
View File
@@ -0,0 +1,26 @@
//go:build linux || darwin
package tlsspoof
import (
"net/netip"
E "github.com/sagernet/sing/common/exceptions"
"golang.org/x/sys/unix"
)
func openIPv4RawSocket(dst netip.AddrPort) (int, unix.Sockaddr, error) {
fd, err := unix.Socket(unix.AF_INET, unix.SOCK_RAW, unix.IPPROTO_TCP)
if err != nil {
return -1, nil, E.Cause(err, "open AF_INET SOCK_RAW")
}
err = unix.SetsockoptInt(fd, unix.IPPROTO_IP, unix.IP_HDRINCL, 1)
if err != nil {
unix.Close(fd)
return -1, nil, E.Cause(err, "set IP_HDRINCL")
}
sockaddr := &unix.SockaddrInet4{Port: int(dst.Port())}
sockaddr.Addr = dst.Addr().As4()
return fd, sockaddr, nil
}
+218
View File
@@ -0,0 +1,218 @@
//go:build windows && (amd64 || 386)
package tlsspoof
import (
"errors"
"net"
"net/netip"
"sync"
"sync/atomic"
"time"
"github.com/sagernet/sing-box/common/windivert"
"github.com/sagernet/sing-tun/gtcpip/header"
E "github.com/sagernet/sing/common/exceptions"
"golang.org/x/sys/windows"
)
const PlatformSupported = true
// closeGracePeriod caps how long Close() waits for the divert goroutine to
// observe the kernel-emitted real ClientHello and perform the reorder
// (fake → real). In practice this completes in microseconds; the cap
// bounds the pathological case where the kernel buffers the packet.
const closeGracePeriod = 2 * time.Second
type windowsSpoofer struct {
method Method
src, dst netip.AddrPort
divertH *windivert.Handle
injectH *windivert.Handle
fakeReady chan []byte // buffered(1): staged by Inject
done chan struct{} // closed by run() on exit
closeOnce sync.Once
runErr atomic.Pointer[error]
}
func newRawSpoofer(conn net.Conn, method Method) (Spoofer, error) {
_, src, dst, err := tcpEndpoints(conn)
if err != nil {
return nil, err
}
filter, err := windivert.OutboundTCP(src, dst)
if err != nil {
return nil, err
}
divertH, err := windivert.Open(filter, windivert.LayerNetwork, 0, 0)
if err != nil {
return nil, E.Cause(err, "tls_spoof: open WinDivert")
}
injectH, err := windivert.Open(nil, windivert.LayerNetwork, 0, windivert.FlagSendOnly)
if err != nil {
divertH.Close()
return nil, E.Cause(err, "tls_spoof: open WinDivert")
}
s := &windowsSpoofer{
method: method,
src: src,
dst: dst,
divertH: divertH,
injectH: injectH,
fakeReady: make(chan []byte, 1),
done: make(chan struct{}),
}
go s.run()
return s, nil
}
func (s *windowsSpoofer) Inject(payload []byte) error {
select {
case s.fakeReady <- payload:
return nil
case <-s.done:
if p := s.runErr.Load(); p != nil {
return *p
}
return E.New("tls_spoof: spoofer closed before Inject")
}
}
func (s *windowsSpoofer) Close() error {
s.closeOnce.Do(func() {
// Give run() a grace window to finish handling the real packet.
select {
case <-s.done:
case <-time.After(closeGracePeriod):
// Force Recv() to return by closing the divert handle.
s.divertH.Close()
<-s.done
}
s.injectH.Close()
})
if p := s.runErr.Load(); p != nil {
return *p
}
return nil
}
func (s *windowsSpoofer) recordErr(err error) { s.runErr.Store(&err) }
func (s *windowsSpoofer) run() {
defer close(s.done)
defer s.divertH.Close()
buf := make([]byte, windivert.MTUMax)
for {
n, addr, err := s.divertH.Recv(buf)
if err != nil {
if errors.Is(err, windows.ERROR_OPERATION_ABORTED) ||
errors.Is(err, windows.ERROR_NO_DATA) {
return
}
s.recordErr(E.Cause(err, "windivert recv"))
return
}
pkt := buf[:n]
seq, ack, payloadLen, ok := parseTCPFields(pkt, addr.IPv6())
if !ok {
// Malformed / not TCP — shouldn't match our filter, but be safe.
_, _ = s.divertH.Send(pkt, &addr)
continue
}
if payloadLen == 0 {
// Handshake ACK, keepalive, FIN — pass through unchanged.
_, err := s.divertH.Send(pkt, &addr)
if err != nil {
s.recordErr(E.Cause(err, "windivert re-inject empty"))
return
}
continue
}
// Non-empty outbound TCP payload = the real ClientHello.
var fake []byte
select {
case fake = <-s.fakeReady:
default:
// Inject() not yet called — pass through and keep observing.
_, err := s.divertH.Send(pkt, &addr)
if err != nil {
s.recordErr(E.Cause(err, "windivert re-inject early data"))
return
}
continue
}
frame, err := buildSpoofFrame(s.method, s.src, s.dst, seq, ack, fake)
if err != nil {
s.recordErr(err)
return
}
fakeAddr := addr // inherit Outbound, IfIdx
// buildSpoofFrame emits ready-to-wire bytes. The driver recomputes
// checksums on Send when TCPChecksum/IPChecksum are 0 — which would
// overwrite the intentionally corrupt checksum in WrongChecksum mode.
// Force both to 1 to keep our bytes intact.
fakeAddr.SetIPChecksum(true)
fakeAddr.SetTCPChecksum(true)
_, err = s.injectH.Send(frame, &fakeAddr)
if err != nil {
s.recordErr(E.Cause(err, "windivert inject fake"))
return
}
_, err = s.divertH.Send(pkt, &addr)
if err != nil {
s.recordErr(E.Cause(err, "windivert re-inject real"))
return
}
return // single-shot reorder complete
}
}
func parseTCPFields(pkt []byte, isV6 bool) (seq, ack uint32, payloadLen int, ok bool) {
if isV6 {
if len(pkt) < header.IPv6MinimumSize+header.TCPMinimumSize {
return 0, 0, 0, false
}
ip := header.IPv6(pkt)
if ip.TransportProtocol() != header.TCPProtocolNumber {
return 0, 0, 0, false
}
tcp := header.TCP(pkt[header.IPv6MinimumSize:])
tcpHdr := int(tcp.DataOffset())
if tcpHdr < header.TCPMinimumSize || header.IPv6MinimumSize+tcpHdr > len(pkt) {
return 0, 0, 0, false
}
return tcp.SequenceNumber(), tcp.AckNumber(),
len(pkt) - header.IPv6MinimumSize - tcpHdr, true
}
if len(pkt) < header.IPv4MinimumSize+header.TCPMinimumSize {
return 0, 0, 0, false
}
ip := header.IPv4(pkt)
if ip.Protocol() != uint8(header.TCPProtocolNumber) {
return 0, 0, 0, false
}
ihl := int(ip.HeaderLength())
// ihl+TCPMinimumSize guards the TCP-header field reads below; without
// this, an IPv4 packet with options (ihl>20) against a 40-byte buffer
// reads past the TCP slice when calling DataOffset.
if ihl < header.IPv4MinimumSize || ihl+header.TCPMinimumSize > len(pkt) {
return 0, 0, 0, false
}
tcp := header.TCP(pkt[ihl:])
tcpHdr := int(tcp.DataOffset())
if tcpHdr < header.TCPMinimumSize || ihl+tcpHdr > len(pkt) {
return 0, 0, 0, false
}
total := int(ip.TotalLength())
if total == 0 || total > len(pkt) {
total = len(pkt)
}
return tcp.SequenceNumber(), tcp.AckNumber(),
total - ihl - tcpHdr, true
}
+112
View File
@@ -0,0 +1,112 @@
//go:build windows && (amd64 || 386)
package tlsspoof
import (
"net/netip"
"testing"
"github.com/sagernet/sing-tun/gtcpip/header"
"github.com/stretchr/testify/require"
)
func TestParseTCPFieldsIPv4Valid(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("10.0.0.1:54321")
dst := netip.MustParseAddrPort("1.2.3.4:443")
payload := []byte("hello")
frame := buildTCPSegment(src, dst, 1000, 2000, payload, false)
seq, ack, payloadLen, ok := parseTCPFields(frame, false)
require.True(t, ok)
require.Equal(t, uint32(1000), seq)
require.Equal(t, uint32(2000), ack)
require.Equal(t, len(payload), payloadLen)
}
func TestParseTCPFieldsIPv4NoPayload(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("10.0.0.1:54321")
dst := netip.MustParseAddrPort("1.2.3.4:443")
frame := buildTCPSegment(src, dst, 42, 100, nil, false)
seq, ack, payloadLen, ok := parseTCPFields(frame, false)
require.True(t, ok)
require.Equal(t, uint32(42), seq)
require.Equal(t, uint32(100), ack)
require.Equal(t, 0, payloadLen)
}
func TestParseTCPFieldsIPv6Valid(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("[fe80::1]:54321")
dst := netip.MustParseAddrPort("[2606:4700::1]:443")
payload := []byte("hello-v6")
frame := buildTCPSegment(src, dst, 0xDEADBEEF, 0x12345678, payload, false)
seq, ack, payloadLen, ok := parseTCPFields(frame, true)
require.True(t, ok)
require.Equal(t, uint32(0xDEADBEEF), seq)
require.Equal(t, uint32(0x12345678), ack)
require.Equal(t, len(payload), payloadLen)
}
func TestParseTCPFieldsIPv4TooShort(t *testing.T) {
t.Parallel()
_, _, _, ok := parseTCPFields(make([]byte, header.IPv4MinimumSize+header.TCPMinimumSize-1), false)
require.False(t, ok)
}
func TestParseTCPFieldsIPv6TooShort(t *testing.T) {
t.Parallel()
_, _, _, ok := parseTCPFields(make([]byte, header.IPv6MinimumSize+header.TCPMinimumSize-1), true)
require.False(t, ok)
}
// buildTCPSegment only produces TCP; a UDP packet hitting parseTCPFields
// (for example from a mis-specified filter) must be rejected.
func TestParseTCPFieldsIPv4WrongProtocol(t *testing.T) {
t.Parallel()
frame := make([]byte, header.IPv4MinimumSize+header.TCPMinimumSize)
ip := header.IPv4(frame[:header.IPv4MinimumSize])
ip.Encode(&header.IPv4Fields{
TotalLength: uint16(len(frame)),
TTL: 64,
Protocol: 17, // UDP
SrcAddr: netip.MustParseAddr("10.0.0.1"),
DstAddr: netip.MustParseAddr("10.0.0.2"),
})
_, _, _, ok := parseTCPFields(frame, false)
require.False(t, ok)
}
func TestParseTCPFieldsIPv6WrongProtocol(t *testing.T) {
t.Parallel()
frame := make([]byte, header.IPv6MinimumSize+header.TCPMinimumSize)
ip := header.IPv6(frame[:header.IPv6MinimumSize])
ip.Encode(&header.IPv6Fields{
PayloadLength: header.TCPMinimumSize,
TransportProtocol: 17, // UDP
HopLimit: 64,
SrcAddr: netip.MustParseAddr("fe80::1"),
DstAddr: netip.MustParseAddr("fe80::2"),
})
_, _, _, ok := parseTCPFields(frame, true)
require.False(t, ok)
}
// ihl > 20 must not read past the TCP slice. Build an IPv4 packet with
// options header but truncate so ihl*4 + TCPMinimumSize exceeds len.
func TestParseTCPFieldsIPv4OptionsOverflow(t *testing.T) {
t.Parallel()
// Start with a valid IPv4+TCP frame, then lie about the header length.
src := netip.MustParseAddrPort("10.0.0.1:1")
dst := netip.MustParseAddrPort("10.0.0.2:2")
frame := buildTCPSegment(src, dst, 0, 0, []byte("x"), false)
ip := header.IPv4(frame[:header.IPv4MinimumSize])
// ihl=15 → 60 bytes of IP header claimed, but buffer only has 20.
ip.SetHeaderLength(60)
_, _, _, ok := parseTCPFields(frame, false)
require.False(t, ok)
}
+100
View File
@@ -0,0 +1,100 @@
package tlsspoof
import (
"net"
E "github.com/sagernet/sing/common/exceptions"
)
type Method int
const (
MethodWrongSequence Method = iota
MethodWrongChecksum
)
const (
MethodNameWrongSequence = "wrong-sequence"
MethodNameWrongChecksum = "wrong-checksum"
)
func ParseMethod(s string) (Method, error) {
switch s {
case "", MethodNameWrongSequence:
return MethodWrongSequence, nil
case MethodNameWrongChecksum:
return MethodWrongChecksum, nil
default:
return 0, E.New("tls_spoof: unknown method: ", s)
}
}
func (m Method) String() string {
switch m {
case MethodWrongSequence:
return MethodNameWrongSequence
case MethodWrongChecksum:
return MethodNameWrongChecksum
default:
return "unknown"
}
}
type Spoofer interface {
Inject(payload []byte) error
Close() error
}
func NewSpoofer(conn net.Conn, method Method) (Spoofer, error) {
return newRawSpoofer(conn, method)
}
type Conn struct {
net.Conn
spoofer Spoofer
fakeSNI string
injected bool
}
func NewConn(conn net.Conn, spoofer Spoofer, fakeSNI string) *Conn {
return &Conn{
Conn: conn,
spoofer: spoofer,
fakeSNI: fakeSNI,
}
}
func (c *Conn) Write(b []byte) (int, error) {
if c.injected {
return c.Conn.Write(b)
}
defer c.spoofer.Close()
fake, err := rewriteSNI(b, c.fakeSNI)
if err != nil {
return 0, E.Cause(err, "tls_spoof: rewrite SNI")
}
err = c.spoofer.Inject(fake)
if err != nil {
return 0, E.Cause(err, "tls_spoof: inject")
}
c.injected = true
return c.Conn.Write(b)
}
func (c *Conn) Close() error {
return E.Append(c.Conn.Close(), c.spoofer.Close(), func(e error) error {
return E.Cause(e, "close spoofer")
})
}
func (c *Conn) ReaderReplaceable() bool {
return true
}
func (c *Conn) WriterReplaceable() bool {
return c.injected
}
func (c *Conn) Upstream() any {
return c.Conn
}
+53
View File
@@ -0,0 +1,53 @@
package windivert
import (
"testing"
"unsafe"
"github.com/stretchr/testify/require"
)
func TestAddressSize(t *testing.T) {
t.Parallel()
require.Equal(t, uintptr(80), unsafe.Sizeof(Address{}))
}
func TestAddressIPv6(t *testing.T) {
t.Parallel()
var addr Address
require.False(t, addr.IPv6())
addr.bits = 1 << addrBitIPv6
require.True(t, addr.IPv6())
}
func TestAddressSetIPChecksum(t *testing.T) {
t.Parallel()
var addr Address
addr.SetIPChecksum(true)
require.Equal(t, uint32(1<<addrBitIPChecksum), addr.bits)
addr.SetIPChecksum(false)
require.Equal(t, uint32(0), addr.bits)
}
func TestAddressSetTCPChecksum(t *testing.T) {
t.Parallel()
var addr Address
addr.SetTCPChecksum(true)
require.Equal(t, uint32(1<<addrBitTCPChecksum), addr.bits)
addr.SetTCPChecksum(false)
require.Equal(t, uint32(0), addr.bits)
}
// Setters must not disturb sibling bits.
func TestAddressFlagBitsIndependent(t *testing.T) {
t.Parallel()
var addr Address
addr.SetIPChecksum(true)
addr.SetTCPChecksum(true)
addr.bits |= 1 << addrBitIPv6
addr.SetIPChecksum(false)
require.False(t, addr.bits&(1<<addrBitIPChecksum) != 0)
require.True(t, addr.bits&(1<<addrBitTCPChecksum) != 0)
require.True(t, addr.bits&(1<<addrBitIPv6) != 0)
}
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
//go:build windows && 386
package windivert
import _ "embed"
//go:embed assets/WinDivert32.sys
var sysBytes []byte
func assetFiles() []assetFile {
return []assetFile{{"WinDivert32.sys", sysBytes}}
}
func driverSysName() string { return "WinDivert32.sys" }
+14
View File
@@ -0,0 +1,14 @@
//go:build windows && amd64
package windivert
import _ "embed"
//go:embed assets/WinDivert64.sys
var sysBytes []byte
func assetFiles() []assetFile {
return []assetFile{{"WinDivert64.sys", sysBytes}}
}
func driverSysName() string { return "WinDivert64.sys" }
+7
View File
@@ -0,0 +1,7 @@
//go:build windows && !amd64 && !386
package windivert
func assetFiles() []assetFile { return nil }
func driverSysName() string { return "" }
+212
View File
@@ -0,0 +1,212 @@
//go:build windows
package windivert
import (
"errors"
"os"
"path/filepath"
"runtime"
"strconv"
"sync"
E "github.com/sagernet/sing/common/exceptions"
"golang.org/x/sys/windows"
)
const (
driverServiceName = "WinDivert"
driverDeviceName = `\\.\WinDivert`
)
var (
driverOnce sync.Once
driverErr error
// driverDevName is ASCII-safe and must be available before ensureDriver
// so Open can try CreateFile first and only install on FILE_NOT_FOUND.
driverDevName, _ = windows.UTF16PtrFromString(driverDeviceName)
)
// Requires SeLoadDriverPrivilege (Administrator). Running the 386 build
// under WOW64 on a 64-bit kernel is rejected — use the amd64 build.
func ensureDriver() error {
driverOnce.Do(func() {
driverErr = installDriver()
})
return driverErr
}
func installDriver() error {
if runtime.GOARCH == "386" {
var isWow64 bool
err := windows.IsWow64Process(windows.CurrentProcess(), &isWow64)
if err == nil && isWow64 {
return E.New("windivert: 386 build detected running under WOW64 on a 64-bit kernel; use the amd64 build")
}
}
dir, err := ensureExtracted()
if err != nil {
return err
}
sysPath := filepath.Join(dir, driverSysName())
sysPathW, err := windows.UTF16PtrFromString(sysPath)
if err != nil {
return E.Cause(err, "windivert: utf16 driver path")
}
// Serialize driver install across concurrent processes.
mutexName, _ := windows.UTF16PtrFromString("WinDivertDriverInstallMutex")
mutex, err := windows.CreateMutex(nil, false, mutexName)
if err != nil {
return E.Cause(err, "windivert: create install mutex")
}
defer windows.CloseHandle(mutex)
_, err = windows.WaitForSingleObject(mutex, windows.INFINITE)
if err != nil {
return E.Cause(err, "windivert: wait install mutex")
}
defer windows.ReleaseMutex(mutex)
manager, err := windows.OpenSCManager(nil, nil, windows.SC_MANAGER_ALL_ACCESS)
if err != nil {
return E.Cause(err, "windivert: open SCM")
}
defer windows.CloseServiceHandle(manager)
serviceNameW, _ := windows.UTF16PtrFromString(driverServiceName)
service, err := windows.OpenService(manager, serviceNameW, windows.SERVICE_ALL_ACCESS)
if err != nil {
service, err = windows.CreateService(
manager,
serviceNameW,
serviceNameW,
windows.SERVICE_ALL_ACCESS,
windows.SERVICE_KERNEL_DRIVER,
windows.SERVICE_DEMAND_START,
windows.SERVICE_ERROR_NORMAL,
sysPathW,
nil, nil, nil, nil, nil,
)
if err != nil {
if errors.Is(err, windows.ERROR_SERVICE_EXISTS) {
service, err = windows.OpenService(manager, serviceNameW, windows.SERVICE_ALL_ACCESS)
}
if err != nil {
return wrapDriverInstallError(err)
}
}
}
defer windows.CloseServiceHandle(service)
err = windows.StartService(service, 0, nil)
if err != nil && errors.Is(err, windows.ERROR_SERVICE_DISABLED) {
// A prior process called DeleteService on a still-running kernel
// driver: SCM marks the record for deletion and flips START_TYPE
// to DISABLED until the last handle closes. Re-enable so we can
// start it instead of waiting for a reboot.
err = windows.ChangeServiceConfig(
service,
windows.SERVICE_NO_CHANGE,
windows.SERVICE_DEMAND_START,
windows.SERVICE_NO_CHANGE,
nil, nil, nil, nil, nil, nil, nil,
)
if err != nil {
return E.Cause(err, "windivert: re-enable disabled service")
}
err = windows.StartService(service, 0, nil)
}
if err == nil {
// Mark for deletion so the driver unregisters when the last handle
// closes or on next reboot. Matches the upstream DLL's behavior:
// only the process that actually started the service takes on the
// cleanup responsibility. If another process already started it,
// we leave DeleteService to them.
_ = windows.DeleteService(service)
} else if !errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) {
return E.Cause(err, "windivert: start service")
}
return nil
}
func wrapDriverInstallError(err error) error {
if errors.Is(err, windows.ERROR_ACCESS_DENIED) {
return E.Cause(err, "windivert: installing the kernel driver requires Administrator privileges")
}
return E.Cause(err, "windivert: create service")
}
type assetFile struct {
name string
data []byte
}
var (
extractOnce sync.Once
extractErr error
extractDir string
)
// The on-disk copy is protected by Windows Authenticode signature
// enforcement, which rejects any tampered .sys at StartService time.
func ensureExtracted() (string, error) {
extractOnce.Do(func() {
extractDir, extractErr = extractImpl()
})
return extractDir, extractErr
}
func extractImpl() (string, error) {
files := assetFiles()
if len(files) == 0 {
return "", E.New("windivert: unsupported architecture ", runtime.GOARCH)
}
base, err := os.UserCacheDir()
if err != nil {
return "", E.Cause(err, "windivert: locate user cache dir")
}
dir := filepath.Join(base, "sing-box", "windivert", "v"+AssetVersion)
err = os.MkdirAll(dir, 0o755)
if err != nil {
return "", E.Cause(err, "windivert: mkdir ", dir)
}
for _, asset := range files {
err = ensureAsset(dir, asset)
if err != nil {
return "", err
}
}
return dir, nil
}
// Concurrent sing-box processes race on os.Rename (atomic on NTFS);
// whichever wins creates the final file. Writers that lose the race
// silently discard their temp copy.
func ensureAsset(dir string, asset assetFile) error {
target := filepath.Join(dir, asset.name)
_, err := os.Stat(target)
if err == nil {
return nil
}
if !os.IsNotExist(err) {
return E.Cause(err, "windivert: stat ", asset.name)
}
tmp := target + ".tmp-" + strconv.Itoa(os.Getpid())
err = os.WriteFile(tmp, asset.data, 0o644)
if err != nil {
return E.Cause(err, "windivert: write ", asset.name)
}
err = os.Rename(tmp, target)
if err != nil {
os.Remove(tmp)
if _, statErr := os.Stat(target); statErr == nil {
return nil
}
return E.Cause(err, "windivert: rename ", asset.name)
}
return nil
}
+182
View File
@@ -0,0 +1,182 @@
package windivert
import (
"encoding/binary"
"net/netip"
E "github.com/sagernet/sing/common/exceptions"
)
// WINDIVERT_FILTER VM instruction layout (24 bytes, #pragma pack(1)):
//
// word 0 (LE): field:11 | test:5 | success:16
// word 1 (LE): failure:16 | neg:1 | reserved:15
// words 2..5: arg[4] (native-endian uint32 each)
//
// The driver walks this as a decision tree: evaluate the test at inst i;
// on success jump to success; on failure jump to failure. Continuations
// 0x7FFE and 0x7FFF are ACCEPT and REJECT terminals.
const (
filterInstBytes = 24
filterMaxInsts = 256
fieldZero = 0
fieldOutbound = 2
fieldIP = 5
fieldIPv6 = 6
fieldTCP = 8
fieldIPSrcAddr = 21
fieldIPDstAddr = 22
fieldIPv6SrcAddr = 28
fieldIPv6DstAddr = 29
fieldTCPSrcPort = 38
fieldTCPDstPort = 39
testEQ = 0
resultAccept uint16 = 0x7FFE
resultReject uint16 = 0x7FFF
)
// Filter flags passed to IOCTL_WINDIVERT_STARTUP alongside the compiled
// filter. These tell the driver what *kinds* of packets the filter might
// match, used as a kernel-side fast-reject.
const (
filterFlagOutbound uint64 = 0x0020
filterFlagIP uint64 = 0x0040
filterFlagIPv6 uint64 = 0x0080
)
type filterInst struct {
field uint16 // 11 bits used
test uint8 // 5 bits used
success uint16
failure uint16
neg bool
arg [4]uint32
}
// Filter is a typed specification of packets to capture. It replaces
// WinDivert's filter string language.
//
// Zero value = "reject all" (match nothing), suitable for send-only handles.
type Filter struct {
insts []filterInst
flags uint64 // filter flags for STARTUP ioctl
}
// reject returns a filter that matches no packet. The empty insts slice
// is encoded as a single rejecting instruction by encode().
func reject() *Filter {
return &Filter{}
}
// OutboundTCP returns a filter matching outbound TCP packets on the given
// 5-tuple. Both addresses must share an address family (IPv4 or IPv6).
func OutboundTCP(src, dst netip.AddrPort) (*Filter, error) {
if !src.IsValid() || !dst.IsValid() {
return nil, E.New("windivert: filter: invalid address port")
}
if src.Addr().Is4() != dst.Addr().Is4() {
return nil, E.New("windivert: filter: mixed IPv4/IPv6")
}
f := &Filter{
flags: filterFlagOutbound,
}
// Insts chain as AND: each test's failure = REJECT, success = next inst.
// The final inst's success = ACCEPT.
f.add(fieldOutbound, testEQ, argUint32(1))
if src.Addr().Is4() {
f.flags |= filterFlagIP
f.add(fieldIP, testEQ, argUint32(1))
f.add(fieldTCP, testEQ, argUint32(1))
f.add(fieldIPSrcAddr, testEQ, argIPv4(src.Addr()))
f.add(fieldIPDstAddr, testEQ, argIPv4(dst.Addr()))
} else {
f.flags |= filterFlagIPv6
f.add(fieldIPv6, testEQ, argUint32(1))
f.add(fieldTCP, testEQ, argUint32(1))
f.add(fieldIPv6SrcAddr, testEQ, argIPv6(src.Addr()))
f.add(fieldIPv6DstAddr, testEQ, argIPv6(dst.Addr()))
}
f.add(fieldTCPSrcPort, testEQ, argUint32(uint32(src.Port())))
f.add(fieldTCPDstPort, testEQ, argUint32(uint32(dst.Port())))
return f, nil
}
func (f *Filter) add(field uint16, test uint8, arg [4]uint32) {
f.insts = append(f.insts, filterInst{field: field, test: test, arg: arg})
}
func argUint32(v uint32) [4]uint32 { return [4]uint32{v, 0, 0, 0} }
// argIPv4 encodes an IPv4 address for IP_SRCADDR/IP_DSTADDR. The driver
// compares against an IPv4-mapped-IPv6 form: {host_order_u32, 0x0000FFFF,
// 0, 0} (see sys/windivert.c windivert_get_ipv4_addr and the IPv4_SRCADDR
// val-word construction). Omitting the 0x0000FFFF marker causes the EQ
// test to fail for every packet.
func argIPv4(addr netip.Addr) [4]uint32 {
b := addr.As4()
return [4]uint32{binary.BigEndian.Uint32(b[:]), 0x0000FFFF, 0, 0}
}
// argIPv6 encodes an IPv6 address for IPV6_SRCADDR/IPV6_DSTADDR. The
// driver stores the address as four host-order uint32s in REVERSED word
// order: val[0]=low (bytes 12..15), val[3]=high (bytes 0..3). See
// sys/windivert.c windivert_outbound_network_v6_classify val-word
// construction.
func argIPv6(addr netip.Addr) [4]uint32 {
b := addr.As16()
return [4]uint32{
binary.BigEndian.Uint32(b[12:16]),
binary.BigEndian.Uint32(b[8:12]),
binary.BigEndian.Uint32(b[4:8]),
binary.BigEndian.Uint32(b[0:4]),
}
}
// encode serializes the Filter to the on-wire WINDIVERT_FILTER[] format
// plus the filter_flags for STARTUP ioctl.
func (f *Filter) encode() ([]byte, uint64, error) {
if len(f.insts) == 0 {
// "Reject all" — one instruction, ZERO == 0 is always true, but we
// invert by setting both success and failure to REJECT.
return encodeInst(filterInst{
field: fieldZero,
test: testEQ,
success: resultReject,
failure: resultReject,
}), 0, nil
}
if len(f.insts) > filterMaxInsts-1 {
return nil, 0, E.New("windivert: filter too long")
}
buf := make([]byte, 0, filterInstBytes*len(f.insts))
for i, inst := range f.insts {
if i == len(f.insts)-1 {
inst.success = resultAccept
} else {
inst.success = uint16(i + 1)
}
inst.failure = resultReject
buf = append(buf, encodeInst(inst)...)
}
return buf, f.flags, nil
}
func encodeInst(inst filterInst) []byte {
out := make([]byte, filterInstBytes)
word0 := uint32(inst.field&0x7FF) | uint32(inst.test&0x1F)<<11 |
uint32(inst.success)<<16
word1 := uint32(inst.failure)
if inst.neg {
word1 |= 1 << 16
}
binary.LittleEndian.PutUint32(out[0:4], word0)
binary.LittleEndian.PutUint32(out[4:8], word1)
binary.LittleEndian.PutUint32(out[8:12], inst.arg[0])
binary.LittleEndian.PutUint32(out[12:16], inst.arg[1])
binary.LittleEndian.PutUint32(out[16:20], inst.arg[2])
binary.LittleEndian.PutUint32(out[20:24], inst.arg[3])
return out
}
+140
View File
@@ -0,0 +1,140 @@
package windivert
import (
"encoding/binary"
"net/netip"
"testing"
)
func TestRejectFilter(t *testing.T) {
t.Parallel()
bin, flags, err := reject().encode()
if err != nil {
t.Fatal(err)
}
if len(bin) != filterInstBytes {
t.Fatalf("reject filter len: got %d, want %d", len(bin), filterInstBytes)
}
if flags != 0 {
t.Fatalf("reject filter flags: got %x, want 0", flags)
}
// word0: field=ZERO=0, test=EQ=0, success=REJECT=0x7FFF
word0 := binary.LittleEndian.Uint32(bin[0:4])
if word0 != uint32(resultReject)<<16 {
t.Fatalf("reject word0 = %08x", word0)
}
// word1: failure=REJECT
word1 := binary.LittleEndian.Uint32(bin[4:8])
if word1 != uint32(resultReject) {
t.Fatalf("reject word1 = %08x", word1)
}
}
func TestOutboundTCPFilterIPv4(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("10.1.2.3:54321")
dst := netip.MustParseAddrPort("1.2.3.4:443")
f, err := OutboundTCP(src, dst)
if err != nil {
t.Fatal(err)
}
bin, flags, err := f.encode()
if err != nil {
t.Fatal(err)
}
if want := filterFlagOutbound | filterFlagIP; flags != want {
t.Fatalf("flags: got %x, want %x", flags, want)
}
// 7 instructions: OUTBOUND, IP, TCP, IP_SRCADDR, IP_DSTADDR, TCP_SRCPORT, TCP_DSTPORT
const wantInsts = 7
if len(bin) != wantInsts*filterInstBytes {
t.Fatalf("instruction count: got %d, want %d", len(bin)/filterInstBytes, wantInsts)
}
// Inst 0: OUTBOUND == 1, success=1, failure=REJECT
checkInst(t, bin[0*filterInstBytes:], 0, fieldOutbound, testEQ, 1, resultReject, 1)
// Inst 1: IP == 1, success=2
checkInst(t, bin[1*filterInstBytes:], 1, fieldIP, testEQ, 2, resultReject, 1)
// Inst 2: TCP == 1, success=3
checkInst(t, bin[2*filterInstBytes:], 2, fieldTCP, testEQ, 3, resultReject, 1)
// Inst 3: IP_SRCADDR == 10.1.2.3 (host-order uint32 = 0x0A010203, arg[1]=0x0000FFFF marker)
checkInst(t, bin[3*filterInstBytes:], 3, fieldIPSrcAddr, testEQ, 4, resultReject, 0x0A010203)
checkArg1(t, bin[3*filterInstBytes:], 3, 0x0000FFFF)
// Inst 4: IP_DSTADDR == 1.2.3.4
checkInst(t, bin[4*filterInstBytes:], 4, fieldIPDstAddr, testEQ, 5, resultReject, 0x01020304)
checkArg1(t, bin[4*filterInstBytes:], 4, 0x0000FFFF)
// Inst 5: TCP_SRCPORT == 54321
checkInst(t, bin[5*filterInstBytes:], 5, fieldTCPSrcPort, testEQ, 6, resultReject, 54321)
// Last inst 6: TCP_DSTPORT == 443, success=ACCEPT
checkInst(t, bin[6*filterInstBytes:], 6, fieldTCPDstPort, testEQ, resultAccept, resultReject, 443)
}
func TestOutboundTCPFilterIPv6(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("[2001:db8::1]:54321")
dst := netip.MustParseAddrPort("[2001:db8::2]:443")
f, err := OutboundTCP(src, dst)
if err != nil {
t.Fatal(err)
}
bin, flags, err := f.encode()
if err != nil {
t.Fatal(err)
}
if want := filterFlagOutbound | filterFlagIPv6; flags != want {
t.Fatalf("flags: got %x, want %x", flags, want)
}
// Inst 3: IPv6_SRCADDR. The driver stores the address in reversed
// word order: arg[0]=low (bytes 12..15)=1, arg[3]=high (bytes 0..3)=0x20010db8.
off := 3 * filterInstBytes
a0 := binary.LittleEndian.Uint32(bin[off+8:])
a1 := binary.LittleEndian.Uint32(bin[off+12:])
a2 := binary.LittleEndian.Uint32(bin[off+16:])
a3 := binary.LittleEndian.Uint32(bin[off+20:])
if a0 != 1 || a1 != 0 || a2 != 0 || a3 != 0x20010db8 {
t.Fatalf("ipv6 src arg=[%08x %08x %08x %08x], want [1 0 0 0x20010db8]", a0, a1, a2, a3)
}
}
func TestOutboundTCPFilterMixedFamily(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("10.0.0.1:1234")
dst := netip.MustParseAddrPort("[2001:db8::1]:443")
if _, err := OutboundTCP(src, dst); err == nil {
t.Fatal("expected error for mixed families")
}
}
func checkArg1(t *testing.T, raw []byte, idx int, arg1 uint32) {
t.Helper()
got := binary.LittleEndian.Uint32(raw[12:16])
if got != arg1 {
t.Errorf("inst %d arg[1]: got %08x, want %08x", idx, got, arg1)
}
}
func checkInst(t *testing.T, raw []byte, idx int, field uint16, test uint8, success, failure uint16, arg0 uint32) {
t.Helper()
word0 := binary.LittleEndian.Uint32(raw[0:4])
word1 := binary.LittleEndian.Uint32(raw[4:8])
a0 := binary.LittleEndian.Uint32(raw[8:12])
gotField := uint16(word0 & 0x7FF)
gotTest := uint8((word0 >> 11) & 0x1F)
gotSuccess := uint16(word0 >> 16)
gotFailure := uint16(word1 & 0xFFFF)
if gotField != field {
t.Errorf("inst %d field: got %d, want %d", idx, gotField, field)
}
if gotTest != test {
t.Errorf("inst %d test: got %d, want %d", idx, gotTest, test)
}
if gotSuccess != success {
t.Errorf("inst %d success: got %d, want %d", idx, gotSuccess, success)
}
if gotFailure != failure {
t.Errorf("inst %d failure: got %d, want %d", idx, gotFailure, failure)
}
if a0 != arg0 {
t.Errorf("inst %d arg[0]: got %08x, want %08x", idx, a0, arg0)
}
}
+320
View File
@@ -0,0 +1,320 @@
//go:build windows
package windivert
import (
"encoding/binary"
"errors"
"runtime"
"sync"
"unsafe"
E "github.com/sagernet/sing/common/exceptions"
"golang.org/x/sys/windows"
)
// Handle owns a WinDivert kernel device handle plus a private event for
// overlapped I/O. Methods on *Handle are not safe for concurrent use
// across goroutines (there is a single shared event per Handle).
//
// addr is a per-Handle Address buffer the IOCTL struct embeds a pointer
// to. It lives on the heap (as a field of a heap-allocated Handle) so
// the pointer value stored as bytes in the ioctl buffer remains valid
// across stack growth between buildIoctl* and the DeviceIoControl
// syscall — stack-local Address values are not safe for this pattern
// because Go's escape analysis does not see the pointer through the
// unsafe.Pointer → uintptr → bytes conversion.
type Handle struct {
device windows.Handle
event windows.Handle
closing sync.Once
closeErr error
addr Address
}
// Filter may be nil for "reject all", suitable for send-only handles.
// Requires Administrator on first call per process (installs the kernel
// driver via SCM); subsequent calls reuse the running driver.
func Open(filter *Filter, layer Layer, priority int16, flags Flag) (*Handle, error) {
err := validateOpenArgs(layer, priority, flags)
if err != nil {
return nil, err
}
if filter == nil {
filter = reject()
}
filterBin, filterFlags, err := filter.encode()
if err != nil {
return nil, err
}
device, err := openDevice()
if err != nil {
if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) &&
!errors.Is(err, windows.ERROR_PATH_NOT_FOUND) {
if errors.Is(err, windows.ERROR_ACCESS_DENIED) {
return nil, E.Cause(err, "windivert: open device (administrator required)")
}
return nil, E.Cause(err, "windivert: open device")
}
// Device node missing: kernel driver not loaded. Install + retry.
// Matches WinDivertOpen's lazy-install path; avoids racing StartService
// against a still-loaded driver whose SCM record is marked for deletion.
err = ensureDriver()
if err != nil {
return nil, err
}
device, err = openDevice()
if err != nil {
if errors.Is(err, windows.ERROR_ACCESS_DENIED) {
return nil, E.Cause(err, "windivert: open device (administrator required)")
}
return nil, E.Cause(err, "windivert: open device")
}
}
event, err := windows.CreateEvent(nil, 1, 0, nil) // manual reset, unsignaled
if err != nil {
windows.CloseHandle(device)
return nil, E.Cause(err, "windivert: create event")
}
h := &Handle{device: device, event: event}
err = h.initialize(layer, priority, flags)
if err != nil {
h.Close()
return nil, err
}
err = h.startup(filterBin, filterFlags)
if err != nil {
h.Close()
return nil, err
}
return h, nil
}
func openDevice() (windows.Handle, error) {
return windows.CreateFile(
driverDevName,
windows.GENERIC_READ|windows.GENERIC_WRITE,
0, nil,
windows.OPEN_EXISTING,
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OVERLAPPED,
0,
)
}
func validateOpenArgs(layer Layer, priority int16, flags Flag) error {
if layer != LayerNetwork {
return E.New("windivert: invalid layer ", uint32(layer))
}
if priority < PriorityLowest || priority > PriorityHighest {
return E.New("windivert: priority out of range")
}
if flags&^FlagSendOnly != 0 {
return E.New("windivert: unknown flag bits")
}
return nil
}
func (h *Handle) initialize(layer Layer, priority int16, flags Flag) error {
in := buildIoctlInitialize(layer, priority, flags)
// WINDIVERT_VERSION is a 64-byte packed struct; only the first 20
// bytes (magic, major, minor, bits) carry data, the rest is reserved.
var outBuf [versionStructSize]byte
binary.LittleEndian.PutUint64(outBuf[0:8], magicDLL)
binary.LittleEndian.PutUint32(outBuf[8:12], versionMajor)
binary.LittleEndian.PutUint32(outBuf[12:16], versionMinor)
binary.LittleEndian.PutUint32(outBuf[16:20], uint32(unsafe.Sizeof(uintptr(0))*8))
_, err := doIoctl(h.device, ioctlInitialize, in[:], outBuf[:], h.event)
if err != nil {
return E.Cause(err, "windivert: initialize ioctl")
}
gotMagic := binary.LittleEndian.Uint64(outBuf[0:8])
if gotMagic != magicSYS {
return E.New("windivert: driver magic mismatch (got ", gotMagic, ")")
}
gotMajor := binary.LittleEndian.Uint32(outBuf[8:12])
if gotMajor < versionMajor {
gotMinor := binary.LittleEndian.Uint32(outBuf[12:16])
return E.New("windivert: driver version too old: ", gotMajor, ".", gotMinor)
}
return nil
}
func (h *Handle) startup(filterBin []byte, filterFlags uint64) error {
in := buildIoctlStartup(filterFlags)
_, err := doIoctl(h.device, ioctlStartup, in[:], filterBin, h.event)
if err != nil {
return E.Cause(err, "windivert: startup ioctl")
}
return nil
}
// If the handle is closed mid-Recv the error wraps ERROR_OPERATION_ABORTED.
func (h *Handle) Recv(buf []byte) (int, Address, error) {
if len(buf) == 0 {
return 0, Address{}, E.New("windivert: recv: zero-length buffer")
}
h.addr = Address{}
in := buildIoctlRecv(&h.addr)
n, err := doIoctl(h.device, ioctlRecv, in[:], buf, h.event)
runtime.KeepAlive(h)
if err != nil {
return 0, Address{}, err
}
return int(n), h.addr, nil
}
// The address's Outbound flag controls whether the packet is sent toward
// the wire (outbound=true) or delivered up the stack (outbound=false).
// IfIdx and SubIfIdx can stay zero — the driver uses the routing table
// when IfIdx=0.
func (h *Handle) Send(packet []byte, addr *Address) (int, error) {
if len(packet) == 0 {
return 0, E.New("windivert: send: empty packet")
}
if addr == nil {
return 0, E.New("windivert: send: nil address")
}
h.addr = *addr
in := buildIoctlSend(&h.addr)
n, err := doIoctl(h.device, ioctlSend, in[:], packet, h.event)
runtime.KeepAlive(h)
if err != nil {
return 0, err
}
return int(n), nil
}
// Idempotent. Aborts any in-flight I/O on the handle.
func (h *Handle) Close() error {
h.closing.Do(func() {
var errs []error
if h.device != 0 {
err := windows.CloseHandle(h.device)
if err != nil {
errs = append(errs, err)
}
h.device = 0
}
if h.event != 0 {
err := windows.CloseHandle(h.event)
if err != nil {
errs = append(errs, err)
}
h.event = 0
}
h.closeErr = E.Errors(errs...)
})
return h.closeErr
}
// IOCTL codes from windivert_device.h. CTL_CODE macro layout:
//
// (DeviceType << 16) | (Access << 14) | (Function << 2) | Method
const (
fileDeviceNetwork uint32 = 0x12
accessReadWrite uint32 = 3 // FILE_READ_DATA | FILE_WRITE_DATA
accessRead uint32 = 1
methodInDirect uint32 = 1
methodOutDirect uint32 = 2
)
func ctlCode(deviceType, access, function, method uint32) uint32 {
return (deviceType << 16) | (access << 14) | (function << 2) | method
}
var (
ioctlInitialize = ctlCode(fileDeviceNetwork, accessReadWrite, 0x921, methodOutDirect)
ioctlStartup = ctlCode(fileDeviceNetwork, accessReadWrite, 0x922, methodInDirect)
ioctlRecv = ctlCode(fileDeviceNetwork, accessRead, 0x923, methodOutDirect)
ioctlSend = ctlCode(fileDeviceNetwork, accessReadWrite, 0x924, methodInDirect)
)
// Magic numbers exchanged during INITIALIZE. DLL sends magicDLL in the
// version struct; driver returns magicSYS on success.
const (
magicDLL uint64 = 0x4C4C447669645724 // "$WdivDLL" in LE bytes
magicSYS uint64 = 0x5359537669645723 // "#WdivSYS" in LE bytes
)
const (
versionMajor uint32 = 2
versionMinor uint32 = 2
)
// Size of the WINDIVERT_IOCTL union on wire (packed).
const ioctlSize = 16
// Size of WINDIVERT_VERSION on wire (packed). Only the first 20 bytes
// carry data; the rest is reserved zero padding.
const versionStructSize = 64
// doIoctl performs a single synchronous (blocking) overlapped
// DeviceIoControl. The handle is opened with FILE_FLAG_OVERLAPPED so
// DeviceIoControl returns ERROR_IO_PENDING; we then wait for completion
// via GetOverlappedResult. Event is passed in so callers can reuse it
// across calls on the same handle (avoids per-call CreateEvent).
func doIoctl(handle windows.Handle, code uint32, in []byte, out []byte, event windows.Handle) (uint32, error) {
var overlapped windows.Overlapped
overlapped.HEvent = event
_ = windows.ResetEvent(event)
var inPtr *byte
var inLen uint32
if len(in) > 0 {
inPtr = &in[0]
inLen = uint32(len(in))
}
var outPtr *byte
var outLen uint32
if len(out) > 0 {
outPtr = &out[0]
outLen = uint32(len(out))
}
var returned uint32
err := windows.DeviceIoControl(handle, code, inPtr, inLen, outPtr, outLen, &returned, &overlapped)
if err != nil && !errors.Is(err, windows.ERROR_IO_PENDING) {
return 0, err
}
err = windows.GetOverlappedResult(handle, &overlapped, &returned, true)
if err != nil {
return 0, err
}
return returned, nil
}
func buildIoctlInitialize(layer Layer, priority int16, flags Flag) [ioctlSize]byte {
var buf [ioctlSize]byte
binary.LittleEndian.PutUint32(buf[0:4], uint32(layer))
// The driver expects priority + WINDIVERT_PRIORITY_HIGHEST (30000) so
// the low range maps to non-negative integers.
binary.LittleEndian.PutUint32(buf[4:8], uint32(int32(priority)+int32(PriorityHighest)))
binary.LittleEndian.PutUint64(buf[8:16], uint64(flags))
return buf
}
func buildIoctlStartup(filterFlags uint64) [ioctlSize]byte {
var buf [ioctlSize]byte
binary.LittleEndian.PutUint64(buf[0:8], filterFlags)
return buf
}
// buildIoctlRecv packs a user-space pointer to a WINDIVERT_ADDRESS into
// the ioctl struct. The driver dereferences it to write the address for
// the received packet. Caller must keep the Address alive via
// runtime.KeepAlive.
func buildIoctlRecv(addr *Address) [ioctlSize]byte {
var buf [ioctlSize]byte
binary.LittleEndian.PutUint64(buf[0:8], uint64(uintptr(unsafe.Pointer(addr))))
binary.LittleEndian.PutUint64(buf[8:16], 0)
return buf
}
func buildIoctlSend(addr *Address) [ioctlSize]byte {
var buf [ioctlSize]byte
binary.LittleEndian.PutUint64(buf[0:8], uint64(uintptr(unsafe.Pointer(addr))))
binary.LittleEndian.PutUint64(buf[8:16], uint64(unsafe.Sizeof(Address{})))
return buf
}
+106
View File
@@ -0,0 +1,106 @@
//go:build windows
package windivert
import (
"encoding/binary"
"testing"
"unsafe"
"github.com/stretchr/testify/require"
)
// CTL_CODE macro from Windows DDK:
//
// (DeviceType<<16) | (Access<<14) | (Function<<2) | Method
func TestCtlCodeMatchesDDK(t *testing.T) {
t.Parallel()
// FILE_DEVICE_NETWORK=0x12, FILE_READ_DATA|FILE_WRITE_DATA=3, METHOD_OUT_DIRECT=2
require.Equal(t, uint32(0x12E486), ctlCode(0x12, 3, 0x921, 2))
// FILE_READ_DATA=1, METHOD_OUT_DIRECT=2
require.Equal(t, uint32(0x12648E), ctlCode(0x12, 1, 0x923, 2))
}
// Baked-in against windivert_device.h @ v2.2.2. A mismatch here means the
// kernel will reject every ioctl with ERROR_INVALID_FUNCTION.
func TestIoctlCodesMatchUpstream(t *testing.T) {
t.Parallel()
require.Equal(t, uint32(0x12E486), ioctlInitialize)
require.Equal(t, uint32(0x12E489), ioctlStartup)
require.Equal(t, uint32(0x12648E), ioctlRecv)
require.Equal(t, uint32(0x12E491), ioctlSend)
}
func TestBuildIoctlInitialize(t *testing.T) {
t.Parallel()
buf := buildIoctlInitialize(LayerNetwork, 100, FlagSendOnly)
require.Equal(t, uint32(LayerNetwork), binary.LittleEndian.Uint32(buf[0:4]))
// Driver expects priority+PriorityHighest(30000) so the range is non-negative.
require.Equal(t, uint32(30100), binary.LittleEndian.Uint32(buf[4:8]))
require.Equal(t, uint64(FlagSendOnly), binary.LittleEndian.Uint64(buf[8:16]))
}
func TestBuildIoctlInitializePriorityRange(t *testing.T) {
t.Parallel()
lowest := buildIoctlInitialize(LayerNetwork, PriorityLowest, 0)
require.Equal(t, uint32(0), binary.LittleEndian.Uint32(lowest[4:8]))
highest := buildIoctlInitialize(LayerNetwork, PriorityHighest, 0)
require.Equal(t, uint32(60000), binary.LittleEndian.Uint32(highest[4:8]))
zero := buildIoctlInitialize(LayerNetwork, 0, 0)
require.Equal(t, uint32(30000), binary.LittleEndian.Uint32(zero[4:8]))
}
func TestBuildIoctlStartup(t *testing.T) {
t.Parallel()
flags := filterFlagOutbound | filterFlagIP
buf := buildIoctlStartup(flags)
require.Equal(t, flags, binary.LittleEndian.Uint64(buf[0:8]))
// The second quad-word is unused for STARTUP.
require.Equal(t, uint64(0), binary.LittleEndian.Uint64(buf[8:16]))
}
func TestBuildIoctlRecvEmbedsAddressPointer(t *testing.T) {
t.Parallel()
addr := &Address{Timestamp: 0xCAFEBABE}
buf := buildIoctlRecv(addr)
require.Equal(t, uint64(uintptr(unsafe.Pointer(addr))),
binary.LittleEndian.Uint64(buf[0:8]))
// RECV does not carry an address length; driver writes full Address back.
require.Equal(t, uint64(0), binary.LittleEndian.Uint64(buf[8:16]))
}
func TestBuildIoctlSendEmbedsAddressPointerAndSize(t *testing.T) {
t.Parallel()
addr := &Address{}
buf := buildIoctlSend(addr)
require.Equal(t, uint64(uintptr(unsafe.Pointer(addr))),
binary.LittleEndian.Uint64(buf[0:8]))
require.Equal(t, uint64(unsafe.Sizeof(Address{})),
binary.LittleEndian.Uint64(buf[8:16]))
require.Equal(t, uint64(80), binary.LittleEndian.Uint64(buf[8:16]))
}
func TestValidateOpenArgsLayer(t *testing.T) {
t.Parallel()
require.NoError(t, validateOpenArgs(LayerNetwork, 0, 0))
require.Error(t, validateOpenArgs(Layer(1), 0, 0))
require.Error(t, validateOpenArgs(Layer(42), 0, 0))
}
func TestValidateOpenArgsPriorityBounds(t *testing.T) {
t.Parallel()
require.NoError(t, validateOpenArgs(LayerNetwork, PriorityHighest, 0))
require.NoError(t, validateOpenArgs(LayerNetwork, PriorityLowest, 0))
require.NoError(t, validateOpenArgs(LayerNetwork, 0, 0))
require.Error(t, validateOpenArgs(LayerNetwork, PriorityHighest+1, 0))
require.Error(t, validateOpenArgs(LayerNetwork, PriorityLowest-1, 0))
}
func TestValidateOpenArgsFlags(t *testing.T) {
t.Parallel()
require.NoError(t, validateOpenArgs(LayerNetwork, 0, 0))
require.NoError(t, validateOpenArgs(LayerNetwork, 0, FlagSendOnly))
// Unknown flag bits must be rejected to surface caller mistakes early.
require.Error(t, validateOpenArgs(LayerNetwork, 0, Flag(0x10)))
require.Error(t, validateOpenArgs(LayerNetwork, 0, FlagSendOnly|Flag(0x10)))
}
@@ -0,0 +1,88 @@
//go:build windows
package windivert
import (
"errors"
"net/netip"
"testing"
"time"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
func openHandle(t *testing.T, filter *Filter, flags Flag) *Handle {
t.Helper()
h, err := Open(filter, LayerNetwork, 0, flags)
require.NoError(t, err)
return h
}
// A send-only handle installs+opens the driver but does not attach a
// receive filter, so it exercises the full driver-install path without
// diverting any live traffic on the host.
func TestIntegrationOpenSendOnly(t *testing.T) {
h := openHandle(t, nil, FlagSendOnly)
require.NoError(t, h.Close())
}
// Close is idempotent per the doc contract.
func TestIntegrationCloseTwice(t *testing.T) {
h := openHandle(t, nil, FlagSendOnly)
require.NoError(t, h.Close())
require.NoError(t, h.Close())
}
// Recv must unblock when the handle is closed concurrently. Without this,
// the spoofer's run goroutine could deadlock on shutdown.
func TestIntegrationRecvAbortsOnClose(t *testing.T) {
// A filter no live traffic will match, so Recv blocks indefinitely
// until Close aborts the overlapped I/O.
filter, err := OutboundTCP(
netip.MustParseAddrPort("10.255.255.254:1"),
netip.MustParseAddrPort("10.255.255.253:2"),
)
require.NoError(t, err)
h := openHandle(t, filter, 0)
errCh := make(chan error, 1)
go func() {
buf := make([]byte, MTUMax)
_, _, recvErr := h.Recv(buf)
errCh <- recvErr
}()
// Let Recv reach the blocking DeviceIoControl before Close races in.
time.Sleep(200 * time.Millisecond)
require.NoError(t, h.Close())
select {
case err := <-errCh:
require.Error(t, err)
require.True(t, errors.Is(err, windows.ERROR_OPERATION_ABORTED),
"Recv should return ERROR_OPERATION_ABORTED, got %v", err)
case <-time.After(3 * time.Second):
t.Fatal("Recv did not unblock within 3s after Close")
}
}
// Two concurrent Open calls must both succeed: the first wins the driver
// install race, the second reuses the already-running service.
func TestIntegrationConcurrentOpen(t *testing.T) {
errCh := make(chan error, 2)
handles := make(chan *Handle, 2)
for i := 0; i < 2; i++ {
go func() {
h, err := Open(nil, LayerNetwork, 0, FlagSendOnly)
handles <- h
errCh <- err
}()
}
for i := 0; i < 2; i++ {
err := <-errCh
h := <-handles
require.NoError(t, err)
require.NoError(t, h.Close())
}
}
+71
View File
@@ -0,0 +1,71 @@
// Package windivert provides a pure-Go binding to the WinDivert kernel
// driver on Windows (amd64 and 386). User-mode WinDivert calls are
// reimplemented in Go; only the signed kernel driver is embedded as an
// asset, since SCM-installed drivers must live on disk and their
// Authenticode signature forbids modification.
//
// Administrator is required for the first Open in a process so SCM can
// load the driver. Upstream: https://github.com/basil00/WinDivert v2.2.2,
// redistributed under its LGPL v3 option; see assets/LICENSE.txt.
package windivert
import "unsafe"
const AssetVersion = "2.2.2"
// MTUMax is WINDIVERT_MTU_MAX from windivert.h (40 + 0xFFFF). Suitable as
// a single-packet receive buffer size.
const MTUMax = 40 + 0xFFFF
type Layer uint32
const LayerNetwork Layer = 0
type Flag uint64
const FlagSendOnly Flag = 0x0008
const (
PriorityHighest int16 = 30000
PriorityLowest int16 = -30000
)
// Address mirrors WINDIVERT_ADDRESS from windivert.h (80 bytes,
// little-endian on both amd64 and 386):
//
// 0: INT64 Timestamp
// 8: UINT32 bitfield: Layer:8 | Event:8 | flags | Reserved1:8
// 12: UINT32 Reserved2
// 16: 64 bytes union (WINDIVERT_DATA_NETWORK / FLOW / SOCKET / REFLECT)
type Address struct {
Timestamp int64
bits uint32
Reserved2 uint32
union [64]byte
}
var _ [80]byte = [unsafe.Sizeof(Address{})]byte{}
// Bit positions inside the Address's packed flags word.
const (
addrBitIPv6 = 20
addrBitIPChecksum = 21
addrBitTCPChecksum = 22
)
func getFlagBit(bits uint32, pos uint) bool { return bits&(1<<pos) != 0 }
func setFlagBit(bits uint32, pos uint, v bool) uint32 {
if v {
return bits | (1 << pos)
}
return bits &^ (1 << pos)
}
func (a *Address) IPv6() bool { return getFlagBit(a.bits, addrBitIPv6) }
func (a *Address) SetIPChecksum(v bool) {
a.bits = setFlagBit(a.bits, addrBitIPChecksum, v)
}
func (a *Address) SetTCPChecksum(v bool) {
a.bits = setFlagBit(a.bits, addrBitTCPChecksum, v)
}