mirror of
https://github.com/SagerNet/sing-box.git
synced 2026-07-19 21:08:40 +08:00
Add TLS spoof support
This commit is contained in:
parent
ed294d8c1e
commit
a2872c3f32
55
.github/workflows/test.yml
vendored
Normal file
55
.github/workflows/test.yml
vendored
Normal 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" ./...
|
||||
@ -18,7 +18,6 @@ linters:
|
||||
default: none
|
||||
enable:
|
||||
- ineffassign
|
||||
- paralleltest
|
||||
- staticcheck
|
||||
- unused
|
||||
- modernize
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -23,9 +23,10 @@ const (
|
||||
)
|
||||
|
||||
type MyServerName struct {
|
||||
Index int
|
||||
Length int
|
||||
ServerName string
|
||||
Index int
|
||||
Length int
|
||||
ServerName string
|
||||
ExtensionsListLengthIndex int
|
||||
}
|
||||
|
||||
func IndexTLSServerName(payload []byte) *MyServerName {
|
||||
@ -41,6 +42,7 @@ func IndexTLSServerName(payload []byte) *MyServerName {
|
||||
return nil
|
||||
}
|
||||
serverName.Index += recordLayerHeaderLen
|
||||
serverName.ExtensionsListLengthIndex += recordLayerHeaderLen
|
||||
return serverName
|
||||
}
|
||||
|
||||
@ -82,6 +84,7 @@ func indexTLSServerNameFromHandshake(handshake []byte) *MyServerName {
|
||||
return nil
|
||||
}
|
||||
serverName.Index += currentIndex
|
||||
serverName.ExtensionsListLengthIndex = currentIndex
|
||||
return serverName
|
||||
}
|
||||
|
||||
|
||||
86
common/tlsspoof/client_hello.go
Normal file
86
common/tlsspoof/client_hello.go
Normal file
@ -0,0 +1,86 @@
|
||||
package tlsspoof
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
|
||||
tf "github.com/sagernet/sing-box/common/tlsfragment"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
)
|
||||
|
||||
const (
|
||||
recordLengthOffset = 3
|
||||
handshakeLengthOffset = 6
|
||||
)
|
||||
|
||||
// server_name extension layout (RFC 6066 §3). Offsets are relative to the
|
||||
// SNI host name (index returned by the parser):
|
||||
//
|
||||
// ... uint16 extension_type = 0x0000 (host_name - 9)
|
||||
// ... uint16 extension_data_length (host_name - 7)
|
||||
// ... uint16 server_name_list_length (host_name - 5)
|
||||
// ... uint8 name_type = host_name (host_name - 3)
|
||||
// ... uint16 host_name_length (host_name - 2)
|
||||
// sni host_name (host_name)
|
||||
const (
|
||||
extensionDataLengthOffsetFromSNI = -7
|
||||
listLengthOffsetFromSNI = -5
|
||||
hostNameLengthOffsetFromSNI = -2
|
||||
)
|
||||
|
||||
func rewriteSNI(record []byte, fakeSNI string) ([]byte, error) {
|
||||
if len(fakeSNI) > 0xFFFF {
|
||||
return nil, E.New("fake SNI too long: ", len(fakeSNI), " bytes")
|
||||
}
|
||||
serverName := tf.IndexTLSServerName(record)
|
||||
if serverName == nil {
|
||||
return nil, E.New("not a ClientHello with SNI")
|
||||
}
|
||||
|
||||
delta := len(fakeSNI) - serverName.Length
|
||||
out := make([]byte, len(record)+delta)
|
||||
copy(out, record[:serverName.Index])
|
||||
copy(out[serverName.Index:], fakeSNI)
|
||||
copy(out[serverName.Index+len(fakeSNI):], record[serverName.Index+serverName.Length:])
|
||||
|
||||
err := patchUint16(out, recordLengthOffset, delta)
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "patch record length")
|
||||
}
|
||||
err = patchUint24(out, handshakeLengthOffset, delta)
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "patch handshake length")
|
||||
}
|
||||
for _, off := range []int{
|
||||
serverName.ExtensionsListLengthIndex,
|
||||
serverName.Index + extensionDataLengthOffsetFromSNI,
|
||||
serverName.Index + listLengthOffsetFromSNI,
|
||||
serverName.Index + hostNameLengthOffsetFromSNI,
|
||||
} {
|
||||
err = patchUint16(out, off, delta)
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "patch length at offset ", off)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func patchUint16(data []byte, offset, delta int) error {
|
||||
patched := int(binary.BigEndian.Uint16(data[offset:])) + delta
|
||||
if patched < 0 || patched > 0xFFFF {
|
||||
return E.New("uint16 out of range: ", patched)
|
||||
}
|
||||
binary.BigEndian.PutUint16(data[offset:], uint16(patched))
|
||||
return nil
|
||||
}
|
||||
|
||||
func patchUint24(data []byte, offset, delta int) error {
|
||||
original := int(data[offset])<<16 | int(data[offset+1])<<8 | int(data[offset+2])
|
||||
patched := original + delta
|
||||
if patched < 0 || patched > 0xFFFFFF {
|
||||
return E.New("uint24 out of range: ", patched)
|
||||
}
|
||||
data[offset] = byte(patched >> 16)
|
||||
data[offset+1] = byte(patched >> 8)
|
||||
data[offset+2] = byte(patched)
|
||||
return nil
|
||||
}
|
||||
79
common/tlsspoof/client_hello_test.go
Normal file
79
common/tlsspoof/client_hello_test.go
Normal file
@ -0,0 +1,79 @@
|
||||
package tlsspoof
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
tf "github.com/sagernet/sing-box/common/tlsfragment"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// realClientHello is a captured Chrome ClientHello for github.com,
|
||||
// reused from common/tlsfragment/index_test.go.
|
||||
const realClientHello = "16030105f8010005f403036e35de7389a679c54029cf452611f2211c70d9ac3897271de589ab6155f8e4ab20637d225f1ef969ad87ed78bfb9d171300bcb1703b6f314ccefb964f79b7d0961002a0a0a130213031301c02cc02bcca9c030c02fcca8c00ac009c014c013009d009c0035002fc008c012000a01000581baba00000000000f000d00000a6769746875622e636f6d00170000ff01000100000a000e000c3a3a11ec001d001700180019000b000201000010000e000c02683208687474702f312e31000500050100000000000d00160014040308040401050308050805050108060601020100120000003304ef04ed3a3a00010011ec04c0aeb2250c092a3463161cccb29d9183331a424964248579507ed23a180b0ceab2a5f5d9ce41547e497a89055471ea572867ba3a1fc3c9e45025274a20f60c6b60e62476b6afed0403af59ab83660ef4112ae20386a602010d0a5d454c0ed34c84ed4423e750213e6a2baab1bf9c4367a6007ab40a33d95220c2dcaa44f257024a5626b545db0510f4311b1a60714154909c6a61fdfca011fb2626d657aeb6070bf078508babe3b584555013e34acc56198ed4663742b3155a664a9901794c4586820a7dc162c01827291f3792e1237f801a8d1ef096013c181c4a58d2f6859ba75022d18cc4418bd4f351d5c18f83a58857d05af860c4b9ac018a5b63f17184e591532c6bc2cf2215d4a282c8a8a4f6f7aee110422c8bc9ebd3b1d609c568523aaae555db320e6c269473d87af38c256cbb9febc20aea6380c32a8916f7a373c8b1e37554e3260bf6621f6b804ee80b3c516b1d01985bf4c603b6daa9a5991de6a7a29f3a7122b8afb843a7660110fce62b43c615f5bcc2db688ba012649c0952b0a2c031e732d2b454c6b2968683cb8d244be2c9a7fa163222979eaf92722b92b862d81a3d94450c2b60c318421ebb4307c42d1f0473592a5c30e42039cc68cda9721e61aa63f49def17c15221680ed444896340133bbee67556f56b9f9d78a4df715f926a12add0cc9c862e46ea8b7316ae468282c18601b2771c9c9322f982228cf93effaacd3f80cbd12bce5fc36f56e2a3caf91e578a5fae00c9b23a8ed1a66764f4433c3628a70b8f0a6196adc60a4cb4226f07ba4c6b363fe9065563bfc1347452946386bab488686e837ab979c64f9047417fca635fe1bb4f074f256cc8af837c7b455e280426547755af90a61640169ef180aea3a77e662bb6dac1b6c3696027129b1a5edf495314e9c7f4b6110e16378ec893fa24642330a40aba1a85326101acb97c620fd8d71389e69eaed7bdb01bbe1fd428d66191150c7b2cd1ad4257391676a82ba8ce07fb2667c3b289f159003a7c7bc31d361b7b7f49a802961739d950dfcc0fa1c7abce5abdd2245101da391151490862028110465950b9e9c03d08a90998ab83267838d2e74a0593bc81f74cdf734519a05b351c0e5488c68dd810e6e9142ccc1e2f4a7f464297eb340e27acc6b9d64e12e38cce8492b3d939140b5a9e149a75597f10a23874c84323a07cdd657274378f887c85c4259b9c04cd33ba58ed630ef2a744f8e19dd34843dff331d2a6be7e2332c599289cd248a611c73d7481cd4a9bd43449a3836f14b2af18a1739e17999e4c67e85cc5bcecabb14185e5bcaff3c96098f03dc5aba819f29587758f49f940585354a2a780830528d68ccd166920dadcaa25cab5fc1907272a826aba3f08bc6b88757776812ecb6c7cec69a223ec0a13a7b62a2349a0f63ed7a27a3b15ba21d71fe6864ec6e089ae17cadd433fa3138f7ee24353c11365818f8fc34f43a05542d18efaac24bfccc1f748a0cc1a67ad379468b76fd34973dba785f5c91d618333cd810fe0700d1bbc8422029782628070a624c52c5309a4a64d625b11f8033ab28df34a1add297517fcc06b92b6817b3c5144438cf260867c57bde68c8c4b82e6a135ef676a52fbae5708002a404e6189a60e2836de565ad1b29e3819e5ed49f6810bcb28e1bd6de57306f94b79d9dae1cc4624d2a068499beef81cd5fe4b76dcbfff2a2008001d002001976128c6d5a934533f28b9914d2480aab2a8c1ab03d212529ce8b27640a716002d00020101002b000706caca03040303001b00030200015a5a000100"
|
||||
|
||||
func decodeClientHello(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
payload, err := hex.DecodeString(realClientHello)
|
||||
require.NoError(t, err)
|
||||
return payload
|
||||
}
|
||||
|
||||
func assertConsistent(t *testing.T, payload []byte, expectedSNI string) {
|
||||
t.Helper()
|
||||
serverName := tf.IndexTLSServerName(payload)
|
||||
require.NotNil(t, serverName, "parser should find SNI in rewritten payload")
|
||||
require.Equal(t, expectedSNI, serverName.ServerName)
|
||||
require.Equal(t, expectedSNI, string(payload[serverName.Index:serverName.Index+serverName.Length]))
|
||||
// Record length must equal len(payload) - 5.
|
||||
recordLen := binary.BigEndian.Uint16(payload[3:5])
|
||||
require.Equal(t, len(payload)-5, int(recordLen), "record length must equal payload - 5")
|
||||
// Handshake length must equal len(payload) - 5 - 4.
|
||||
handshakeLen := int(payload[6])<<16 | int(payload[7])<<8 | int(payload[8])
|
||||
require.Equal(t, len(payload)-5-4, handshakeLen, "handshake length must equal payload - 9")
|
||||
}
|
||||
|
||||
func TestRewriteSNI_ShorterReplacement(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload := decodeClientHello(t)
|
||||
out, err := rewriteSNI(payload, "a.io")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, len(payload)-6) // original "github.com" is 10 bytes, "a.io" is 4 bytes.
|
||||
assertConsistent(t, out, "a.io")
|
||||
}
|
||||
|
||||
func TestRewriteSNI_SameLengthReplacement(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload := decodeClientHello(t)
|
||||
out, err := rewriteSNI(payload, "example.co")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, len(payload))
|
||||
assertConsistent(t, out, "example.co")
|
||||
}
|
||||
|
||||
func TestRewriteSNI_LongerReplacement(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload := decodeClientHello(t)
|
||||
out, err := rewriteSNI(payload, "letsencrypt.org")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, len(payload)+5) // "letsencrypt.org" is 15, original 10, delta 5.
|
||||
assertConsistent(t, out, "letsencrypt.org")
|
||||
}
|
||||
|
||||
func TestRewriteSNI_NoSNIReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Truncated payload — not a valid ClientHello.
|
||||
_, err := rewriteSNI([]byte{0x16, 0x03, 0x01, 0x00, 0x01, 0x01}, "x.com")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRewriteSNI_DoesNotMutateInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload := decodeClientHello(t)
|
||||
original := append([]byte(nil), payload...)
|
||||
_, err := rewriteSNI(payload, "letsencrypt.org")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, original, payload, "input payload must not be mutated")
|
||||
}
|
||||
126
common/tlsspoof/conn_test.go
Normal file
126
common/tlsspoof/conn_test.go
Normal file
@ -0,0 +1,126 @@
|
||||
package tlsspoof
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
tf "github.com/sagernet/sing-box/common/tlsfragment"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeSpoofer struct {
|
||||
injected [][]byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeSpoofer) Inject(payload []byte) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
f.injected = append(f.injected, append([]byte(nil), payload...))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSpoofer) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func readAll(t *testing.T, conn net.Conn) []byte {
|
||||
t.Helper()
|
||||
data, err := io.ReadAll(conn)
|
||||
require.NoError(t, err)
|
||||
return data
|
||||
}
|
||||
|
||||
func TestConn_Write_InjectsThenForwards(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload, err := hex.DecodeString(realClientHello)
|
||||
require.NoError(t, err)
|
||||
|
||||
client, server := net.Pipe()
|
||||
spoofer := &fakeSpoofer{}
|
||||
wrapped := NewConn(client, spoofer, "letsencrypt.org")
|
||||
|
||||
serverRead := make(chan []byte, 1)
|
||||
go func() {
|
||||
serverRead <- readAll(t, server)
|
||||
}()
|
||||
|
||||
n, err := wrapped.Write(payload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, len(payload), n)
|
||||
require.NoError(t, wrapped.Close())
|
||||
|
||||
forwarded := <-serverRead
|
||||
require.Equal(t, payload, forwarded, "underlying conn must receive the real ClientHello unchanged")
|
||||
require.Len(t, spoofer.injected, 1)
|
||||
|
||||
injected := spoofer.injected[0]
|
||||
serverName := tf.IndexTLSServerName(injected)
|
||||
require.NotNil(t, serverName, "injected payload must parse as ClientHello")
|
||||
require.Equal(t, "letsencrypt.org", serverName.ServerName)
|
||||
}
|
||||
|
||||
func TestConn_Write_SecondWriteDoesNotInject(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload, err := hex.DecodeString(realClientHello)
|
||||
require.NoError(t, err)
|
||||
|
||||
client, server := net.Pipe()
|
||||
spoofer := &fakeSpoofer{}
|
||||
wrapped := NewConn(client, spoofer, "letsencrypt.org")
|
||||
|
||||
serverRead := make(chan []byte, 1)
|
||||
go func() {
|
||||
serverRead <- readAll(t, server)
|
||||
}()
|
||||
|
||||
_, err = wrapped.Write(payload)
|
||||
require.NoError(t, err)
|
||||
_, err = wrapped.Write([]byte("second"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, wrapped.Close())
|
||||
|
||||
forwarded := <-serverRead
|
||||
require.Equal(t, append(append([]byte(nil), payload...), []byte("second")...), forwarded)
|
||||
require.Len(t, spoofer.injected, 1)
|
||||
}
|
||||
|
||||
func TestConn_Write_NonClientHelloReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
spoofer := &fakeSpoofer{}
|
||||
wrapped := NewConn(client, spoofer, "letsencrypt.org")
|
||||
|
||||
_, err := wrapped.Write([]byte("not a ClientHello"))
|
||||
require.Error(t, err)
|
||||
require.Empty(t, spoofer.injected)
|
||||
}
|
||||
|
||||
func TestParseMethod(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]struct {
|
||||
want Method
|
||||
ok bool
|
||||
}{
|
||||
"": {MethodWrongSequence, true},
|
||||
"wrong-sequence": {MethodWrongSequence, true},
|
||||
"wrong-checksum": {MethodWrongChecksum, true},
|
||||
"nonsense": {0, false},
|
||||
}
|
||||
for input, expected := range cases {
|
||||
m, err := ParseMethod(input)
|
||||
if !expected.ok {
|
||||
require.Error(t, err, "input=%q", input)
|
||||
continue
|
||||
}
|
||||
require.NoError(t, err, "input=%q", input)
|
||||
require.Equal(t, expected.want, m, "input=%q", input)
|
||||
}
|
||||
}
|
||||
29
common/tlsspoof/endpoints.go
Normal file
29
common/tlsspoof/endpoints.go
Normal 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
|
||||
}
|
||||
5
common/tlsspoof/integration_darwin_test.go
Normal file
5
common/tlsspoof/integration_darwin_test.go
Normal file
@ -0,0 +1,5 @@
|
||||
//go:build darwin
|
||||
|
||||
package tlsspoof
|
||||
|
||||
const loopbackInterface = "lo0"
|
||||
5
common/tlsspoof/integration_linux_test.go
Normal file
5
common/tlsspoof/integration_linux_test.go
Normal file
@ -0,0 +1,5 @@
|
||||
//go:build linux
|
||||
|
||||
package tlsspoof
|
||||
|
||||
const loopbackInterface = "lo"
|
||||
112
common/tlsspoof/integration_test.go
Normal file
112
common/tlsspoof/integration_test.go
Normal file
@ -0,0 +1,112 @@
|
||||
//go:build linux || darwin
|
||||
|
||||
package tlsspoof
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func requireRoot(t *testing.T) {
|
||||
t.Helper()
|
||||
if os.Geteuid() != 0 {
|
||||
t.Fatal("integration test requires root")
|
||||
}
|
||||
}
|
||||
|
||||
func tcpdumpObserver(t *testing.T, iface string, port uint16, needle string, do func(), wait time.Duration) bool {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), wait)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "tcpdump", "-i", iface, "-n", "-A", "-l",
|
||||
"-s", "4096", fmt.Sprintf("tcp and port %d", port))
|
||||
cmd.Cancel = func() error {
|
||||
return cmd.Process.Signal(os.Interrupt)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
require.NoError(t, err)
|
||||
stderr, err := cmd.StderrPipe()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, cmd.Start())
|
||||
t.Cleanup(func() {
|
||||
_ = cmd.Process.Signal(os.Interrupt)
|
||||
_ = cmd.Wait()
|
||||
})
|
||||
|
||||
ready := make(chan struct{})
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(stderr)
|
||||
for scanner.Scan() {
|
||||
if strings.Contains(scanner.Text(), "listening on") {
|
||||
close(ready)
|
||||
io.Copy(io.Discard, stderr)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ready:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("tcpdump did not attach within 2s")
|
||||
}
|
||||
|
||||
var found atomic.Bool
|
||||
readerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(readerDone)
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
if strings.Contains(scanner.Text(), needle) {
|
||||
found.Store(true)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
do()
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
_ = cmd.Process.Signal(os.Interrupt)
|
||||
<-readerDone
|
||||
return found.Load()
|
||||
}
|
||||
|
||||
func dialLocalEchoServer(t *testing.T) (client net.Conn, serverPort uint16) {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
accepted := make(chan net.Conn, 1)
|
||||
go func() {
|
||||
c, err := listener.Accept()
|
||||
if err == nil {
|
||||
accepted <- c
|
||||
}
|
||||
close(accepted)
|
||||
}()
|
||||
addr := listener.Addr().(*net.TCPAddr)
|
||||
client, err = net.Dial("tcp4", addr.String())
|
||||
require.NoError(t, err)
|
||||
server := <-accepted
|
||||
require.NotNil(t, server)
|
||||
|
||||
go io.Copy(io.Discard, server)
|
||||
t.Cleanup(func() {
|
||||
client.Close()
|
||||
server.Close()
|
||||
listener.Close()
|
||||
})
|
||||
return client, uint16(addr.Port)
|
||||
}
|
||||
100
common/tlsspoof/integration_unix_test.go
Normal file
100
common/tlsspoof/integration_unix_test.go
Normal file
@ -0,0 +1,100 @@
|
||||
//go:build linux || darwin
|
||||
|
||||
package tlsspoof
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIntegrationSpoofer_WrongChecksum(t *testing.T) {
|
||||
requireRoot(t)
|
||||
client, serverPort := dialLocalEchoServer(t)
|
||||
spoofer, err := NewSpoofer(client, MethodWrongChecksum)
|
||||
require.NoError(t, err)
|
||||
defer spoofer.Close()
|
||||
|
||||
payload, err := hex.DecodeString(realClientHello)
|
||||
require.NoError(t, err)
|
||||
fake, err := rewriteSNI(payload, "letsencrypt.org")
|
||||
require.NoError(t, err)
|
||||
|
||||
captured := tcpdumpObserver(t, loopbackInterface, serverPort, "letsencrypt.org", func() {
|
||||
require.NoError(t, spoofer.Inject(fake))
|
||||
}, 3*time.Second)
|
||||
require.True(t, captured, "injected fake ClientHello must be observable on loopback")
|
||||
}
|
||||
|
||||
func TestIntegrationSpoofer_WrongSequence(t *testing.T) {
|
||||
requireRoot(t)
|
||||
client, serverPort := dialLocalEchoServer(t)
|
||||
spoofer, err := NewSpoofer(client, MethodWrongSequence)
|
||||
require.NoError(t, err)
|
||||
defer spoofer.Close()
|
||||
|
||||
payload, err := hex.DecodeString(realClientHello)
|
||||
require.NoError(t, err)
|
||||
fake, err := rewriteSNI(payload, "letsencrypt.org")
|
||||
require.NoError(t, err)
|
||||
|
||||
captured := tcpdumpObserver(t, loopbackInterface, serverPort, "letsencrypt.org", func() {
|
||||
require.NoError(t, spoofer.Inject(fake))
|
||||
}, 3*time.Second)
|
||||
require.True(t, captured, "injected fake ClientHello must be observable on loopback")
|
||||
}
|
||||
|
||||
// Loopback bypasses TCP checksum validation, so wrong-sequence is used instead.
|
||||
func TestIntegrationConn_InjectsThenForwardsRealCH(t *testing.T) {
|
||||
requireRoot(t)
|
||||
|
||||
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
serverReceived := make(chan []byte, 1)
|
||||
go func() {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
got, _ := io.ReadAll(conn)
|
||||
serverReceived <- got
|
||||
}()
|
||||
|
||||
addr := listener.Addr().(*net.TCPAddr)
|
||||
serverPort := uint16(addr.Port)
|
||||
client, err := net.Dial("tcp4", addr.String())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
client.Close()
|
||||
listener.Close()
|
||||
})
|
||||
|
||||
spoofer, err := NewSpoofer(client, MethodWrongSequence)
|
||||
require.NoError(t, err)
|
||||
wrapped := NewConn(client, spoofer, "letsencrypt.org")
|
||||
|
||||
payload, err := hex.DecodeString(realClientHello)
|
||||
require.NoError(t, err)
|
||||
|
||||
captured := tcpdumpObserver(t, loopbackInterface, serverPort, "letsencrypt.org", func() {
|
||||
n, err := wrapped.Write(payload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, len(payload), n)
|
||||
}, 3*time.Second)
|
||||
require.True(t, captured, "fake ClientHello with letsencrypt.org SNI must be on the wire")
|
||||
|
||||
_ = wrapped.Close()
|
||||
select {
|
||||
case got := <-serverReceived:
|
||||
require.Equal(t, payload, got, "server must receive real ClientHello unchanged (wrong-sequence fake must be dropped)")
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("echo server did not receive real ClientHello")
|
||||
}
|
||||
}
|
||||
139
common/tlsspoof/integration_windows_test.go
Normal file
139
common/tlsspoof/integration_windows_test.go
Normal 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
100
common/tlsspoof/packet.go
Normal file
@ -0,0 +1,100 @@
|
||||
package tlsspoof
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/sagernet/sing-tun/gtcpip/checksum"
|
||||
"github.com/sagernet/sing-tun/gtcpip/header"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTTL uint8 = 64
|
||||
defaultWindowSize uint16 = 0xFFFF
|
||||
tcpHeaderLen = header.TCPMinimumSize
|
||||
)
|
||||
|
||||
func buildTCPSegment(
|
||||
src netip.AddrPort,
|
||||
dst netip.AddrPort,
|
||||
seqNum uint32,
|
||||
ackNum uint32,
|
||||
payload []byte,
|
||||
corruptChecksum bool,
|
||||
) []byte {
|
||||
if src.Addr().Is4() != dst.Addr().Is4() {
|
||||
panic("tlsspoof: mixed IPv4/IPv6 address family")
|
||||
}
|
||||
var (
|
||||
frame []byte
|
||||
ipHeaderLen int
|
||||
)
|
||||
if src.Addr().Is4() {
|
||||
ipHeaderLen = header.IPv4MinimumSize
|
||||
frame = make([]byte, ipHeaderLen+tcpHeaderLen+len(payload))
|
||||
ip := header.IPv4(frame[:ipHeaderLen])
|
||||
ip.Encode(&header.IPv4Fields{
|
||||
TotalLength: uint16(len(frame)),
|
||||
ID: 0,
|
||||
TTL: defaultTTL,
|
||||
Protocol: uint8(header.TCPProtocolNumber),
|
||||
SrcAddr: src.Addr(),
|
||||
DstAddr: dst.Addr(),
|
||||
})
|
||||
ip.SetChecksum(^ip.CalculateChecksum())
|
||||
} else {
|
||||
ipHeaderLen = header.IPv6MinimumSize
|
||||
frame = make([]byte, ipHeaderLen+tcpHeaderLen+len(payload))
|
||||
ip := header.IPv6(frame[:ipHeaderLen])
|
||||
ip.Encode(&header.IPv6Fields{
|
||||
PayloadLength: uint16(tcpHeaderLen + len(payload)),
|
||||
TransportProtocol: header.TCPProtocolNumber,
|
||||
HopLimit: defaultTTL,
|
||||
SrcAddr: src.Addr(),
|
||||
DstAddr: dst.Addr(),
|
||||
})
|
||||
}
|
||||
encodeTCP(frame, ipHeaderLen, src, dst, seqNum, ackNum, payload, corruptChecksum)
|
||||
return frame
|
||||
}
|
||||
|
||||
func encodeTCP(frame []byte, ipHeaderLen int, src, dst netip.AddrPort, seqNum, ackNum uint32, payload []byte, corruptChecksum bool) {
|
||||
tcp := header.TCP(frame[ipHeaderLen:])
|
||||
copy(frame[ipHeaderLen+tcpHeaderLen:], payload)
|
||||
tcp.Encode(&header.TCPFields{
|
||||
SrcPort: src.Port(),
|
||||
DstPort: dst.Port(),
|
||||
SeqNum: seqNum,
|
||||
AckNum: ackNum,
|
||||
DataOffset: tcpHeaderLen,
|
||||
Flags: header.TCPFlagAck | header.TCPFlagPsh,
|
||||
WindowSize: defaultWindowSize,
|
||||
})
|
||||
applyTCPChecksum(tcp, src.Addr(), dst.Addr(), payload, corruptChecksum)
|
||||
}
|
||||
|
||||
func buildSpoofFrame(method Method, src, dst netip.AddrPort, sendNext, receiveNext uint32, payload []byte) ([]byte, error) {
|
||||
var sequence uint32
|
||||
corrupt := false
|
||||
switch method {
|
||||
case MethodWrongSequence:
|
||||
sequence = sendNext - uint32(len(payload))
|
||||
case MethodWrongChecksum:
|
||||
sequence = sendNext
|
||||
corrupt = true
|
||||
default:
|
||||
return nil, E.New("tls_spoof: unknown method ", method)
|
||||
}
|
||||
return buildTCPSegment(src, dst, sequence, receiveNext, payload, corrupt), nil
|
||||
}
|
||||
|
||||
func applyTCPChecksum(tcp header.TCP, srcAddr, dstAddr netip.Addr, payload []byte, corrupt bool) {
|
||||
tcpLen := tcpHeaderLen + len(payload)
|
||||
pseudo := header.PseudoHeaderChecksum(header.TCPProtocolNumber, srcAddr.AsSlice(), dstAddr.AsSlice(), uint16(tcpLen))
|
||||
payloadChecksum := checksum.Checksum(payload, 0)
|
||||
tcpChecksum := ^tcp.CalculateChecksum(checksum.Combine(pseudo, payloadChecksum))
|
||||
if corrupt {
|
||||
tcpChecksum ^= 0xFFFF
|
||||
}
|
||||
tcp.SetChecksum(tcpChecksum)
|
||||
}
|
||||
77
common/tlsspoof/packet_test.go
Normal file
77
common/tlsspoof/packet_test.go
Normal file
@ -0,0 +1,77 @@
|
||||
package tlsspoof
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/sagernet/sing-tun/gtcpip"
|
||||
"github.com/sagernet/sing-tun/gtcpip/checksum"
|
||||
"github.com/sagernet/sing-tun/gtcpip/header"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildTCPSegment_IPv4_ValidChecksum(t *testing.T) {
|
||||
t.Parallel()
|
||||
src := netip.MustParseAddrPort("10.0.0.1:54321")
|
||||
dst := netip.MustParseAddrPort("1.2.3.4:443")
|
||||
payload := []byte("fake-client-hello")
|
||||
frame := buildTCPSegment(src, dst, 100_000, 200_000, payload, false)
|
||||
|
||||
ip := header.IPv4(frame[:header.IPv4MinimumSize])
|
||||
require.True(t, ip.IsChecksumValid())
|
||||
|
||||
tcp := header.TCP(frame[header.IPv4MinimumSize:])
|
||||
payloadChecksum := checksum.Checksum(payload, 0)
|
||||
require.True(t, tcp.IsChecksumValid(
|
||||
tcpip.AddrFrom4(src.Addr().As4()),
|
||||
tcpip.AddrFrom4(dst.Addr().As4()),
|
||||
payloadChecksum,
|
||||
uint16(len(payload)),
|
||||
))
|
||||
}
|
||||
|
||||
func TestBuildTCPSegment_IPv4_CorruptChecksum(t *testing.T) {
|
||||
t.Parallel()
|
||||
src := netip.MustParseAddrPort("10.0.0.1:54321")
|
||||
dst := netip.MustParseAddrPort("1.2.3.4:443")
|
||||
payload := []byte("fake-client-hello")
|
||||
frame := buildTCPSegment(src, dst, 100_000, 200_000, payload, true)
|
||||
|
||||
tcp := header.TCP(frame[header.IPv4MinimumSize:])
|
||||
payloadChecksum := checksum.Checksum(payload, 0)
|
||||
require.False(t, tcp.IsChecksumValid(
|
||||
tcpip.AddrFrom4(src.Addr().As4()),
|
||||
tcpip.AddrFrom4(dst.Addr().As4()),
|
||||
payloadChecksum,
|
||||
uint16(len(payload)),
|
||||
))
|
||||
// IP checksum must still be valid so the router forwards the packet.
|
||||
require.True(t, header.IPv4(frame[:header.IPv4MinimumSize]).IsChecksumValid())
|
||||
}
|
||||
|
||||
func TestBuildTCPSegment_IPv6_ValidChecksum(t *testing.T) {
|
||||
t.Parallel()
|
||||
src := netip.MustParseAddrPort("[fe80::1]:54321")
|
||||
dst := netip.MustParseAddrPort("[2606:4700::1]:443")
|
||||
payload := []byte("fake-client-hello")
|
||||
frame := buildTCPSegment(src, dst, 0xDEADBEEF, 0x12345678, payload, false)
|
||||
|
||||
tcp := header.TCP(frame[header.IPv6MinimumSize:])
|
||||
payloadChecksum := checksum.Checksum(payload, 0)
|
||||
require.True(t, tcp.IsChecksumValid(
|
||||
tcpip.AddrFrom16(src.Addr().As16()),
|
||||
tcpip.AddrFrom16(dst.Addr().As16()),
|
||||
payloadChecksum,
|
||||
uint16(len(payload)),
|
||||
))
|
||||
}
|
||||
|
||||
func TestBuildTCPSegment_MixedFamilyPanics(t *testing.T) {
|
||||
t.Parallel()
|
||||
src := netip.MustParseAddrPort("10.0.0.1:54321")
|
||||
dst := netip.MustParseAddrPort("[2606:4700::1]:443")
|
||||
require.Panics(t, func() {
|
||||
buildTCPSegment(src, dst, 0, 0, nil, false)
|
||||
})
|
||||
}
|
||||
161
common/tlsspoof/raw_darwin.go
Normal file
161
common/tlsspoof/raw_darwin.go
Normal file
@ -0,0 +1,161 @@
|
||||
package tlsspoof
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const PlatformSupported = true
|
||||
|
||||
// Offsets into xinpcb_n within each net.inet.tcp.pcblist_n record, identical
|
||||
// to the values used by common/process/searcher_darwin_shared.go.
|
||||
const (
|
||||
darwinXinpgenSize = 24
|
||||
darwinXsocketOffset = 104
|
||||
darwinXinpcbForeignPort = 16
|
||||
darwinXinpcbLocalPort = 18
|
||||
darwinXinpcbVFlag = 44
|
||||
darwinXinpcbForeignAddr = 48
|
||||
darwinXinpcbLocalAddr = 64
|
||||
darwinXinpcbIPv4Offset = 12
|
||||
|
||||
darwinTCPExtraSize = 208
|
||||
|
||||
darwinXtcpcbSndNxtOffset = 56
|
||||
darwinXtcpcbRcvNxtOffset = 80
|
||||
)
|
||||
|
||||
var darwinStructSize = sync.OnceValue(func() int {
|
||||
value, _ := syscall.Sysctl("kern.osrelease")
|
||||
major, _, _ := strings.Cut(value, ".")
|
||||
n, _ := strconv.ParseInt(major, 10, 64)
|
||||
if n >= 22 {
|
||||
return 408
|
||||
}
|
||||
return 384
|
||||
})
|
||||
|
||||
type darwinSpoofer struct {
|
||||
method Method
|
||||
src netip.AddrPort
|
||||
dst netip.AddrPort
|
||||
rawFD int
|
||||
rawSockAddr unix.Sockaddr
|
||||
sendNext uint32
|
||||
receiveNext uint32
|
||||
}
|
||||
|
||||
func newRawSpoofer(conn net.Conn, method Method) (Spoofer, error) {
|
||||
_, src, dst, err := tcpEndpoints(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fd, sockaddr, err := openDarwinRawSocket(dst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sendNext, receiveNext, err := readDarwinTCPSequence(src, dst)
|
||||
if err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, err
|
||||
}
|
||||
return &darwinSpoofer{
|
||||
method: method,
|
||||
src: src,
|
||||
dst: dst,
|
||||
rawFD: fd,
|
||||
rawSockAddr: sockaddr,
|
||||
sendNext: sendNext,
|
||||
receiveNext: receiveNext,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// readDarwinTCPSequence scans net.inet.tcp.pcblist_n for the PCB that matches
|
||||
// src -> dst and returns (snd_nxt, rcv_nxt). These live in xtcpcb_n at the end
|
||||
// of each record; see darwin-xnu bsd/netinet/in_pcblist.c:get_pcblist_n.
|
||||
func readDarwinTCPSequence(src, dst netip.AddrPort) (uint32, uint32, error) {
|
||||
buffer, err := unix.SysctlRaw("net.inet.tcp.pcblist_n")
|
||||
if err != nil {
|
||||
return 0, 0, E.Cause(err, "sysctl net.inet.tcp.pcblist_n")
|
||||
}
|
||||
structSize := darwinStructSize()
|
||||
itemSize := structSize + darwinTCPExtraSize
|
||||
for i := darwinXinpgenSize; i+itemSize <= len(buffer); i += itemSize {
|
||||
inpcb := buffer[i : i+darwinXsocketOffset]
|
||||
xtcpcb := buffer[i+structSize : i+itemSize]
|
||||
localPort := binary.BigEndian.Uint16(inpcb[darwinXinpcbLocalPort : darwinXinpcbLocalPort+2])
|
||||
remotePort := binary.BigEndian.Uint16(inpcb[darwinXinpcbForeignPort : darwinXinpcbForeignPort+2])
|
||||
if localPort != src.Port() || remotePort != dst.Port() {
|
||||
continue
|
||||
}
|
||||
versionFlag := inpcb[darwinXinpcbVFlag]
|
||||
var localAddr, remoteAddr netip.Addr
|
||||
switch {
|
||||
case versionFlag&0x1 != 0:
|
||||
localAddr = netip.AddrFrom4([4]byte(inpcb[darwinXinpcbLocalAddr+darwinXinpcbIPv4Offset : darwinXinpcbLocalAddr+darwinXinpcbIPv4Offset+4]))
|
||||
remoteAddr = netip.AddrFrom4([4]byte(inpcb[darwinXinpcbForeignAddr+darwinXinpcbIPv4Offset : darwinXinpcbForeignAddr+darwinXinpcbIPv4Offset+4]))
|
||||
case versionFlag&0x2 != 0:
|
||||
localAddr = netip.AddrFrom16([16]byte(inpcb[darwinXinpcbLocalAddr : darwinXinpcbLocalAddr+16]))
|
||||
remoteAddr = netip.AddrFrom16([16]byte(inpcb[darwinXinpcbForeignAddr : darwinXinpcbForeignAddr+16]))
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if localAddr.Unmap() != src.Addr() || remoteAddr.Unmap() != dst.Addr() {
|
||||
continue
|
||||
}
|
||||
sendNext := binary.NativeEndian.Uint32(xtcpcb[darwinXtcpcbSndNxtOffset : darwinXtcpcbSndNxtOffset+4])
|
||||
receiveNext := binary.NativeEndian.Uint32(xtcpcb[darwinXtcpcbRcvNxtOffset : darwinXtcpcbRcvNxtOffset+4])
|
||||
return sendNext, receiveNext, nil
|
||||
}
|
||||
return 0, 0, E.New("tls_spoof: connection ", src, "->", dst, " not found in pcblist_n")
|
||||
}
|
||||
|
||||
func openDarwinRawSocket(dst netip.AddrPort) (int, unix.Sockaddr, error) {
|
||||
if !dst.Addr().Is4() {
|
||||
// macOS does not expose IPV6_HDRINCL; raw AF_INET6 injection would
|
||||
// require either BPF link-layer writes or kernel-side IPv6 header
|
||||
// synthesis, neither of which is implemented here.
|
||||
return -1, nil, E.New("tls_spoof: IPv6 not supported on darwin")
|
||||
}
|
||||
return openIPv4RawSocket(dst)
|
||||
}
|
||||
|
||||
func (s *darwinSpoofer) Inject(payload []byte) error {
|
||||
frame, err := buildSpoofFrame(s.method, s.src, s.dst, s.sendNext, s.receiveNext, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Darwin inherits the historical BSD quirk: with IP_HDRINCL the kernel
|
||||
// expects ip_len and ip_off in host byte order, not network byte order.
|
||||
// Apple's rip_output swaps them back before transmission. This does not
|
||||
// apply to IPv6.
|
||||
if s.src.Addr().Is4() {
|
||||
totalLen := binary.BigEndian.Uint16(frame[2:4])
|
||||
binary.NativeEndian.PutUint16(frame[2:4], totalLen)
|
||||
fragOff := binary.BigEndian.Uint16(frame[6:8])
|
||||
binary.NativeEndian.PutUint16(frame[6:8], fragOff)
|
||||
}
|
||||
err = unix.Sendto(s.rawFD, frame, 0, s.rawSockAddr)
|
||||
if err != nil {
|
||||
return E.Cause(err, "sendto raw socket")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *darwinSpoofer) Close() error {
|
||||
if s.rawFD < 0 {
|
||||
return nil
|
||||
}
|
||||
err := unix.Close(s.rawFD)
|
||||
s.rawFD = -1
|
||||
return err
|
||||
}
|
||||
127
common/tlsspoof/raw_linux.go
Normal file
127
common/tlsspoof/raw_linux.go
Normal file
@ -0,0 +1,127 @@
|
||||
package tlsspoof
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
|
||||
"github.com/sagernet/sing/common/control"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const PlatformSupported = true
|
||||
|
||||
const (
|
||||
// Values of enum { TCP_NO_QUEUE, TCP_RECV_QUEUE, TCP_SEND_QUEUE } from
|
||||
// include/net/tcp.h; not exported by golang.org/x/sys/unix.
|
||||
tcpRecvQueue = 1
|
||||
tcpSendQueue = 2
|
||||
)
|
||||
|
||||
type linuxSpoofer struct {
|
||||
method Method
|
||||
src netip.AddrPort
|
||||
dst netip.AddrPort
|
||||
rawFD int
|
||||
rawSockAddr unix.Sockaddr
|
||||
sendNext uint32
|
||||
receiveNext uint32
|
||||
}
|
||||
|
||||
func newRawSpoofer(conn net.Conn, method Method) (Spoofer, error) {
|
||||
tcpConn, src, dst, err := tcpEndpoints(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fd, sockaddr, err := openLinuxRawSocket(dst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spoofer := &linuxSpoofer{
|
||||
method: method,
|
||||
src: src,
|
||||
dst: dst,
|
||||
rawFD: fd,
|
||||
rawSockAddr: sockaddr,
|
||||
}
|
||||
err = spoofer.loadSequenceNumbers(tcpConn)
|
||||
if err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, err
|
||||
}
|
||||
return spoofer, nil
|
||||
}
|
||||
|
||||
func openLinuxRawSocket(dst netip.AddrPort) (int, unix.Sockaddr, error) {
|
||||
if dst.Addr().Is4() {
|
||||
return openIPv4RawSocket(dst)
|
||||
}
|
||||
fd, err := unix.Socket(unix.AF_INET6, unix.SOCK_RAW, unix.IPPROTO_TCP)
|
||||
if err != nil {
|
||||
return -1, nil, E.Cause(err, "open AF_INET6 SOCK_RAW")
|
||||
}
|
||||
err = unix.SetsockoptInt(fd, unix.IPPROTO_IPV6, unix.IPV6_HDRINCL, 1)
|
||||
if err != nil {
|
||||
unix.Close(fd)
|
||||
return -1, nil, E.Cause(err, "set IPV6_HDRINCL")
|
||||
}
|
||||
sockaddr := &unix.SockaddrInet6{Port: int(dst.Port())}
|
||||
sockaddr.Addr = dst.Addr().As16()
|
||||
return fd, sockaddr, nil
|
||||
}
|
||||
|
||||
// loadSequenceNumbers puts the socket briefly into TCP_REPAIR mode to read
|
||||
// snd_nxt and rcv_nxt from the kernel. TCP_REPAIR requires CAP_NET_ADMIN;
|
||||
// callers must run as root or grant both CAP_NET_RAW and CAP_NET_ADMIN.
|
||||
func (s *linuxSpoofer) loadSequenceNumbers(tcpConn *net.TCPConn) error {
|
||||
return control.Conn(tcpConn, func(raw uintptr) error {
|
||||
fd := int(raw)
|
||||
err := unix.SetsockoptInt(fd, unix.IPPROTO_TCP, unix.TCP_REPAIR, unix.TCP_REPAIR_ON)
|
||||
if err != nil {
|
||||
return E.Cause(err, "enter TCP_REPAIR (need CAP_NET_ADMIN)")
|
||||
}
|
||||
defer unix.SetsockoptInt(fd, unix.IPPROTO_TCP, unix.TCP_REPAIR, unix.TCP_REPAIR_OFF)
|
||||
|
||||
err = unix.SetsockoptInt(fd, unix.IPPROTO_TCP, unix.TCP_REPAIR_QUEUE, tcpSendQueue)
|
||||
if err != nil {
|
||||
return E.Cause(err, "select TCP_SEND_QUEUE")
|
||||
}
|
||||
sendSequence, err := unix.GetsockoptInt(fd, unix.IPPROTO_TCP, unix.TCP_QUEUE_SEQ)
|
||||
if err != nil {
|
||||
return E.Cause(err, "read send queue sequence")
|
||||
}
|
||||
err = unix.SetsockoptInt(fd, unix.IPPROTO_TCP, unix.TCP_REPAIR_QUEUE, tcpRecvQueue)
|
||||
if err != nil {
|
||||
return E.Cause(err, "select TCP_RECV_QUEUE")
|
||||
}
|
||||
receiveSequence, err := unix.GetsockoptInt(fd, unix.IPPROTO_TCP, unix.TCP_QUEUE_SEQ)
|
||||
if err != nil {
|
||||
return E.Cause(err, "read recv queue sequence")
|
||||
}
|
||||
s.sendNext = uint32(sendSequence)
|
||||
s.receiveNext = uint32(receiveSequence)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *linuxSpoofer) Inject(payload []byte) error {
|
||||
frame, err := buildSpoofFrame(s.method, s.src, s.dst, s.sendNext, s.receiveNext, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = unix.Sendto(s.rawFD, frame, 0, s.rawSockAddr)
|
||||
if err != nil {
|
||||
return E.Cause(err, "sendto raw socket")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *linuxSpoofer) Close() error {
|
||||
if s.rawFD < 0 {
|
||||
return nil
|
||||
}
|
||||
err := unix.Close(s.rawFD)
|
||||
s.rawFD = -1
|
||||
return err
|
||||
}
|
||||
15
common/tlsspoof/raw_stub.go
Normal file
15
common/tlsspoof/raw_stub.go
Normal file
@ -0,0 +1,15 @@
|
||||
//go:build !linux && !darwin && !(windows && (amd64 || 386))
|
||||
|
||||
package tlsspoof
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
)
|
||||
|
||||
const PlatformSupported = false
|
||||
|
||||
func newRawSpoofer(conn net.Conn, method Method) (Spoofer, error) {
|
||||
return nil, E.New("tls_spoof: unsupported platform")
|
||||
}
|
||||
26
common/tlsspoof/raw_unix.go
Normal file
26
common/tlsspoof/raw_unix.go
Normal file
@ -0,0 +1,26 @@
|
||||
//go:build linux || darwin
|
||||
|
||||
package tlsspoof
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func openIPv4RawSocket(dst netip.AddrPort) (int, unix.Sockaddr, error) {
|
||||
fd, err := unix.Socket(unix.AF_INET, unix.SOCK_RAW, unix.IPPROTO_TCP)
|
||||
if err != nil {
|
||||
return -1, nil, E.Cause(err, "open AF_INET SOCK_RAW")
|
||||
}
|
||||
err = unix.SetsockoptInt(fd, unix.IPPROTO_IP, unix.IP_HDRINCL, 1)
|
||||
if err != nil {
|
||||
unix.Close(fd)
|
||||
return -1, nil, E.Cause(err, "set IP_HDRINCL")
|
||||
}
|
||||
sockaddr := &unix.SockaddrInet4{Port: int(dst.Port())}
|
||||
sockaddr.Addr = dst.Addr().As4()
|
||||
return fd, sockaddr, nil
|
||||
}
|
||||
218
common/tlsspoof/raw_windows.go
Normal file
218
common/tlsspoof/raw_windows.go
Normal file
@ -0,0 +1,218 @@
|
||||
//go:build windows && (amd64 || 386)
|
||||
|
||||
package tlsspoof
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/common/windivert"
|
||||
"github.com/sagernet/sing-tun/gtcpip/header"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const PlatformSupported = true
|
||||
|
||||
// closeGracePeriod caps how long Close() waits for the divert goroutine to
|
||||
// observe the kernel-emitted real ClientHello and perform the reorder
|
||||
// (fake → real). In practice this completes in microseconds; the cap
|
||||
// bounds the pathological case where the kernel buffers the packet.
|
||||
const closeGracePeriod = 2 * time.Second
|
||||
|
||||
type windowsSpoofer struct {
|
||||
method Method
|
||||
src, dst netip.AddrPort
|
||||
divertH *windivert.Handle
|
||||
injectH *windivert.Handle
|
||||
|
||||
fakeReady chan []byte // buffered(1): staged by Inject
|
||||
done chan struct{} // closed by run() on exit
|
||||
closeOnce sync.Once
|
||||
runErr atomic.Pointer[error]
|
||||
}
|
||||
|
||||
func newRawSpoofer(conn net.Conn, method Method) (Spoofer, error) {
|
||||
_, src, dst, err := tcpEndpoints(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filter, err := windivert.OutboundTCP(src, dst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
divertH, err := windivert.Open(filter, windivert.LayerNetwork, 0, 0)
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "tls_spoof: open WinDivert")
|
||||
}
|
||||
injectH, err := windivert.Open(nil, windivert.LayerNetwork, 0, windivert.FlagSendOnly)
|
||||
if err != nil {
|
||||
divertH.Close()
|
||||
return nil, E.Cause(err, "tls_spoof: open WinDivert")
|
||||
}
|
||||
s := &windowsSpoofer{
|
||||
method: method,
|
||||
src: src,
|
||||
dst: dst,
|
||||
divertH: divertH,
|
||||
injectH: injectH,
|
||||
fakeReady: make(chan []byte, 1),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go s.run()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *windowsSpoofer) Inject(payload []byte) error {
|
||||
select {
|
||||
case s.fakeReady <- payload:
|
||||
return nil
|
||||
case <-s.done:
|
||||
if p := s.runErr.Load(); p != nil {
|
||||
return *p
|
||||
}
|
||||
return E.New("tls_spoof: spoofer closed before Inject")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *windowsSpoofer) Close() error {
|
||||
s.closeOnce.Do(func() {
|
||||
// Give run() a grace window to finish handling the real packet.
|
||||
select {
|
||||
case <-s.done:
|
||||
case <-time.After(closeGracePeriod):
|
||||
// Force Recv() to return by closing the divert handle.
|
||||
s.divertH.Close()
|
||||
<-s.done
|
||||
}
|
||||
s.injectH.Close()
|
||||
})
|
||||
if p := s.runErr.Load(); p != nil {
|
||||
return *p
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *windowsSpoofer) recordErr(err error) { s.runErr.Store(&err) }
|
||||
|
||||
func (s *windowsSpoofer) run() {
|
||||
defer close(s.done)
|
||||
defer s.divertH.Close()
|
||||
|
||||
buf := make([]byte, windivert.MTUMax)
|
||||
for {
|
||||
n, addr, err := s.divertH.Recv(buf)
|
||||
if err != nil {
|
||||
if errors.Is(err, windows.ERROR_OPERATION_ABORTED) ||
|
||||
errors.Is(err, windows.ERROR_NO_DATA) {
|
||||
return
|
||||
}
|
||||
s.recordErr(E.Cause(err, "windivert recv"))
|
||||
return
|
||||
}
|
||||
pkt := buf[:n]
|
||||
seq, ack, payloadLen, ok := parseTCPFields(pkt, addr.IPv6())
|
||||
if !ok {
|
||||
// Malformed / not TCP — shouldn't match our filter, but be safe.
|
||||
_, _ = s.divertH.Send(pkt, &addr)
|
||||
continue
|
||||
}
|
||||
if payloadLen == 0 {
|
||||
// Handshake ACK, keepalive, FIN — pass through unchanged.
|
||||
_, err := s.divertH.Send(pkt, &addr)
|
||||
if err != nil {
|
||||
s.recordErr(E.Cause(err, "windivert re-inject empty"))
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Non-empty outbound TCP payload = the real ClientHello.
|
||||
var fake []byte
|
||||
select {
|
||||
case fake = <-s.fakeReady:
|
||||
default:
|
||||
// Inject() not yet called — pass through and keep observing.
|
||||
_, err := s.divertH.Send(pkt, &addr)
|
||||
if err != nil {
|
||||
s.recordErr(E.Cause(err, "windivert re-inject early data"))
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
frame, err := buildSpoofFrame(s.method, s.src, s.dst, seq, ack, fake)
|
||||
if err != nil {
|
||||
s.recordErr(err)
|
||||
return
|
||||
}
|
||||
fakeAddr := addr // inherit Outbound, IfIdx
|
||||
// buildSpoofFrame emits ready-to-wire bytes. The driver recomputes
|
||||
// checksums on Send when TCPChecksum/IPChecksum are 0 — which would
|
||||
// overwrite the intentionally corrupt checksum in WrongChecksum mode.
|
||||
// Force both to 1 to keep our bytes intact.
|
||||
fakeAddr.SetIPChecksum(true)
|
||||
fakeAddr.SetTCPChecksum(true)
|
||||
_, err = s.injectH.Send(frame, &fakeAddr)
|
||||
if err != nil {
|
||||
s.recordErr(E.Cause(err, "windivert inject fake"))
|
||||
return
|
||||
}
|
||||
_, err = s.divertH.Send(pkt, &addr)
|
||||
if err != nil {
|
||||
s.recordErr(E.Cause(err, "windivert re-inject real"))
|
||||
return
|
||||
}
|
||||
return // single-shot reorder complete
|
||||
}
|
||||
}
|
||||
|
||||
func parseTCPFields(pkt []byte, isV6 bool) (seq, ack uint32, payloadLen int, ok bool) {
|
||||
if isV6 {
|
||||
if len(pkt) < header.IPv6MinimumSize+header.TCPMinimumSize {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
ip := header.IPv6(pkt)
|
||||
if ip.TransportProtocol() != header.TCPProtocolNumber {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
tcp := header.TCP(pkt[header.IPv6MinimumSize:])
|
||||
tcpHdr := int(tcp.DataOffset())
|
||||
if tcpHdr < header.TCPMinimumSize || header.IPv6MinimumSize+tcpHdr > len(pkt) {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
return tcp.SequenceNumber(), tcp.AckNumber(),
|
||||
len(pkt) - header.IPv6MinimumSize - tcpHdr, true
|
||||
}
|
||||
if len(pkt) < header.IPv4MinimumSize+header.TCPMinimumSize {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
ip := header.IPv4(pkt)
|
||||
if ip.Protocol() != uint8(header.TCPProtocolNumber) {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
ihl := int(ip.HeaderLength())
|
||||
// ihl+TCPMinimumSize guards the TCP-header field reads below; without
|
||||
// this, an IPv4 packet with options (ihl>20) against a 40-byte buffer
|
||||
// reads past the TCP slice when calling DataOffset.
|
||||
if ihl < header.IPv4MinimumSize || ihl+header.TCPMinimumSize > len(pkt) {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
tcp := header.TCP(pkt[ihl:])
|
||||
tcpHdr := int(tcp.DataOffset())
|
||||
if tcpHdr < header.TCPMinimumSize || ihl+tcpHdr > len(pkt) {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
total := int(ip.TotalLength())
|
||||
if total == 0 || total > len(pkt) {
|
||||
total = len(pkt)
|
||||
}
|
||||
return tcp.SequenceNumber(), tcp.AckNumber(),
|
||||
total - ihl - tcpHdr, true
|
||||
}
|
||||
112
common/tlsspoof/raw_windows_test.go
Normal file
112
common/tlsspoof/raw_windows_test.go
Normal 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
100
common/tlsspoof/spoof.go
Normal file
@ -0,0 +1,100 @@
|
||||
package tlsspoof
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
)
|
||||
|
||||
type Method int
|
||||
|
||||
const (
|
||||
MethodWrongSequence Method = iota
|
||||
MethodWrongChecksum
|
||||
)
|
||||
|
||||
const (
|
||||
MethodNameWrongSequence = "wrong-sequence"
|
||||
MethodNameWrongChecksum = "wrong-checksum"
|
||||
)
|
||||
|
||||
func ParseMethod(s string) (Method, error) {
|
||||
switch s {
|
||||
case "", MethodNameWrongSequence:
|
||||
return MethodWrongSequence, nil
|
||||
case MethodNameWrongChecksum:
|
||||
return MethodWrongChecksum, nil
|
||||
default:
|
||||
return 0, E.New("tls_spoof: unknown method: ", s)
|
||||
}
|
||||
}
|
||||
|
||||
func (m Method) String() string {
|
||||
switch m {
|
||||
case MethodWrongSequence:
|
||||
return MethodNameWrongSequence
|
||||
case MethodWrongChecksum:
|
||||
return MethodNameWrongChecksum
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
type Spoofer interface {
|
||||
Inject(payload []byte) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
func NewSpoofer(conn net.Conn, method Method) (Spoofer, error) {
|
||||
return newRawSpoofer(conn, method)
|
||||
}
|
||||
|
||||
type Conn struct {
|
||||
net.Conn
|
||||
spoofer Spoofer
|
||||
fakeSNI string
|
||||
injected bool
|
||||
}
|
||||
|
||||
func NewConn(conn net.Conn, spoofer Spoofer, fakeSNI string) *Conn {
|
||||
return &Conn{
|
||||
Conn: conn,
|
||||
spoofer: spoofer,
|
||||
fakeSNI: fakeSNI,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) Write(b []byte) (int, error) {
|
||||
if c.injected {
|
||||
return c.Conn.Write(b)
|
||||
}
|
||||
defer c.spoofer.Close()
|
||||
fake, err := rewriteSNI(b, c.fakeSNI)
|
||||
if err != nil {
|
||||
return 0, E.Cause(err, "tls_spoof: rewrite SNI")
|
||||
}
|
||||
err = c.spoofer.Inject(fake)
|
||||
if err != nil {
|
||||
return 0, E.Cause(err, "tls_spoof: inject")
|
||||
}
|
||||
c.injected = true
|
||||
return c.Conn.Write(b)
|
||||
}
|
||||
|
||||
func (c *Conn) Close() error {
|
||||
return E.Append(c.Conn.Close(), c.spoofer.Close(), func(e error) error {
|
||||
return E.Cause(e, "close spoofer")
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Conn) ReaderReplaceable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *Conn) WriterReplaceable() bool {
|
||||
return c.injected
|
||||
}
|
||||
|
||||
func (c *Conn) Upstream() any {
|
||||
return c.Conn
|
||||
}
|
||||
53
common/windivert/address_test.go
Normal file
53
common/windivert/address_test.go
Normal 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)
|
||||
}
|
||||
1191
common/windivert/assets/LICENSE.txt
Normal file
1191
common/windivert/assets/LICENSE.txt
Normal file
File diff suppressed because it is too large
Load Diff
BIN
common/windivert/assets/WinDivert32.sys
Normal file
BIN
common/windivert/assets/WinDivert32.sys
Normal file
Binary file not shown.
BIN
common/windivert/assets/WinDivert64.sys
Normal file
BIN
common/windivert/assets/WinDivert64.sys
Normal file
Binary file not shown.
14
common/windivert/assets_386.go
Normal file
14
common/windivert/assets_386.go
Normal file
@ -0,0 +1,14 @@
|
||||
//go:build windows && 386
|
||||
|
||||
package windivert
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed assets/WinDivert32.sys
|
||||
var sysBytes []byte
|
||||
|
||||
func assetFiles() []assetFile {
|
||||
return []assetFile{{"WinDivert32.sys", sysBytes}}
|
||||
}
|
||||
|
||||
func driverSysName() string { return "WinDivert32.sys" }
|
||||
14
common/windivert/assets_amd64.go
Normal file
14
common/windivert/assets_amd64.go
Normal file
@ -0,0 +1,14 @@
|
||||
//go:build windows && amd64
|
||||
|
||||
package windivert
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed assets/WinDivert64.sys
|
||||
var sysBytes []byte
|
||||
|
||||
func assetFiles() []assetFile {
|
||||
return []assetFile{{"WinDivert64.sys", sysBytes}}
|
||||
}
|
||||
|
||||
func driverSysName() string { return "WinDivert64.sys" }
|
||||
7
common/windivert/assets_unsupported.go
Normal file
7
common/windivert/assets_unsupported.go
Normal file
@ -0,0 +1,7 @@
|
||||
//go:build windows && !amd64 && !386
|
||||
|
||||
package windivert
|
||||
|
||||
func assetFiles() []assetFile { return nil }
|
||||
|
||||
func driverSysName() string { return "" }
|
||||
212
common/windivert/driver_windows.go
Normal file
212
common/windivert/driver_windows.go
Normal 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
182
common/windivert/filter.go
Normal file
@ -0,0 +1,182 @@
|
||||
package windivert
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net/netip"
|
||||
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
)
|
||||
|
||||
// WINDIVERT_FILTER VM instruction layout (24 bytes, #pragma pack(1)):
|
||||
//
|
||||
// word 0 (LE): field:11 | test:5 | success:16
|
||||
// word 1 (LE): failure:16 | neg:1 | reserved:15
|
||||
// words 2..5: arg[4] (native-endian uint32 each)
|
||||
//
|
||||
// The driver walks this as a decision tree: evaluate the test at inst i;
|
||||
// on success jump to success; on failure jump to failure. Continuations
|
||||
// 0x7FFE and 0x7FFF are ACCEPT and REJECT terminals.
|
||||
const (
|
||||
filterInstBytes = 24
|
||||
filterMaxInsts = 256
|
||||
|
||||
fieldZero = 0
|
||||
fieldOutbound = 2
|
||||
fieldIP = 5
|
||||
fieldIPv6 = 6
|
||||
fieldTCP = 8
|
||||
fieldIPSrcAddr = 21
|
||||
fieldIPDstAddr = 22
|
||||
fieldIPv6SrcAddr = 28
|
||||
fieldIPv6DstAddr = 29
|
||||
fieldTCPSrcPort = 38
|
||||
fieldTCPDstPort = 39
|
||||
|
||||
testEQ = 0
|
||||
|
||||
resultAccept uint16 = 0x7FFE
|
||||
resultReject uint16 = 0x7FFF
|
||||
)
|
||||
|
||||
// Filter flags passed to IOCTL_WINDIVERT_STARTUP alongside the compiled
|
||||
// filter. These tell the driver what *kinds* of packets the filter might
|
||||
// match, used as a kernel-side fast-reject.
|
||||
const (
|
||||
filterFlagOutbound uint64 = 0x0020
|
||||
filterFlagIP uint64 = 0x0040
|
||||
filterFlagIPv6 uint64 = 0x0080
|
||||
)
|
||||
|
||||
type filterInst struct {
|
||||
field uint16 // 11 bits used
|
||||
test uint8 // 5 bits used
|
||||
success uint16
|
||||
failure uint16
|
||||
neg bool
|
||||
arg [4]uint32
|
||||
}
|
||||
|
||||
// Filter is a typed specification of packets to capture. It replaces
|
||||
// WinDivert's filter string language.
|
||||
//
|
||||
// Zero value = "reject all" (match nothing), suitable for send-only handles.
|
||||
type Filter struct {
|
||||
insts []filterInst
|
||||
flags uint64 // filter flags for STARTUP ioctl
|
||||
}
|
||||
|
||||
// reject returns a filter that matches no packet. The empty insts slice
|
||||
// is encoded as a single rejecting instruction by encode().
|
||||
func reject() *Filter {
|
||||
return &Filter{}
|
||||
}
|
||||
|
||||
// OutboundTCP returns a filter matching outbound TCP packets on the given
|
||||
// 5-tuple. Both addresses must share an address family (IPv4 or IPv6).
|
||||
func OutboundTCP(src, dst netip.AddrPort) (*Filter, error) {
|
||||
if !src.IsValid() || !dst.IsValid() {
|
||||
return nil, E.New("windivert: filter: invalid address port")
|
||||
}
|
||||
if src.Addr().Is4() != dst.Addr().Is4() {
|
||||
return nil, E.New("windivert: filter: mixed IPv4/IPv6")
|
||||
}
|
||||
f := &Filter{
|
||||
flags: filterFlagOutbound,
|
||||
}
|
||||
// Insts chain as AND: each test's failure = REJECT, success = next inst.
|
||||
// The final inst's success = ACCEPT.
|
||||
f.add(fieldOutbound, testEQ, argUint32(1))
|
||||
if src.Addr().Is4() {
|
||||
f.flags |= filterFlagIP
|
||||
f.add(fieldIP, testEQ, argUint32(1))
|
||||
f.add(fieldTCP, testEQ, argUint32(1))
|
||||
f.add(fieldIPSrcAddr, testEQ, argIPv4(src.Addr()))
|
||||
f.add(fieldIPDstAddr, testEQ, argIPv4(dst.Addr()))
|
||||
} else {
|
||||
f.flags |= filterFlagIPv6
|
||||
f.add(fieldIPv6, testEQ, argUint32(1))
|
||||
f.add(fieldTCP, testEQ, argUint32(1))
|
||||
f.add(fieldIPv6SrcAddr, testEQ, argIPv6(src.Addr()))
|
||||
f.add(fieldIPv6DstAddr, testEQ, argIPv6(dst.Addr()))
|
||||
}
|
||||
f.add(fieldTCPSrcPort, testEQ, argUint32(uint32(src.Port())))
|
||||
f.add(fieldTCPDstPort, testEQ, argUint32(uint32(dst.Port())))
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (f *Filter) add(field uint16, test uint8, arg [4]uint32) {
|
||||
f.insts = append(f.insts, filterInst{field: field, test: test, arg: arg})
|
||||
}
|
||||
|
||||
func argUint32(v uint32) [4]uint32 { return [4]uint32{v, 0, 0, 0} }
|
||||
|
||||
// argIPv4 encodes an IPv4 address for IP_SRCADDR/IP_DSTADDR. The driver
|
||||
// compares against an IPv4-mapped-IPv6 form: {host_order_u32, 0x0000FFFF,
|
||||
// 0, 0} (see sys/windivert.c windivert_get_ipv4_addr and the IPv4_SRCADDR
|
||||
// val-word construction). Omitting the 0x0000FFFF marker causes the EQ
|
||||
// test to fail for every packet.
|
||||
func argIPv4(addr netip.Addr) [4]uint32 {
|
||||
b := addr.As4()
|
||||
return [4]uint32{binary.BigEndian.Uint32(b[:]), 0x0000FFFF, 0, 0}
|
||||
}
|
||||
|
||||
// argIPv6 encodes an IPv6 address for IPV6_SRCADDR/IPV6_DSTADDR. The
|
||||
// driver stores the address as four host-order uint32s in REVERSED word
|
||||
// order: val[0]=low (bytes 12..15), val[3]=high (bytes 0..3). See
|
||||
// sys/windivert.c windivert_outbound_network_v6_classify val-word
|
||||
// construction.
|
||||
func argIPv6(addr netip.Addr) [4]uint32 {
|
||||
b := addr.As16()
|
||||
return [4]uint32{
|
||||
binary.BigEndian.Uint32(b[12:16]),
|
||||
binary.BigEndian.Uint32(b[8:12]),
|
||||
binary.BigEndian.Uint32(b[4:8]),
|
||||
binary.BigEndian.Uint32(b[0:4]),
|
||||
}
|
||||
}
|
||||
|
||||
// encode serializes the Filter to the on-wire WINDIVERT_FILTER[] format
|
||||
// plus the filter_flags for STARTUP ioctl.
|
||||
func (f *Filter) encode() ([]byte, uint64, error) {
|
||||
if len(f.insts) == 0 {
|
||||
// "Reject all" — one instruction, ZERO == 0 is always true, but we
|
||||
// invert by setting both success and failure to REJECT.
|
||||
return encodeInst(filterInst{
|
||||
field: fieldZero,
|
||||
test: testEQ,
|
||||
success: resultReject,
|
||||
failure: resultReject,
|
||||
}), 0, nil
|
||||
}
|
||||
if len(f.insts) > filterMaxInsts-1 {
|
||||
return nil, 0, E.New("windivert: filter too long")
|
||||
}
|
||||
buf := make([]byte, 0, filterInstBytes*len(f.insts))
|
||||
for i, inst := range f.insts {
|
||||
if i == len(f.insts)-1 {
|
||||
inst.success = resultAccept
|
||||
} else {
|
||||
inst.success = uint16(i + 1)
|
||||
}
|
||||
inst.failure = resultReject
|
||||
buf = append(buf, encodeInst(inst)...)
|
||||
}
|
||||
return buf, f.flags, nil
|
||||
}
|
||||
|
||||
func encodeInst(inst filterInst) []byte {
|
||||
out := make([]byte, filterInstBytes)
|
||||
word0 := uint32(inst.field&0x7FF) | uint32(inst.test&0x1F)<<11 |
|
||||
uint32(inst.success)<<16
|
||||
word1 := uint32(inst.failure)
|
||||
if inst.neg {
|
||||
word1 |= 1 << 16
|
||||
}
|
||||
binary.LittleEndian.PutUint32(out[0:4], word0)
|
||||
binary.LittleEndian.PutUint32(out[4:8], word1)
|
||||
binary.LittleEndian.PutUint32(out[8:12], inst.arg[0])
|
||||
binary.LittleEndian.PutUint32(out[12:16], inst.arg[1])
|
||||
binary.LittleEndian.PutUint32(out[16:20], inst.arg[2])
|
||||
binary.LittleEndian.PutUint32(out[20:24], inst.arg[3])
|
||||
return out
|
||||
}
|
||||
140
common/windivert/filter_test.go
Normal file
140
common/windivert/filter_test.go
Normal file
@ -0,0 +1,140 @@
|
||||
package windivert
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRejectFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
bin, flags, err := reject().encode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(bin) != filterInstBytes {
|
||||
t.Fatalf("reject filter len: got %d, want %d", len(bin), filterInstBytes)
|
||||
}
|
||||
if flags != 0 {
|
||||
t.Fatalf("reject filter flags: got %x, want 0", flags)
|
||||
}
|
||||
// word0: field=ZERO=0, test=EQ=0, success=REJECT=0x7FFF
|
||||
word0 := binary.LittleEndian.Uint32(bin[0:4])
|
||||
if word0 != uint32(resultReject)<<16 {
|
||||
t.Fatalf("reject word0 = %08x", word0)
|
||||
}
|
||||
// word1: failure=REJECT
|
||||
word1 := binary.LittleEndian.Uint32(bin[4:8])
|
||||
if word1 != uint32(resultReject) {
|
||||
t.Fatalf("reject word1 = %08x", word1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundTCPFilterIPv4(t *testing.T) {
|
||||
t.Parallel()
|
||||
src := netip.MustParseAddrPort("10.1.2.3:54321")
|
||||
dst := netip.MustParseAddrPort("1.2.3.4:443")
|
||||
f, err := OutboundTCP(src, dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin, flags, err := f.encode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := filterFlagOutbound | filterFlagIP; flags != want {
|
||||
t.Fatalf("flags: got %x, want %x", flags, want)
|
||||
}
|
||||
// 7 instructions: OUTBOUND, IP, TCP, IP_SRCADDR, IP_DSTADDR, TCP_SRCPORT, TCP_DSTPORT
|
||||
const wantInsts = 7
|
||||
if len(bin) != wantInsts*filterInstBytes {
|
||||
t.Fatalf("instruction count: got %d, want %d", len(bin)/filterInstBytes, wantInsts)
|
||||
}
|
||||
|
||||
// Inst 0: OUTBOUND == 1, success=1, failure=REJECT
|
||||
checkInst(t, bin[0*filterInstBytes:], 0, fieldOutbound, testEQ, 1, resultReject, 1)
|
||||
// Inst 1: IP == 1, success=2
|
||||
checkInst(t, bin[1*filterInstBytes:], 1, fieldIP, testEQ, 2, resultReject, 1)
|
||||
// Inst 2: TCP == 1, success=3
|
||||
checkInst(t, bin[2*filterInstBytes:], 2, fieldTCP, testEQ, 3, resultReject, 1)
|
||||
// Inst 3: IP_SRCADDR == 10.1.2.3 (host-order uint32 = 0x0A010203, arg[1]=0x0000FFFF marker)
|
||||
checkInst(t, bin[3*filterInstBytes:], 3, fieldIPSrcAddr, testEQ, 4, resultReject, 0x0A010203)
|
||||
checkArg1(t, bin[3*filterInstBytes:], 3, 0x0000FFFF)
|
||||
// Inst 4: IP_DSTADDR == 1.2.3.4
|
||||
checkInst(t, bin[4*filterInstBytes:], 4, fieldIPDstAddr, testEQ, 5, resultReject, 0x01020304)
|
||||
checkArg1(t, bin[4*filterInstBytes:], 4, 0x0000FFFF)
|
||||
// Inst 5: TCP_SRCPORT == 54321
|
||||
checkInst(t, bin[5*filterInstBytes:], 5, fieldTCPSrcPort, testEQ, 6, resultReject, 54321)
|
||||
// Last inst 6: TCP_DSTPORT == 443, success=ACCEPT
|
||||
checkInst(t, bin[6*filterInstBytes:], 6, fieldTCPDstPort, testEQ, resultAccept, resultReject, 443)
|
||||
}
|
||||
|
||||
func TestOutboundTCPFilterIPv6(t *testing.T) {
|
||||
t.Parallel()
|
||||
src := netip.MustParseAddrPort("[2001:db8::1]:54321")
|
||||
dst := netip.MustParseAddrPort("[2001:db8::2]:443")
|
||||
f, err := OutboundTCP(src, dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin, flags, err := f.encode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := filterFlagOutbound | filterFlagIPv6; flags != want {
|
||||
t.Fatalf("flags: got %x, want %x", flags, want)
|
||||
}
|
||||
// Inst 3: IPv6_SRCADDR. The driver stores the address in reversed
|
||||
// word order: arg[0]=low (bytes 12..15)=1, arg[3]=high (bytes 0..3)=0x20010db8.
|
||||
off := 3 * filterInstBytes
|
||||
a0 := binary.LittleEndian.Uint32(bin[off+8:])
|
||||
a1 := binary.LittleEndian.Uint32(bin[off+12:])
|
||||
a2 := binary.LittleEndian.Uint32(bin[off+16:])
|
||||
a3 := binary.LittleEndian.Uint32(bin[off+20:])
|
||||
if a0 != 1 || a1 != 0 || a2 != 0 || a3 != 0x20010db8 {
|
||||
t.Fatalf("ipv6 src arg=[%08x %08x %08x %08x], want [1 0 0 0x20010db8]", a0, a1, a2, a3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundTCPFilterMixedFamily(t *testing.T) {
|
||||
t.Parallel()
|
||||
src := netip.MustParseAddrPort("10.0.0.1:1234")
|
||||
dst := netip.MustParseAddrPort("[2001:db8::1]:443")
|
||||
if _, err := OutboundTCP(src, dst); err == nil {
|
||||
t.Fatal("expected error for mixed families")
|
||||
}
|
||||
}
|
||||
|
||||
func checkArg1(t *testing.T, raw []byte, idx int, arg1 uint32) {
|
||||
t.Helper()
|
||||
got := binary.LittleEndian.Uint32(raw[12:16])
|
||||
if got != arg1 {
|
||||
t.Errorf("inst %d arg[1]: got %08x, want %08x", idx, got, arg1)
|
||||
}
|
||||
}
|
||||
|
||||
func checkInst(t *testing.T, raw []byte, idx int, field uint16, test uint8, success, failure uint16, arg0 uint32) {
|
||||
t.Helper()
|
||||
word0 := binary.LittleEndian.Uint32(raw[0:4])
|
||||
word1 := binary.LittleEndian.Uint32(raw[4:8])
|
||||
a0 := binary.LittleEndian.Uint32(raw[8:12])
|
||||
gotField := uint16(word0 & 0x7FF)
|
||||
gotTest := uint8((word0 >> 11) & 0x1F)
|
||||
gotSuccess := uint16(word0 >> 16)
|
||||
gotFailure := uint16(word1 & 0xFFFF)
|
||||
if gotField != field {
|
||||
t.Errorf("inst %d field: got %d, want %d", idx, gotField, field)
|
||||
}
|
||||
if gotTest != test {
|
||||
t.Errorf("inst %d test: got %d, want %d", idx, gotTest, test)
|
||||
}
|
||||
if gotSuccess != success {
|
||||
t.Errorf("inst %d success: got %d, want %d", idx, gotSuccess, success)
|
||||
}
|
||||
if gotFailure != failure {
|
||||
t.Errorf("inst %d failure: got %d, want %d", idx, gotFailure, failure)
|
||||
}
|
||||
if a0 != arg0 {
|
||||
t.Errorf("inst %d arg[0]: got %08x, want %08x", idx, a0, arg0)
|
||||
}
|
||||
}
|
||||
320
common/windivert/handle_windows.go
Normal file
320
common/windivert/handle_windows.go
Normal file
@ -0,0 +1,320 @@
|
||||
//go:build windows
|
||||
|
||||
package windivert
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"runtime"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// Handle owns a WinDivert kernel device handle plus a private event for
|
||||
// overlapped I/O. Methods on *Handle are not safe for concurrent use
|
||||
// across goroutines (there is a single shared event per Handle).
|
||||
//
|
||||
// addr is a per-Handle Address buffer the IOCTL struct embeds a pointer
|
||||
// to. It lives on the heap (as a field of a heap-allocated Handle) so
|
||||
// the pointer value stored as bytes in the ioctl buffer remains valid
|
||||
// across stack growth between buildIoctl* and the DeviceIoControl
|
||||
// syscall — stack-local Address values are not safe for this pattern
|
||||
// because Go's escape analysis does not see the pointer through the
|
||||
// unsafe.Pointer → uintptr → bytes conversion.
|
||||
type Handle struct {
|
||||
device windows.Handle
|
||||
event windows.Handle
|
||||
closing sync.Once
|
||||
closeErr error
|
||||
addr Address
|
||||
}
|
||||
|
||||
// Filter may be nil for "reject all", suitable for send-only handles.
|
||||
// Requires Administrator on first call per process (installs the kernel
|
||||
// driver via SCM); subsequent calls reuse the running driver.
|
||||
func Open(filter *Filter, layer Layer, priority int16, flags Flag) (*Handle, error) {
|
||||
err := validateOpenArgs(layer, priority, flags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filter == nil {
|
||||
filter = reject()
|
||||
}
|
||||
filterBin, filterFlags, err := filter.encode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
device, err := openDevice()
|
||||
if err != nil {
|
||||
if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) &&
|
||||
!errors.Is(err, windows.ERROR_PATH_NOT_FOUND) {
|
||||
if errors.Is(err, windows.ERROR_ACCESS_DENIED) {
|
||||
return nil, E.Cause(err, "windivert: open device (administrator required)")
|
||||
}
|
||||
return nil, E.Cause(err, "windivert: open device")
|
||||
}
|
||||
// Device node missing: kernel driver not loaded. Install + retry.
|
||||
// Matches WinDivertOpen's lazy-install path; avoids racing StartService
|
||||
// against a still-loaded driver whose SCM record is marked for deletion.
|
||||
err = ensureDriver()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
device, err = openDevice()
|
||||
if err != nil {
|
||||
if errors.Is(err, windows.ERROR_ACCESS_DENIED) {
|
||||
return nil, E.Cause(err, "windivert: open device (administrator required)")
|
||||
}
|
||||
return nil, E.Cause(err, "windivert: open device")
|
||||
}
|
||||
}
|
||||
event, err := windows.CreateEvent(nil, 1, 0, nil) // manual reset, unsignaled
|
||||
if err != nil {
|
||||
windows.CloseHandle(device)
|
||||
return nil, E.Cause(err, "windivert: create event")
|
||||
}
|
||||
h := &Handle{device: device, event: event}
|
||||
|
||||
err = h.initialize(layer, priority, flags)
|
||||
if err != nil {
|
||||
h.Close()
|
||||
return nil, err
|
||||
}
|
||||
err = h.startup(filterBin, filterFlags)
|
||||
if err != nil {
|
||||
h.Close()
|
||||
return nil, err
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func openDevice() (windows.Handle, error) {
|
||||
return windows.CreateFile(
|
||||
driverDevName,
|
||||
windows.GENERIC_READ|windows.GENERIC_WRITE,
|
||||
0, nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OVERLAPPED,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
func validateOpenArgs(layer Layer, priority int16, flags Flag) error {
|
||||
if layer != LayerNetwork {
|
||||
return E.New("windivert: invalid layer ", uint32(layer))
|
||||
}
|
||||
if priority < PriorityLowest || priority > PriorityHighest {
|
||||
return E.New("windivert: priority out of range")
|
||||
}
|
||||
if flags&^FlagSendOnly != 0 {
|
||||
return E.New("windivert: unknown flag bits")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handle) initialize(layer Layer, priority int16, flags Flag) error {
|
||||
in := buildIoctlInitialize(layer, priority, flags)
|
||||
// WINDIVERT_VERSION is a 64-byte packed struct; only the first 20
|
||||
// bytes (magic, major, minor, bits) carry data, the rest is reserved.
|
||||
var outBuf [versionStructSize]byte
|
||||
binary.LittleEndian.PutUint64(outBuf[0:8], magicDLL)
|
||||
binary.LittleEndian.PutUint32(outBuf[8:12], versionMajor)
|
||||
binary.LittleEndian.PutUint32(outBuf[12:16], versionMinor)
|
||||
binary.LittleEndian.PutUint32(outBuf[16:20], uint32(unsafe.Sizeof(uintptr(0))*8))
|
||||
_, err := doIoctl(h.device, ioctlInitialize, in[:], outBuf[:], h.event)
|
||||
if err != nil {
|
||||
return E.Cause(err, "windivert: initialize ioctl")
|
||||
}
|
||||
gotMagic := binary.LittleEndian.Uint64(outBuf[0:8])
|
||||
if gotMagic != magicSYS {
|
||||
return E.New("windivert: driver magic mismatch (got ", gotMagic, ")")
|
||||
}
|
||||
gotMajor := binary.LittleEndian.Uint32(outBuf[8:12])
|
||||
if gotMajor < versionMajor {
|
||||
gotMinor := binary.LittleEndian.Uint32(outBuf[12:16])
|
||||
return E.New("windivert: driver version too old: ", gotMajor, ".", gotMinor)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handle) startup(filterBin []byte, filterFlags uint64) error {
|
||||
in := buildIoctlStartup(filterFlags)
|
||||
_, err := doIoctl(h.device, ioctlStartup, in[:], filterBin, h.event)
|
||||
if err != nil {
|
||||
return E.Cause(err, "windivert: startup ioctl")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// If the handle is closed mid-Recv the error wraps ERROR_OPERATION_ABORTED.
|
||||
func (h *Handle) Recv(buf []byte) (int, Address, error) {
|
||||
if len(buf) == 0 {
|
||||
return 0, Address{}, E.New("windivert: recv: zero-length buffer")
|
||||
}
|
||||
h.addr = Address{}
|
||||
in := buildIoctlRecv(&h.addr)
|
||||
n, err := doIoctl(h.device, ioctlRecv, in[:], buf, h.event)
|
||||
runtime.KeepAlive(h)
|
||||
if err != nil {
|
||||
return 0, Address{}, err
|
||||
}
|
||||
return int(n), h.addr, nil
|
||||
}
|
||||
|
||||
// The address's Outbound flag controls whether the packet is sent toward
|
||||
// the wire (outbound=true) or delivered up the stack (outbound=false).
|
||||
// IfIdx and SubIfIdx can stay zero — the driver uses the routing table
|
||||
// when IfIdx=0.
|
||||
func (h *Handle) Send(packet []byte, addr *Address) (int, error) {
|
||||
if len(packet) == 0 {
|
||||
return 0, E.New("windivert: send: empty packet")
|
||||
}
|
||||
if addr == nil {
|
||||
return 0, E.New("windivert: send: nil address")
|
||||
}
|
||||
h.addr = *addr
|
||||
in := buildIoctlSend(&h.addr)
|
||||
n, err := doIoctl(h.device, ioctlSend, in[:], packet, h.event)
|
||||
runtime.KeepAlive(h)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
// Idempotent. Aborts any in-flight I/O on the handle.
|
||||
func (h *Handle) Close() error {
|
||||
h.closing.Do(func() {
|
||||
var errs []error
|
||||
if h.device != 0 {
|
||||
err := windows.CloseHandle(h.device)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
h.device = 0
|
||||
}
|
||||
if h.event != 0 {
|
||||
err := windows.CloseHandle(h.event)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
h.event = 0
|
||||
}
|
||||
h.closeErr = E.Errors(errs...)
|
||||
})
|
||||
return h.closeErr
|
||||
}
|
||||
|
||||
// IOCTL codes from windivert_device.h. CTL_CODE macro layout:
|
||||
//
|
||||
// (DeviceType << 16) | (Access << 14) | (Function << 2) | Method
|
||||
const (
|
||||
fileDeviceNetwork uint32 = 0x12
|
||||
accessReadWrite uint32 = 3 // FILE_READ_DATA | FILE_WRITE_DATA
|
||||
accessRead uint32 = 1
|
||||
|
||||
methodInDirect uint32 = 1
|
||||
methodOutDirect uint32 = 2
|
||||
)
|
||||
|
||||
func ctlCode(deviceType, access, function, method uint32) uint32 {
|
||||
return (deviceType << 16) | (access << 14) | (function << 2) | method
|
||||
}
|
||||
|
||||
var (
|
||||
ioctlInitialize = ctlCode(fileDeviceNetwork, accessReadWrite, 0x921, methodOutDirect)
|
||||
ioctlStartup = ctlCode(fileDeviceNetwork, accessReadWrite, 0x922, methodInDirect)
|
||||
ioctlRecv = ctlCode(fileDeviceNetwork, accessRead, 0x923, methodOutDirect)
|
||||
ioctlSend = ctlCode(fileDeviceNetwork, accessReadWrite, 0x924, methodInDirect)
|
||||
)
|
||||
|
||||
// Magic numbers exchanged during INITIALIZE. DLL sends magicDLL in the
|
||||
// version struct; driver returns magicSYS on success.
|
||||
const (
|
||||
magicDLL uint64 = 0x4C4C447669645724 // "$WdivDLL" in LE bytes
|
||||
magicSYS uint64 = 0x5359537669645723 // "#WdivSYS" in LE bytes
|
||||
)
|
||||
|
||||
const (
|
||||
versionMajor uint32 = 2
|
||||
versionMinor uint32 = 2
|
||||
)
|
||||
|
||||
// Size of the WINDIVERT_IOCTL union on wire (packed).
|
||||
const ioctlSize = 16
|
||||
|
||||
// Size of WINDIVERT_VERSION on wire (packed). Only the first 20 bytes
|
||||
// carry data; the rest is reserved zero padding.
|
||||
const versionStructSize = 64
|
||||
|
||||
// doIoctl performs a single synchronous (blocking) overlapped
|
||||
// DeviceIoControl. The handle is opened with FILE_FLAG_OVERLAPPED so
|
||||
// DeviceIoControl returns ERROR_IO_PENDING; we then wait for completion
|
||||
// via GetOverlappedResult. Event is passed in so callers can reuse it
|
||||
// across calls on the same handle (avoids per-call CreateEvent).
|
||||
func doIoctl(handle windows.Handle, code uint32, in []byte, out []byte, event windows.Handle) (uint32, error) {
|
||||
var overlapped windows.Overlapped
|
||||
overlapped.HEvent = event
|
||||
_ = windows.ResetEvent(event)
|
||||
|
||||
var inPtr *byte
|
||||
var inLen uint32
|
||||
if len(in) > 0 {
|
||||
inPtr = &in[0]
|
||||
inLen = uint32(len(in))
|
||||
}
|
||||
var outPtr *byte
|
||||
var outLen uint32
|
||||
if len(out) > 0 {
|
||||
outPtr = &out[0]
|
||||
outLen = uint32(len(out))
|
||||
}
|
||||
var returned uint32
|
||||
err := windows.DeviceIoControl(handle, code, inPtr, inLen, outPtr, outLen, &returned, &overlapped)
|
||||
if err != nil && !errors.Is(err, windows.ERROR_IO_PENDING) {
|
||||
return 0, err
|
||||
}
|
||||
err = windows.GetOverlappedResult(handle, &overlapped, &returned, true)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return returned, nil
|
||||
}
|
||||
|
||||
func buildIoctlInitialize(layer Layer, priority int16, flags Flag) [ioctlSize]byte {
|
||||
var buf [ioctlSize]byte
|
||||
binary.LittleEndian.PutUint32(buf[0:4], uint32(layer))
|
||||
// The driver expects priority + WINDIVERT_PRIORITY_HIGHEST (30000) so
|
||||
// the low range maps to non-negative integers.
|
||||
binary.LittleEndian.PutUint32(buf[4:8], uint32(int32(priority)+int32(PriorityHighest)))
|
||||
binary.LittleEndian.PutUint64(buf[8:16], uint64(flags))
|
||||
return buf
|
||||
}
|
||||
|
||||
func buildIoctlStartup(filterFlags uint64) [ioctlSize]byte {
|
||||
var buf [ioctlSize]byte
|
||||
binary.LittleEndian.PutUint64(buf[0:8], filterFlags)
|
||||
return buf
|
||||
}
|
||||
|
||||
// buildIoctlRecv packs a user-space pointer to a WINDIVERT_ADDRESS into
|
||||
// the ioctl struct. The driver dereferences it to write the address for
|
||||
// the received packet. Caller must keep the Address alive via
|
||||
// runtime.KeepAlive.
|
||||
func buildIoctlRecv(addr *Address) [ioctlSize]byte {
|
||||
var buf [ioctlSize]byte
|
||||
binary.LittleEndian.PutUint64(buf[0:8], uint64(uintptr(unsafe.Pointer(addr))))
|
||||
binary.LittleEndian.PutUint64(buf[8:16], 0)
|
||||
return buf
|
||||
}
|
||||
|
||||
func buildIoctlSend(addr *Address) [ioctlSize]byte {
|
||||
var buf [ioctlSize]byte
|
||||
binary.LittleEndian.PutUint64(buf[0:8], uint64(uintptr(unsafe.Pointer(addr))))
|
||||
binary.LittleEndian.PutUint64(buf[8:16], uint64(unsafe.Sizeof(Address{})))
|
||||
return buf
|
||||
}
|
||||
106
common/windivert/handle_windows_test.go
Normal file
106
common/windivert/handle_windows_test.go
Normal 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)))
|
||||
}
|
||||
88
common/windivert/integration_windows_test.go
Normal file
88
common/windivert/integration_windows_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
71
common/windivert/windivert.go
Normal file
71
common/windivert/windivert.go
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 废弃"
|
||||
|
||||
@ -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"`
|
||||
|
||||
Loading…
Reference in New Issue
Block a user