Преглед на файлове

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.
tags/v2.2.7^2^2
appolimp преди 1 месец
родител
ревизия
38abee7d7f
променени са 3 файла, в които са добавени 395 реда и са изтрити 17 реда
  1. 1
    1
      Dockerfile
  2. 122
    16
      mtglib/internal/tls/fake/client_side.go
  3. 272
    0
      mtglib/internal/tls/fake/client_side_test.go

+ 1
- 1
Dockerfile Целия файл

33
 COPY . /app
33
 COPY . /app
34
 
34
 
35
 RUN set -x \
35
 RUN set -x \
36
-  && version="$(git describe --exact-match HEAD 2>/dev/null || git describe --tags --always)" \
36
+  && version="$(git describe --exact-match HEAD 2>/dev/null || git describe --tags --always 2>/dev/null || echo dev)" \
37
   && go build \
37
   && go build \
38
       -trimpath \
38
       -trimpath \
39
       -mod=readonly \
39
       -mod=readonly \

+ 122
- 16
mtglib/internal/tls/fake/client_side.go Целия файл

6
 	"crypto/sha256"
6
 	"crypto/sha256"
7
 	"crypto/subtle"
7
 	"crypto/subtle"
8
 	"encoding/binary"
8
 	"encoding/binary"
9
+	"errors"
9
 	"fmt"
10
 	"fmt"
10
 	"io"
11
 	"io"
11
 	"net"
12
 	"net"
23
 	RandomOffset = 1 + 2 + 2 + 1 + 3 + 2
24
 	RandomOffset = 1 + 2 + 2 + 1 + 3 + 2
24
 
25
 
25
 	sniDNSNamesListType = 0
26
 	sniDNSNamesListType = 0
27
+
28
+	// maxContinuationRecords limits the number of continuation TLS records
29
+	// that reassembleTLSHandshake will read. This prevents resource exhaustion
30
+	// from adversarial fragmentation.
31
+	maxContinuationRecords = 10
26
 )
32
 )
27
 
33
 
28
 var (
34
 var (
56
 	//  4. New digest should be all 0 except of last 4 bytes
62
 	//  4. New digest should be all 0 except of last 4 bytes
57
 	//  5. Last 4 bytes are little endian uint32 of UNIX timestamp when
63
 	//  5. Last 4 bytes are little endian uint32 of UNIX timestamp when
58
 	//     this message was created.
64
 	//     this message was created.
65
+	reassembled, err := reassembleTLSHandshake(conn)
66
+	if err != nil {
67
+		return nil, fmt.Errorf("cannot reassemble TLS records: %w", err)
68
+	}
69
+
59
 	handshakeCopyBuf := &bytes.Buffer{}
70
 	handshakeCopyBuf := &bytes.Buffer{}
60
-	reader := io.TeeReader(conn, handshakeCopyBuf)
71
+	reader := io.TeeReader(reassembled, handshakeCopyBuf)
61
 
72
 
62
-	reader, err := parseTLSHeader(reader)
63
-	if err != nil {
64
-		return nil, fmt.Errorf("cannot parse tls header: %w", err)
73
+	// Skip the TLS record header (validated during reassembly).
74
+	// The header still flows through TeeReader into handshakeCopyBuf for HMAC.
75
+	if _, err = io.CopyN(io.Discard, reader, tls.SizeHeader); err != nil {
76
+		return nil, fmt.Errorf("cannot skip tls header: %w", err)
65
 	}
77
 	}
66
 
78
 
67
 	reader, err = parseHandshakeHeader(reader)
79
 	reader, err = parseHandshakeHeader(reader)
110
 	return hello, nil
122
 	return hello, nil
111
 }
123
 }
112
 
124
 
