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)                   |
+----------+----------+----------+----------+

ALPN Extension Format

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

  1. Certificate Validation: The client still validates the server certificate normally
  2. Encryption: All traffic after the handshake remains encrypted
  3. Protocol Downgrade: Servers gracefully fall back to HTTP/1.1
  4. Transparent Operation: Applications are unaware of the ALPN modification

Performance Impact

  • 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

  1. TLS 1.3 Only: Our current implementation focuses on modern TLS versions
  2. Single ALPN Modification: Only modifies the initial ClientHello
  3. No Certificate Pinning: Applications using certificate pinning may fail
  4. 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.