Selaa lähdekoodia

Merge pull request #431 from appolimp/tls-record-reassembly-pr

Support fragmented TLS handshake records
tags/v2.2.7^2^2
Sergei Arkhipov 1 kuukausi sitten
vanhempi
commit
e8368f7645
No account linked to committer's email address
3 muutettua tiedostoa jossa 395 lisäystä ja 17 poistoa
  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 Näytä tiedosto

@@ -33,7 +33,7 @@ RUN go mod download
33 33
 COPY . /app
34 34
 
35 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 37
   && go build \
38 38
       -trimpath \
39 39
       -mod=readonly \

+ 122
- 16
mtglib/internal/tls/fake/client_side.go Näytä tiedosto

@@ -6,6 +6,7 @@ import (
6 6
 	"crypto/sha256"
7 7
 	"crypto/subtle"
8 8
 	"encoding/binary"
9
+	"errors"
9 10
 	"fmt"
10 11
 	"io"
11 12
 	"net"
@@ -23,6 +24,11 @@ const (
23 24
 	RandomOffset = 1 + 2 + 2 + 1 + 3 + 2
24 25
 
25 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 34
 var (
@@ -56,12 +62,18 @@ func ReadClientHello(
56 62
 	//  4. New digest should be all 0 except of last 4 bytes
57 63
 	//  5. Last 4 bytes are little endian uint32 of UNIX timestamp when
58 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 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 79
 	reader, err = parseHandshakeHeader(reader)
@@ -110,17 +122,30 @@ func ReadClientHello(
110 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 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 149
 	if header[0] != tls.TypeHandshake {
125 150
 		return nil, fmt.Errorf("unexpected record type %#x", header[0])
126 151
 	}
@@ -129,12 +154,93 @@ func parseTLSHeader(r io.Reader) (io.Reader, error) {
129 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 246
 func parseHandshakeHeader(r io.Reader) (io.Reader, error) {

+ 272
- 0
mtglib/internal/tls/fake/client_side_test.go Näytä tiedosto

@@ -3,8 +3,10 @@ package fake_test
3 3
 import (
4 4
 	"bytes"
5 5
 	"encoding/binary"
6
+	"encoding/json"
6 7
 	"errors"
7 8
 	"io"
9
+	"os"
8 10
 	"testing"
9 11
 	"time"
10 12
 
@@ -393,3 +395,273 @@ func TestParseClientHelloSNI(t *testing.T) {
393 395
 	t.Parallel()
394 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…
Peruuta
Tallenna