From 8bc40a65088268942abac2f09c3f10645bd2b82c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ky=C5=8Dchikut=C5=8D=20=7C=20=E3=82=AD=E3=83=A7=E3=82=A6?= =?UTF-8?q?=E3=83=81=E3=82=AF=E3=83=88=E3=82=A6?= Date: Fri, 24 Nov 2023 17:25:22 +0330 Subject: [PATCH] feat: add TLS fragmentation Introduce a feature to fragment TLS clientHello packets to circumvent TLS-based blockings (mostly seen in Iran's firewall) --- common/dialer/default.go | 30 +++++- common/dialer/default_go1.20.go | 8 +- common/dialer/default_nongo1.20.go | 5 +- common/dialer/extended_tcp.go | 55 +++++++++++ common/dialer/extended_tcp_stub.go | 36 +++++++ common/dialer/fragment.go | 154 +++++++++++++++++++++++++++++ common/dialer/tfo.go | 19 ---- common/dialer/tfo_stub.go | 20 ---- option/fragment.go | 38 +++++++ option/outbound.go | 29 +++--- 10 files changed, 331 insertions(+), 63 deletions(-) create mode 100644 common/dialer/extended_tcp.go create mode 100644 common/dialer/extended_tcp_stub.go create mode 100644 common/dialer/fragment.go delete mode 100644 common/dialer/tfo_stub.go create mode 100644 option/fragment.go diff --git a/common/dialer/default.go b/common/dialer/default.go index 9fbd1d8e..799c1f40 100644 --- a/common/dialer/default.go +++ b/common/dialer/default.go @@ -98,11 +98,33 @@ func NewDefault(router adapter.Router, options option.DialerOptions) (*DefaultDi } setMultiPathTCP(&dialer4) } - tcpDialer4, err := newTCPDialer(dialer4, options.TCPFastOpen) + if options.TLSFragment.Enabled && options.TCPFastOpen { + return nil, E.New("TLS Fragmentation is not compatible with TCP Fast Open, set `tcp_fast_open` to `false` in your outbound if you intend to enable TLS fragmentation.") + } + var tlsFragment TLSFragment + if options.TLSFragment.Enabled { + tlsFragment.Enabled = true + + sleep, err := option.ParseIntRange(options.TLSFragment.Sleep) + if err != nil { + return nil, E.Cause(err, "invalid TLS fragment sleep period supplied") + } + tlsFragment.SleepMin = sleep[0] + tlsFragment.SleepMax = sleep[1] + + size, err := option.ParseIntRange(options.TLSFragment.Size) + if err != nil { + return nil, E.Cause(err, "invalid TLS fragment size supplied") + } + tlsFragment.SizeMin = size[0] + tlsFragment.SizeMax = size[1] + + } + tcpDialer4, err := newTCPDialer(dialer4, options.TCPFastOpen, tlsFragment) if err != nil { return nil, err } - tcpDialer6, err := newTCPDialer(dialer6, options.TCPFastOpen) + tcpDialer6, err := newTCPDialer(dialer6, options.TCPFastOpen, tlsFragment) if err != nil { return nil, err } @@ -130,9 +152,9 @@ func (d *DefaultDialer) DialContext(ctx context.Context, network string, address } } if !address.IsIPv6() { - return trackConn(DialSlowContext(&d.dialer4, ctx, network, address)) + return trackConn(d.dialer4.DialContext(ctx, network, address)) } else { - return trackConn(DialSlowContext(&d.dialer6, ctx, network, address)) + return trackConn(d.dialer6.DialContext(ctx, network, address)) } } diff --git a/common/dialer/default_go1.20.go b/common/dialer/default_go1.20.go index 8c3507c2..26e14f6f 100644 --- a/common/dialer/default_go1.20.go +++ b/common/dialer/default_go1.20.go @@ -4,12 +4,10 @@ package dialer import ( "net" - - "github.com/sagernet/tfo-go" ) -type tcpDialer = tfo.Dialer +type tcpDialer = ExtendedTCPDialer -func newTCPDialer(dialer net.Dialer, tfoEnabled bool) (tcpDialer, error) { - return tfo.Dialer{Dialer: dialer, DisableTFO: !tfoEnabled}, nil +func newTCPDialer(dialer net.Dialer, tfoEnabled bool, tlsFragment TLSFragment) (tcpDialer, error) { + return tcpDialer{Dialer: dialer, DisableTFO: !tfoEnabled, TLSFragment: tlsFragment}, nil } diff --git a/common/dialer/default_nongo1.20.go b/common/dialer/default_nongo1.20.go index 21502424..e9eb4195 100644 --- a/common/dialer/default_nongo1.20.go +++ b/common/dialer/default_nongo1.20.go @@ -10,9 +10,12 @@ import ( type tcpDialer = net.Dialer -func newTCPDialer(dialer net.Dialer, tfoEnabled bool) (tcpDialer, error) { +func newTCPDialer(dialer net.Dialer, tfoEnabled bool, tlsFragment TLSFragment) (tcpDialer, error) { if tfoEnabled { return dialer, E.New("TCP Fast Open requires go1.20, please recompile your binary.") } + if tlsFragment.Enabled { + return tcpDialer{Dialer: dialer, DisableTFO: true, TLSFragment: tlsFragment}, nil + } return dialer, nil } diff --git a/common/dialer/extended_tcp.go b/common/dialer/extended_tcp.go new file mode 100644 index 00000000..65b7ef4e --- /dev/null +++ b/common/dialer/extended_tcp.go @@ -0,0 +1,55 @@ +//go:build go1.20 + +package dialer + +import ( + "context" + "net" + + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/tfo-go" +) + +// Custom TCP dialer with extra features such as "TCP Fast Open" or "TLS Fragmentation" +type ExtendedTCPDialer struct { + net.Dialer + DisableTFO bool + TLSFragment TLSFragment +} + +func (d *ExtendedTCPDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if (d.DisableTFO && !d.TLSFragment.Enabled) || N.NetworkName(network) != N.NetworkTCP { + switch N.NetworkName(network) { + case N.NetworkTCP, N.NetworkUDP: + return d.Dialer.DialContext(ctx, network, destination.String()) + default: + return d.Dialer.DialContext(ctx, network, destination.AddrString()) + } + } + // Create a TLS-Fragmented dialer + if d.TLSFragment.Enabled { + fragmentConn := &fragmentConn{ + dialer: &d.Dialer, + fragment: d.TLSFragment, + network: network, + destination: destination, + } + conn, err := d.Dialer.DialContext(ctx, network, destination.String()) + if err != nil { + fragmentConn.err = err + return nil, err + } + fragmentConn.conn = conn + return fragmentConn, nil + } + // Create a TFO dialer + return &slowOpenConn{ + dialer: &tfo.Dialer{Dialer: d.Dialer, DisableTFO: d.DisableTFO}, + ctx: ctx, + network: network, + destination: destination, + create: make(chan struct{}), + }, + nil +} diff --git a/common/dialer/extended_tcp_stub.go b/common/dialer/extended_tcp_stub.go new file mode 100644 index 00000000..5055c373 --- /dev/null +++ b/common/dialer/extended_tcp_stub.go @@ -0,0 +1,36 @@ +//go:build !go1.20 + +package dialer + +import ( + "context" + "net" + + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func (d *ExtendedTCPDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if !d.TLSFragment.Enabled || N.NetworkName(network) != N.NetworkTCP { + switch N.NetworkName(network) { + case N.NetworkTCP, N.NetworkUDP: + return d.Dialer.DialContext(ctx, network, destination.String()) + default: + return d.Dialer.DialContext(ctx, network, destination.AddrString()) + } + } + // Create a TLS-Fragmented dialer + fragmentConn := &fragmentConn{ + dialer: &d.Dialer, + fragment: d.TLSFragment, + network: network, + destination: destination, + } + conn, err := d.Dialer.DialContext(ctx, network, destination.String()) + if err != nil { + fragmentConn.err = err + return nil, err + } + fragmentConn.conn = conn + return fragmentConn, nil +} diff --git a/common/dialer/fragment.go b/common/dialer/fragment.go new file mode 100644 index 00000000..b087e0da --- /dev/null +++ b/common/dialer/fragment.go @@ -0,0 +1,154 @@ +package dialer + +import ( + "crypto/rand" + "encoding/binary" + "io" + "math/big" + "net" + "os" + "time" + + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" + M "github.com/sagernet/sing/common/metadata" +) + +type TLSFragment struct { + Enabled bool + SizeMin uint64 + SizeMax uint64 + SleepMin uint64 + SleepMax uint64 +} + +type fragmentConn struct { + dialer *net.Dialer + fragment TLSFragment + network string + destination M.Socksaddr + conn net.Conn + err error +} + +func (c *fragmentConn) Read(b []byte) (n int, err error) { + if c.conn == nil { + return 0, c.err + } + return c.conn.Read(b) +} + +func randBetween(left int64, right int64) int64 { + if left == right { + return left + } + bigInt, _ := rand.Int(rand.Reader, big.NewInt(right-left)) + return left + bigInt.Int64() +} + +func (c *fragmentConn) Write(b []byte) (n int, err error) { + if c.conn == nil { + return 0, c.err + } + // Check if payload is a valid TLS clientHello packet + if len(b) >= 5 && b[0] == 22 { + clientHelloLen := int(binary.BigEndian.Uint16(b[3:5])) + clientHelloData := b[5:] + + for i := 0; i < clientHelloLen; { + fragmentEnd := i + int(randBetween(int64(c.fragment.SizeMin), int64(c.fragment.SizeMax))) + if fragmentEnd > clientHelloLen { + fragmentEnd = clientHelloLen + } + + fragment := clientHelloData[i:fragmentEnd] + i = fragmentEnd + + header := make([]byte, 5) + header[0] = b[0] + binary.BigEndian.PutUint16(header[1:], binary.BigEndian.Uint16(b[1:3])) + binary.BigEndian.PutUint16(header[3:], uint16(len(fragment))) + payload := append(header, fragment...) + + _, err := c.conn.Write(payload) + if err != nil { + c.err = err + return 0, c.err + } + + randomInterval := randBetween(int64(c.fragment.SleepMin), int64(c.fragment.SleepMax)) + time.Sleep(time.Duration(randomInterval)) + } + + return len(b), nil + } + + // Write directly if not a clientHello packet + return c.conn.Write(b) +} + +func (c *fragmentConn) Close() error { + return common.Close(c.conn) +} + +func (c *fragmentConn) LocalAddr() net.Addr { + if c.conn == nil { + return M.Socksaddr{} + } + return c.conn.LocalAddr() +} + +func (c *fragmentConn) RemoteAddr() net.Addr { + if c.conn == nil { + return M.Socksaddr{} + } + return c.conn.RemoteAddr() +} + +func (c *fragmentConn) SetDeadline(t time.Time) error { + if c.conn == nil { + return os.ErrInvalid + } + return c.conn.SetDeadline(t) +} + +func (c *fragmentConn) SetReadDeadline(t time.Time) error { + if c.conn == nil { + return os.ErrInvalid + } + return c.conn.SetReadDeadline(t) +} + +func (c *fragmentConn) SetWriteDeadline(t time.Time) error { + if c.conn == nil { + return os.ErrInvalid + } + return c.conn.SetWriteDeadline(t) +} + +func (c *fragmentConn) Upstream() any { + return c.conn +} + +func (c *fragmentConn) ReaderReplaceable() bool { + return c.conn != nil +} + +func (c *fragmentConn) WriterReplaceable() bool { + return c.conn != nil +} + +func (c *fragmentConn) LazyHeadroom() bool { + return c.conn == nil +} + +func (c *fragmentConn) NeedHandshake() bool { + return c.conn == nil +} + +func (c *fragmentConn) WriteTo(w io.Writer) (n int64, err error) { + if c.conn == nil { + return 0, c.err + } + return bufio.Copy(w, c.conn) +} diff --git a/common/dialer/tfo.go b/common/dialer/tfo.go index 0b0c9fcc..ae446e5b 100644 --- a/common/dialer/tfo.go +++ b/common/dialer/tfo.go @@ -14,7 +14,6 @@ import ( "github.com/sagernet/sing/common/bufio" E "github.com/sagernet/sing/common/exceptions" M "github.com/sagernet/sing/common/metadata" - N "github.com/sagernet/sing/common/network" "github.com/sagernet/tfo-go" ) @@ -29,24 +28,6 @@ type slowOpenConn struct { err error } -func DialSlowContext(dialer *tcpDialer, ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { - if dialer.DisableTFO || N.NetworkName(network) != N.NetworkTCP { - switch N.NetworkName(network) { - case N.NetworkTCP, N.NetworkUDP: - return dialer.Dialer.DialContext(ctx, network, destination.String()) - default: - return dialer.Dialer.DialContext(ctx, network, destination.AddrString()) - } - } - return &slowOpenConn{ - dialer: dialer, - ctx: ctx, - network: network, - destination: destination, - create: make(chan struct{}), - }, nil -} - func (c *slowOpenConn) Read(b []byte) (n int, err error) { if c.conn == nil { select { diff --git a/common/dialer/tfo_stub.go b/common/dialer/tfo_stub.go deleted file mode 100644 index 144902e5..00000000 --- a/common/dialer/tfo_stub.go +++ /dev/null @@ -1,20 +0,0 @@ -//go:build !go1.20 - -package dialer - -import ( - "context" - "net" - - M "github.com/sagernet/sing/common/metadata" - N "github.com/sagernet/sing/common/network" -) - -func DialSlowContext(dialer *tcpDialer, ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { - switch N.NetworkName(network) { - case N.NetworkTCP, N.NetworkUDP: - return dialer.DialContext(ctx, network, destination.String()) - default: - return dialer.DialContext(ctx, network, destination.AddrString()) - } -} diff --git a/option/fragment.go b/option/fragment.go new file mode 100644 index 00000000..c25225b0 --- /dev/null +++ b/option/fragment.go @@ -0,0 +1,38 @@ +package option + +import ( + "strconv" + "strings" + + E "github.com/sagernet/sing/common/exceptions" +) + +type TLSFragmentOptions struct { + Enabled bool `json:"enabled,omitempty"` + Size string `json:"size,omitempty"` // Fragment size in Bytes + Sleep string `json:"sleep,omitempty"` // Time to sleep between sending the fragments in milliseconds +} + +func ParseIntRange(str string) ([]uint64, error) { + var err error + result := make([]uint64, 2) + + splitString := strings.Split(str, "-") + if len(splitString) == 2 { + result[0], err = strconv.ParseUint(splitString[0], 10, 64) + if err != nil { + return nil, E.Cause(err, "Error parsing string to integer") + } + result[1], err = strconv.ParseUint(splitString[1], 10, 64) + if err != nil { + return nil, E.Cause(err, "Error parsing string to integer") + } + } else { + result[0], err = strconv.ParseUint(splitString[0], 10, 64) + if err != nil { + return nil, E.Cause(err, "Error parsing string to integer") + } + result[1] = result[0] + } + return result, err +} diff --git a/option/outbound.go b/option/outbound.go index 2985319e..36ddd11a 100644 --- a/option/outbound.go +++ b/option/outbound.go @@ -130,20 +130,21 @@ func (h *Outbound) UnmarshalJSON(bytes []byte) error { } type DialerOptions struct { - Detour string `json:"detour,omitempty"` - BindInterface string `json:"bind_interface,omitempty"` - Inet4BindAddress *ListenAddress `json:"inet4_bind_address,omitempty"` - Inet6BindAddress *ListenAddress `json:"inet6_bind_address,omitempty"` - ProtectPath string `json:"protect_path,omitempty"` - RoutingMark int `json:"routing_mark,omitempty"` - ReuseAddr bool `json:"reuse_addr,omitempty"` - ConnectTimeout Duration `json:"connect_timeout,omitempty"` - TCPFastOpen bool `json:"tcp_fast_open,omitempty"` - TCPMultiPath bool `json:"tcp_multi_path,omitempty"` - UDPFragment *bool `json:"udp_fragment,omitempty"` - UDPFragmentDefault bool `json:"-"` - DomainStrategy DomainStrategy `json:"domain_strategy,omitempty"` - FallbackDelay Duration `json:"fallback_delay,omitempty"` + Detour string `json:"detour,omitempty"` + BindInterface string `json:"bind_interface,omitempty"` + Inet4BindAddress *ListenAddress `json:"inet4_bind_address,omitempty"` + Inet6BindAddress *ListenAddress `json:"inet6_bind_address,omitempty"` + ProtectPath string `json:"protect_path,omitempty"` + RoutingMark int `json:"routing_mark,omitempty"` + ReuseAddr bool `json:"reuse_addr,omitempty"` + ConnectTimeout Duration `json:"connect_timeout,omitempty"` + TCPFastOpen bool `json:"tcp_fast_open,omitempty"` + TCPMultiPath bool `json:"tcp_multi_path,omitempty"` + TLSFragment TLSFragmentOptions `json:"tls_fragment,omitempty"` + UDPFragment *bool `json:"udp_fragment,omitempty"` + UDPFragmentDefault bool `json:"-"` + DomainStrategy DomainStrategy `json:"domain_strategy,omitempty"` + FallbackDelay Duration `json:"fallback_delay,omitempty"` } type ServerOptions struct {