mirror of
https://github.com/ScuroNeko/mtg.git
synced 2026-08-31 12:04:03 +03:00
Support fragmented TLS handshake records
DPI bypass tools like ByeDPI fragment a single TLS record into multiple records to evade censorship. This broke ReadClientHello because it assumed the entire ClientHello arrives in one TLS record. Add reassembleTLSHandshake that reads continuation records and reconstructs a single TLS record before parsing and HMAC verification. Per RFC 5246 Section 6.2.1, handshake messages may be fragmented across multiple records — this is valid TLS behavior.
This commit is contained in:
@@ -3,8 +3,10 @@ package fake_test
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -393,3 +395,273 @@ func TestParseClientHelloSNI(t *testing.T) {
|
||||
t.Parallel()
|
||||
suite.Run(t, &ParseClientHelloSNITestSuite{})
|
||||
}
|
||||
|
||||
// fragmentTLSRecord splits a single TLS record into n TLS records by
|
||||
// dividing the payload into roughly equal parts. Each part gets its own
|
||||
// TLS record header with the same record type and version.
|
||||
func fragmentTLSRecord(t testing.TB, full []byte, n int) []byte {
|
||||
t.Helper()
|
||||
|
||||
recordType := full[0]
|
||||
version := full[1:3]
|
||||
payload := full[tls.SizeHeader:]
|
||||
|
||||
chunkSize := len(payload) / n
|
||||
result := &bytes.Buffer{}
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
start := i * chunkSize
|
||||
end := start + chunkSize
|
||||
|
||||
if i == n-1 {
|
||||
end = len(payload)
|
||||
}
|
||||
|
||||
chunk := payload[start:end]
|
||||
result.WriteByte(recordType)
|
||||
result.Write(version)
|
||||
require.NoError(t, binary.Write(result, binary.BigEndian, uint16(len(chunk))))
|
||||
result.Write(chunk)
|
||||
}
|
||||
|
||||
return result.Bytes()
|
||||
}
|
||||
|
||||
// splitPayloadAt creates two TLS records from a single record by splitting
|
||||
// the payload at the given byte position.
|
||||
func splitPayloadAt(t testing.TB, full []byte, pos int) []byte {
|
||||
t.Helper()
|
||||
|
||||
payload := full[tls.SizeHeader:]
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
buf.WriteByte(tls.TypeHandshake)
|
||||
buf.Write(full[1:3])
|
||||
require.NoError(t, binary.Write(buf, binary.BigEndian, uint16(pos)))
|
||||
buf.Write(payload[:pos])
|
||||
|
||||
buf.WriteByte(tls.TypeHandshake)
|
||||
buf.Write(full[1:3])
|
||||
require.NoError(t, binary.Write(buf, binary.BigEndian, uint16(len(payload)-pos)))
|
||||
buf.Write(payload[pos:])
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
type ParseClientHelloFragmentedTestSuite struct {
|
||||
suite.Suite
|
||||
|
||||
secret mtglib.Secret
|
||||
snapshot *clientHelloSnapshot
|
||||
}
|
||||
|
||||
func (s *ParseClientHelloFragmentedTestSuite) SetupSuite() {
|
||||
parsed, err := mtglib.ParseSecret(
|
||||
"ee367a189aee18fa31c190054efd4a8e9573746f726167652e676f6f676c65617069732e636f6d",
|
||||
)
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
s.secret = parsed
|
||||
|
||||
fileData, err := os.ReadFile("testdata/client-hello-ok-19dfe38384b9884b.json")
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
s.snapshot = &clientHelloSnapshot{}
|
||||
require.NoError(s.T(), json.Unmarshal(fileData, s.snapshot))
|
||||
}
|
||||
|
||||
func (s *ParseClientHelloFragmentedTestSuite) makeConn(data []byte) *parseClientHelloConnMock {
|
||||
readBuf := &bytes.Buffer{}
|
||||
readBuf.Write(data)
|
||||
|
||||
connMock := &parseClientHelloConnMock{
|
||||
readBuf: readBuf,
|
||||
}
|
||||
|
||||
connMock.
|
||||
On("SetReadDeadline", mock.AnythingOfType("time.Time")).
|
||||
Twice().
|
||||
Return(nil)
|
||||
|
||||
return connMock
|
||||
}
|
||||
|
||||
func (s *ParseClientHelloFragmentedTestSuite) TestReassemblySuccess() {
|
||||
full := s.snapshot.GetFull()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
}{
|
||||
{"two equal fragments", fragmentTLSRecord(s.T(), full, 2)},
|
||||
{"three equal fragments", fragmentTLSRecord(s.T(), full, 3)},
|
||||
{"single byte first fragment", splitPayloadAt(s.T(), full, 1)},
|
||||
{"three byte first fragment", splitPayloadAt(s.T(), full, 3)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.Run(tt.name, func() {
|
||||
connMock := s.makeConn(tt.data)
|
||||
defer connMock.AssertExpectations(s.T())
|
||||
|
||||
hello, err := fake.ReadClientHello(
|
||||
connMock,
|
||||
s.secret.Key[:],
|
||||
s.secret.Host,
|
||||
TolerateTime,
|
||||
)
|
||||
s.Require().NoError(err)
|
||||
|
||||
s.Equal(s.snapshot.GetRandom(), hello.Random[:])
|
||||
s.Equal(s.snapshot.GetSessionID(), hello.SessionID)
|
||||
s.Equal(uint16(s.snapshot.CipherSuite), hello.CipherSuite)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ParseClientHelloFragmentedTestSuite) TestReassemblyErrors() {
|
||||
full := s.snapshot.GetFull()
|
||||
payload := full[tls.SizeHeader:]
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
buildData func() []byte
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "wrong continuation record type",
|
||||
buildData: func() []byte {
|
||||
buf := &bytes.Buffer{}
|
||||
buf.WriteByte(tls.TypeHandshake)
|
||||
buf.Write(full[1:3])
|
||||
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10)))
|
||||
buf.Write(payload[:10])
|
||||
// Wrong type: application data instead of handshake
|
||||
buf.WriteByte(tls.TypeApplicationData)
|
||||
buf.Write(full[1:3])
|
||||
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(len(payload)-10)))
|
||||
buf.Write(payload[10:])
|
||||
return buf.Bytes()
|
||||
},
|
||||
errMsg: "unexpected continuation record type",
|
||||
},
|
||||
{
|
||||
name: "too many continuation records",
|
||||
buildData: func() []byte {
|
||||
// Handshake header claiming 256 bytes, but we only send 1 byte per continuation
|
||||
handshakePayload := []byte{0x01, 0x00, 0x01, 0x00}
|
||||
buf := &bytes.Buffer{}
|
||||
buf.WriteByte(tls.TypeHandshake)
|
||||
buf.Write([]byte{3, 1})
|
||||
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(len(handshakePayload))))
|
||||
buf.Write(handshakePayload)
|
||||
for range 11 {
|
||||
buf.WriteByte(tls.TypeHandshake)
|
||||
buf.Write([]byte{3, 1})
|
||||
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(1)))
|
||||
buf.WriteByte(0xAB)
|
||||
}
|
||||
return buf.Bytes()
|
||||
},
|
||||
errMsg: "too many continuation records",
|
||||
},
|
||||
{
|
||||
name: "zero-length continuation record",
|
||||
buildData: func() []byte {
|
||||
buf := &bytes.Buffer{}
|
||||
buf.WriteByte(tls.TypeHandshake)
|
||||
buf.Write(full[1:3])
|
||||
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10)))
|
||||
buf.Write(payload[:10])
|
||||
// Valid header but zero-length payload
|
||||
buf.WriteByte(tls.TypeHandshake)
|
||||
buf.Write(full[1:3])
|
||||
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(0)))
|
||||
return buf.Bytes()
|
||||
},
|
||||
errMsg: "zero-length continuation record",
|
||||
},
|
||||
{
|
||||
name: "wrong continuation record version",
|
||||
buildData: func() []byte {
|
||||
buf := &bytes.Buffer{}
|
||||
buf.WriteByte(tls.TypeHandshake)
|
||||
buf.Write(full[1:3])
|
||||
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10)))
|
||||
buf.Write(payload[:10])
|
||||
// Wrong version: 3.3 instead of 3.1
|
||||
buf.WriteByte(tls.TypeHandshake)
|
||||
buf.Write([]byte{3, 3})
|
||||
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(len(payload)-10)))
|
||||
buf.Write(payload[10:])
|
||||
return buf.Bytes()
|
||||
},
|
||||
errMsg: "unexpected continuation record version",
|
||||
},
|
||||
{
|
||||
name: "handshake message too large",
|
||||
buildData: func() []byte {
|
||||
// Handshake header claiming 0x010000 (65536) bytes — exceeds 0xFFFF limit
|
||||
handshakePayload := []byte{0x01, 0x01, 0x00, 0x00}
|
||||
buf := &bytes.Buffer{}
|
||||
buf.WriteByte(tls.TypeHandshake)
|
||||
buf.Write([]byte{3, 1})
|
||||
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(len(handshakePayload))))
|
||||
buf.Write(handshakePayload)
|
||||
return buf.Bytes()
|
||||
},
|
||||
errMsg: "handshake message too large",
|
||||
},
|
||||
{
|
||||
name: "truncated continuation record header",
|
||||
buildData: func() []byte {
|
||||
buf := &bytes.Buffer{}
|
||||
buf.WriteByte(tls.TypeHandshake)
|
||||
buf.Write(full[1:3])
|
||||
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10)))
|
||||
buf.Write(payload[:10])
|
||||
// Connection ends mid-header (only 2 bytes)
|
||||
buf.WriteByte(tls.TypeHandshake)
|
||||
buf.WriteByte(3)
|
||||
return buf.Bytes()
|
||||
},
|
||||
errMsg: "cannot read continuation record header",
|
||||
},
|
||||
{
|
||||
name: "truncated continuation record payload",
|
||||
buildData: func() []byte {
|
||||
buf := &bytes.Buffer{}
|
||||
buf.WriteByte(tls.TypeHandshake)
|
||||
buf.Write(full[1:3])
|
||||
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10)))
|
||||
buf.Write(payload[:10])
|
||||
// Claims 100 bytes but no payload follows
|
||||
buf.WriteByte(tls.TypeHandshake)
|
||||
buf.Write(full[1:3])
|
||||
require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(100)))
|
||||
return buf.Bytes()
|
||||
},
|
||||
errMsg: "cannot read continuation record payload",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.Run(tt.name, func() {
|
||||
connMock := s.makeConn(tt.buildData())
|
||||
defer connMock.AssertExpectations(s.T())
|
||||
|
||||
_, err := fake.ReadClientHello(
|
||||
connMock,
|
||||
s.secret.Key[:],
|
||||
s.secret.Host,
|
||||
TolerateTime,
|
||||
)
|
||||
s.ErrorContains(err, tt.errMsg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClientHelloFragmented(t *testing.T) {
|
||||
t.Parallel()
|
||||
suite.Run(t, &ParseClientHelloFragmentedTestSuite{})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user