Skip to content

Commit beaa6bd

Browse files
committed
Fix tls-spoof
1 parent 4c01502 commit beaa6bd

21 files changed

Lines changed: 980 additions & 238 deletions

common/tls/client.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"errors"
77
"net"
88
"os"
9+
"strings"
910

1011
"github.com/sagernet/sing-box/common/badtls"
1112
"github.com/sagernet/sing-box/common/tlsspoof"
@@ -33,6 +34,9 @@ func parseTLSSpoofOptions(serverName string, options option.OutboundTLSOptions)
3334
if options.DisableSNI || serverName == "" || M.ParseAddr(serverName).IsValid() {
3435
return "", 0, E.New("`spoof` requires TLS ClientHello with SNI")
3536
}
37+
if strings.EqualFold(options.Spoof, serverName) {
38+
return "", 0, E.New("`spoof` must differ from `server_name`")
39+
}
3640
method, err := tlsspoof.ParseMethod(options.SpoofMethod)
3741
if err != nil {
3842
return "", 0, err
@@ -44,11 +48,7 @@ func applyTLSSpoof(conn net.Conn, spoof string, method tlsspoof.Method) (net.Con
4448
if spoof == "" {
4549
return conn, nil
4650
}
47-
spoofer, err := tlsspoof.NewSpoofer(conn, method)
48-
if err != nil {
49-
return nil, err
50-
}
51-
return tlsspoof.NewConn(conn, spoofer, spoof), nil
51+
return tlsspoof.NewConn(conn, method, spoof)
5252
}
5353

5454
func NewDialerFromOptions(ctx context.Context, logger logger.ContextLogger, dialer N.Dialer, serverAddress string, options option.OutboundTLSOptions) (N.Dialer, error) {

common/tls/client_test.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package tls
2+
3+
import (
4+
"context"
5+
"crypto/tls"
6+
"net"
7+
"testing"
8+
9+
tf "github.com/sagernet/sing-box/common/tlsfragment"
10+
"github.com/sagernet/sing-box/common/tlsspoof"
11+
"github.com/sagernet/sing-box/option"
12+
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
func TestParseTLSSpoofOptions_Disabled(t *testing.T) {
17+
t.Parallel()
18+
spoof, method, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{})
19+
require.NoError(t, err)
20+
require.Empty(t, spoof)
21+
require.Equal(t, tlsspoof.MethodWrongSequence, method)
22+
}
23+
24+
func TestParseTLSSpoofOptions_MethodWithoutSpoof(t *testing.T) {
25+
t.Parallel()
26+
_, _, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{
27+
SpoofMethod: tlsspoof.MethodNameWrongChecksum,
28+
})
29+
require.Error(t, err)
30+
}
31+
32+
func TestParseTLSSpoofOptions_IPLiteralRejected(t *testing.T) {
33+
t.Parallel()
34+
_, _, err := parseTLSSpoofOptions("1.2.3.4", option.OutboundTLSOptions{
35+
Spoof: "example.com",
36+
})
37+
require.Error(t, err)
38+
}
39+
40+
func TestParseTLSSpoofOptions_EmptyServerNameRejected(t *testing.T) {
41+
t.Parallel()
42+
_, _, err := parseTLSSpoofOptions("", option.OutboundTLSOptions{
43+
Spoof: "example.com",
44+
})
45+
require.Error(t, err)
46+
}
47+
48+
func TestParseTLSSpoofOptions_DisableSNIRejected(t *testing.T) {
49+
t.Parallel()
50+
_, _, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{
51+
Spoof: "decoy.com",
52+
DisableSNI: true,
53+
})
54+
require.Error(t, err)
55+
}
56+
57+
// TestParseTLSSpoofOptions_RejectsSameSNI is the primary regression test for
58+
// the "spoofed packet contains the original SNI" bug report: when a user
59+
// configures spoof equal to server_name, the rewriter produces a byte-identical
60+
// record, so the fake and real ClientHellos on the wire look the same. Reject
61+
// at parse time.
62+
func TestParseTLSSpoofOptions_RejectsSameSNI(t *testing.T) {
63+
t.Parallel()
64+
_, _, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{
65+
Spoof: "example.com",
66+
})
67+
require.Error(t, err)
68+
69+
_, _, err = parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{
70+
Spoof: "EXAMPLE.com",
71+
})
72+
require.Error(t, err, "comparison must be case-insensitive")
73+
}
74+
75+
func TestParseTLSSpoofOptions_UnknownMethodRejected(t *testing.T) {
76+
t.Parallel()
77+
_, _, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{
78+
Spoof: "decoy.com",
79+
SpoofMethod: "nonsense",
80+
})
81+
require.Error(t, err)
82+
}
83+
84+
func TestParseTLSSpoofOptions_DistinctSNIAccepted(t *testing.T) {
85+
t.Parallel()
86+
if !tlsspoof.PlatformSupported {
87+
t.Skip("tlsspoof not supported on this platform")
88+
}
89+
spoof, method, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{
90+
Spoof: "decoy.com",
91+
SpoofMethod: tlsspoof.MethodNameWrongSequence,
92+
})
93+
require.NoError(t, err)
94+
require.Equal(t, "decoy.com", spoof)
95+
require.Equal(t, tlsspoof.MethodWrongSequence, method)
96+
}
97+
98+
// The following tests guard the wrap gate in STDClientConfig.Client():
99+
// tf.Conn must wrap the underlying connection whenever either `fragment` or
100+
// `record_fragment` is set, so that TLS fragmentation coexists with features
101+
// like tls_spoof that layer on top of tf.Conn.
102+
103+
func newSTDClientConfigForGateTest(fragment, recordFragment bool) *STDClientConfig {
104+
return &STDClientConfig{
105+
ctx: context.Background(),
106+
config: &tls.Config{ServerName: "example.com", InsecureSkipVerify: true},
107+
fragment: fragment,
108+
recordFragment: recordFragment,
109+
}
110+
}
111+
112+
func TestSTDClient_Client_NoFragment_DoesNotWrap(t *testing.T) {
113+
t.Parallel()
114+
client, server := net.Pipe()
115+
defer client.Close()
116+
defer server.Close()
117+
wrapped, err := newSTDClientConfigForGateTest(false, false).Client(client)
118+
require.NoError(t, err)
119+
_, isTF := wrapped.NetConn().(*tf.Conn)
120+
require.False(t, isTF, "no fragment flags: must not wrap with tf.Conn")
121+
}
122+
123+
func TestSTDClient_Client_FragmentOnly_Wraps(t *testing.T) {
124+
t.Parallel()
125+
client, server := net.Pipe()
126+
defer client.Close()
127+
defer server.Close()
128+
wrapped, err := newSTDClientConfigForGateTest(true, false).Client(client)
129+
require.NoError(t, err)
130+
_, isTF := wrapped.NetConn().(*tf.Conn)
131+
require.True(t, isTF, "fragment=true: must wrap with tf.Conn")
132+
}
133+
134+
func TestSTDClient_Client_RecordFragmentOnly_Wraps(t *testing.T) {
135+
t.Parallel()
136+
client, server := net.Pipe()
137+
defer client.Close()
138+
defer server.Close()
139+
wrapped, err := newSTDClientConfigForGateTest(false, true).Client(client)
140+
require.NoError(t, err)
141+
_, isTF := wrapped.NetConn().(*tf.Conn)
142+
require.True(t, isTF, "record_fragment=true: must wrap with tf.Conn")
143+
}
144+
145+
func TestSTDClient_Client_BothFragment_Wraps(t *testing.T) {
146+
t.Parallel()
147+
client, server := net.Pipe()
148+
defer client.Close()
149+
defer server.Close()
150+
wrapped, err := newSTDClientConfigForGateTest(true, true).Client(client)
151+
require.NoError(t, err)
152+
_, isTF := wrapped.NetConn().(*tf.Conn)
153+
require.True(t, isTF, "both fragment flags: must wrap with tf.Conn")
154+
}

