TLS ALPN Manipulation for HTTP/2 Disabling#
Overview#
This document provides a technical deep-dive into how our HTTP/1.1 enforcing proxy intercepts and modifies TLS handshake traffic to force HTTP/1.1 connections by removing HTTP/2 (h2) from ALPN (Application Layer Protocol Negotiation) extensions.
Architecture#
Client ──TLS──> Proxy ──Modified TLS──> Upstream ──TLS──> Target Server
ClientHello ClientHello (no h2) HTTP/1.1 Only
The proxy sits between the client and upstream server, intercepting the initial TLS ClientHello message, modifying the ALPN extension to remove HTTP/2 protocol advertisement, and forwarding the modified handshake.
TLS Record Structure#
TLS operates on records with the following structure:
TLS Record:
+----------+----------+----------+----------+----------+
| Type (1) | Version | Length | Payload |
| byte | (2 bytes)| (2 bytes)| (Length bytes) |
+----------+----------+----------+----------+----------+
TLS Record Types#
- 22 (0x16): Handshake records (what we intercept)
- 20 (0x14): Change Cipher Spec
- 21 (0x15): Alert
- 23 (0x17): Application Data
ClientHello Structure#
Within handshake records, ClientHello messages have this structure:
ClientHello:
+----------+----------+----------+----------+
| Type=1 | Length | Version | Random |
| (1 byte) | (3 bytes)| (2 bytes)| (32 bytes)|
+----------+----------+----------+----------+
| SessionID Length | SessionID | Cipher Suites...
| (1 byte) | (variable)|
+----------+----------+----------+----------+
| Compression Methods | Extensions Length |
| (variable) | (2 bytes) |
+----------+----------+----------+----------+
| Extensions |
| (variable) |
+----------+----------+----------+----------+
The ALPN extension (type 16/0x0010) within ClientHello contains:
ALPN Extension:
+----------+----------+----------+----------+
| Type=16 | Length | Protocol List Len |
| (2 bytes)| (2 bytes)| (2 bytes) |
+----------+----------+----------+----------+
| Proto1 Len | Protocol 1 Name | Proto2 Len |
| (1 byte) | (variable) | (1 byte) |
+----------+----------+----------+----------+
| Protocol 2 Name | ... more protocols ... |
| (variable) | |
+----------+----------+----------+----------+
Example ALPN with h2 and http/1.1:
Protocol List Length: 0x000c (12 bytes)
h2: 0x02 + "h2" (3 bytes total)
http/1.1: 0x08 + "http/1.1" (9 bytes total)
Implementation Deep Dive#
1. TLS Record Interception#
Our proxy intercepts TLS traffic during HTTPS CONNECT tunneling:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| // From proxy.go - handleConnect method
func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request, upstream *UpstreamConfig) {
// ... establish upstream CONNECT tunnel ...
log.Printf("Upstream CONNECT successful, starting TLS interception for %s", r.URL.Host)
// Start TLS interception with raw connections
if err := p.handleTLSInterception(clientConn, upstreamConn, r.URL.Host); err != nil {
log.Printf("TLS interception failed: %v", err)
}
}
func (p *Proxy) handleTLSInterception(clientConn, upstreamConn net.Conn, targetHost string) error {
log.Printf("Starting TLS interception for %s", targetHost)
// Create TLS interceptor with raw connections
interceptor := NewTLSInterceptor(clientConn, upstreamConn)
// Start interception and modification
return interceptor.InterceptAndModify()
}
|
2. TLS Record Reading#
We read complete TLS records by parsing the 5-byte header first:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // From tls.go - readTLSRecord method
func (t *TLSInterceptor) readTLSRecord(conn net.Conn) ([]byte, error) {
// Read TLS record header (5 bytes)
header := make([]byte, 5)
if _, err := io.ReadFull(conn, header); err != nil {
return nil, fmt.Errorf("reading TLS record header: %v", err)
}
// Extract length from header (bytes 3-4, big endian)
length := int(header[3])<<8 | int(header[4])
// Read the record payload
payload := make([]byte, length)
if _, err := io.ReadFull(conn, payload); err != nil {
return nil, fmt.Errorf("reading TLS record payload: %v", err)
}
// Return complete record (header + payload)
record := make([]byte, 5+length)
copy(record[:5], header)
copy(record[5:], payload)
return record, nil
}
|
3. ClientHello Validation#
We validate that we’re intercepting the correct message type:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| // From tls.go - InterceptAndModify method
func (t *TLSInterceptor) InterceptAndModify() error {
log.Printf("TLS: Reading first record from client...")
record, err := t.readTLSRecord(t.clientConn)
if err != nil {
log.Printf("TLS: Error reading TLS record: %v", err)
return fmt.Errorf("reading TLS record: %v", err)
}
log.Printf("TLS: Read record of %d bytes, type: %d", len(record), record[0])
// Check if it's a handshake record (type 22)
if len(record) < 6 || record[0] != TLSRecordTypeHandshake {
log.Printf("TLS: Not a handshake record (type %d), forwarding as-is", record[0])
// Forward non-handshake records unchanged
if _, err := t.upstreamConn.Write(record); err != nil {
return fmt.Errorf("forwarding non-handshake record: %v", err)
}
return t.startTunneling()
}
// Check if it's ClientHello (type 1)
if record[5] != TLSHandshakeTypeClientHello {
log.Printf("TLS: Not a ClientHello (type %d), forwarding as-is", record[5])
// Forward non-ClientHello handshake messages unchanged
if _, err := t.upstreamConn.Write(record); err != nil {
return fmt.Errorf("forwarding non-ClientHello: %v", err)
}
return t.startTunneling()
}
log.Printf("TLS: Found ClientHello, modifying ALPN...")
// Proceed with ALPN modification...
}
|
4. ClientHello Parsing and ALPN Modification#
The core ALPN modification logic parses the ClientHello structure:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
| // From tls.go - modifyClientHelloALPN method
func (t *TLSInterceptor) modifyClientHelloALPN(record []byte) ([]byte, error) {
if len(record) < 43 { // Minimum ClientHello size
return record, nil // Too small to contain extensions
}
// Skip TLS record header (5 bytes) + handshake header (4 bytes)
payload := record[9:]
// Parse ClientHello structure:
// Skip protocol version (2 bytes) + random (32 bytes)
offset := 34
// Skip session ID
if offset >= len(payload) {
return record, nil
}
sessionIDLen := int(payload[offset])
offset += 1 + sessionIDLen
// Skip cipher suites
if offset+2 > len(payload) {
return record, nil
}
cipherSuitesLen := int(payload[offset])<<8 | int(payload[offset+1])
offset += 2 + cipherSuitesLen
// Skip compression methods
if offset >= len(payload) {
return record, nil
}
compressionMethodsLen := int(payload[offset])
offset += 1 + compressionMethodsLen
// Check if extensions are present
if offset+2 > len(payload) {
return record, nil // No extensions
}
// Parse extensions
extensionsLen := int(payload[offset])<<8 | int(payload[offset+1])
offset += 2
if offset+extensionsLen > len(payload) {
return record, nil // Invalid extensions length
}
extensionsEnd := offset + extensionsLen
modifiedExtensions := bytes.Buffer{}
// Process each extension
for offset < extensionsEnd {
if offset+4 > extensionsEnd {
break // Invalid extension
}
extType := int(payload[offset])<<8 | int(payload[offset+1])
extLen := int(payload[offset+2])<<8 | int(payload[offset+3])
offset += 4
if offset+extLen > extensionsEnd {
break // Invalid extension length
}
extData := payload[offset : offset+extLen]
if extType == TLSExtensionTypeALPN {
// THIS IS WHERE THE MAGIC HAPPENS!
// Modify ALPN extension to only include http/1.1
modifiedALPN := t.createHTTP1OnlyALPN()
// Write extension type and length
modifiedExtensions.Write([]byte{byte(extType >> 8), byte(extType)})
modifiedExtensions.Write([]byte{byte(len(modifiedALPN) >> 8), byte(len(modifiedALPN))})
modifiedExtensions.Write(modifiedALPN)
} else {
// Copy other extensions as-is
modifiedExtensions.Write([]byte{byte(extType >> 8), byte(extType)})
modifiedExtensions.Write([]byte{byte(extLen >> 8), byte(extLen)})
modifiedExtensions.Write(extData)
}
offset += extLen
}
// Reconstruct the TLS record with modified extensions...
// (length updates and reassembly code continues)
}
|
5. HTTP/1.1-Only ALPN Creation#
When we find an ALPN extension, we replace it with one that only advertises HTTP/1.1:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| // From tls.go - createHTTP1OnlyALPN method
func (t *TLSInterceptor) createHTTP1OnlyALPN() []byte {
// ALPN extension format:
// - Protocol list length (2 bytes)
// - Protocol string length (1 byte) + protocol string
protocol := "http/1.1"
alpn := make([]byte, 0)
// Protocol list length (total length of all protocols)
protocolListLen := 1 + len(protocol) // 1 byte length + protocol string
alpn = append(alpn, byte(protocolListLen>>8), byte(protocolListLen))
// Protocol string length + protocol string
alpn = append(alpn, byte(len(protocol)))
alpn = append(alpn, []byte(protocol)...)
return alpn
}
|
This creates an ALPN extension containing only:
Protocol List Length: 0x0009 (9 bytes)
http/1.1: 0x08 + "http/1.1" (9 bytes total)
6. TLS Record Reconstruction#
After modifying the ALPN extension, we must reconstruct the entire TLS record with correct lengths:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| // From tls.go - modifyClientHelloALPN method (continued)
func (t *TLSInterceptor) modifyClientHelloALPN(record []byte) ([]byte, error) {
// ... (ALPN modification code above) ...
// Create new record with modified extensions
newPayload := make([]byte, offset-extensionsLen)
copy(newPayload, payload[:offset-extensionsLen])
// Append modified extensions
modifiedExtensionsBytes := modifiedExtensions.Bytes()
newExtensionsLen := len(modifiedExtensionsBytes)
newPayload = append(newPayload, byte(newExtensionsLen>>8), byte(newExtensionsLen))
newPayload = append(newPayload, modifiedExtensionsBytes...)
// Update handshake length in the handshake header
newHandshakeLen := len(newPayload)
newRecord := make([]byte, 9+newHandshakeLen)
// Copy TLS record header and update length
copy(newRecord[:5], record[:5])
newRecordDataLen := 4 + newHandshakeLen // handshake header + payload
newRecord[3] = byte(newRecordDataLen >> 8)
newRecord[4] = byte(newRecordDataLen)
// Copy handshake type and update handshake length
copy(newRecord[5:9], record[5:9])
newRecord[6] = byte(newHandshakeLen >> 16)
newRecord[7] = byte(newHandshakeLen >> 8)
newRecord[8] = byte(newHandshakeLen)
// Copy modified payload
copy(newRecord[9:], newPayload)
return newRecord, nil
}
|
7. Bidirectional Tunneling#
After sending the modified ClientHello, we establish transparent tunneling:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| // From tls.go - startTunneling method
func (t *TLSInterceptor) startTunneling() error {
done := make(chan error, 2)
// Copy from client to upstream
go func() {
_, err := io.Copy(t.upstreamConn, t.clientConn)
done <- err
}()
// Copy from upstream to client
go func() {
_, err := io.Copy(t.clientConn, t.upstreamConn)
done <- err
}()
// Wait for one direction to finish
return <-done
}
|
Testing and Validation#
Observed Behavior#
When the proxy successfully intercepts and modifies ALPN:
2025/08/16 17:49:46 tls.go:52: TLS: Read record of 321 bytes, type: 22
2025/08/16 17:49:46 tls.go:74: TLS: Found ClientHello, modifying ALPN...
2025/08/16 17:49:46 tls.go:83: TLS: Modified record from 321 to 320 bytes
2025/08/16 17:49:46 tls.go:91: TLS: Successfully sent modified ClientHello, starting tunnel...
Key Observation: The record size decreased from 321 to 320 bytes. This 1-byte reduction indicates successful ALPN modification, though the exact byte difference depends on the original ALPN content and protocol count. Note that removing “h2” (3 bytes: length prefix + protocol name) and potentially other protocols while keeping “http/1.1” could result in various size changes depending on the original ALPN extension content.
Client-Side ALPN Offer#
Before modification, curl offers both protocols:
* ALPN: curl offers h2,http/1.1
After our modification, the upstream server only sees:
ALPN protocols: http/1.1
This forces the server to negotiate HTTP/1.1, preventing HTTP/2 connections.
Security Considerations#
- Certificate Validation: The client still validates the server certificate normally
- Encryption: All traffic after the handshake remains encrypted
- Protocol Downgrade: Servers gracefully fall back to HTTP/1.1
- Transparent Operation: Applications are unaware of the ALPN modification
- Latency: Minimal additional latency (single packet inspection)
- Memory: Small memory overhead for record buffering
- CPU: Low CPU usage for packet parsing and modification
- Throughput: No impact on established connection throughput
Limitations#
- TLS 1.3 Only: Our current implementation focuses on modern TLS versions
- Single ALPN Modification: Only modifies the initial ClientHello
- No Certificate Pinning: Applications using certificate pinning may fail
- Protocol Detection: Some applications may detect the protocol change
Conclusion#
This implementation provides transparent HTTP/2 disabling by surgically modifying TLS handshake traffic at the packet level. By intercepting and modifying ALPN extensions in ClientHello messages, we force HTTP/1.1 negotiation without breaking TLS security or requiring client-side configuration changes.
The approach is efficient, transparent, and maintains full TLS security while achieving the goal of preventing HTTP/2 connections through network-level protocol manipulation.
The code is designed to be a demonstration of the concept and may require additional error handling, logging, and testing for production use. It serves as a foundation for building more complex network proxies that need to enforce specific protocol behaviors without client-side modifications.