DEV Community

Greta
Greta

Posted on

HPACK State Is a Fingerprint Too: Why Your HTTP/2 Dynamic Table Leaks Across Proxy Hops

HPACK State Is a Fingerprint Too: Why Your HTTP/2 Dynamic Table Leaks Across Proxy Hops

Everyone talks about JA3 and JA4 for TLS fingerprinting. Fewer talk about Akamai's fingerprinting format. Almost nobody talks about the fingerprint hiding inside HTTP/2's HPACK dynamic table — the stateful compression layer that makes every connection you open over an HTTP/2 proxy a unique, trackable signature.

Here's the problem in one sentence: HPACK's dynamic table accumulates compressed header indices across an entire HTTP/2 connection, meaning the sequence of headers you send creates a stateful signature that survives individual request boundaries. If your proxy multiplexes requests from different sessions onto the same upstream connection, you've leaked other users' header patterns into your fingerprint.

This article walks through what HPACK state looks like, how proxies create cross-session fingerprint pollution, and a Python-based detection harness that tells you whether your HTTP/2 proxy is keeping your connections cleanly separated.

HPACK's Dynamic Table: A 2-Minute Primer

HTTP/2 compresses headers using HPACK (RFC 7541). Each endpoint maintains two lists:

  • Static table: 61 predefined entries (like :method: GET = index 2). Same for everyone.
  • Dynamic table: entries learned during the connection. If you send user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64), the server stores it and you reference it by index in subsequent requests.

The dynamic table is per-connection, not per-request. This means:

  1. The first request on a connection has a different header encoding than the tenth.
  2. If two logical sessions share the same connection, they share the table.
  3. A proxy that pools connections upstream will merge sessions that should be independent.

From a fingerprinting perspective, the table state acts like a "memory" of all prior requests. A server that tracks the evolution of dynamic table indices can distinguish a single clean session from a multiplexed proxy connection.

How Proxies Create HPACK Pollution

Consider this topology: your scraper opens 5 concurrent sessions, each with a distinct User-Agent and Cookie. All 5 sessions go through one HTTP/2 proxy connection to the origin. The proxy reuses the same TCP+TLS+HTTP/2 connection because connection reuse is cheaper.

What the origin sees:

  • Request 1: user-agent not in dynamic table → full literal encoding → added to table at index 62.
  • Request 2: user-agent in dynamic table at index 62 → indexed representation. But it's a different User-Agent than Request 1!
  • The mismatch is subtle but detectable: the same HPACK slot being "redefined" in rapid succession.

The result is a fingerprint that looks like either a proxy multiplexer or a browser with unusual tab-switching behavior. Neither is what you want.

The HPACK State Auditor

Here's a Python script that connects to an HTTP/2 endpoint, tracks what the server echoes back about the dynamic table, and detects pollution:

import h2.connection
import h2.config
import h2.events
import ssl
import socket
import json
from dataclasses import dataclass, field
from typing import Optional

@dataclass
class HpackState:
    """Track our view of the dynamic table across requests."""
    dynamic_entries: list[dict] = field(default_factory=list)
    max_size: int = 4096
    current_size: int = 0

class HpackAuditor:
    def __init__(self, proxy_host: str, proxy_port: int, 
                 target_host: str, target_port: int = 443):
        self.proxy_host = proxy_host
        self.proxy_port = proxy_port
        self.target_host = target_host
        self.target_port = target_port
        self.h2_conn = h2.connection.H2Connection(
            config=h2.config.H2Configuration(client_side=True)
        )
        self.state = HpackState()
        self.session_headers: list[dict] = []
        self.pollution_detected = False

    def _create_connection(self) -> ssl.SSLSocket:
        """Connect through the proxy via CONNECT tunnel."""
        sock = socket.create_connection(
            (self.proxy_host, self.proxy_port), timeout=10
        )
        # Issue CONNECT to establish tunnel
        connect_req = (
            f"CONNECT {self.target_host}:{self.target_port} HTTP/1.1\r\n"
            f"Host: {self.target_host}:{self.target_port}\r\n"
            f"Proxy-Authorization: Basic {self._get_auth()}\r\n"
            f"\r\n"
        )
        sock.sendall(connect_req.encode())
        # Read tunnel response
        resp = b""
        while b"\r\n\r\n" not in resp:
            resp += sock.recv(1024)

        # Upgrade to TLS + HTTP/2
        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
        ctx.set_alpn_protocols(["h2"])
        return ctx.wrap_socket(sock, server_hostname=self.target_host)

    def _get_auth(self) -> str:
        import base64
        creds = f"{self.proxy_user}:{self.proxy_pass}"
        return base64.b64encode(creds.encode()).decode()

    def send_request(self, headers: dict, stream_id: int) -> dict:
        """Send one HTTP/2 request and return timing + state info."""
        h2_headers = [(":method", "GET"), (":scheme", "https"),
                      (":path", "/"), (":authority", self.target_host)]
        h2_headers.extend((k, v) for k, v in headers.items())

        self.h2_conn.send_headers(stream_id, h2_headers, end_stream=True)
        data = self.h2_conn.data_to_send()
        self.sock.sendall(data)

        # Read response
        self.sock.settimeout(10)
        response_data = self.sock.recv(65535)
        events = self.h2_conn.receive_data(response_data)

        result = {"stream_id": stream_id, "headers_sent": headers}
        for event in events:
            if isinstance(event, h2.events.ResponseReceived):
                result["response_headers"] = dict(event.headers)
            if isinstance(event, h2.events.SettingsAcknowledged):
                result["settings"] = "ack"
            if isinstance(event, h2.events.WindowUpdated):
                result["window_update"] = event.delta

        # Track if this header set conflicts with prior ones
        ua = headers.get("user-agent", "")
        if self.session_headers:
            prev_uas = [h.get("user-agent", "") for h in self.session_headers]
            if ua and prev_uas and ua not in prev_uas and ua != prev_uas[-1]:
                # Multiple UAs on same connection = potential pollution
                self.pollution_detected = True

        self.session_headers.append(headers)
        return result

    def audit_isolated_sessions(self, n: int = 5) -> dict:
        """Send requests with distinct session identities on what 
        we believe are separate connections. Check if the server
        sees independent HPACK state (clean) or shared state (polluted)."""
        results = []
        for i in range(n):
            # Each "session" uses a unique fingerprint
            headers = {
                "user-agent": f"Mozilla/5.0 (Session-{i}) Chrome/125.0",
                "accept-language": f"en-US;q=0.9, s{int(i*0.1):.1f}",
                "x-session-probe": f"audit-{i}-{hash(str(i))}",
            }
            result = self.send_request(headers, stream_id=1 + i*2)
            results.append(result)

        return {
            "total_sessions": n,
            "pollution_detected": self.pollution_detected,
            "unique_ua_count": len(set(
                r["headers_sent"]["user-agent"] for r in results
            )),
        }