common/tls/std_client.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ func (c *STDClientConfig) STDConfig() (*STDConfig, error) {
7575
}
7676

7777
func (c *STDClientConfig) Client(conn net.Conn) (Conn, error) {
78-
if c.recordFragment {
78+
if c.fragment || c.recordFragment {
7979
conn = tf.NewConn(conn, c.ctx, c.fragment, c.recordFragment, c.fragmentFallbackDelay)
8080
}
8181
conn, err := applyTLSSpoof(conn, c.spoof, c.spoofMethod)

common/tls/utls_client.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ func (c *UTLSClientConfig) STDConfig() (*STDConfig, error) {
8383
}
8484

8585
func (c *UTLSClientConfig) Client(conn net.Conn) (Conn, error) {
86-
if c.recordFragment {
86+
if c.fragment || c.recordFragment {
8787
conn = tf.NewConn(conn, c.ctx, c.fragment, c.recordFragment, c.fragmentFallbackDelay)
8888
}
8989
conn, err := applyTLSSpoof(conn, c.spoof, c.spoofMethod)

common/tls/utls_client_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
//go:build with_utls
2+
3+
package tls
4+
5+
import (
6+
"context"
7+
"net"
8+
"testing"
9+
10+
tf "github.com/sagernet/sing-box/common/tlsfragment"
11+
12+
utls "github.com/metacubex/utls"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
// Guards the wrap gate in UTLSClientConfig.Client(): tf.Conn must wrap the
17+
// underlying connection whenever either `fragment` or `record_fragment` is
18+
// set. Mirrors the STDClientConfig gate tests to keep both code paths in
19+
// lockstep.
20+
21+
func newUTLSClientConfigForGateTest(fragment, recordFragment bool) *UTLSClientConfig {
22+
return &UTLSClientConfig{
23+
ctx: context.Background(),
24+
config: &utls.Config{ServerName: "example.com", InsecureSkipVerify: true},
25+
id: utls.HelloChrome_Auto,
26+
fragment: fragment,
27+
recordFragment: recordFragment,
28+
}
29+
}
30+
31+
func TestUTLSClient_Client_NoFragment_DoesNotWrap(t *testing.T) {
32+
t.Parallel()
33+
client, server := net.Pipe()
34+
defer client.Close()
35+
defer server.Close()
36+
wrapped, err := newUTLSClientConfigForGateTest(false, false).Client(client)
37+
require.NoError(t, err)
38+
_, isTF := wrapped.NetConn().(*tf.Conn)
39+
require.False(t, isTF, "no fragment flags: must not wrap with tf.Conn")
40+
}
41+
42+
func TestUTLSClient_Client_FragmentOnly_Wraps(t *testing.T) {
43+
t.Parallel()
44+
client, server := net.Pipe()
45+
defer client.Close()
46+
defer server.Close()
47+
wrapped, err := newUTLSClientConfigForGateTest(true, false).Client(client)
48+
require.NoError(t, err)
49+
_, isTF := wrapped.NetConn().(*tf.Conn)
50+
require.True(t, isTF, "fragment=true: must wrap with tf.Conn")
51+
}
52+
53+
func TestUTLSClient_Client_RecordFragmentOnly_Wraps(t *testing.T) {
54+
t.Parallel()
55+
client, server := net.Pipe()
56+
defer client.Close()
57+
defer server.Close()
58+
wrapped, err := newUTLSClientConfigForGateTest(false, true).Client(client)
59+
require.NoError(t, err)
60+
_, isTF := wrapped.NetConn().(*tf.Conn)
61+
require.True(t, isTF, "record_fragment=true: must wrap with tf.Conn")
62+
}
63+
64+
func TestUTLSClient_Client_BothFragment_Wraps(t *testing.T) {
65+
t.Parallel()
66+
client, server := net.Pipe()
67+
defer client.Close()
68+
defer server.Close()
69+
wrapped, err := newUTLSClientConfigForGateTest(true, true).Client(client)
70+
require.NoError(t, err)
71+
_, isTF := wrapped.NetConn().(*tf.Conn)
72+
require.True(t, isTF, "both fragment flags: must wrap with tf.Conn")
73+
}

common/tlsspoof/client_hello.go

Lines changed: 27 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,37 @@
11
package tlsspoof
22

33
import (
4-
"encoding/binary"
4+
"bytes"
5+
"context"
6+
"crypto/tls"
57

6-
tf "github.com/sagernet/sing-box/common/tlsfragment"
8+
"github.com/sagernet/sing/common/bufio"
79
E "github.com/sagernet/sing/common/exceptions"
810
)
911

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")
12+
// buildFakeClientHello drives crypto/tls against a write-only in-memory conn
13+
// to capture a generated ClientHello. CurvePreferences pins classical groups
14+
// to suppress Go's default X25519MLKEM768 hybrid key share; without this the
15+
// post-quantum public key alone (~1184 bytes) pushes the record past one MSS,
16+
// and middleboxes do not reassemble fragmented ClientHellos. The handshake
17+
// error is discarded because the stub conn's Read returns immediately.
18+
func buildFakeClientHello(sni string) ([]byte, error) {
19+
if sni == "" {
20+
return nil, E.New("empty sni")
4821
}
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)
22+
var buf bytes.Buffer
23+
tlsConn := tls.Client(bufio.NewWriteOnlyConn(&buf), &tls.Config{
24+
ServerName: sni,
25+
// Order matches what browsers advertised before post-quantum.
26+
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256, tls.CurveP384},
27+
MinVersion: tls.VersionTLS12,
28+
MaxVersion: tls.VersionTLS13,
29+
NextProtos: []string{"h2", "http/1.1"},
30+
InsecureSkipVerify: true,
31+
})
32+
_ = tlsConn.HandshakeContext(context.Background())
33+
if buf.Len() == 0 {
34+
return nil, E.New("tls ClientHello not produced")
8135
}
82-
data[offset] = byte(patched >> 16)
83-
data[offset+1] = byte(patched >> 8)
84-
data[offset+2] = byte(patched)
85-
return nil
36+
return buf.Bytes(), nil
8637
}

0 commit comments

Comments
 (0)