diff --git a/transport/tlsfrag/doc.go b/transport/tlsfrag/doc.go new file mode 100644 index 00000000..8b050c9d --- /dev/null +++ b/transport/tlsfrag/doc.go @@ -0,0 +1,38 @@ +// Copyright 2023 Jigsaw Operations LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/* +Package tlsfrag provides tools to split the [TLS handshake record] containing +the [Client Hello message] into multiple [TLS records]. This technique, +known as TLS record fragmentation, forces censors to maintain state and +allocate memory for potential reassembly, making censorship more difficult and +resource-intensive. For detailed explanation on how this technique works, refer +to [Circumventing the GFW with TLS Record Fragmentation]. + +This package offers convenient helper functions to create a TLS +[transport.StreamDialer] that fragments the [TLS handshake record]: + - [NewFixedLenStreamDialer] creates a [transport.StreamDialer] that splits + the [Client Hello message] into two records. One of the records will have + the specified length of splitLen bytes. + - [NewStreamDialerFunc] offers a more flexible way to fragment [Client Hello + message]. It accepts a callback function that determines the split point, + enabling advanced splitting logic such as splitting based on the SNI + extension. + +[Circumventing the GFW with TLS Record Fragmentation]: https://upb-syssec.github.io/blog/2023/record-fragmentation/#tls-record-fragmentation +[TLS records]: https://datatracker.ietf.org/doc/html/rfc8446#section-5.1 +[TLS handshake record]: https://datatracker.ietf.org/doc/html/rfc8446#appendix-B.3 +[Client Hello message]: https://datatracker.ietf.org/doc/html/rfc8446#section-4.1.2 +*/ +package tlsfrag diff --git a/transport/tlsfrag/stream_dialer.go b/transport/tlsfrag/stream_dialer.go index 28ae761f..550ae131 100644 --- a/transport/tlsfrag/stream_dialer.go +++ b/transport/tlsfrag/stream_dialer.go @@ -78,3 +78,25 @@ func WrapConnFunc(base transport.StreamConn, frag FragFunc) (transport.StreamCon } return transport.WrapConn(base, base, w), nil } + +// NewFixedLenStreamDialer is a [transport.StreamDialer] that fragments the [TLS handshake record]. It splits the +// record into two records based on the given splitLen. If splitLen is positive, the first piece will contain the +// specified number of leading bytes from the original message. If it is negative, the second piece will contain +// the specified number of trailing bytes. +// +// [TLS handshake record]: https://datatracker.ietf.org/doc/html/rfc8446#section-5.1 +func NewFixedLenStreamDialer(base transport.StreamDialer, splitLen int) (transport.StreamDialer, error) { + if base == nil { + return nil, errors.New("base dialer must not be nil") + } + if splitLen == 0 { + return base, nil + } + return NewStreamDialerFunc(base, func(record []byte) int { + if splitLen > 0 { + // TODO: optimize for the leading bytes split + return splitLen + } + return len(record) + splitLen + }) +} diff --git a/transport/tlsfrag/stream_dialer_test.go b/transport/tlsfrag/stream_dialer_test.go index 887f8f5c..c88754a8 100644 --- a/transport/tlsfrag/stream_dialer_test.go +++ b/transport/tlsfrag/stream_dialer_test.go @@ -28,23 +28,23 @@ import ( "github.com/stretchr/testify/require" ) -// Make sure only the first Client Hello is splitted. +// Make sure only the first Client Hello is splitted in half. func TestStreamDialerFuncSplitsClientHello(t *testing.T) { hello := constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x01, 0x00, 0x00, 0x03, 0xaa, 0xbb, 0xcc}) cipher := constructTLSRecord(t, layers.TLSChangeCipherSpec, 0x0303, []byte{0x01}) req1 := constructTLSRecord(t, layers.TLSApplicationData, 0x0303, []byte{0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88}) inner := &collectStreamDialer{} - conn := assertCanDialFragFunc(t, inner, "ipinfo.io:443", func(_ []byte) int { return 2 }) + conn := assertCanDialFragFunc(t, inner, "ipinfo.io:443", func(payload []byte) int { return len(payload) / 2 }) defer conn.Close() assertCanWriteAll(t, conn, net.Buffers{hello, cipher, req1, hello, cipher, req1}) - frag1 := constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x01, 0x00}) - frag2 := constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x00, 0x03, 0xaa, 0xbb, 0xcc}) + frag1 := constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x01, 0x00, 0x00}) + frag2 := constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x03, 0xaa, 0xbb, 0xcc}) expected := net.Buffers{ - append(frag1, frag2...), // fragment 1 and fragment 2 will be merged in one single Write - cipher, req1, hello, cipher, req1, // unchanged + append(frag1, frag2...), // First two fragments will be merged in one single Write + cipher, req1, hello, cipher, req1, // Unchanged } require.Equal(t, expected, inner.bufs) } @@ -78,19 +78,104 @@ func TestStreamDialerFuncDontSplitNonClientHello(t *testing.T) { for _, tc := range cases { inner := &collectStreamDialer{} - conn := assertCanDialFragFunc(t, inner, "ipinfo.io:443", func(_ []byte) int { return 2 }) + conn := assertCanDialFragFunc(t, inner, "ipinfo.io:443", func(payload []byte) int { return len(payload) / 2 }) defer conn.Close() assertCanWriteAll(t, conn, net.Buffers{tc.pkt, cipher, req}) expected := net.Buffers{tc.pkt, cipher, req} if len(tc.pkt) > 5 { - // header and content of the first pkt might be issued by two Writes, but they are not fragmented + // Header and content of the first pkt might be issued by two Writes, but they are not fragmented expected = net.Buffers{tc.pkt[:5], tc.pkt[5:], cipher, req} } require.Equal(t, expected, inner.bufs, tc.msg) } } +// Make sure only the first Client Hello is splitted by a fixed length. +func TestFixedLenStreamDialerSplitsClientHello(t *testing.T) { + hello := constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x01, 0x00, 0x00, 0x03, 0xaa, 0xbb, 0xcc}) + cipher := constructTLSRecord(t, layers.TLSChangeCipherSpec, 0x0303, []byte{0x01}) + req1 := constructTLSRecord(t, layers.TLSApplicationData, 0x0303, []byte{0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88}) + + cases := []struct { + msg string + original, splitted net.Buffers + splitLen int + }{ + { + msg: "split leading bytes", + original: net.Buffers{hello, cipher, req1, hello, cipher, req1}, + splitLen: 2, + splitted: net.Buffers{ + // First two fragments will be merged in one single Write + append( + constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x01, 0x00}), + constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x00, 0x03, 0xaa, 0xbb, 0xcc})...), + cipher, req1, hello, cipher, req1, + }, + }, + { + msg: "split trailing bytes", + original: net.Buffers{hello, cipher, req1, hello, cipher, req1}, + splitLen: -2, + splitted: net.Buffers{ + // First two fragments will be merged in one single Write + append( + constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x01, 0x00, 0x00, 0x03, 0xaa}), + constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0xbb, 0xcc})...), + cipher, req1, hello, cipher, req1, + }, + }, + { + msg: "no split", + original: net.Buffers{hello, cipher, req1, hello, cipher, req1}, + splitLen: 0, + splitted: net.Buffers{hello, cipher, req1, hello, cipher, req1}, + }, + } + + for _, tc := range cases { + inner := &collectStreamDialer{} + conn := assertCanDialFixedLenFrag(t, inner, "ipinfo.io:443", tc.splitLen) + defer conn.Close() + + assertCanWriteAll(t, conn, tc.original) + require.Equal(t, tc.splitted, inner.bufs, tc.msg) + } +} + +// Make sure the first Client Hello can be splitted multiple times. +func TestNestedFixedLenStreamDialerSplitsClientHello(t *testing.T) { + hello := constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{ + 0x01, 0x00, 0x00, 0x03, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x99, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, + }) + frag1 := constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x01, 0x00, 0x00}) + frag2 := constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x03, 0xaa, 0xbb, 0xcc, 0xdd}) + frag3 := constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0xee, 0xff, 0x99, 0x88, 0x77, 0x66, 0x55, 0x44}) + frag4 := constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x33, 0x22, 0x11}) + + cipher := constructTLSRecord(t, layers.TLSChangeCipherSpec, 0x0303, []byte{0x01}) + req1 := constructTLSRecord(t, layers.TLSApplicationData, 0x0303, []byte{0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88}) + + inner := &collectStreamDialer{} + d, err := NewFixedLenStreamDialer(inner, 3) // Further split msg[:8] mentioned below into msg[:3] + msg[3:8] + require.NoError(t, err) + d, err = NewFixedLenStreamDialer(d, 8) // Further split msg[:16] mentioned below into msg[:8] + msg[8:16] + require.NoError(t, err) + conn := assertCanDialFixedLenFrag(t, d, "ipinfo.io:443", -3) // Split msg[:19] into msg[:16] + msg[16:19] + defer conn.Close() + + assertCanWriteAll(t, conn, net.Buffers{hello, cipher, req1, hello, cipher, req1}) + + expected := net.Buffers{ + append(frag1, frag2...), // First two fragments will be merged in one single Write + frag3, + frag4, + cipher, req1, hello, cipher, req1, // Unchanged + } + require.Equal(t, expected, inner.bufs) +} + // test assertions func assertCanDialFragFunc(t *testing.T, inner transport.StreamDialer, raddr string, frag FragFunc) transport.StreamConn { @@ -103,6 +188,16 @@ func assertCanDialFragFunc(t *testing.T, inner transport.StreamDialer, raddr str return conn } +func assertCanDialFixedLenFrag(t *testing.T, inner transport.StreamDialer, raddr string, splitLen int) transport.StreamConn { + d, err := NewFixedLenStreamDialer(inner, splitLen) + require.NoError(t, err) + require.NotNil(t, d) + conn, err := d.Dial(context.Background(), raddr) + require.NoError(t, err) + require.NotNil(t, conn) + return conn +} + func assertCanWriteAll(t *testing.T, w io.Writer, buf net.Buffers) { for _, p := range buf { n, err := w.Write(p) @@ -111,7 +206,7 @@ func assertCanWriteAll(t *testing.T, w io.Writer, buf net.Buffers) { } } -// private test helpers +// Private test helpers func constructTLSRecord(t *testing.T, typ layers.TLSType, ver layers.TLSVersion, payload []byte) []byte { pkt := layers.TLS{ @@ -141,7 +236,7 @@ func (d *collectStreamDialer) Dial(ctx context.Context, raddr string) (transport } func (c *collectStreamDialer) Write(p []byte) (int, error) { - c.bufs = append(c.bufs, append([]byte{}, p...)) // copy p rather than retaining it according to the principle of Write + c.bufs = append(c.bufs, append([]byte{}, p...)) // Copy p rather than retaining it according to the principle of Write return len(p), nil }