Skip to content

Commit ef99cd4

Browse files
committed
Add TLS spoof support
1 parent 407405e commit ef99cd4

44 files changed

Lines changed: 4214 additions & 17 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 -exec sudo -v -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:

common/tls/std_client.go

Lines changed: 28 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 {
@@ -72,9 +75,20 @@ func (c *STDClientConfig) STDConfig() (*STDConfig, error) {
7275
}
7376

7477
func (c *STDClientConfig) Client(conn net.Conn) (Conn, error) {
78+
var spoofer tlsspoof.Spoofer
79+
if c.spoof != "" {
80+
var err error
81+
spoofer, err = tlsspoof.NewSpoofer(conn, c.spoofMethod)
82+
if err != nil {
83+
return nil, err
84+
}
85+
}
7586
if c.recordFragment {
7687
conn = tf.NewConn(conn, c.ctx, c.fragment, c.recordFragment, c.fragmentFallbackDelay)
7788
}
89+
if spoofer != nil {
90+
conn = tlsspoof.NewConn(conn, spoofer, c.spoof)
91+
}
7892
return tls.Client(conn, c.config), nil
7993
}
8094

@@ -89,6 +103,8 @@ func (c *STDClientConfig) Clone() Config {
89103
fragment: c.fragment,
90104
fragmentFallbackDelay: c.fragmentFallbackDelay,
91105
recordFragment: c.recordFragment,
106+
spoof: c.spoof,
107+
spoofMethod: c.spoofMethod,
92108
}
93109
cloned.SetServerName(cloned.serverName)
94110
return cloned
@@ -218,6 +234,16 @@ func newSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres
218234
} else {
219235
handshakeTimeout = C.TCPTimeout
220236
}
237+
if options.Spoof != "" && !tlsspoof.PlatformSupported {
238+
return nil, E.New("`spoof` is not supported on this platform")
239+
}
240+
if options.Spoof == "" && options.SpoofMethod != "" {
241+
return nil, E.New("`spoof_method` requires `spoof`")
242+
}
243+
spoofMethod, err := tlsspoof.ParseMethod(options.SpoofMethod)
244+
if err != nil {
245+
return nil, err
246+
}
221247
var config Config = &STDClientConfig{
222248
ctx: ctx,
223249
config: &tlsConfig,
@@ -228,6 +254,8 @@ func newSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres
228254
fragment: options.Fragment,
229255
fragmentFallbackDelay: time.Duration(options.FragmentFallbackDelay),
230256
recordFragment: options.RecordFragment,
257+
spoof: options.Spoof,
258+
spoofMethod: spoofMethod,
231259
}
232260
config.SetServerName(serverName)
233261
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+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package tlsspoof
2+
3+
import (
4+
"encoding/binary"
5+
"encoding/hex"
6+
"testing"
7+
8+
tf "github.com/sagernet/sing-box/common/tlsfragment"
9+
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// realClientHello is a captured Chrome ClientHello for github.com,
14+
// reused from common/tlsfragment/index_test.go.
15+
const realClientHello = "16030105f8010005f403036e35de7389a679c54029cf452611f2211c70d9ac3897271de589ab6155f8e4ab20637d225f1ef969ad87ed78bfb9d171300bcb1703b6f314ccefb964f79b7d0961002a0a0a130213031301c02cc02bcca9c030c02fcca8c00ac009c014c013009d009c0035002fc008c012000a01000581baba00000000000f000d00000a6769746875622e636f6d00170000ff01000100000a000e000c3a3a11ec001d001700180019000b000201000010000e000c02683208687474702f312e31000500050100000000000d00160014040308040401050308050805050108060601020100120000003304ef04ed3a3a00010011ec04c0aeb2250c092a3463161cccb29d9183331a424964248579507ed23a180b0ceab2a5f5d9ce41547e497a89055471ea572867ba3a1fc3c9e45025274a20f60c6b60e62476b6afed0403af59ab83660ef4112ae20386a602010d0a5d454c0ed34c84ed4423e750213e6a2baab1bf9c4367a6007ab40a33d95220c2dcaa44f257024a5626b545db0510f4311b1a60714154909c6a61fdfca011fb2626d657aeb6070bf078508babe3b584555013e34acc56198ed4663742b3155a664a9901794c4586820a7dc162c01827291f3792e1237f801a8d1ef096013c181c4a58d2f6859ba75022d18cc4418bd4f351d5c18f83a58857d05af860c4b9ac018a5b63f17184e591532c6bc2cf2215d4a282c8a8a4f6f7aee110422c8bc9ebd3b1d609c568523aaae555db320e6c269473d87af38c256cbb9febc20aea6380c32a8916f7a373c8b1e37554e3260bf6621f6b804ee80b3c516b1d01985bf4c603b6daa9a5991de6a7a29f3a7122b8afb843a7660110fce62b43c615f5bcc2db688ba012649c0952b0a2c031e732d2b454c6b2968683cb8d244be2c9a7fa163222979eaf92722b92b862d81a3d94450c2b60c318421ebb4307c42d1f0473592a5c30e42039cc68cda9721e61aa63f49def17c15221680ed444896340133bbee67556f56b9f9d78a4df715f926a12add0cc9c862e46ea8b7316ae468282c18601b2771c9c9322f982228cf93effaacd3f80cbd12bce5fc36f56e2a3caf91e578a5fae00c9b23a8ed1a66764f4433c3628a70b8f0a6196adc60a4cb4226f07ba4c6b363fe9065563bfc1347452946386bab488686e837ab979c64f9047417fca635fe1bb4f074f256cc8af837c7b455e280426547755af90a61640169ef180aea3a77e662bb6dac1b6c3696027129b1a5edf495314e9c7f4b6110e16378ec893fa24642330a40aba1a85326101acb97c620fd8d71389e69eaed7bdb01bbe1fd428d66191150c7b2cd1ad4257391676a82ba8ce07fb2667c3b289f159003a7c7bc31d361b7b7f49a802961739d950dfcc0fa1c7abce5abdd2245101da391151490862028110465950b9e9c03d08a90998ab83267838d2e74a0593bc81f74cdf734519a05b351c0e5488c68dd810e6e9142ccc1e2f4a7f464297eb340e27acc6b9d64e12e38cce8492b3d939140b5a9e149a75597f10a23874c84323a07cdd657274378f887c85c4259b9c04cd33ba58ed630ef2a744f8e19dd34843dff331d2a6be7e2332c599289cd248a611c73d7481cd4a9bd43449a3836f14b2af18a1739e17999e4c67e85cc5bcecabb14185e5bcaff3c96098f03dc5aba819f29587758f49f940585354a2a780830528d68ccd166920dadcaa25cab5fc1907272a826aba3f08bc6b88757776812ecb6c7cec69a223ec0a13a7b62a2349a0f63ed7a27a3b15ba21d71fe6864ec6e089ae17cadd433fa3138f7ee24353c11365818f8fc34f43a05542d18efaac24bfccc1f748a0cc1a67ad379468b76fd34973dba785f5c91d618333cd810fe0700d1bbc8422029782628070a624c52c5309a4a64d625b11f8033ab28df34a1add297517fcc06b92b6817b3c5144438cf260867c57bde68c8c4b82e6a135ef676a52fbae5708002a404e6189a60e2836de565ad1b29e3819e5ed49f6810bcb28e1bd6de57306f94b79d9dae1cc4624d2a068499beef81cd5fe4b76dcbfff2a2008001d002001976128c6d5a934533f28b9914d2480aab2a8c1ab03d212529ce8b27640a716002d00020101002b000706caca03040303001b00030200015a5a000100"
16+
17+
func decodeClientHello(t *testing.T) []byte {
18+
t.Helper()
19+
payload, err := hex.DecodeString(realClientHello)
20+
require.NoError(t, err)
21+
return payload
22+
}
23+
24+
func assertConsistent(t *testing.T, payload []byte, expectedSNI string) {
25+
t.Helper()
26+
serverName := tf.IndexTLSServerName(payload)
27+
require.NotNil(t, serverName, "parser should find SNI in rewritten payload")
28+
require.Equal(t, expectedSNI, serverName.ServerName)
29+
require.Equal(t, expectedSNI, string(payload[serverName.Index:serverName.Index+serverName.Length]))
30+
// Record length must equal len(payload) - 5.
31+
recordLen := binary.BigEndian.Uint16(payload[3:5])
32+
require.Equal(t, len(payload)-5, int(recordLen), "record length must equal payload - 5")
33+
// Handshake length must equal len(payload) - 5 - 4.
34+
handshakeLen := int(payload[6])<<16 | int(payload[7])<<8 | int(payload[8])
35+
require.Equal(t, len(payload)-5-4, handshakeLen, "handshake length must equal payload - 9")
36+
}
37+
38+
func TestRewriteSNI_ShorterReplacement(t *testing.T) {
39+
t.Parallel()
40+
payload := decodeClientHello(t)
41+
out, err := rewriteSNI(payload, "a.io")
42+
require.NoError(t, err)
43+
require.Len(t, out, len(payload)-6) // original "github.com" is 10 bytes, "a.io" is 4 bytes.
44+
assertConsistent(t, out, "a.io")
45+
}
46+
47+
func TestRewriteSNI_SameLengthReplacement(t *testing.T) {
48+
t.Parallel()
49+
payload := decodeClientHello(t)
50+
out, err := rewriteSNI(payload, "example.co")
51+
require.NoError(t, err)
52+
require.Len(t, out, len(payload))
53+
assertConsistent(t, out, "example.co")
54+
}
55+
56+
func TestRewriteSNI_LongerReplacement(t *testing.T) {
57+
t.Parallel()
58+
payload := decodeClientHello(t)
59+
out, err := rewriteSNI(payload, "letsencrypt.org")
60+
require.NoError(t, err)
61+
require.Len(t, out, len(payload)+5) // "letsencrypt.org" is 15, original 10, delta 5.
62+
assertConsistent(t, out, "letsencrypt.org")
63+
}
64+
65+
func TestRewriteSNI_NoSNIReturnsError(t *testing.T) {
66+
t.Parallel()
67+
// Truncated payload — not a valid ClientHello.
68+
_, err := rewriteSNI([]byte{0x16, 0x03, 0x01, 0x00, 0x01, 0x01}, "x.com")
69+
require.Error(t, err)
70+
}
71+
72+
func TestRewriteSNI_DoesNotMutateInput(t *testing.T) {
73+
t.Parallel()
74+
payload := decodeClientHello(t)
75+
original := append([]byte(nil), payload...)
76+
_, err := rewriteSNI(payload, "letsencrypt.org")
77+
require.NoError(t, err)
78+
require.Equal(t, original, payload, "input payload must not be mutated")
79+
}

0 commit comments

Comments
 (0)