Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(transport): TLS Client Hello fragmentation by fixed length #134

Merged
merged 7 commits into from
Dec 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
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]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works different from the TCP split.
If you do split:3|split:8, you end up with [3][5][...], because it's the absolute position.
Your code for tlsfrag:3|tlsfrag:8 is doing [3][8][...].
Perhaps we should align that behavior, otherwise it's surprising.

I find it more helpful to use an absolute number, as it makes it easier to specify them.

Copy link
Contributor Author

@jyyi1 jyyi1 Nov 30, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'm doing the absolute value as well. See the four fragmented packets above: frag1, frag2, frag3 and frag4, they are of lengths: [3][5][8][...].

And the config would be tlsfrag:3|tlsfrag:8|tlsfrag:16 (or tlsfrag:3|tlsfrag:8|tlsfrag:-3 since the total message size is 19).

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