Enter fullscreen mode Exit fullscreen mode

Detecting the Proxy's HPACK Behavior Without a Custom Server

Not everyone can deploy an HTTP/2 echo server. You can still detect multiplexing with a heuristic:

import curl_cffi.requests as cf_requests

def detect_hpack_multiplexing(proxy_url: str, target: str = "https://httpbin.org") -> bool:
    """If requests through the same proxy session share HPACK state,
    sending a unique header on request 1 should make request 2 
    (different logical session) faster — because the server already 
    has the header indexed."""
    session = cf_requests.Session(impersonate="chrome_125", proxies={"https": proxy_url})

    # Prime: send a custom header
    resp1 = session.get(f"{target}/headers", headers={"x-prime": "a"*200})
    time_prime = resp1.elapsed

    # Probe: different logical identity, same proxy connection
    resp2 = session.get(f"{target}/headers", 
                        headers={"x-probe": "b"*200, "user-agent": "CompletelyDifferent"})
    time_probe = resp2.elapsed

    # On a fresh connection, HPACK has to encode everything literal.
    # On a reused connection, common headers benefit from dynamic table entries.
    # If resp2 is consistently faster by >10%, the proxy is pooling.
    return (time_prime - time_probe) / time_prime > 0.10
Enter fullscreen mode Exit fullscreen mode

This isn't bulletproof (network jitter dominates for small responses), but across 20 samples you'll see a bimodal distribution: exits that cleanly isolate sessions vs. exits that pool your connections.

The Practical Fix: One Connection Per Identity

Once you've identified multiplexing exits, the fix is architectural. Force one HTTP/2 connection per logical session by controlling your connection lifecycle:

class SessionPinnedProxy:
    """Ensure each logical session gets its own TCP+TLS+HPACK context."""

    def __init__(self, proxy_url: str):
        self.proxy_url = proxy_url
        self.active_connections: dict[str, h2.connection.H2Connection] = {}

    def get_connection(self, session_id: str) -> h2.connection.H2Connection:
        """Get or create a fresh H2 connection for this session."""
        if session_id not in self.active_connections:
            conn = h2.connection.H2Connection(
                config=h2.config.H2Configuration(client_side=True)
            )
            self.active_connections[session_id] = conn
        return self.active_connections[session_id]

    def release_connection(self, session_id: str):
        """Close and remove — never let two sessions share."""
        conn = self.active_connections.pop(session_id, None)
        if conn:
            conn.close_connection()
Enter fullscreen mode Exit fullscreen mode

Why This Matters More Than You Think

Most fingerprinting evasion focuses on static values: extension order, cipher list, header order. These are necessary but insufficient. HPACK dynamic table state is a sequence fingerprint — it captures the temporal evolution of your connection's header encoding. No amount of ClientHello spoofing hides the fact that your 50 requests arrived on one connection with one shared compression dictionary.

If your proxy pools connections, you're leaking HPACK state across every identity you pretend to be. The fix costs one extra TCP handshake per session. That's the price of coherence.


Disclosure: I use Thordata's residential proxies for this project. New users get 500MB free — code thor020 (10% off): https://www.thordata.com/?ls=uXcSHJzx&lk=02-tele

Top comments (0)