113
-func parseTLSHeader(r io.Reader) (io.Reader, error) {
114
-	// record_type(1) + version(2) + size(2)
115
-	//   16 - type is 0x16 (handshake record)
116
-	//   03 01 - protocol version is "3,1" (also known as TLS 1.0)
117
-	//   00 f8 - 0xF8 (248) bytes of handshake message follows
118
-	header := [1 + 2 + 2]byte{}
119
-
120
-	if _, err := io.ReadFull(r, header[:]); err != nil {
125
+// reassembleTLSHandshake reads one or more TLS records from conn,
126
+// validates the record type and version, and reassembles fragmented
127
+// handshake payloads into a single TLS record.
128
+//
129
+// Per RFC 5246 Section 6.2.1, handshake messages may be fragmented
130
+// across multiple TLS records. DPI bypass tools like ByeDPI use this
131
+// to evade censorship.
132
+//
133
+// The returned buffer contains the full TLS record (header + payload)
134
+// so that callers can include the header in HMAC computation.
135
+func reassembleTLSHandshake(conn io.Reader) (*bytes.Buffer, error) {
136
+	header := [tls.SizeHeader]byte{}
137
+
138
+	if _, err := io.ReadFull(conn, header[:]); err != nil {
121
 		return nil, fmt.Errorf("cannot read record header: %w", err)
139
 		return nil, fmt.Errorf("cannot read record header: %w", err)
122
 	}
140
 	}
123
 
141
 
142
+	length := int64(binary.BigEndian.Uint16(header[3:]))
143
+	payload := &bytes.Buffer{}
144
+
145
+	if _, err := io.CopyN(payload, conn, length); err != nil {
146
+		return nil, fmt.Errorf("cannot read record payload: %w", err)
147
+	}
148
+
124
 	if header[0] != tls.TypeHandshake {
149
 	if header[0] != tls.TypeHandshake {
125
 		return nil, fmt.Errorf("unexpected record type %#x", header[0])
150
 		return nil, fmt.Errorf("unexpected record type %#x", header[0])
126
 	}
151
 	}
129
 		return nil, fmt.Errorf("unexpected protocol version %#x %#x", header[1], header[2])
154
 		return nil, fmt.Errorf("unexpected protocol version %#x %#x", header[1], header[2])
130
 	}
155
 	}
131
 
156
 
132
-	length := int64(binary.BigEndian.Uint16(header[3:]))
133
-	buf := &bytes.Buffer{}
157
+	// Reassemble fragmented payload. continuationCount caps the total
158
+	// number of continuation records across both phases below.
159
+	continuationCount := 0
134
 
160
 
135
-	_, err := io.CopyN(buf, r, length)
161
+	// Phase 1: read continuation records until we have at least the
162
+	// 4-byte handshake header (type + uint24 length) to determine the
163
+	// expected total size.
164
+	for ; payload.Len() < 4 && continuationCount < maxContinuationRecords; continuationCount++ {
165
+		prevLen := payload.Len()
136
 
166
 
137
-	return buf, err
167
+		if err := readContinuationRecord(conn, payload); err != nil {
168
+			payload.Truncate(prevLen) // discard partial data on error
169
+
170
+			if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
171
+				break // no more records — let downstream parsing handle what we have
172
+			}
173
+
174
+			return nil, err
175
+		}
176
+	}
177
+
178
+	// Phase 2: we know the expected handshake size — read remaining
179
+	// continuation records until the payload is complete.
180
+	if payload.Len() >= 4 {
181
+		p := payload.Bytes()
182
+		expectedTotal := 4 + (int(p[1])<<16 | int(p[2])<<8 | int(p[3]))
183
+
184
+		if expectedTotal > 0xFFFF {
185
+			return nil, fmt.Errorf("handshake message too large: %d bytes", expectedTotal)
186
+		}
187
+
188
+		for ; payload.Len() < expectedTotal && continuationCount < maxContinuationRecords; continuationCount++ {
189
+			if err := readContinuationRecord(conn, payload); err != nil {
190
+				return nil, err
191
+			}
192
+		}
193
+
194
+		if payload.Len() < expectedTotal {
195
+			return nil, fmt.Errorf("cannot reassemble handshake: too many continuation records")
196
+		}
197
+
198
+		payload.Truncate(expectedTotal)
199
+	}
200
+
201
+	if payload.Len() > 0xFFFF {
202
+		return nil, fmt.Errorf("reassembled payload too large: %d bytes", payload.Len())
203
+	}
204
+
205
+	// Reconstruct a single TLS record with the reassembled payload.
206
+	result := &bytes.Buffer{}
207
+	result.Grow(tls.SizeHeader + payload.Len())
208
+	result.Write(header[:3])
209
+	binary.Write(result, binary.BigEndian, uint16(payload.Len())) //nolint:errcheck // bytes.Buffer.Write never fails
210
+	result.Write(payload.Bytes())
211
+
212
+	return result, nil
213
+}
214
+
215
+// readContinuationRecord reads the next TLS record header and appends its
216
+// full payload to dst. It returns an error if the record is not a handshake
217
+// record.
218
+func readContinuationRecord(conn io.Reader, dst *bytes.Buffer) error {
219
+	nextHeader := [tls.SizeHeader]byte{}
220
+
221
+	if _, err := io.ReadFull(conn, nextHeader[:]); err != nil {
222
+		return fmt.Errorf("cannot read continuation record header: %w", err)
223
+	}
224
+
225
+	if nextHeader[0] != tls.TypeHandshake {
226
+		return fmt.Errorf("unexpected continuation record type %#x", nextHeader[0])
227
+	}
228
+
229
+	if nextHeader[1] != 3 || nextHeader[2] != 1 {
230
+		return fmt.Errorf("unexpected continuation record version %#x %#x", nextHeader[1], nextHeader[2])
231
+	}
232
+
233
+	nextLength := int64(binary.BigEndian.Uint16(nextHeader[3:]))
234
+
235
+	if nextLength == 0 {
236
+		return fmt.Errorf("zero-length continuation record")
237
+	}
238
+
239
+	if _, err := io.CopyN(dst, conn, nextLength); err != nil {
240
+		return fmt.Errorf("cannot read continuation record payload: %w", err)
241
+	}
242
+
243
+	return nil
138
 }
