Add TLS spoof support

This commit is contained in:
世界 2026-04-15 17:59:18 +08:00
parent ed294d8c1e
commit a2872c3f32
No known key found for this signature in database
GPG Key ID: CD109927C34A63C4
44 changed files with 4226 additions and 5 deletions

55
.github/workflows/test.yml vendored Normal file
View File

@ -0,0 +1,55 @@
name: Test
on:
push:
branches:
- stable
- testing
- unstable
paths-ignore:
- '**.md'
- '.github/**'
- '!.github/workflows/test.yml'
pull_request:
branches:
- stable
- testing
- unstable
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}-${{ inputs.build }}
cancel-in-progress: true
jobs:
test:
name: Test
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- windows-latest
- macos-latest
go:
- ~1.24
- ~1.25
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Set build tags and ldflags
shell: bash
run: |
echo "BUILD_TAGS=$(cat release/DEFAULT_BUILD_TAGS_OTHERS)" >> "$GITHUB_ENV"
echo "LDFLAGS_SHARED=$(cat release/LDFLAGS)" >> "$GITHUB_ENV"
- name: Test (unix)
if: matrix.os != 'windows-latest'
run: go test -v -exec sudo -tags "$BUILD_TAGS" -ldflags "$LDFLAGS_SHARED" ./...
- name: Test (windows)
if: matrix.os == 'windows-latest'
shell: bash
run: go test -v -tags "$BUILD_TAGS" -ldflags "$LDFLAGS_SHARED" ./...

View File

@ -18,7 +18,6 @@ linters:
default: none
enable:
- ineffassign
- paralleltest
- staticcheck
- unused
- modernize

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")
}

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

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 {

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 {

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 {

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
}

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
}

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")
}

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)
}
}

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
}

View File

@ -0,0 +1,5 @@
//go:build darwin
package tlsspoof
const loopbackInterface = "lo0"

View File

@ -0,0 +1,5 @@
//go:build linux
package tlsspoof
const loopbackInterface = "lo"

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)
}

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")
}
}

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
common/tlsspoof/packet.go Normal file
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)
}

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)
})
}

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
}

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
}

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")
}

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
}

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
}

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
common/tlsspoof/spoof.go Normal file
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
}

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.

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" }

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" }

View File

@ -0,0 +1,7 @@
//go:build windows && !amd64 && !386
package windivert
func assetFiles() []assetFile { return nil }
func driverSysName() string { return "" }

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
common/windivert/filter.go Normal file
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
}

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)
}
}

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
}

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)))
}

View File

@ -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())
}
}

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)
}

View File

