Skip to content

Commit

Permalink
feat(transport): TLS Client Hello fragmentation by fixed length (#134)
Browse files Browse the repository at this point in the history
This PR adds `NewFixedBytesStreamDialer` that accepts a fixed `splitBytes` to fragment the Client Hello message:
- `splitBytes > 0`: split a fixed leading bytes to two records: `msg[:splitBytes]` and `msg[splitBytes:]`
- `splitBytes < 0`: split a fixed trailing bytes to two records: `msg[:len(msg)-abs(splitBytes)]` and `msg[len(msg)-abs(splitBytes):]`
- `splitBytes = 0`: no split
  • Loading branch information
jyyi1 authored Dec 1, 2023
1 parent 2772043 commit a1906fd
Show file tree
Hide file tree
Showing 3 changed files with 165 additions and 10 deletions.
38 changes: 38 additions & 0 deletions transport/tlsfrag/doc.go
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions transport/tlsfrag/stream_dialer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
}
115 changes: 105 additions & 10 deletions transport/tlsfrag/stream_dialer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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{
Expand Down Expand Up @@ -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
}

Expand Down

0 comments on commit a1906fd

Please sign in to comment.