From 1581a729246a671d93870b4e75a111923794cb50 Mon Sep 17 00:00:00 2001 From: macronut <4027187+macronut@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:24:49 +0800 Subject: [PATCH] Add more spoof method Signed-off-by: macronut <4027187+macronut@users.noreply.github.com> --- adapter/inbound.go | 3 + common/tls/client.go | 20 +- common/tls/client_test.go | 154 --------- common/tlsspoof/client_hello_test.go | 83 ----- common/tlsspoof/conn_test.go | 369 --------------------- common/tlsspoof/integration_unix_test.go | 97 +++--- common/tlsspoof/packet.go | 121 +++++-- common/tlsspoof/packet_test.go | 136 -------- common/tlsspoof/raw_darwin.go | 7 +- common/tlsspoof/raw_linux.go | 12 +- common/tlsspoof/raw_windows.go | 41 ++- common/tlsspoof/raw_windows_test.go | 112 ------- common/tlsspoof/spoof.go | 39 ++- common/tlsspoof/testdata_test.go | 4 + docs/configuration/route/rule_action.md | 28 +- docs/configuration/route/rule_action.zh.md | 26 +- docs/configuration/shared/tls.md | 13 +- docs/configuration/shared/tls.zh.md | 13 +- option/rule_action.go | 2 + route/conn.go | 12 + route/route.go | 8 + route/rule/rule_action.go | 81 +++-- 22 files changed, 360 insertions(+), 1021 deletions(-) delete mode 100644 common/tls/client_test.go delete mode 100644 common/tlsspoof/client_hello_test.go delete mode 100644 common/tlsspoof/conn_test.go delete mode 100644 common/tlsspoof/packet_test.go delete mode 100644 common/tlsspoof/raw_windows_test.go create mode 100644 common/tlsspoof/testdata_test.go diff --git a/adapter/inbound.go b/adapter/inbound.go index 923a8e668..44b327372 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -6,6 +6,7 @@ import ( "net/netip" "time" + "github.com/sagernet/sing-box/common/tlsspoof" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" @@ -75,6 +76,8 @@ type InboundContext struct { TLSFragment bool TLSFragmentFallbackDelay time.Duration TLSRecordFragment bool + TLSSpoof string + TLSSpoofMethod tlsspoof.Method NetworkStrategy *C.NetworkStrategy NetworkType []C.InterfaceType diff --git a/common/tls/client.go b/common/tls/client.go index 513438419..7b303687c 100644 --- a/common/tls/client.go +++ b/common/tls/client.go @@ -22,26 +22,20 @@ 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 + spoof, method, err := tlsspoof.ParseOptions(options.Spoof, options.SpoofMethod) + if err != nil { + return "", 0, err } - if !tlsspoof.PlatformSupported { - return "", 0, E.New("`spoof` is not supported on this platform") + if spoof == "" { + return "", 0, nil } if options.DisableSNI || serverName == "" || M.ParseAddr(serverName).IsValid() { return "", 0, E.New("`spoof` requires TLS ClientHello with SNI") } - if strings.EqualFold(options.Spoof, serverName) { + if strings.EqualFold(spoof, serverName) { return "", 0, E.New("`spoof` must differ from `server_name`") } - method, err := tlsspoof.ParseMethod(options.SpoofMethod) - if err != nil { - return "", 0, err - } - return options.Spoof, method, nil + return spoof, method, nil } func applyTLSSpoof(conn net.Conn, spoof string, method tlsspoof.Method) (net.Conn, error) { diff --git a/common/tls/client_test.go b/common/tls/client_test.go deleted file mode 100644 index 5bc939e29..000000000 --- a/common/tls/client_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package tls - -import ( - "context" - "crypto/tls" - "net" - "testing" - - tf "github.com/sagernet/sing-box/common/tlsfragment" - "github.com/sagernet/sing-box/common/tlsspoof" - "github.com/sagernet/sing-box/option" - - "github.com/stretchr/testify/require" -) - -func TestParseTLSSpoofOptions_Disabled(t *testing.T) { - t.Parallel() - spoof, method, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{}) - require.NoError(t, err) - require.Empty(t, spoof) - require.Equal(t, tlsspoof.MethodWrongSequence, method) -} - -func TestParseTLSSpoofOptions_MethodWithoutSpoof(t *testing.T) { - t.Parallel() - _, _, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{ - SpoofMethod: tlsspoof.MethodNameWrongChecksum, - }) - require.Error(t, err) -} - -func TestParseTLSSpoofOptions_IPLiteralRejected(t *testing.T) { - t.Parallel() - _, _, err := parseTLSSpoofOptions("1.2.3.4", option.OutboundTLSOptions{ - Spoof: "example.com", - }) - require.Error(t, err) -} - -func TestParseTLSSpoofOptions_EmptyServerNameRejected(t *testing.T) { - t.Parallel() - _, _, err := parseTLSSpoofOptions("", option.OutboundTLSOptions{ - Spoof: "example.com", - }) - require.Error(t, err) -} - -func TestParseTLSSpoofOptions_DisableSNIRejected(t *testing.T) { - t.Parallel() - _, _, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{ - Spoof: "decoy.com", - DisableSNI: true, - }) - require.Error(t, err) -} - -// TestParseTLSSpoofOptions_RejectsSameSNI is the primary regression test for -// the "spoofed packet contains the original SNI" bug report: when a user -// configures spoof equal to server_name, the rewriter produces a byte-identical -// record, so the fake and real ClientHellos on the wire look the same. Reject -// at parse time. -func TestParseTLSSpoofOptions_RejectsSameSNI(t *testing.T) { - t.Parallel() - _, _, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{ - Spoof: "example.com", - }) - require.Error(t, err) - - _, _, err = parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{ - Spoof: "EXAMPLE.com", - }) - require.Error(t, err, "comparison must be case-insensitive") -} - -func TestParseTLSSpoofOptions_UnknownMethodRejected(t *testing.T) { - t.Parallel() - _, _, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{ - Spoof: "decoy.com", - SpoofMethod: "nonsense", - }) - require.Error(t, err) -} - -func TestParseTLSSpoofOptions_DistinctSNIAccepted(t *testing.T) { - t.Parallel() - if !tlsspoof.PlatformSupported { - t.Skip("tlsspoof not supported on this platform") - } - spoof, method, err := parseTLSSpoofOptions("example.com", option.OutboundTLSOptions{ - Spoof: "decoy.com", - SpoofMethod: tlsspoof.MethodNameWrongSequence, - }) - require.NoError(t, err) - require.Equal(t, "decoy.com", spoof) - require.Equal(t, tlsspoof.MethodWrongSequence, method) -} - -// The following tests guard the wrap gate in STDClientConfig.Client(): -// tf.Conn must wrap the underlying connection whenever either `fragment` or -// `record_fragment` is set, so that TLS fragmentation coexists with features -// like tls_spoof that layer on top of tf.Conn. - -func newSTDClientConfigForGateTest(fragment, recordFragment bool) *STDClientConfig { - return &STDClientConfig{ - ctx: context.Background(), - config: &tls.Config{ServerName: "example.com", InsecureSkipVerify: true}, - fragment: fragment, - recordFragment: recordFragment, - } -} - -func TestSTDClient_Client_NoFragment_DoesNotWrap(t *testing.T) { - t.Parallel() - client, server := net.Pipe() - defer client.Close() - defer server.Close() - wrapped, err := newSTDClientConfigForGateTest(false, false).Client(client) - require.NoError(t, err) - _, isTF := wrapped.NetConn().(*tf.Conn) - require.False(t, isTF, "no fragment flags: must not wrap with tf.Conn") -} - -func TestSTDClient_Client_FragmentOnly_Wraps(t *testing.T) { - t.Parallel() - client, server := net.Pipe() - defer client.Close() - defer server.Close() - wrapped, err := newSTDClientConfigForGateTest(true, false).Client(client) - require.NoError(t, err) - _, isTF := wrapped.NetConn().(*tf.Conn) - require.True(t, isTF, "fragment=true: must wrap with tf.Conn") -} - -func TestSTDClient_Client_RecordFragmentOnly_Wraps(t *testing.T) { - t.Parallel() - client, server := net.Pipe() - defer client.Close() - defer server.Close() - wrapped, err := newSTDClientConfigForGateTest(false, true).Client(client) - require.NoError(t, err) - _, isTF := wrapped.NetConn().(*tf.Conn) - require.True(t, isTF, "record_fragment=true: must wrap with tf.Conn") -} - -func TestSTDClient_Client_BothFragment_Wraps(t *testing.T) { - t.Parallel() - client, server := net.Pipe() - defer client.Close() - defer server.Close() - wrapped, err := newSTDClientConfigForGateTest(true, true).Client(client) - require.NoError(t, err) - _, isTF := wrapped.NetConn().(*tf.Conn) - require.True(t, isTF, "both fragment flags: must wrap with tf.Conn") -} diff --git a/common/tlsspoof/client_hello_test.go b/common/tlsspoof/client_hello_test.go deleted file mode 100644 index 3eb7a2e04..000000000 --- a/common/tlsspoof/client_hello_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package tlsspoof - -import ( - "bytes" - "encoding/binary" - "strings" - "testing" - - tf "github.com/sagernet/sing-box/common/tlsfragment" - - "github.com/stretchr/testify/require" -) - -// x25519MLKEM768 is the IANA code point for the post-quantum hybrid named -// group (0x11EC). The fake ClientHello must never carry it — its 1184-byte -// key share is the reason kernel-generated ClientHellos exceed one MSS, and -// the reason this builder has to force CurvePreferences. -const x25519MLKEM768 uint16 = 0x11EC - -func TestBuildFakeClientHello_ParsesWithSNI(t *testing.T) { - t.Parallel() - record, err := buildFakeClientHello("example.com") - require.NoError(t, err) - - serverName := tf.IndexTLSServerName(record) - require.NotNil(t, serverName, "output must parse as a ClientHello") - require.Equal(t, "example.com", serverName.ServerName) - - recordLen := binary.BigEndian.Uint16(record[3:5]) - require.Equal(t, len(record)-5, int(recordLen), - "record length header must match on-wire record size") - handshakeLen := int(record[6])<<16 | int(record[7])<<8 | int(record[8]) - require.Equal(t, len(record)-5-4, handshakeLen, - "handshake length header must match handshake body size") -} - -// TestBuildFakeClientHello_FitsOneSegment is the regression guard for the -// whole point of the rewrite: the fake must never need fragmenting on a -// standard 1500-byte path MTU. 1200 leaves ~260 bytes for IP+TCP headers and -// a generous safety margin — the X25519MLKEM768 ClientHello this replaces -// hit ~1400+. -func TestBuildFakeClientHello_FitsOneSegment(t *testing.T) { - t.Parallel() - for _, sni := range []string{"a.io", "example.com", strings.Repeat("a", 253)} { - record, err := buildFakeClientHello(sni) - require.NoError(t, err, "sni=%q", sni) - require.Less(t, len(record), 1200, "sni=%q built %d bytes", sni, len(record)) - } -} - -// TestBuildFakeClientHello_NoPostQuantumKeyShare catches regressions that -// would accidentally pull an X25519MLKEM768 key share (the reason the prior -// implementation had to fragment) back into the fake — e.g. if CurvePreferences -// stopped being respected by a future Go version. -func TestBuildFakeClientHello_NoPostQuantumKeyShare(t *testing.T) { - t.Parallel() - record, err := buildFakeClientHello("example.com") - require.NoError(t, err) - - var needle [2]byte - binary.BigEndian.PutUint16(needle[:], x25519MLKEM768) - require.False(t, bytes.Contains(record, needle[:]), - "output must not contain the X25519MLKEM768 code point (0x%04x)", x25519MLKEM768) -} - -// TestBuildFakeClientHello_RandomizesPerCall ensures crypto/tls generates a -// fresh random + session_id + key_share on every call, as required to avoid -// trivial fingerprinting of the spoof. -func TestBuildFakeClientHello_RandomizesPerCall(t *testing.T) { - t.Parallel() - first, err := buildFakeClientHello("example.com") - require.NoError(t, err) - second, err := buildFakeClientHello("example.com") - require.NoError(t, err) - require.NotEqual(t, first, second, - "repeated calls must produce distinct bytes (random/session_id/key_share must vary)") -} - -func TestBuildFakeClientHello_RejectsEmpty(t *testing.T) { - t.Parallel() - _, err := buildFakeClientHello("") - require.Error(t, err) -} diff --git a/common/tlsspoof/conn_test.go b/common/tlsspoof/conn_test.go deleted file mode 100644 index b41cf5475..000000000 --- a/common/tlsspoof/conn_test.go +++ /dev/null @@ -1,369 +0,0 @@ -package tlsspoof - -import ( - "bytes" - "context" - "encoding/binary" - "encoding/hex" - "io" - "net" - "testing" - "time" - - tf "github.com/sagernet/sing-box/common/tlsfragment" - - "github.com/stretchr/testify/require" -) - -// realClientHello is a captured Chrome ClientHello for github.com. Tests that -// stack tlsspoof.Conn on top of tf.Conn still need a parseable payload to -// exercise the fragment transform. -const realClientHello = "16030105f8010005f403036e35de7389a679c54029cf452611f2211c70d9ac3897271de589ab6155f8e4ab20637d225f1ef969ad87ed78bfb9d171300bcb1703b6f314ccefb964f79b7d0961002a0a0a130213031301c02cc02bcca9c030c02fcca8c00ac009c014c013009d009c0035002fc008c012000a01000581baba00000000000f000d00000a6769746875622e636f6d00170000ff01000100000a000e000c3a3a11ec001d001700180019000b000201000010000e000c02683208687474702f312e31000500050100000000000d00160014040308040401050308050805050108060601020100120000003304ef04ed3a3a00010011ec04c0aeb2250c092a3463161cccb29d9183331a424964248579507ed23a180b0ceab2a5f5d9ce41547e497a89055471ea572867ba3a1fc3c9e45025274a20f60c6b60e62476b6afed0403af59ab83660ef4112ae20386a602010d0a5d454c0ed34c84ed4423e750213e6a2baab1bf9c4367a6007ab40a33d95220c2dcaa44f257024a5626b545db0510f4311b1a60714154909c6a61fdfca011fb2626d657aeb6070bf078508babe3b584555013e34acc56198ed4663742b3155a664a9901794c4586820a7dc162c01827291f3792e1237f801a8d1ef096013c181c4a58d2f6859ba75022d18cc4418bd4f351d5c18f83a58857d05af860c4b9ac018a5b63f17184e591532c6bc2cf2215d4a282c8a8a4f6f7aee110422c8bc9ebd3b1d609c568523aaae555db320e6c269473d87af38c256cbb9febc20aea6380c32a8916f7a373c8b1e37554e3260bf6621f6b804ee80b3c516b1d01985bf4c603b6daa9a5991de6a7a29f3a7122b8afb843a7660110fce62b43c615f5bcc2db688ba012649c0952b0a2c031e732d2b454c6b2968683cb8d244be2c9a7fa163222979eaf92722b92b862d81a3d94450c2b60c318421ebb4307c42d1f0473592a5c30e42039cc68cda9721e61aa63f49def17c15221680ed444896340133bbee67556f56b9f9d78a4df715f926a12add0cc9c862e46ea8b7316ae468282c18601b2771c9c9322f982228cf93effaacd3f80cbd12bce5fc36f56e2a3caf91e578a5fae00c9b23a8ed1a66764f4433c3628a70b8f0a6196adc60a4cb4226f07ba4c6b363fe9065563bfc1347452946386bab488686e837ab979c64f9047417fca635fe1bb4f074f256cc8af837c7b455e280426547755af90a61640169ef180aea3a77e662bb6dac1b6c3696027129b1a5edf495314e9c7f4b6110e16378ec893fa24642330a40aba1a85326101acb97c620fd8d71389e69eaed7bdb01bbe1fd428d66191150c7b2cd1ad4257391676a82ba8ce07fb2667c3b289f159003a7c7bc31d361b7b7f49a802961739d950dfcc0fa1c7abce5abdd2245101da391151490862028110465950b9e9c03d08a90998ab83267838d2e74a0593bc81f74cdf734519a05b351c0e5488c68dd810e6e9142ccc1e2f4a7f464297eb340e27acc6b9d64e12e38cce8492b3d939140b5a9e149a75597f10a23874c84323a07cdd657274378f887c85c4259b9c04cd33ba58ed630ef2a744f8e19dd34843dff331d2a6be7e2332c599289cd248a611c73d7481cd4a9bd43449a3836f14b2af18a1739e17999e4c67e85cc5bcecabb14185e5bcaff3c96098f03dc5aba819f29587758f49f940585354a2a780830528d68ccd166920dadcaa25cab5fc1907272a826aba3f08bc6b88757776812ecb6c7cec69a223ec0a13a7b62a2349a0f63ed7a27a3b15ba21d71fe6864ec6e089ae17cadd433fa3138f7ee24353c11365818f8fc34f43a05542d18efaac24bfccc1f748a0cc1a67ad379468b76fd34973dba785f5c91d618333cd810fe0700d1bbc8422029782628070a624c52c5309a4a64d625b11f8033ab28df34a1add297517fcc06b92b6817b3c5144438cf260867c57bde68c8c4b82e6a135ef676a52fbae5708002a404e6189a60e2836de565ad1b29e3819e5ed49f6810bcb28e1bd6de57306f94b79d9dae1cc4624d2a068499beef81cd5fe4b76dcbfff2a2008001d002001976128c6d5a934533f28b9914d2480aab2a8c1ab03d212529ce8b27640a716002d00020101002b000706caca03040303001b00030200015a5a000100" - -func decodeClientHello(t *testing.T) []byte { - t.Helper() - payload, err := hex.DecodeString(realClientHello) - require.NoError(t, err) - return payload -} - -type fakeSpoofer struct { - injected [][]byte - err error - closeErr 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 f.closeErr -} - -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 := decodeClientHello(t) - - client, server := net.Pipe() - spoofer := &fakeSpoofer{} - wrapped, err := newConn(client, spoofer, "letsencrypt.org") - require.NoError(t, err) - - 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 := decodeClientHello(t) - - client, server := net.Pipe() - spoofer := &fakeSpoofer{} - wrapped, err := newConn(client, spoofer, "letsencrypt.org") - require.NoError(t, err) - - 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) -} - -// TestConn_Write_SurfacesCloseError guards against the defer pattern silently -// dropping the spoofer's Close() error on the success path. -func TestConn_Write_SurfacesCloseError(t *testing.T) { - t.Parallel() - - client, server := net.Pipe() - defer client.Close() - defer server.Close() - spoofer := &fakeSpoofer{closeErr: errSpoofClose} - wrapped, err := newConn(client, spoofer, "letsencrypt.org") - require.NoError(t, err) - - go func() { _, _ = io.ReadAll(server) }() - - _, err = wrapped.Write([]byte("trigger inject")) - require.ErrorIs(t, err, errSpoofClose, - "Close() error must be wrapped into Write's return") -} - -func TestConn_NewConn_RejectsEmptySNI(t *testing.T) { - t.Parallel() - client, server := net.Pipe() - defer client.Close() - defer server.Close() - _, err := newConn(client, &fakeSpoofer{}, "") - require.Error(t, err, "empty SNI must fail at construction") -} - -var errSpoofClose = errTest("spoof-close-failed") - -type errTest string - -func (e errTest) Error() string { return string(e) } - -// recordingConn intercepts each Write call so tests can assert how many -// downstream writes occurred and in what order with respect to spoof -// injection. It does not implement WithUpstream, so tf.Conn's -// N.UnwrapReader(conn).(*net.TCPConn) returns nil and fragment-mode falls -// back to its plain Write + time.Sleep path — which is what we want to -// exercise over a net.Pipe. -type recordingConn struct { - net.Conn - writes [][]byte - timeline *[]string -} - -func (c *recordingConn) Write(p []byte) (int, error) { - c.writes = append(c.writes, append([]byte(nil), p...)) - if c.timeline != nil { - *c.timeline = append(*c.timeline, "write") - } - return c.Conn.Write(p) -} - -type tlsRecord struct { - contentType byte - payload []byte -} - -func parseTLSRecords(t *testing.T, data []byte) []tlsRecord { - t.Helper() - var records []tlsRecord - for len(data) > 0 { - require.GreaterOrEqual(t, len(data), 5, "record header incomplete") - recordLen := int(binary.BigEndian.Uint16(data[3:5])) - require.GreaterOrEqual(t, len(data), 5+recordLen, "record payload truncated") - records = append(records, tlsRecord{ - contentType: data[0], - payload: append([]byte(nil), data[5:5+recordLen]...), - }) - data = data[5+recordLen:] - } - return records -} - -// TestConn_StackedWithRecordFragment mirrors the wrapping order that -// STDClientConfig.Client() produces when record_fragment is enabled: -// tls.Client → tlsspoof.Conn → tf.Conn → raw conn. -// Asserts the decoy is injected and the real handshake arrives split into -// multiple TLS records whose payloads reassemble to the original. -func TestConn_StackedWithRecordFragment(t *testing.T) { - t.Parallel() - payload := decodeClientHello(t) - - client, server := net.Pipe() - defer server.Close() - - fragConn := tf.NewConn(client, context.Background(), false, true, time.Millisecond) - spoofer := &fakeSpoofer{} - wrapped, err := newConn(fragConn, spoofer, "letsencrypt.org") - require.NoError(t, err) - - serverRead := make(chan []byte, 1) - go func() { serverRead <- readAll(t, server) }() - - _, err = wrapped.Write(payload) - require.NoError(t, err) - require.NoError(t, wrapped.Close()) - forwarded := <-serverRead - - require.Len(t, spoofer.injected, 1, "spoof must inject exactly once") - injected := tf.IndexTLSServerName(spoofer.injected[0]) - require.NotNil(t, injected, "injected payload must parse as ClientHello") - require.Equal(t, "letsencrypt.org", injected.ServerName) - - records := parseTLSRecords(t, forwarded) - require.Greater(t, len(records), 1, "record_fragment must produce multiple records") - var reassembled []byte - for _, r := range records { - require.Equal(t, byte(0x16), r.contentType, "all records must be handshake") - reassembled = append(reassembled, r.payload...) - } - require.Equal(t, payload[5:], reassembled, "record payloads must reassemble to original handshake") -} - -// TestConn_StackedWithPacketFragment is the primary regression test for the -// fragment-only gate fix in STDClientConfig.Client(). It verifies that -// packet-level fragmentation combined with spoof produces: -// - one spoof injection carrying the decoy SNI, -// - multiple separate writes to the underlying conn, -// - an unmodified byte stream when those writes are concatenated -// (no extra record framing). -func TestConn_StackedWithPacketFragment(t *testing.T) { - t.Parallel() - payload := decodeClientHello(t) - - client, server := net.Pipe() - defer server.Close() - - rc := &recordingConn{Conn: client} - fragConn := tf.NewConn(rc, context.Background(), true, false, time.Millisecond) - spoofer := &fakeSpoofer{} - wrapped, err := newConn(fragConn, spoofer, "letsencrypt.org") - require.NoError(t, err) - - serverRead := make(chan []byte, 1) - go func() { serverRead <- readAll(t, server) }() - - _, err = wrapped.Write(payload) - require.NoError(t, err) - require.NoError(t, wrapped.Close()) - forwarded := <-serverRead - - require.Len(t, spoofer.injected, 1, "spoof must inject exactly once") - injected := tf.IndexTLSServerName(spoofer.injected[0]) - require.NotNil(t, injected) - require.Equal(t, "letsencrypt.org", injected.ServerName) - - require.Greater(t, len(rc.writes), 1, "fragment must split the ClientHello into multiple writes") - require.Equal(t, payload, bytes.Join(rc.writes, nil), - "concatenated writes must equal original bytes (no extra framing)") - require.Equal(t, payload, forwarded) -} - -// TestConn_StackedWithBothFragment exercises the combination that produces -// the strongest obfuscation: each chunk becomes its own TLS record and its -// own TCP write. -func TestConn_StackedWithBothFragment(t *testing.T) { - t.Parallel() - payload := decodeClientHello(t) - - client, server := net.Pipe() - defer server.Close() - - rc := &recordingConn{Conn: client} - fragConn := tf.NewConn(rc, context.Background(), true, true, time.Millisecond) - spoofer := &fakeSpoofer{} - wrapped, err := newConn(fragConn, spoofer, "letsencrypt.org") - require.NoError(t, err) - - serverRead := make(chan []byte, 1) - go func() { serverRead <- readAll(t, server) }() - - _, err = wrapped.Write(payload) - require.NoError(t, err) - require.NoError(t, wrapped.Close()) - forwarded := <-serverRead - - require.Len(t, spoofer.injected, 1) - injected := tf.IndexTLSServerName(spoofer.injected[0]) - require.NotNil(t, injected) - require.Equal(t, "letsencrypt.org", injected.ServerName) - - require.Greater(t, len(rc.writes), 1, "split-packet must produce multiple writes") - records := parseTLSRecords(t, forwarded) - require.Greater(t, len(records), 1, "split-record must produce multiple records") - var reassembled []byte - for _, r := range records { - require.Equal(t, byte(0x16), r.contentType) - reassembled = append(reassembled, r.payload...) - } - require.Equal(t, payload[5:], reassembled, - "record payloads must reassemble to the original handshake") -} - -// trackingSpoofer adds the spoof injection to a shared event timeline so -// TestConn_StackedInjectionOrder can prove the decoy precedes the first -// downstream write. -type trackingSpoofer struct { - injected [][]byte - timeline *[]string -} - -func (s *trackingSpoofer) Inject(payload []byte) error { - s.injected = append(s.injected, append([]byte(nil), payload...)) - *s.timeline = append(*s.timeline, "inject") - return nil -} - -func (s *trackingSpoofer) Close() error { return nil } - -// TestConn_StackedInjectionOrder asserts the documented wire order: the -// decoy injection happens before any write reaches the underlying conn. -func TestConn_StackedInjectionOrder(t *testing.T) { - t.Parallel() - payload := decodeClientHello(t) - - client, server := net.Pipe() - defer server.Close() - - var timeline []string - rc := &recordingConn{Conn: client, timeline: &timeline} - fragConn := tf.NewConn(rc, context.Background(), true, true, time.Millisecond) - spoofer := &trackingSpoofer{timeline: &timeline} - wrapped, err := newConn(fragConn, spoofer, "letsencrypt.org") - require.NoError(t, err) - - serverRead := make(chan []byte, 1) - go func() { serverRead <- readAll(t, server) }() - - _, err = wrapped.Write(payload) - require.NoError(t, err) - require.NoError(t, wrapped.Close()) - <-serverRead - - require.NotEmpty(t, timeline) - require.Equal(t, "inject", timeline[0], "decoy must be injected before any downstream write") - require.Contains(t, timeline[1:], "write", "at least one downstream write must follow the inject") -} - -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) - } -} diff --git a/common/tlsspoof/integration_unix_test.go b/common/tlsspoof/integration_unix_test.go index 0f4585fd8..6b262ff71 100644 --- a/common/tlsspoof/integration_unix_test.go +++ b/common/tlsspoof/integration_unix_test.go @@ -6,74 +6,53 @@ import ( "encoding/hex" "io" "net" + "runtime" "testing" "time" "github.com/stretchr/testify/require" ) -func TestIntegrationSpoofer_WrongChecksum(t *testing.T) { +func TestIntegrationSpoofer(t *testing.T) { requireRoot(t) - client, serverPort := dialLocalEchoServer(t) - spoofer, err := newRawSpoofer(client, MethodWrongChecksum) - require.NoError(t, err) - defer spoofer.Close() + methods := []struct { + name string + method Method + }{ + {"WrongChecksum", MethodWrongChecksum}, + {"WrongSequence", MethodWrongSequence}, + {"WrongAcknowledgment", MethodWrongAcknowledgment}, + {"WrongMD5Sig", MethodWrongMD5Sig}, + {"WrongTimestamp", MethodWrongTimestamp}, + } + families := []struct { + name string + dial func(*testing.T) (net.Conn, uint16) + }{ + {"IPv4", dialLocalEchoServer}, + {"IPv6", dialLocalEchoServerIPv6}, + } + for _, family := range families { + for _, tc := range methods { + t.Run(family.name+"/"+tc.name, func(t *testing.T) { + if tc.method == MethodWrongTimestamp && runtime.GOOS == "darwin" { + t.Skip("wrong-timestamp is not supported on macOS") + } + client, serverPort := family.dial(t) + spoofer, err := newRawSpoofer(client, tc.method) + require.NoError(t, err) + defer spoofer.Close() - fake, err := buildFakeClientHello("letsencrypt.org") - require.NoError(t, err) + fake, err := buildFakeClientHello("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 := newRawSpoofer(client, MethodWrongSequence) - require.NoError(t, err) - defer spoofer.Close() - - fake, err := buildFakeClientHello("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_IPv6_WrongChecksum(t *testing.T) { - requireRoot(t) - client, serverPort := dialLocalEchoServerIPv6(t) - spoofer, err := newRawSpoofer(client, MethodWrongChecksum) - require.NoError(t, err) - defer spoofer.Close() - - fake, err := buildFakeClientHello("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_IPv6_WrongSequence(t *testing.T) { - requireRoot(t) - client, serverPort := dialLocalEchoServerIPv6(t) - spoofer, err := newRawSpoofer(client, MethodWrongSequence) - require.NoError(t, err) - defer spoofer.Close() - - fake, err := buildFakeClientHello("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") + 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. diff --git a/common/tlsspoof/packet.go b/common/tlsspoof/packet.go index 9bdf7a59d..bee53f537 100644 --- a/common/tlsspoof/packet.go +++ b/common/tlsspoof/packet.go @@ -1,6 +1,7 @@ package tlsspoof import ( + "encoding/binary" "net/netip" "github.com/sagernet/sing-tun/gtcpip/checksum" @@ -12,15 +13,24 @@ const ( defaultTTL uint8 = 64 defaultWindowSize uint16 = 0xFFFF tcpHeaderLen = header.TCPMinimumSize + + tcpOptionMD5Signature = 19 + tcpOptionMD5SignatureLength = 18 + tcpTimestampBackdate = 3600000 ) +type spoofPacketInfo struct { + seqNum uint32 + ackNum uint32 + corrupt bool + options []byte +} + func buildTCPSegment( src netip.AddrPort, dst netip.AddrPort, - seqNum uint32, - ackNum uint32, + packetInfo spoofPacketInfo, payload []byte, - corruptChecksum bool, ) []byte { if src.Addr().Is4() != dst.Addr().Is4() { panic("tlsspoof: mixed IPv4/IPv6 address family") @@ -29,9 +39,10 @@ func buildTCPSegment( frame []byte ipHeaderLen int ) + ipPayloadLen := tcpHeaderLen + len(packetInfo.options) + len(payload) if src.Addr().Is4() { ipHeaderLen = header.IPv4MinimumSize - frame = make([]byte, ipHeaderLen+tcpHeaderLen+len(payload)) + frame = make([]byte, ipHeaderLen+ipPayloadLen) ip := header.IPv4(frame[:ipHeaderLen]) ip.Encode(&header.IPv4Fields{ TotalLength: uint16(len(frame)), @@ -44,68 +55,128 @@ func buildTCPSegment( ip.SetChecksum(^ip.CalculateChecksum()) } else { ipHeaderLen = header.IPv6MinimumSize - frame = make([]byte, ipHeaderLen+tcpHeaderLen+len(payload)) + frame = make([]byte, ipHeaderLen+ipPayloadLen) ip := header.IPv6(frame[:ipHeaderLen]) ip.Encode(&header.IPv6Fields{ - PayloadLength: uint16(tcpHeaderLen + len(payload)), + PayloadLength: uint16(ipPayloadLen), TransportProtocol: header.TCPProtocolNumber, HopLimit: defaultTTL, SrcAddr: src.Addr(), DstAddr: dst.Addr(), }) } - encodeTCP(frame, ipHeaderLen, src, dst, seqNum, ackNum, payload, corruptChecksum) + encodeTCP(frame, ipHeaderLen, src, dst, packetInfo, payload) return frame } -func encodeTCP(frame []byte, ipHeaderLen int, src, dst netip.AddrPort, seqNum, ackNum uint32, payload []byte, corruptChecksum bool) { +func encodeTCP(frame []byte, ipHeaderLen int, src, dst netip.AddrPort, packetInfo spoofPacketInfo, payload []byte) { tcp := header.TCP(frame[ipHeaderLen:]) - copy(frame[ipHeaderLen+tcpHeaderLen:], payload) + copy(frame[ipHeaderLen+tcpHeaderLen:], packetInfo.options) + optionsLen := len(packetInfo.options) + copy(frame[ipHeaderLen+tcpHeaderLen+optionsLen:], payload) tcp.Encode(&header.TCPFields{ SrcPort: src.Port(), DstPort: dst.Port(), - SeqNum: seqNum, - AckNum: ackNum, - DataOffset: tcpHeaderLen, + SeqNum: packetInfo.seqNum, + AckNum: packetInfo.ackNum, + DataOffset: uint8(tcpHeaderLen + optionsLen), Flags: header.TCPFlagAck | header.TCPFlagPsh, WindowSize: defaultWindowSize, }) - applyTCPChecksum(tcp, src.Addr(), dst.Addr(), payload, corruptChecksum) + applyTCPChecksum(tcp, src.Addr(), dst.Addr(), payload, packetInfo.corrupt) } -func buildSpoofFrame(method Method, src, dst netip.AddrPort, sendNext, receiveNext uint32, payload []byte) ([]byte, error) { - sequence, corrupt, err := resolveSpoofSequence(method, sendNext, payload) +func buildSpoofFrame(method Method, src, dst netip.AddrPort, sendNext, receiveNext, timestamp uint32, tcpOptions, payload []byte) ([]byte, error) { + packetInfo, err := resolveSpoofPacketInfo(method, sendNext, receiveNext, timestamp, tcpOptions, payload) if err != nil { return nil, err } - return buildTCPSegment(src, dst, sequence, receiveNext, payload, corrupt), nil + return buildTCPSegment(src, dst, packetInfo, payload), nil } // buildSpoofTCPSegment returns a TCP segment without an IP header, for // platforms where the kernel synthesises the IP header (darwin IPv6). -func buildSpoofTCPSegment(method Method, src, dst netip.AddrPort, sendNext, receiveNext uint32, payload []byte) ([]byte, error) { - sequence, corrupt, err := resolveSpoofSequence(method, sendNext, payload) +func buildSpoofTCPSegment(method Method, src, dst netip.AddrPort, sendNext, receiveNext, timestamp uint32, payload []byte) ([]byte, error) { + packetInfo, err := resolveSpoofPacketInfo(method, sendNext, receiveNext, timestamp, nil, payload) if err != nil { return nil, err } - segment := make([]byte, tcpHeaderLen+len(payload)) - encodeTCP(segment, 0, src, dst, sequence, receiveNext, payload, corrupt) + segment := make([]byte, tcpHeaderLen+len(packetInfo.options)+len(payload)) + encodeTCP(segment, 0, src, dst, packetInfo, payload) return segment, nil } -func resolveSpoofSequence(method Method, sendNext uint32, payload []byte) (uint32, bool, error) { +func resolveSpoofPacketInfo(method Method, sendNext, receiveNext, timestamp uint32, tcpOptions, payload []byte) (spoofPacketInfo, error) { + packetInfo := spoofPacketInfo{seqNum: sendNext, ackNum: receiveNext} switch method { case MethodWrongSequence: - return sendNext - uint32(len(payload)), false, nil + packetInfo.seqNum = sendNext - uint32(len(payload)) case MethodWrongChecksum: - return sendNext, true, nil + packetInfo.corrupt = true + case MethodWrongAcknowledgment: + packetInfo.ackNum = receiveNext - uint32(defaultWindowSize/2) + case MethodWrongMD5Sig: + packetInfo.options = buildMD5SignatureOptions() + case MethodWrongTimestamp: + packetInfo.options = buildWrongTimestampOptions(timestamp, tcpOptions) default: - return 0, false, E.New("tls_spoof: unknown method ", method) + return packetInfo, E.New("tls_spoof: unknown method ", method) } + return packetInfo, nil +} + +func buildMD5SignatureOptions() []byte { + options := make([]byte, tcpOptionMD5SignatureLength+2) + options[0] = tcpOptionMD5Signature + options[1] = tcpOptionMD5SignatureLength + return options +} + +func buildWrongTimestampOptions(timestamp uint32, tcpOptions []byte) []byte { + spoofedTimestamp := timestamp + if spoofedTimestamp > tcpTimestampBackdate { + spoofedTimestamp -= tcpTimestampBackdate + } else { + spoofedTimestamp = 0 + } + if rewriteTCPOptionTimestamp(tcpOptions, spoofedTimestamp) { + return tcpOptions + } + options := make([]byte, header.TCPOptionTSLength+2) + header.EncodeTSOption(spoofedTimestamp, 0, options) + return options +} + +// rewriteTCPOptionTimestamp finds the TS option in tcpOptions and writes +// timestamp into its TSVal field in place. The caller must own tcpOptions +// (parseTCPPacket already returns a private copy on Windows). +func rewriteTCPOptionTimestamp(tcpOptions []byte, timestamp uint32) bool { + for i := 0; i < len(tcpOptions); { + switch tcpOptions[i] { + case header.TCPOptionEOL: + return false + case header.TCPOptionNOP: + i++ + continue + } + if i+1 >= len(tcpOptions) { + return false + } + optionLen := int(tcpOptions[i+1]) + if optionLen < 2 || i+optionLen > len(tcpOptions) { + return false + } + if tcpOptions[i] == header.TCPOptionTS && optionLen == header.TCPOptionTSLength { + binary.BigEndian.PutUint32(tcpOptions[i+2:], timestamp) + return true + } + i += optionLen + } + return false } func applyTCPChecksum(tcp header.TCP, srcAddr, dstAddr netip.Addr, payload []byte, corrupt bool) { - tcpLen := tcpHeaderLen + len(payload) + tcpLen := int(tcp.DataOffset()) + 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)) diff --git a/common/tlsspoof/packet_test.go b/common/tlsspoof/packet_test.go deleted file mode 100644 index 5c6d5b6be..000000000 --- a/common/tlsspoof/packet_test.go +++ /dev/null @@ -1,136 +0,0 @@ -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) - }) -} - -func TestBuildSpoofFrame_WrongSequence(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") - const sendNext uint32 = 10_000 - frame, err := buildSpoofFrame(MethodWrongSequence, src, dst, sendNext, 20_000, payload) - require.NoError(t, err) - - tcp := header.TCP(frame[header.IPv4MinimumSize:]) - require.Equal(t, sendNext-uint32(len(payload)), tcp.SequenceNumber(), - "wrong-sequence places the fake at sendNext-len(payload)") - require.True(t, tcp.Flags().Contains(header.TCPFlagAck|header.TCPFlagPsh)) - - // Checksum must still be valid — only the sequence number is wrong. - 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 TestBuildSpoofFrame_WrongChecksum(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") - const sendNext uint32 = 5_000 - frame, err := buildSpoofFrame(MethodWrongChecksum, src, dst, sendNext, 20_000, payload) - require.NoError(t, err) - - tcp := header.TCP(frame[header.IPv4MinimumSize:]) - require.Equal(t, sendNext, tcp.SequenceNumber(), - "wrong-checksum keeps the real sequence number") - - payloadChecksum := checksum.Checksum(payload, 0) - require.False(t, tcp.IsChecksumValid( - tcpip.AddrFrom4(src.Addr().As4()), - tcpip.AddrFrom4(dst.Addr().As4()), - payloadChecksum, - uint16(len(payload)), - )) - require.True(t, header.IPv4(frame[:header.IPv4MinimumSize]).IsChecksumValid(), - "IPv4 checksum must remain valid so the router forwards the packet") -} - -func TestBuildSpoofTCPSegment_EncodesWithoutIPHeader(t *testing.T) { - t.Parallel() - src := netip.MustParseAddrPort("[fe80::1]:54321") - dst := netip.MustParseAddrPort("[2606:4700::1]:443") - payload := []byte("fake-client-hello") - segment, err := buildSpoofTCPSegment(MethodWrongSequence, src, dst, 1000, 2000, payload) - require.NoError(t, err) - require.Equal(t, tcpHeaderLen+len(payload), len(segment), - "segment must be TCP header + payload, no IP header") -} diff --git a/common/tlsspoof/raw_darwin.go b/common/tlsspoof/raw_darwin.go index ab3168769..6f1dd96b6 100644 --- a/common/tlsspoof/raw_darwin.go +++ b/common/tlsspoof/raw_darwin.go @@ -68,6 +68,9 @@ type darwinSpoofer struct { } func newRawSpoofer(conn net.Conn, method Method) (rawSpoofer, error) { + if method == MethodWrongTimestamp { + return nil, E.New("tls_spoof: wrong-timestamp is not supported on macOS") + } _, src, dst, err := tcpEndpoints(conn) if err != nil { return nil, err @@ -159,7 +162,7 @@ func openDarwinRawSocket(src, dst netip.AddrPort) (int, unix.Sockaddr, error) { func (s *darwinSpoofer) Inject(payload []byte) error { if !s.src.Addr().Is4() { - segment, err := buildSpoofTCPSegment(s.method, s.src, s.dst, s.sendNext, s.receiveNext, payload) + segment, err := buildSpoofTCPSegment(s.method, s.src, s.dst, s.sendNext, s.receiveNext, 0, payload) if err != nil { return err } @@ -169,7 +172,7 @@ func (s *darwinSpoofer) Inject(payload []byte) error { } return nil } - frame, err := buildSpoofFrame(s.method, s.src, s.dst, s.sendNext, s.receiveNext, payload) + frame, err := buildSpoofFrame(s.method, s.src, s.dst, s.sendNext, s.receiveNext, 0, nil, payload) if err != nil { return err } diff --git a/common/tlsspoof/raw_linux.go b/common/tlsspoof/raw_linux.go index f82fbc9ef..3e4761147 100644 --- a/common/tlsspoof/raw_linux.go +++ b/common/tlsspoof/raw_linux.go @@ -27,6 +27,7 @@ type linuxSpoofer struct { rawSockAddr unix.Sockaddr sendNext uint32 receiveNext uint32 + timestamp uint32 } func newRawSpoofer(conn net.Conn, method Method) (rawSpoofer, error) { @@ -84,6 +85,15 @@ func openLinuxRawSocket(dst netip.AddrPort) (int, unix.Sockaddr, error) { func (s *linuxSpoofer) loadSequenceNumbers(tcpConn *net.TCPConn) error { return control.Conn(tcpConn, func(raw uintptr) (err error) { fd := int(raw) + + if s.method == MethodWrongTimestamp { + timestamp, err := unix.GetsockoptInt(fd, unix.IPPROTO_TCP, unix.TCP_TIMESTAMP) + if err != nil { + return E.Cause(err, "read timestamp") + } + s.timestamp = uint32(timestamp) + } + 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)") @@ -118,7 +128,7 @@ func (s *linuxSpoofer) loadSequenceNumbers(tcpConn *net.TCPConn) error { } func (s *linuxSpoofer) Inject(payload []byte) error { - frame, err := buildSpoofFrame(s.method, s.src, s.dst, s.sendNext, s.receiveNext, payload) + frame, err := buildSpoofFrame(s.method, s.src, s.dst, s.sendNext, s.receiveNext, s.timestamp, nil, payload) if err != nil { return err } diff --git a/common/tlsspoof/raw_windows.go b/common/tlsspoof/raw_windows.go index 9f6553f1b..d7d8a2682 100644 --- a/common/tlsspoof/raw_windows.go +++ b/common/tlsspoof/raw_windows.go @@ -6,6 +6,7 @@ import ( "errors" "net" "net/netip" + "slices" "sync" "sync/atomic" "time" @@ -113,7 +114,7 @@ func (s *windowsSpoofer) run() { return } pkt := buf[:n] - seq, ack, payloadLen, ok := parseTCPFields(pkt, addr.IPv6()) + seq, ack, tcpOptions, payloadLen, ok := parseTCPPacket(pkt, addr.IPv6()) if !ok { // Our filter is OutboundTCP(src, dst); a non-TCP or truncated // match means driver state is suspect. Re-inject so the kernel @@ -151,7 +152,11 @@ func (s *windowsSpoofer) run() { continue } - frame, err := buildSpoofFrame(s.method, s.src, s.dst, seq, ack, fake) + var timestamp uint32 + if parsed := header.ParseTCPOptions(tcpOptions); parsed.TS { + timestamp = parsed.TSVal + } + frame, err := buildSpoofFrame(s.method, s.src, s.dst, seq, ack, timestamp, tcpOptions, fake) if err != nil { s.recordErr(err) return @@ -177,46 +182,56 @@ func (s *windowsSpoofer) run() { } } -func parseTCPFields(pkt []byte, isV6 bool) (seq, ack uint32, payloadLen int, ok bool) { +func parseTCPPacket(pkt []byte, isV6 bool) (seq, ack uint32, options []byte, payloadLen int, ok bool) { if isV6 { if len(pkt) < header.IPv6MinimumSize+header.TCPMinimumSize { - return 0, 0, 0, false + return 0, 0, nil, 0, false } ip := header.IPv6(pkt) if ip.TransportProtocol() != header.TCPProtocolNumber { - return 0, 0, 0, false + return 0, 0, nil, 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 0, 0, nil, 0, false } - return tcp.SequenceNumber(), tcp.AckNumber(), - len(pkt) - header.IPv6MinimumSize - tcpHdr, true + total := header.IPv6MinimumSize + int(ip.PayloadLength()) + if total == header.IPv6MinimumSize || total > len(pkt) { + total = len(pkt) + } + if total < header.IPv6MinimumSize+tcpHdr { + return 0, 0, nil, 0, false + } + return tcp.SequenceNumber(), tcp.AckNumber(), slices.Clone(tcp.Options()), + total - header.IPv6MinimumSize - tcpHdr, true } if len(pkt) < header.IPv4MinimumSize+header.TCPMinimumSize { - return 0, 0, 0, false + return 0, 0, nil, 0, false } ip := header.IPv4(pkt) if ip.Protocol() != uint8(header.TCPProtocolNumber) { - return 0, 0, 0, false + return 0, 0, nil, 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 + return 0, 0, nil, 0, false } tcp := header.TCP(pkt[ihl:]) tcpHdr := int(tcp.DataOffset()) if tcpHdr < header.TCPMinimumSize || ihl+tcpHdr > len(pkt) { - return 0, 0, 0, false + return 0, 0, nil, 0, false } total := int(ip.TotalLength()) if total == 0 || total > len(pkt) { total = len(pkt) } - return tcp.SequenceNumber(), tcp.AckNumber(), + if total < ihl+tcpHdr { + return 0, 0, nil, 0, false + } + return tcp.SequenceNumber(), tcp.AckNumber(), slices.Clone(tcp.Options()), total - ihl - tcpHdr, true } diff --git a/common/tlsspoof/raw_windows_test.go b/common/tlsspoof/raw_windows_test.go deleted file mode 100644 index 58566b875..000000000 --- a/common/tlsspoof/raw_windows_test.go +++ /dev/null @@ -1,112 +0,0 @@ -//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) -} diff --git a/common/tlsspoof/spoof.go b/common/tlsspoof/spoof.go index 1bca5693f..e36d39e1f 100644 --- a/common/tlsspoof/spoof.go +++ b/common/tlsspoof/spoof.go @@ -11,19 +11,48 @@ type Method int const ( MethodWrongSequence Method = iota MethodWrongChecksum + MethodWrongAcknowledgment + MethodWrongMD5Sig + MethodWrongTimestamp ) const ( - MethodNameWrongSequence = "wrong-sequence" - MethodNameWrongChecksum = "wrong-checksum" + MethodNameWrongSequence = "wrong-sequence" + MethodNameWrongChecksum = "wrong-checksum" + MethodNameWrongAcknowledgment = "wrong-ack" + MethodNameWrongMD5Sig = "wrong-md5" + MethodNameWrongTimestamp = "wrong-timestamp" ) +func ParseOptions(spoof, method string) (string, Method, error) { + if spoof == "" { + if method != "" { + return "", 0, E.New("spoof_method requires spoof") + } + return "", 0, nil + } + if !PlatformSupported { + return "", 0, E.New("tls_spoof is not supported on this platform") + } + parsedMethod, err := ParseMethod(method) + if err != nil { + return "", 0, err + } + return spoof, parsedMethod, nil +} + func ParseMethod(s string) (Method, error) { switch s { case "", MethodNameWrongSequence: return MethodWrongSequence, nil case MethodNameWrongChecksum: return MethodWrongChecksum, nil + case MethodNameWrongAcknowledgment: + return MethodWrongAcknowledgment, nil + case MethodNameWrongMD5Sig: + return MethodWrongMD5Sig, nil + case MethodNameWrongTimestamp: + return MethodWrongTimestamp, nil default: return 0, E.New("tls_spoof: unknown method: ", s) } @@ -35,6 +64,12 @@ func (m Method) String() string { return MethodNameWrongSequence case MethodWrongChecksum: return MethodNameWrongChecksum + case MethodWrongAcknowledgment: + return MethodNameWrongAcknowledgment + case MethodWrongMD5Sig: + return MethodNameWrongMD5Sig + case MethodWrongTimestamp: + return MethodNameWrongTimestamp default: return "unknown" } diff --git a/common/tlsspoof/testdata_test.go b/common/tlsspoof/testdata_test.go new file mode 100644 index 000000000..85e74c524 --- /dev/null +++ b/common/tlsspoof/testdata_test.go @@ -0,0 +1,4 @@ +package tlsspoof + +// realClientHello is a captured Chrome ClientHello for github.com. +const realClientHello = "16030105f8010005f403036e35de7389a679c54029cf452611f2211c70d9ac3897271de589ab6155f8e4ab20637d225f1ef969ad87ed78bfb9d171300bcb1703b6f314ccefb964f79b7d0961002a0a0a130213031301c02cc02bcca9c030c02fcca8c00ac009c014c013009d009c0035002fc008c012000a01000581baba00000000000f000d00000a6769746875622e636f6d00170000ff01000100000a000e000c3a3a11ec001d001700180019000b000201000010000e000c02683208687474702f312e31000500050100000000000d00160014040308040401050308050805050108060601020100120000003304ef04ed3a3a00010011ec04c0aeb2250c092a3463161cccb29d9183331a424964248579507ed23a180b0ceab2a5f5d9ce41547e497a89055471ea572867ba3a1fc3c9e45025274a20f60c6b60e62476b6afed0403af59ab83660ef4112ae20386a602010d0a5d454c0ed34c84ed4423e750213e6a2baab1bf9c4367a6007ab40a33d95220c2dcaa44f257024a5626b545db0510f4311b1a60714154909c6a61fdfca011fb2626d657aeb6070bf078508babe3b584555013e34acc56198ed4663742b3155a664a9901794c4586820a7dc162c01827291f3792e1237f801a8d1ef096013c181c4a58d2f6859ba75022d18cc4418bd4f351d5c18f83a58857d05af860c4b9ac018a5b63f17184e591532c6bc2cf2215d4a282c8a8a4f6f7aee110422c8bc9ebd3b1d609c568523aaae555db320e6c269473d87af38c256cbb9febc20aea6380c32a8916f7a373c8b1e37554e3260bf6621f6b804ee80b3c516b1d01985bf4c603b6daa9a5991de6a7a29f3a7122b8afb843a7660110fce62b43c615f5bcc2db688ba012649c0952b0a2c031e732d2b454c6b2968683cb8d244be2c9a7fa163222979eaf92722b92b862d81a3d94450c2b60c318421ebb4307c42d1f0473592a5c30e42039cc68cda9721e61aa63f49def17c15221680ed444896340133bbee67556f56b9f9d78a4df715f926a12add0cc9c862e46ea8b7316ae468282c18601b2771c9c9322f982228cf93effaacd3f80cbd12bce5fc36f56e2a3caf91e578a5fae00c9b23a8ed1a66764f4433c3628a70b8f0a6196adc60a4cb4226f07ba4c6b363fe9065563bfc1347452946386bab488686e837ab979c64f9047417fca635fe1bb4f074f256cc8af837c7b455e280426547755af90a61640169ef180aea3a77e662bb6dac1b6c3696027129b1a5edf495314e9c7f4b6110e16378ec893fa24642330a40aba1a85326101acb97c620fd8d71389e69eaed7bdb01bbe1fd428d66191150c7b2cd1ad4257391676a82ba8ce07fb2667c3b289f159003a7c7bc31d361b7b7f49a802961739d950dfcc0fa1c7abce5abdd2245101da391151490862028110465950b9e9c03d08a90998ab83267838d2e74a0593bc81f74cdf734519a05b351c0e5488c68dd810e6e9142ccc1e2f4a7f464297eb340e27acc6b9d64e12e38cce8492b3d939140b5a9e149a75597f10a23874c84323a07cdd657274378f887c85c4259b9c04cd33ba58ed630ef2a744f8e19dd34843dff331d2a6be7e2332c599289cd248a611c73d7481cd4a9bd43449a3836f14b2af18a1739e17999e4c67e85cc5bcecabb14185e5bcaff3c96098f03dc5aba819f29587758f49f940585354a2a780830528d68ccd166920dadcaa25cab5fc1907272a826aba3f08bc6b88757776812ecb6c7cec69a223ec0a13a7b62a2349a0f63ed7a27a3b15ba21d71fe6864ec6e089ae17cadd433fa3138f7ee24353c11365818f8fc34f43a05542d18efaac24bfccc1f748a0cc1a67ad379468b76fd34973dba785f5c91d618333cd810fe0700d1bbc8422029782628070a624c52c5309a4a64d625b11f8033ab28df34a1add297517fcc06b92b6817b3c5144438cf260867c57bde68c8c4b82e6a135ef676a52fbae5708002a404e6189a60e2836de565ad1b29e3819e5ed49f6810bcb28e1bd6de57306f94b79d9dae1cc4624d2a068499beef81cd5fe4b76dcbfff2a2008001d002001976128c6d5a934533f28b9914d2480aab2a8c1ab03d212529ce8b27640a716002d00020101002b000706caca03040303001b00030200015a5a000100" diff --git a/docs/configuration/route/rule_action.md b/docs/configuration/route/rule_action.md index f4dfad384..b362eefe5 100644 --- a/docs/configuration/route/rule_action.md +++ b/docs/configuration/route/rule_action.md @@ -10,7 +10,9 @@ icon: material/new-box !!! quote "Changes in sing-box 1.14.0" :material-plus: [resolve.disable_optimistic_cache](#disable_optimistic_cache) - :material-plus: [resolve.timeout](#timeout) + :material-plus: [resolve.timeout](#timeout) + :material-plus: [tls_spoof](#tls_spoof) + :material-plus: [tls_spoof_method](#tls_spoof_method) !!! quote "Changes in sing-box 1.12.0" @@ -149,7 +151,9 @@ Not available when `method` is set to drop. "udp_timeout": "", "tls_fragment": false, "tls_fragment_fallback_delay": "", - "tls_record_fragment": "" + "tls_record_fragment": "", + "tls_spoof": "", + "tls_spoof_method": "" } ``` @@ -248,6 +252,26 @@ The fallback value used when TLS segmentation cannot automatically determine the Fragment TLS handshake into multiple TLS records to bypass firewalls. +#### tls_spoof + +!!! question "Since sing-box 1.14.0" + +==Linux/macOS/Windows only, requires elevated privileges== + +Inject a forged TLS ClientHello carrying this SNI before the real one, +to fool SNI-filtering middleboxes that permit specific hostnames. + +See outbound TLS [`spoof`](/configuration/shared/tls/#spoof) for details +and required privileges. + +#### tls_spoof_method + +!!! question "Since sing-box 1.14.0" + +How the forged segment is rejected by the real server. See outbound TLS +[`spoof_method`](/configuration/shared/tls/#spoof_method) for the full table +of accepted values and platform notes. + ### sniff ```json diff --git a/docs/configuration/route/rule_action.zh.md b/docs/configuration/route/rule_action.zh.md index f832a30d7..3ef8d8d43 100644 --- a/docs/configuration/route/rule_action.zh.md +++ b/docs/configuration/route/rule_action.zh.md @@ -10,7 +10,9 @@ icon: material/new-box !!! quote "sing-box 1.14.0 中的更改" :material-plus: [resolve.disable_optimistic_cache](#disable_optimistic_cache) - :material-plus: [resolve.timeout](#timeout) + :material-plus: [resolve.timeout](#timeout) + :material-plus: [tls_spoof](#tls_spoof) + :material-plus: [tls_spoof_method](#tls_spoof_method) !!! quote "sing-box 1.12.0 中的更改" @@ -142,7 +144,9 @@ icon: material/new-box "udp_timeout": "", "tls_fragment": false, "tls_fragment_fallback_delay": "", - "tls_record_fragment": false + "tls_record_fragment": false, + "tls_spoof": "", + "tls_spoof_method": "" } ``` @@ -240,6 +244,24 @@ UDP 连接超时时间。 通过分段 TLS 握手数据包到多个 TLS 记录来绕过防火墙检测。 +#### tls_spoof + +!!! question "自 sing-box 1.14.0 起" + +==仅 Linux/macOS/Windows,需要管理员权限== + +在真实 ClientHello 之前注入携带本字段所指定 SNI 的伪造 TLS ClientHello, +用于欺骗仅放行特定主机名的 SNI 过滤中间盒。 + +详情与所需权限参阅出站 TLS [`spoof`](/zh/configuration/shared/tls/#spoof)。 + +#### tls_spoof_method + +!!! question "自 sing-box 1.14.0 起" + +控制伪造报文被真实服务器拒绝的方式。完整取值表与平台说明参阅出站 TLS +[`spoof_method`](/zh/configuration/shared/tls/#spoof_method)。 + ### sniff ```json diff --git a/docs/configuration/shared/tls.md b/docs/configuration/shared/tls.md index 3b74e890a..70e3b5c4f 100644 --- a/docs/configuration/shared/tls.md +++ b/docs/configuration/shared/tls.md @@ -702,12 +702,13 @@ driver on first use. Windows on ARM64 is not supported. 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. +| 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. | +| `wrong-ack` | The forged segment's TCP acknowledgment number is placed before the server's send window. | +| `wrong-md5` | The forged segment carries a TCP-MD5 signature option, which the server rejects since no MD5 key is negotiated. | +| `wrong-timestamp` | The forged segment carries a backdated TCP timestamp, which the server rejects as a PAWS replay. Linux/Windows only; not supported on macOS. | ### ACME Fields diff --git a/docs/configuration/shared/tls.zh.md b/docs/configuration/shared/tls.zh.md index 3cc411da4..40762c4f7 100644 --- a/docs/configuration/shared/tls.zh.md +++ b/docs/configuration/shared/tls.zh.md @@ -695,12 +695,13 @@ Windows 上首次使用时需要 Administrator 以安装内嵌的 WinDivert 内 控制伪造报文被真实服务器拒绝的方式。 -| 取值 | 行为 | -|----------------------------|------------------------------------------------| -| `wrong-sequence`(默认) | 伪造报文的 TCP 序列号位于服务器接收窗口之前。 | -| `wrong-checksum` | 伪造报文的 TCP 校验和被故意设为无效。 | - -与 `spoof` 未设置冲突。 +| 取值 | 行为 | +|--------------------------|-------------------------------------------------------------------| +| `wrong-sequence`(默认) | 伪造报文的 TCP 序列号位于服务器接收窗口之前。 | +| `wrong-checksum` | 伪造报文的 TCP 校验和被故意设为无效。 | +| `wrong-ack` | 伪造报文的 TCP 确认号位于服务器发送窗口之前。 | +| `wrong-md5` | 伪造报文携带 TCP-MD5 签名选项,未协商 MD5 密钥的服务器将拒绝。 | +| `wrong-timestamp` | 伪造报文携带回退的 TCP 时间戳,服务器按 PAWS 规则视为重放并拒绝。仅支持 Linux/Windows,不支持 macOS。 | ### ACME 字段 diff --git a/option/rule_action.go b/option/rule_action.go index 303f77d6c..a6f181f2d 100644 --- a/option/rule_action.go +++ b/option/rule_action.go @@ -182,6 +182,8 @@ type RawRouteOptionsActionOptions struct { TLSFragment bool `json:"tls_fragment,omitempty"` TLSFragmentFallbackDelay badoption.Duration `json:"tls_fragment_fallback_delay,omitempty"` TLSRecordFragment bool `json:"tls_record_fragment,omitempty"` + TLSSpoof string `json:"tls_spoof,omitempty"` + TLSSpoofMethod string `json:"tls_spoof_method,omitempty"` } type RouteOptionsActionOptions RawRouteOptionsActionOptions diff --git a/route/conn.go b/route/conn.go index 9fdc6cda8..c2ab90d54 100644 --- a/route/conn.go +++ b/route/conn.go @@ -15,6 +15,7 @@ import ( "github.com/sagernet/sing-box/common/dialer" "github.com/sagernet/sing-box/common/sniff" "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/common" "github.com/sagernet/sing/common/buf" @@ -129,6 +130,17 @@ func (m *ConnectionManager) NewConnection(ctx context.Context, this N.Dialer, co if metadata.TLSFragment || metadata.TLSRecordFragment { remoteConn = tf.NewConn(remoteConn, ctx, metadata.TLSFragment, metadata.TLSRecordFragment, metadata.TLSFragmentFallbackDelay) } + if metadata.TLSSpoof != "" { + spoofConn, spoofErr := tlsspoof.NewConn(remoteConn, metadata.TLSSpoofMethod, metadata.TLSSpoof) + if spoofErr != nil { + spoofErr = E.Cause(spoofErr, "tls_spoof setup") + remoteConn.Close() + N.CloseOnHandshakeFailure(conn, onClose, spoofErr) + m.logger.ErrorContext(ctx, spoofErr) + return + } + remoteConn = spoofConn + } serverFirst := sniff.Skip(&metadata) var done atomic.Bool if m.kickWriteHandshake(ctx, conn, remoteConn, serverFirst, false, &done, onClose) { diff --git a/route/route.go b/route/route.go index 88658f0f9..66dbdc17d 100644 --- a/route/route.go +++ b/route/route.go @@ -489,6 +489,10 @@ match: routeOptions = &action.RuleActionRouteOptions case *R.RuleActionRouteOptions: routeOptions = action + case *R.RuleActionBypass: + if action.Outbound != "" { + routeOptions = &action.RuleActionRouteOptions + } } if routeOptions != nil { // TODO: add nat @@ -538,6 +542,10 @@ match: if routeOptions.TLSRecordFragment { metadata.TLSRecordFragment = true } + if routeOptions.TLSSpoof != "" { + metadata.TLSSpoof = routeOptions.TLSSpoof + metadata.TLSSpoofMethod = routeOptions.TLSSpoofMethod + } } switch action := currentRule.Action().(type) { case *R.RuleActionSniff: diff --git a/route/rule/rule_action.go b/route/rule/rule_action.go index 2187d80d0..207945c41 100644 --- a/route/rule/rule_action.go +++ b/route/rule/rule_action.go @@ -11,6 +11,7 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/dialer" "github.com/sagernet/sing-box/common/sniff" + "github.com/sagernet/sing-box/common/tlsspoof" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-tun" @@ -24,52 +25,54 @@ import ( "github.com/miekg/dns" ) +func newRuleActionRouteOptions(options option.RawRouteOptionsActionOptions) (RuleActionRouteOptions, error) { + spoof, spoofMethod, err := tlsspoof.ParseOptions(options.TLSSpoof, options.TLSSpoofMethod) + if err != nil { + return RuleActionRouteOptions{}, err + } + return RuleActionRouteOptions{ + OverrideAddress: M.ParseSocksaddrHostPort(options.OverrideAddress, 0), + OverridePort: options.OverridePort, + NetworkStrategy: (*C.NetworkStrategy)(options.NetworkStrategy), + FallbackDelay: time.Duration(options.FallbackDelay), + UDPDisableDomainUnmapping: options.UDPDisableDomainUnmapping, + UDPConnect: options.UDPConnect, + UDPTimeout: time.Duration(options.UDPTimeout), + TLSFragment: options.TLSFragment, + TLSFragmentFallbackDelay: time.Duration(options.TLSFragmentFallbackDelay), + TLSRecordFragment: options.TLSRecordFragment, + TLSSpoof: spoof, + TLSSpoofMethod: spoofMethod, + }, nil +} + func NewRuleAction(ctx context.Context, logger logger.ContextLogger, action option.RuleAction) (adapter.RuleAction, error) { switch action.Action { case "": return nil, nil case C.RuleActionTypeRoute: + routeOptions, err := newRuleActionRouteOptions(action.RouteOptions.RawRouteOptionsActionOptions) + if err != nil { + return nil, err + } return &RuleActionRoute{ - Outbound: action.RouteOptions.Outbound, - RuleActionRouteOptions: RuleActionRouteOptions{ - OverrideAddress: M.ParseSocksaddrHostPort(action.RouteOptions.OverrideAddress, 0), - OverridePort: action.RouteOptions.OverridePort, - NetworkStrategy: (*C.NetworkStrategy)(action.RouteOptions.NetworkStrategy), - FallbackDelay: time.Duration(action.RouteOptions.FallbackDelay), - UDPDisableDomainUnmapping: action.RouteOptions.UDPDisableDomainUnmapping, - UDPConnect: action.RouteOptions.UDPConnect, - TLSFragment: action.RouteOptions.TLSFragment, - TLSFragmentFallbackDelay: time.Duration(action.RouteOptions.TLSFragmentFallbackDelay), - TLSRecordFragment: action.RouteOptions.TLSRecordFragment, - }, + Outbound: action.RouteOptions.Outbound, + RuleActionRouteOptions: routeOptions, }, nil case C.RuleActionTypeRouteOptions: - return &RuleActionRouteOptions{ - OverrideAddress: M.ParseSocksaddrHostPort(action.RouteOptionsOptions.OverrideAddress, 0), - OverridePort: action.RouteOptionsOptions.OverridePort, - NetworkStrategy: (*C.NetworkStrategy)(action.RouteOptionsOptions.NetworkStrategy), - FallbackDelay: time.Duration(action.RouteOptionsOptions.FallbackDelay), - UDPDisableDomainUnmapping: action.RouteOptionsOptions.UDPDisableDomainUnmapping, - UDPConnect: action.RouteOptionsOptions.UDPConnect, - UDPTimeout: time.Duration(action.RouteOptionsOptions.UDPTimeout), - TLSFragment: action.RouteOptionsOptions.TLSFragment, - TLSFragmentFallbackDelay: time.Duration(action.RouteOptionsOptions.TLSFragmentFallbackDelay), - TLSRecordFragment: action.RouteOptionsOptions.TLSRecordFragment, - }, nil + routeOptions, err := newRuleActionRouteOptions(option.RawRouteOptionsActionOptions(action.RouteOptionsOptions)) + if err != nil { + return nil, err + } + return &routeOptions, nil case C.RuleActionTypeBypass: + routeOptions, err := newRuleActionRouteOptions(action.BypassOptions.RawRouteOptionsActionOptions) + if err != nil { + return nil, err + } return &RuleActionBypass{ - Outbound: action.BypassOptions.Outbound, - RuleActionRouteOptions: RuleActionRouteOptions{ - OverrideAddress: M.ParseSocksaddrHostPort(action.BypassOptions.OverrideAddress, 0), - OverridePort: action.BypassOptions.OverridePort, - NetworkStrategy: (*C.NetworkStrategy)(action.BypassOptions.NetworkStrategy), - FallbackDelay: time.Duration(action.BypassOptions.FallbackDelay), - UDPDisableDomainUnmapping: action.BypassOptions.UDPDisableDomainUnmapping, - UDPConnect: action.BypassOptions.UDPConnect, - TLSFragment: action.BypassOptions.TLSFragment, - TLSFragmentFallbackDelay: time.Duration(action.BypassOptions.TLSFragmentFallbackDelay), - TLSRecordFragment: action.BypassOptions.TLSRecordFragment, - }, + Outbound: action.BypassOptions.Outbound, + RuleActionRouteOptions: routeOptions, }, nil case C.RuleActionTypeDirect: directDialer, err := dialer.New(ctx, option.DialerOptions(action.DirectOptions), false) @@ -225,6 +228,8 @@ type RuleActionRouteOptions struct { TLSFragment bool TLSFragmentFallbackDelay time.Duration TLSRecordFragment bool + TLSSpoof string + TLSSpoofMethod tlsspoof.Method } func (r *RuleActionRouteOptions) Type() string { @@ -273,6 +278,10 @@ func (r *RuleActionRouteOptions) Descriptions() []string { if r.TLSRecordFragment { descriptions = append(descriptions, "tls-record-fragment") } + if r.TLSSpoof != "" { + descriptions = append(descriptions, F.ToString("tls-spoof=", r.TLSSpoof)) + descriptions = append(descriptions, F.ToString("tls-spoof-method=", r.TLSSpoofMethod.String())) + } return descriptions }