Skip to content

Commit 37a8928

Browse files
committed
Add TLS spoof support
1 parent 86cbbbf commit 37a8928

38 files changed

Lines changed: 4179 additions & 4 deletions

common/tcpspoof/client_hello.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package tcpspoof
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 tcpspoof
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+
}

common/tcpspoof/conn_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package tcpspoof
2+
3+
import (
4+
"encoding/hex"
5+
"io"
6+
"net"
7+
"testing"
8+
9+
tf "github.com/sagernet/sing-box/common/tlsfragment"
10+
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
type fakeSpoofer struct {
15+
injected [][]byte
16+
err error
17+
}
18+
19+
func (f *fakeSpoofer) Inject(payload []byte) error {
20+
if f.err != nil {
21+
return f.err
22+
}
23+
f.injected = append(f.injected, append([]byte(nil), payload...))
24+
return nil
25+
}
26+
27+
func (f *fakeSpoofer) Close() error {
28+
return nil
29+
}
30+
31+
func readAll(t *testing.T, conn net.Conn) []byte {
32+
t.Helper()
33+
data, err := io.ReadAll(conn)
34+
require.NoError(t, err)
35+
return data
36+
}
37+
38+
func TestConn_Write_InjectsThenForwards(t *testing.T) {
39+
t.Parallel()
40+
payload, err := hex.DecodeString(realClientHello)
41+
require.NoError(t, err)
42+
43+
client, server := net.Pipe()
44+
spoofer := &fakeSpoofer{}
45+
wrapped := NewConn(client, spoofer, "letsencrypt.org")
46+
47+
serverRead := make(chan []byte, 1)
48+
go func() {
49+
serverRead <- readAll(t, server)
50+
}()
51+
52+
n, err := wrapped.Write(payload)
53+
require.NoError(t, err)
54+
require.Equal(t, len(payload), n)
55+
require.NoError(t, wrapped.Close())
56+
57+
forwarded := <-serverRead
58+
require.Equal(t, payload, forwarded, "underlying conn must receive the real ClientHello unchanged")
59+
require.Len(t, spoofer.injected, 1)
60+
61+
injected := spoofer.injected[0]
62+
serverName := tf.IndexTLSServerName(injected)
63+
require.NotNil(t, serverName, "injected payload must parse as ClientHello")
64+
require.Equal(t, "letsencrypt.org", serverName.ServerName)
65+
}
66+
67+
func TestConn_Write_SecondWriteDoesNotInject(t *testing.T) {
68+
t.Parallel()
69+
payload, err := hex.DecodeString(realClientHello)
70+
require.NoError(t, err)
71+
72+
client, server := net.Pipe()
73+
spoofer := &fakeSpoofer{}
74+
wrapped := NewConn(client, spoofer, "letsencrypt.org")
75+
76+
serverRead := make(chan []byte, 1)
77+
go func() {
78+
serverRead <- readAll(t, server)
79+
}()
80+
81+
_, err = wrapped.Write(payload)
82+
require.NoError(t, err)
83+
_, err = wrapped.Write([]byte("second"))
84+
require.NoError(t, err)
85+
require.NoError(t, wrapped.Close())
86+
87+
forwarded := <-serverRead
88+
require.Equal(t, append(append([]byte(nil), payload...), []byte("second")...), forwarded)
89+
require.Len(t, spoofer.injected, 1)
90+
}
91+
92+
func TestConn_Write_NonClientHelloReturnsError(t *testing.T) {
93+
t.Parallel()
94+
client, server := net.Pipe()
95+
defer client.Close()
96+
defer server.Close()
97+
98+
spoofer := &fakeSpoofer{}
99+
wrapped := NewConn(client, spoofer, "letsencrypt.org")
100+
101+
_, err := wrapped.Write([]byte("not a ClientHello"))
102+
require.Error(t, err)
103+
require.Empty(t, spoofer.injected)
104+
}
105+
106+
func TestParseMethod(t *testing.T) {
107+
t.Parallel()
108+
cases := map[string]struct {
109+
want Method
110+
ok bool
111+
}{
112+
"": {MethodWrongSequence, true},
113+
"wrong-sequence": {MethodWrongSequence, true},
114+
"wrong-checksum": {MethodWrongChecksum, true},
115+
"nonsense": {0, false},
116+
}
117+
for input, expected := range cases {
118+
m, err := ParseMethod(input)
119+
if !expected.ok {
120+
require.Error(t, err, "input=%q", input)
121+
continue
122+
}
123+
require.NoError(t, err, "input=%q", input)
124+
require.Equal(t, expected.want, m, "input=%q", input)
125+
}
126+
}

common/tcpspoof/endpoints.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package tcpspoof
2+
3+
import (
4+
"net"
5+
"net/netip"
6+
7+
"github.com/sagernet/sing/common"
8+
E "github.com/sagernet/sing/common/exceptions"
9+
M "github.com/sagernet/sing/common/metadata"
10+
)
11+
12+
// The returned addresses are v4-unmapped and share the same family.
13+
func tcpEndpoints(conn net.Conn) (*net.TCPConn, netip.AddrPort, netip.AddrPort, error) {
14+
tcpConn, isTCP := common.Cast[*net.TCPConn](conn)
15+
if !isTCP {
16+
return nil, netip.AddrPort{}, netip.AddrPort{}, E.New("tls_spoof: underlying conn is not *net.TCPConn")
17+
}
18+
local := M.AddrPortFromNet(tcpConn.LocalAddr())
19+
remote := M.AddrPortFromNet(tcpConn.RemoteAddr())
20+
if !local.IsValid() || !remote.IsValid() {
21+
return nil, netip.AddrPort{}, netip.AddrPort{}, E.New("tls_spoof: invalid conn address")
22+
}
23+
local = netip.AddrPortFrom(local.Addr().Unmap(), local.Port())
24+
remote = netip.AddrPortFrom(remote.Addr().Unmap(), remote.Port())
25+
if local.Addr().Is4() != remote.Addr().Is4() {
26+
return nil, netip.AddrPort{}, netip.AddrPort{}, E.New("tls_spoof: local/remote address family mismatch")
27+
}
28+
return tcpConn, local, remote, nil
29+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
//go:build integration_tcpspoof
2+
3+
package tcpspoof
4+
5+
const loopbackInterface = "lo0"
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
//go:build integration_tcpspoof
2+
3+
package tcpspoof
4+
5+
const loopbackInterface = "lo"

0 commit comments

Comments
 (0)