244
 }
139
 
245
 
140
 func parseHandshakeHeader(r io.Reader) (io.Reader, error) {
246
 func parseHandshakeHeader(r io.Reader) (io.Reader, error) {

+ 272
- 0
mtglib/internal/tls/fake/client_side_test.go Целия файл

3
 import (
3
 import (
4
 	"bytes"
4
 	"bytes"
5
 	"encoding/binary"
5
 	"encoding/binary"
6
+	"encoding/json"
6
 	"errors"
7
 	"errors"
7
 	"io"
8
 	"io"
9
+	"os"
8
 	"testing"
10
 	"testing"
9
 	"time"
11
 	"time"
10
 
12
 
393
 	t.Parallel()
395
 	t.Parallel()
394
 	suite.Run(t, &ParseClientHelloSNITestSuite{})
396
 	suite.Run(t, &ParseClientHelloSNITestSuite{})
395
 }
397
 }
398
+
399
+// fragmentTLSRecord splits a single TLS record into n TLS records by
400
+// dividing the payload into roughly equal parts. Each part gets its own
401
+// TLS record header with the same record type and version.
402
+func fragmentTLSRecord(t testing.TB, full []byte, n int) []byte {
403
+	t.Helper()
404
+
405
+	recordType := full[0]
406
+	version := full[1:3]
407
+	payload := full[tls.SizeHeader:]
408
+
409
+	chunkSize := len(payload) / n
410
+	result := &bytes.Buffer{}
411
+
412
+	for i := 0; i < n; i++ {
413
+		start := i * chunkSize
414
+		end := start + chunkSize
415
+
416
+		if i == n-1 {
417
+			end = len(payload)
418
+		}
419
+
420
+		chunk := payload[start:end]
421
+		result.WriteByte(recordType)
422
+		result.Write(version)
423
+		require.NoError(t, binary.Write(result, binary.BigEndian, uint16(len(chunk))))
424
+		result.Write(chunk)
425
+	}
426
+
427
+	return result.Bytes()
428
+}
429
+
430
+// splitPayloadAt creates two TLS records from a single record by splitting
431
+// the payload at the given byte position.
432
+func splitPayloadAt(t testing.TB, full []byte, pos int) []byte {
433
+	t.Helper()
434
+
435
+	payload := full[tls.SizeHeader:]
436
+	buf := &bytes.Buffer{}
437
+
438
+	buf.WriteByte(tls.TypeHandshake)
439
+	buf.Write(full[1:3])
440
+	require.NoError(t, binary.Write(buf, binary.BigEndian, uint16(pos)))
441
+	buf.Write(payload[:pos])
442
+
443
+	buf.WriteByte(tls.TypeHandshake)
444
+	buf.Write(full[1:3])
445
+	require.NoError(t, binary.Write(buf, binary.BigEndian, uint16(len(payload)-pos)))
446
+	buf.Write(payload[pos:])
447
+
448
+	return buf.Bytes()
449
+}
450
+
451
+type ParseClientHelloFragmentedTestSuite struct {
452
+	suite.Suite
453
+
454
+	secret   mtglib.Secret
455
+	snapshot *clientHelloSnapshot
456
+}
457
+
458
+func (s *ParseClientHelloFragmentedTestSuite) SetupSuite() {
459
+	parsed, err := mtglib.ParseSecret(
460
+		"ee367a189aee18fa31c190054efd4a8e9573746f726167652e676f6f676c65617069732e636f6d",
461
+	)
462
+	require.NoError(s.T(), err)
463
+
464
+	s.secret = parsed
465
+
466
+	fileData, err := os.ReadFile("testdata/client-hello-ok-19dfe38384b9884b.json")
467
+	require.NoError(s.T(), err)
468
+
469
+	s.snapshot = &clientHelloSnapshot{}
470
+	require.NoError(s.T(), json.Unmarshal(fileData, s.snapshot))
471
+}
472
+
473
+func (s *ParseClientHelloFragmentedTestSuite) makeConn(data []byte) *parseClientHelloConnMock {
474
+	readBuf := &bytes.Buffer{}
475
+	readBuf.Write(data)
476
+
477
+	connMock := &parseClientHelloConnMock{
478
+		readBuf: readBuf,
479
+	}
480
+
481
+	connMock.
482
+		On("SetReadDeadline", mock.AnythingOfType("time.Time")).
483
+		Twice().
484
+		Return(nil)
485
+
486
+	return connMock
487
+}
488
+
489
+func (s *ParseClientHelloFragmentedTestSuite) TestReassemblySuccess() {
490
+	full := s.snapshot.GetFull()
491
+
492
+	tests := []struct {
493
+		name string
494
+		data []byte
495
+	}{
496
+		{"two equal fragments", fragmentTLSRecord(s.T(), full, 2)},
497
+		{"three equal fragments", fragmentTLSRecord(s.T(), full, 3)},
498
+		{"single byte first fragment", splitPayloadAt(s.T(), full, 1)},
499
+		{"three byte first fragment", splitPayloadAt(s.T(), full, 3)},
500
+	}
501
+
502
+	for _, tt := range tests {
503
+		s.Run(tt.name, func() {
504
+			connMock := s.makeConn(tt.data)
505
+			defer connMock.AssertExpectations(s.T())
506
+
507
+			hello, err := fake.ReadClientHello(
508
+				connMock,
509
+				s.secret.Key[:],
510
+				s.secret.Host,
511
+				TolerateTime,
512
+			)
513
+			s.Require().NoError(err)
514
+
515
+			s.Equal(s.snapshot.GetRandom(), hello.Random[:])
516
+			s.Equal(s.snapshot.GetSessionID(), hello.SessionID)
517
+			s.Equal(uint16(s.snapshot.CipherSuite), hello.CipherSuite)
518
+		})
519
+	}
520
+}
521
+
522
+func (s *ParseClientHelloFragmentedTestSuite) TestReassemblyErrors() {
523
+	full := s.snapshot.GetFull()
524
+	payload := full[tls.SizeHeader:]
525
+
526
+	tests := []struct {
527
+		name      string
528
+		buildData func() []byte
529
+		errMsg    string
530
+	}{
531
+		{
532
+			name: "wrong continuation record type",
533
+			buildData: func() []byte {
534
+				buf := &bytes.Buffer{}
535
+				buf.WriteByte(tls.TypeHandshake)
536
+				buf.Write(full[1:3])
537
+				require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10)))
538
+				buf.Write(payload[:10])
539
+				// Wrong type: application data instead of handshake
540
+				buf.WriteByte(tls.TypeApplicationData)
541
+				buf.Write(full[1:3])
542
+				require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(len(payload)-10)))
543
+				buf.Write(payload[10:])
544
+				return buf.Bytes()
545
+			},
546
+			errMsg: "unexpected continuation record type",
547
+		},
548
+		{
549
+			name: "too many continuation records",
550
+			buildData: func() []byte {
551
+				// Handshake header claiming 256 bytes, but we only send 1 byte per continuation
552
+				handshakePayload := []byte{0x01, 0x00, 0x01, 0x00}
553
+				buf := &bytes.Buffer{}
554
+				buf.WriteByte(tls.TypeHandshake)
555
+				buf.Write([]byte{3, 1})
556
+				require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(len(handshakePayload))))
557
+				buf.Write(handshakePayload)
558
+				for range 11 {
559
+					buf.WriteByte(tls.TypeHandshake)
560
+					buf.Write([]byte{3, 1})
561
+					require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(1)))
562
+					buf.WriteByte(0xAB)
563
+				}
564
+				return buf.Bytes()
565
+			},
566
+			errMsg: "too many continuation records",
567
+		},
568
+		{
569
+			name: "zero-length continuation record",
570
+			buildData: func() []byte {
571
+				buf := &bytes.Buffer{}
572
+				buf.WriteByte(tls.TypeHandshake)
573
+				buf.Write(full[1:3])
574
+				require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10)))
575
+				buf.Write(payload[:10])
576
+				// Valid header but zero-length payload
577
+				buf.WriteByte(tls.TypeHandshake)
578
+				buf.Write(full[1:3])
579
+				require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(0)))
580
+				return buf.Bytes()
581
+			},
582
+			errMsg: "zero-length continuation record",
583
+		},
584
+		{
585
+			name: "wrong continuation record version",
586
+			buildData: func() []byte {
587
+				buf := &bytes.Buffer{}
588
+				buf.WriteByte(tls.TypeHandshake)
589
+				buf.Write(full[1:3])
590
+				require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10)))
591
+				buf.Write(payload[:10])
592
+				// Wrong version: 3.3 instead of 3.1
593
+				buf.WriteByte(tls.TypeHandshake)
594
+				buf.Write([]byte{3, 3})
595
+				require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(len(payload)-10)))
596
+				buf.Write(payload[10:])
597
+				return buf.Bytes()
598
+			},
599
+			errMsg: "unexpected continuation record version",
600
+		},
601
+		{
602
+			name: "handshake message too large",
603
+			buildData: func() []byte {
604
+				// Handshake header claiming 0x010000 (65536) bytes — exceeds 0xFFFF limit
605
+				handshakePayload := []byte{0x01, 0x01, 0x00, 0x00}
606
+				buf := &bytes.Buffer{}
607
+				buf.WriteByte(tls.TypeHandshake)
608
+				buf.Write([]byte{3, 1})
609
+				require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(len(handshakePayload))))
610
+				buf.Write(handshakePayload)
611
+				return buf.Bytes()
612
+			},
613
+			errMsg: "handshake message too large",
614
+		},
615
+		{
616
+			name: "truncated continuation record header",
617
+			buildData: func() []byte {
618
+				buf := &bytes.Buffer{}
619
+				buf.WriteByte(tls.TypeHandshake)
620
+				buf.Write(full[1:3])
621
+				require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10)))
622
+				buf.Write(payload[:10])
623
+				// Connection ends mid-header (only 2 bytes)
624
+				buf.WriteByte(tls.TypeHandshake)
625
+				buf.WriteByte(3)
626
+				return buf.Bytes()
627
+			},
628
+			errMsg: "cannot read continuation record header",
629
+		},
630
+		{
631
+			name: "truncated continuation record payload",
632
+			buildData: func() []byte {
633
+				buf := &bytes.Buffer{}
634
+				buf.WriteByte(tls.TypeHandshake)
635
+				buf.Write(full[1:3])
636
+				require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10)))
637
+				buf.Write(payload[:10])
638
+				// Claims 100 bytes but no payload follows
639
+				buf.WriteByte(tls.TypeHandshake)
640
+				buf.Write(full[1:3])
641
+				require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(100)))
642
+				return buf.Bytes()
643
+			},
644
+			errMsg: "cannot read continuation record payload",
645
+		},
646
+	}
647
+
648
+	for _, tt := range tests {
649
+		s.Run(tt.name, func() {
650
+			connMock := s.makeConn(tt.buildData())
651
+			defer connMock.AssertExpectations(s.T())
652
+
653
+			_, err := fake.ReadClientHello(
654
+				connMock,
655
+				s.secret.Key[:],
656
+				s.secret.Host,
657
+				TolerateTime,
658
+			)
659
+			s.ErrorContains(err, tt.errMsg)
660
+		})
661
+	}
662
+}
663
+
664
+func TestParseClientHelloFragmented(t *testing.T) {
665
+	t.Parallel()
666
+	suite.Run(t, &ParseClientHelloFragmentedTestSuite{})
667
+}

Loading…
Отказ
Запис