mirror of
https://github.com/SagerNet/sing-box.git
synced 2026-06-06 21:02:06 +08:00
Add more spoof method
Signed-off-by: macronut <4027187+macronut@users.noreply.github.com>
This commit is contained in:
parent
ac900b1742
commit
ef06e4c2c0
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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")
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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")
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
|
||||
4
common/tlsspoof/testdata_test.go
Normal file
4
common/tlsspoof/testdata_test.go
Normal file
@ -0,0 +1,4 @@
|
||||
package tlsspoof
|
||||
|
||||
// realClientHello is a captured Chrome ClientHello for github.com.
|
||||
const realClientHello = "16030105f8010005f403036e35de7389a679c54029cf452611f2211c70d9ac3897271de589ab6155f8e4ab20637d225f1ef969ad87ed78bfb9d171300bcb1703b6f314ccefb964f79b7d0961002a0a0a130213031301c02cc02bcca9c030c02fcca8c00ac009c014c013009d009c0035002fc008c012000a01000581baba00000000000f000d00000a6769746875622e636f6d00170000ff01000100000a000e000c3a3a11ec001d001700180019000b000201000010000e000c02683208687474702f312e31000500050100000000000d00160014040308040401050308050805050108060601020100120000003304ef04ed3a3a00010011ec04c0aeb2250c092a3463161cccb29d9183331a424964248579507ed23a180b0ceab2a5f5d9ce41547e497a89055471ea572867ba3a1fc3c9e45025274a20f60c6b60e62476b6afed0403af59ab83660ef4112ae20386a602010d0a5d454c0ed34c84ed4423e750213e6a2baab1bf9c4367a6007ab40a33d95220c2dcaa44f257024a5626b545db0510f4311b1a60714154909c6a61fdfca011fb2626d657aeb6070bf078508babe3b584555013e34acc56198ed4663742b3155a664a9901794c4586820a7dc162c01827291f3792e1237f801a8d1ef096013c181c4a58d2f6859ba75022d18cc4418bd4f351d5c18f83a58857d05af860c4b9ac018a5b63f17184e591532c6bc2cf2215d4a282c8a8a4f6f7aee110422c8bc9ebd3b1d609c568523aaae555db320e6c269473d87af38c256cbb9febc20aea6380c32a8916f7a373c8b1e37554e3260bf6621f6b804ee80b3c516b1d01985bf4c603b6daa9a5991de6a7a29f3a7122b8afb843a7660110fce62b43c615f5bcc2db688ba012649c0952b0a2c031e732d2b454c6b2968683cb8d244be2c9a7fa163222979eaf92722b92b862d81a3d94450c2b60c318421ebb4307c42d1f0473592a5c30e42039cc68cda9721e61aa63f49def17c15221680ed444896340133bbee67556f56b9f9d78a4df715f926a12add0cc9c862e46ea8b7316ae468282c18601b2771c9c9322f982228cf93effaacd3f80cbd12bce5fc36f56e2a3caf91e578a5fae00c9b23a8ed1a66764f4433c3628a70b8f0a6196adc60a4cb4226f07ba4c6b363fe9065563bfc1347452946386bab488686e837ab979c64f9047417fca635fe1bb4f074f256cc8af837c7b455e280426547755af90a61640169ef180aea3a77e662bb6dac1b6c3696027129b1a5edf495314e9c7f4b6110e16378ec893fa24642330a40aba1a85326101acb97c620fd8d71389e69eaed7bdb01bbe1fd428d66191150c7b2cd1ad4257391676a82ba8ce07fb2667c3b289f159003a7c7bc31d361b7b7f49a802961739d950dfcc0fa1c7abce5abdd2245101da391151490862028110465950b9e9c03d08a90998ab83267838d2e74a0593bc81f74cdf734519a05b351c0e5488c68dd810e6e9142ccc1e2f4a7f464297eb340e27acc6b9d64e12e38cce8492b3d939140b5a9e149a75597f10a23874c84323a07cdd657274378f887c85c4259b9c04cd33ba58ed630ef2a744f8e19dd34843dff331d2a6be7e2332c599289cd248a611c73d7481cd4a9bd43449a3836f14b2af18a1739e17999e4c67e85cc5bcecabb14185e5bcaff3c96098f03dc5aba819f29587758f49f940585354a2a780830528d68ccd166920dadcaa25cab5fc1907272a826aba3f08bc6b88757776812ecb6c7cec69a223ec0a13a7b62a2349a0f63ed7a27a3b15ba21d71fe6864ec6e089ae17cadd433fa3138f7ee24353c11365818f8fc34f43a05542d18efaac24bfccc1f748a0cc1a67ad379468b76fd34973dba785f5c91d618333cd810fe0700d1bbc8422029782628070a624c52c5309a4a64d625b11f8033ab28df34a1add297517fcc06b92b6817b3c5144438cf260867c57bde68c8c4b82e6a135ef676a52fbae5708002a404e6189a60e2836de565ad1b29e3819e5ed49f6810bcb28e1bd6de57306f94b79d9dae1cc4624d2a068499beef81cd5fe4b76dcbfff2a2008001d002001976128c6d5a934533f28b9914d2480aab2a8c1ab03d212529ce8b27640a716002d00020101002b000706caca03040303001b00030200015a5a000100"
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 字段
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user