Skip to content

Commit dd08f80

Browse files
committed
Add TLS spoof support
1 parent 1586acf commit dd08f80

45 files changed

Lines changed: 4227 additions & 6 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test.yml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
name: Test
2+
3+
on:
4+
push:
5+
branches:
6+
- stable
7+
- testing
8+
- unstable
9+
paths-ignore:
10+
- '**.md'
11+
- '.github/**'
12+
- '!.github/workflows/test.yml'
13+
pull_request:
14+
branches:
15+
- stable
16+
- testing
17+
- unstable
18+
19+
concurrency:
20+
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}-${{ inputs.build }}
21+
cancel-in-progress: true
22+
23+
jobs:
24+
test:
25+
name: Test
26+
strategy:
27+
fail-fast: false
28+
matrix:
29+
os:
30+
- ubuntu-latest
31+
- windows-latest
32+
- macos-latest
33+
go:
34+
- ~1.24
35+
- ~1.25
36+
runs-on: ${{ matrix.os }}
37+
steps:
38+
- name: Checkout
39+
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
40+
- name: Setup Go
41+
uses: actions/setup-go@v5
42+
with:
43+
go-version: ${{ matrix.go }}
44+
- name: Set build tags and ldflags
45+
shell: bash
46+
run: |
47+
echo "BUILD_TAGS=$(cat release/DEFAULT_BUILD_TAGS_OTHERS)" >> "$GITHUB_ENV"
48+
echo "LDFLAGS_SHARED=$(cat release/LDFLAGS)" >> "$GITHUB_ENV"
49+
- name: Test (unix)
50+
if: matrix.os != 'windows-latest'
51+
run: go test -v -exec sudo -tags "$BUILD_TAGS" -ldflags "$LDFLAGS_SHARED" ./...
52+
- name: Test (windows)
53+
if: matrix.os == 'windows-latest'
54+
shell: bash
55+
run: go test -v -tags "$BUILD_TAGS" -ldflags "$LDFLAGS_SHARED" ./...

.golangci.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ linters:
1919
enable:
2020
- govet
2121
- ineffassign
22-
- paralleltest
2322
- staticcheck
2423
settings:
2524
staticcheck:

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ lint:
5252
GOOS=android golangci-lint run ./...
5353
GOOS=windows golangci-lint run ./...
5454
GOOS=darwin golangci-lint run ./...
55-
GOOS=freebsd golangci-lint run ./...
55+
# GOOS=freebsd golangci-lint run ./...
5656

5757
lint_install:
5858
go install -v github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest

common/tls/apple_client.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,9 @@ func ValidateAppleTLSOptions(ctx context.Context, options option.OutboundTLSOpti
155155
if options.KernelTx || options.KernelRx {
156156
return AppleTLSValidated{}, E.New("ktls is unsupported in ", engineName)
157157
}
158+
if options.Spoof != "" || options.SpoofMethod != "" {
159+
return AppleTLSValidated{}, E.New("spoof is unsupported in ", engineName)
160+
}
158161
if len(options.CertificatePublicKeySHA256) > 0 && (len(options.Certificate) > 0 || options.CertificatePath != "") {
159162
return AppleTLSValidated{}, E.New("certificate_public_key_sha256 is conflict with certificate or certificate_path")
160163
}

common/tls/client.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"os"
99

1010
"github.com/sagernet/sing-box/common/badtls"
11+
"github.com/sagernet/sing-box/common/tlsspoof"
1112
C "github.com/sagernet/sing-box/constant"
1213
"github.com/sagernet/sing-box/option"
1314
E "github.com/sagernet/sing/common/exceptions"
@@ -19,6 +20,37 @@ import (
1920

2021
var errMissingServerName = E.New("missing server_name or insecure=true")
2122

23+
func parseTLSSpoofOptions(serverName string, options option.OutboundTLSOptions) (string, tlsspoof.Method, error) {
24+
if options.Spoof == "" {
25+
if options.SpoofMethod != "" {
26+
return "", 0, E.New("`spoof_method` requires `spoof`")
27+
}
28+
return "", 0, nil
29+
}
30+
if !tlsspoof.PlatformSupported {
31+
return "", 0, E.New("`spoof` is not supported on this platform")
32+
}
33+
if options.DisableSNI || serverName == "" {
34+
return "", 0, E.New("`spoof` requires TLS ClientHello with SNI")
35+
}
36+
method, err := tlsspoof.ParseMethod(options.SpoofMethod)
37+
if err != nil {
38+
return "", 0, err
39+
}
40+
return options.Spoof, method, nil
41+
}
42+
43+
func applyTLSSpoof(conn net.Conn, spoof string, method tlsspoof.Method) (net.Conn, error) {
44+
if spoof == "" {
45+
return conn, nil
46+
}
47+
spoofer, err := tlsspoof.NewSpoofer(conn, method)
48+
if err != nil {
49+
return nil, err
50+
}
51+
return tlsspoof.NewConn(conn, spoofer, spoof), nil
52+
}
53+
2254
func NewDialerFromOptions(ctx context.Context, logger logger.ContextLogger, dialer N.Dialer, serverAddress string, options option.OutboundTLSOptions) (N.Dialer, error) {
2355
if !options.Enabled {
2456
return dialer, nil

common/tls/reality_client.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ func newRealityClient(ctx context.Context, logger logger.ContextLogger, serverAd
5959
if options.UTLS == nil || !options.UTLS.Enabled {
6060
return nil, E.New("uTLS is required by reality client")
6161
}
62+
if options.Spoof != "" || options.SpoofMethod != "" {
63+
return nil, E.New("spoof is unsupported in reality")
64+
}
6265

6366
uClient, err := newUTLSClient(ctx, logger, serverAddress, options, allowEmptyServerName)
6467
if err != nil {

common/tls/std_client.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
"github.com/sagernet/sing-box/adapter"
1616
"github.com/sagernet/sing-box/common/tlsfragment"
17+
"github.com/sagernet/sing-box/common/tlsspoof"
1718
C "github.com/sagernet/sing-box/constant"
1819
"github.com/sagernet/sing-box/option"
1920
E "github.com/sagernet/sing/common/exceptions"
@@ -31,6 +32,8 @@ type STDClientConfig struct {
3132
fragment bool
3233
fragmentFallbackDelay time.Duration
3334
recordFragment bool
35+
spoof string
36+
spoofMethod tlsspoof.Method
3437
}
3538

3639
func (c *STDClientConfig) ServerName() string {
@@ -75,6 +78,10 @@ func (c *STDClientConfig) Client(conn net.Conn) (Conn, error) {
7578
if c.recordFragment {
7679
conn = tf.NewConn(conn, c.ctx, c.fragment, c.recordFragment, c.fragmentFallbackDelay)
7780
}
81+
conn, err := applyTLSSpoof(conn, c.spoof, c.spoofMethod)
82+
if err != nil {
83+
return nil, err
84+
}
7885
return tls.Client(conn, c.config), nil
7986
}
8087

@@ -89,6 +96,8 @@ func (c *STDClientConfig) Clone() Config {
8996
fragment: c.fragment,
9097
fragmentFallbackDelay: c.fragmentFallbackDelay,
9198
recordFragment: c.recordFragment,
99+
spoof: c.spoof,
100+
spoofMethod: c.spoofMethod,
92101
}
93102
cloned.SetServerName(cloned.serverName)
94103
return cloned
@@ -218,6 +227,10 @@ func newSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres
218227
} else {
219228
handshakeTimeout = C.TCPTimeout
220229
}
230+
spoof, spoofMethod, err := parseTLSSpoofOptions(serverName, options)
231+
if err != nil {
232+
return nil, err
233+
}
221234
var config Config = &STDClientConfig{
222235
ctx: ctx,
223236
config: &tlsConfig,
@@ -228,6 +241,8 @@ func newSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres
228241
fragment: options.Fragment,
229242
fragmentFallbackDelay: time.Duration(options.FragmentFallbackDelay),
230243
recordFragment: options.RecordFragment,
244+
spoof: spoof,
245+
spoofMethod: spoofMethod,
231246
}
232247
config.SetServerName(serverName)
233248
if options.ECH != nil && options.ECH.Enabled {

common/tls/utls_client.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
"github.com/sagernet/sing-box/adapter"
1616
"github.com/sagernet/sing-box/common/tlsfragment"
17+
"github.com/sagernet/sing-box/common/tlsspoof"
1718
C "github.com/sagernet/sing-box/constant"
1819
"github.com/sagernet/sing-box/option"
1920
"github.com/sagernet/sing/common"
@@ -36,6 +37,8 @@ type UTLSClientConfig struct {
3637
fragment bool
3738
fragmentFallbackDelay time.Duration
3839
recordFragment bool
40+
spoof string
41+
spoofMethod tlsspoof.Method
3942
}
4043

4144
func (c *UTLSClientConfig) ServerName() string {
@@ -83,6 +86,10 @@ func (c *UTLSClientConfig) Client(conn net.Conn) (Conn, error) {
8386
if c.recordFragment {
8487
conn = tf.NewConn(conn, c.ctx, c.fragment, c.recordFragment, c.fragmentFallbackDelay)
8588
}
89+
conn, err := applyTLSSpoof(conn, c.spoof, c.spoofMethod)
90+
if err != nil {
91+
return nil, err
92+
}
8693
return &utlsALPNWrapper{utlsConnWrapper{utls.UClient(conn, c.config.Clone(), c.id)}, c.config.NextProtos}, nil
8794
}
8895

@@ -102,6 +109,8 @@ func (c *UTLSClientConfig) Clone() Config {
102109
fragment: c.fragment,
103110
fragmentFallbackDelay: c.fragmentFallbackDelay,
104111
recordFragment: c.recordFragment,
112+
spoof: c.spoof,
113+
spoofMethod: c.spoofMethod,
105114
}
106115
cloned.SetServerName(cloned.serverName)
107116
return cloned
@@ -290,6 +299,10 @@ func newUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddre
290299
} else {
291300
handshakeTimeout = C.TCPTimeout
292301
}
302+
spoof, spoofMethod, err := parseTLSSpoofOptions(serverName, options)
303+
if err != nil {
304+
return nil, err
305+
}
293306
id, err := uTLSClientHelloID(options.UTLS.Fingerprint)
294307
if err != nil {
295308
return nil, err
@@ -305,6 +318,8 @@ func newUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddre
305318
fragment: options.Fragment,
306319
fragmentFallbackDelay: time.Duration(options.FragmentFallbackDelay),
307320
recordFragment: options.RecordFragment,
321+
spoof: spoof,
322+
spoofMethod: spoofMethod,
308323
}
309324
config.SetServerName(serverName)
310325
if options.ECH != nil && options.ECH.Enabled {

common/tlsfragment/index.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,10 @@ const (
2323
)
2424

2525
type MyServerName struct {
26-
Index int
27-
Length int
28-
ServerName string
26+
Index int
27+
Length int
28+
ServerName string
29+
ExtensionsListLengthIndex int
2930
}
3031

3132
func IndexTLSServerName(payload []byte) *MyServerName {
@@ -41,6 +42,7 @@ func IndexTLSServerName(payload []byte) *MyServerName {
4142
return nil
4243
}
4344
serverName.Index += recordLayerHeaderLen
45+
serverName.ExtensionsListLengthIndex += recordLayerHeaderLen
4446
return serverName
4547
}
4648

@@ -82,6 +84,7 @@ func indexTLSServerNameFromHandshake(handshake []byte) *MyServerName {
8284
return nil
8385
}
8486
serverName.Index += currentIndex
87+
serverName.ExtensionsListLengthIndex = currentIndex
8588
return serverName
8689
}
8790

common/tlsspoof/client_hello.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package tlsspoof
2+
3+
import (
4+
"encoding/binary"
5+
6+
tf "github.com/sagernet/sing-box/common/tlsfragment"
7+
E "github.com/sagernet/sing/common/exceptions"
8+
)
9+
10+
const (
11+
recordLengthOffset = 3
12+
handshakeLengthOffset = 6
13+
)
14+
15+
// server_name extension layout (RFC 6066 §3). Offsets are relative to the
16+
// SNI host name (index returned by the parser):
17+
//
18+
// ... uint16 extension_type = 0x0000 (host_name - 9)
19+
// ... uint16 extension_data_length (host_name - 7)
20+
// ... uint16 server_name_list_length (host_name - 5)
21+
// ... uint8 name_type = host_name (host_name - 3)
22+
// ... uint16 host_name_length (host_name - 2)
23+
// sni host_name (host_name)
24+
const (
25+
extensionDataLengthOffsetFromSNI = -7
26+
listLengthOffsetFromSNI = -5
27+
hostNameLengthOffsetFromSNI = -2
28+
)
29+
30+
func rewriteSNI(record []byte, fakeSNI string) ([]byte, error) {
31+
if len(fakeSNI) > 0xFFFF {
32+
return nil, E.New("fake SNI too long: ", len(fakeSNI), " bytes")
33+
}
34+
serverName := tf.IndexTLSServerName(record)
35+
if serverName == nil {
36+
return nil, E.New("not a ClientHello with SNI")
37+
}
38+
39+
delta := len(fakeSNI) - serverName.Length
40+
out := make([]byte, len(record)+delta)
41+
copy(out, record[:serverName.Index])
42+
copy(out[serverName.Index:], fakeSNI)
43+
copy(out[serverName.Index+len(fakeSNI):], record[serverName.Index+serverName.Length:])
44+
45+
err := patchUint16(out, recordLengthOffset, delta)
46+
if err != nil {
47+
return nil, E.Cause(err, "patch record length")
48+
}
49+
err = patchUint24(out, handshakeLengthOffset, delta)
50+
if err != nil {
51+
return nil, E.Cause(err, "patch handshake length")
52+
}
53+
for _, off := range []int{
54+
serverName.ExtensionsListLengthIndex,
55+
serverName.Index + extensionDataLengthOffsetFromSNI,
56+
serverName.Index + listLengthOffsetFromSNI,
57+
serverName.Index + hostNameLengthOffsetFromSNI,
58+
} {
59+
err = patchUint16(out, off, delta)
60+
if err != nil {
61+
return nil, E.Cause(err, "patch length at offset ", off)
62+
}
63+
}
64+
return out, nil
65+
}
66+
67+
func patchUint16(data []byte, offset, delta int) error {
68+
patched := int(binary.BigEndian.Uint16(data[offset:])) + delta
69+
if patched < 0 || patched > 0xFFFF {
70+
return E.New("uint16 out of range: ", patched)
71+
}
72+
binary.BigEndian.PutUint16(data[offset:], uint16(patched))
73+
return nil
74+
}
75+
76+
func patchUint24(data []byte, offset, delta int) error {
77+
original := int(data[offset])<<16 | int(data[offset+1])<<8 | int(data[offset+2])
78+
patched := original + delta
79+
if patched < 0 || patched > 0xFFFFFF {
80+
return E.New("uint24 out of range: ", patched)
81+
}
82+
data[offset] = byte(patched >> 16)
83+
data[offset+1] = byte(patched >> 8)
84+
data[offset+2] = byte(patched)
85+
return nil
86+
}

0 commit comments

Comments
 (0)