@ -138,7 +138,10 @@ icon: material/new-box
"fallback_delay": "",
"udp_disable_domain_unmapping": false,
"udp_connect": false,
"udp_timeout": ""
"udp_timeout": "",
"tls_fragment": false,
"tls_fragment_fallback_delay": "",
"tls_record_fragment": false
}
```

View File

@ -6,6 +6,8 @@ icon: material/new-box
:material-plus: [certificate_provider](#certificate_provider)
:material-plus: [handshake_timeout](#handshake_timeout)
:material-plus: [spoof](#spoof)
:material-plus: [spoof_method](#spoof_method)
:material-delete-clock: [acme](#acme-fields)
!!! quote "Changes in sing-box 1.13.0"
@ -127,6 +129,8 @@ icon: material/new-box
"fragment": false,
"fragment_fallback_delay": "",
"record_fragment": false,
"spoof": "",
"spoof_method": "",
"kernel_tx": false,
"kernel_rx": false,
"handshake_timeout": "",
@ -642,6 +646,41 @@ The fallback value used when TLS segmentation cannot automatically determine the
Fragment TLS handshake into multiple TLS records to bypass firewalls.
#### spoof
!!! question "Since sing-box 1.14.0"
==Client only, Linux/macOS/Windows only, requires elevated privileges==
Inject a forged TLS ClientHello carrying a whitelisted SNI before the real one,
to fool SNI-filtering middleboxes that permit specific hostnames.
The forged segment is a copy of the real ClientHello with only the SNI value
replaced by the value of this field, so TLS fingerprinting cannot distinguish
it from the real one. The receiving server drops the forged segment
(see `spoof_method`) while the middlebox treats it as a legitimate session.
Requires raw-socket access (`CAP_NET_RAW` on Linux, root on macOS);
on Linux, `CAP_NET_ADMIN` is additionally required because the send sequence
number is read via `TCP_REPAIR`.
On Windows, Administrator is required to install the embedded WinDivert kernel
driver on first use. Windows on ARM64 is not supported.
#### spoof_method
!!! question "Since sing-box 1.14.0"
==Client only==
How the forged segment is rejected by the real server.
| Value | Behavior |
|----------------------------|----------------------------------------------------------------------------------------|
| `wrong-sequence` (default) | The forged segment's TCP sequence number is placed before the server's receive window. |
| `wrong-checksum` | The forged segment's TCP checksum is deliberately invalid. |
Conflict with `spoof` unset.
### ACME Fields
!!! failure "Deprecated in sing-box 1.14.0"

View File

@ -6,6 +6,8 @@ icon: material/new-box
:material-plus: [certificate_provider](#certificate_provider)
:material-plus: [handshake_timeout](#handshake_timeout)
:material-plus: [spoof](#spoof)
:material-plus: [spoof_method](#spoof_method)
:material-delete-clock: [acme](#acme-字段)
!!! quote "sing-box 1.13.0 中的更改"
@ -127,6 +129,8 @@ icon: material/new-box
"fragment": false,
"fragment_fallback_delay": "",
"record_fragment": false,
"spoof": "",
"spoof_method": "",
"kernel_tx": false,
"kernel_rx": false,
"handshake_timeout": "",
@ -636,6 +640,39 @@ ECH 配置路径PEM 格式。
将 TLS 握手分段为多个 TLS 记录以绕过防火墙。
#### spoof
!!! question "自 sing-box 1.14.0 起"
==仅客户端,仅 Linux/macOS/Windows需要提权==
在真实 ClientHello 之前注入一个伪造的、携带白名单 SNI 的 TLS ClientHello
以欺骗基于 SNI 过滤的中间盒放行连接。
伪造报文是真实 ClientHello 的副本,仅将 SNI 值替换为本字段的值,
因此 TLS 指纹无法区分伪造与真实报文。真实服务器会丢弃伪造报文(见 `spoof_method`
而中间盒将该连接视为合法会话。
需要原始套接字权限Linux 上需 `CAP_NET_RAW`macOS 上需 root
在 Linux 上还需 `CAP_NET_ADMIN`,因为需要通过 `TCP_REPAIR` 读取发送序列号。
Windows 上首次使用时需要 Administrator 以安装内嵌的 WinDivert 内核驱动,
不支持 Windows ARM64。
#### spoof_method
!!! question "自 sing-box 1.14.0 起"
==仅客户端==
控制伪造报文被真实服务器拒绝的方式。
| 取值 | 行为 |
|----------------------------|------------------------------------------------|
| `wrong-sequence`(默认) | 伪造报文的 TCP 序列号位于服务器接收窗口之前。 |
| `wrong-checksum` | 伪造报文的 TCP 校验和被故意设为无效。 |
`spoof` 未设置冲突。
### ACME 字段
!!! failure "已在 sing-box 1.14.0 废弃"

View File

@ -120,6 +120,8 @@ type OutboundTLSOptions struct {
Fragment bool `json:"fragment,omitempty"`
FragmentFallbackDelay badoption.Duration `json:"fragment_fallback_delay,omitempty"`
RecordFragment bool `json:"record_fragment,omitempty"`
Spoof string `json:"spoof,omitempty"`
SpoofMethod string `json:"spoof_method,omitempty"`
KernelTx bool `json:"kernel_tx,omitempty"`
KernelRx bool `json:"kernel_rx,omitempty"`
HandshakeTimeout badoption.Duration `json:"handshake_timeout,omitempty"`