Playing with HTTP/3

QUIC (Quick UDP Internet Connections) and HTTP/3 represent the next step in the evolution of Internet protocols. Their development is driven by the need for faster, more reliable, and more secure online communications.

While traditional web traffic has relied on the TCP protocol, which is reliable but can introduce delays, QUIC operates over UDP. This allows QUIC to maintain high reliability while reducing some of the delays typically associated with TCP. It also has built-in encryption (TLS 1.3).

HTTP/3 is an adaptation of the HTTP protocol designed to work with QUIC.

How are HTTP/3 services advertised?

HTTP/3 services are mainly advertised using the "Alt-Svc" header in HTTP/2 and HTTP/1.1 responses. The "Alt-Svc" header indicates that the same service is available over a different protocol or at a different location. In the context of HTTP/3, it informs the client that the service can also be accessed via the HTTP/3 protocol, typically over QUIC.

A simple Python function to check for the HTTP/3 on a site is shown below:

def check_http3_support(url):
    try:
        response = requests.get(url, timeout=10)
        alt_svc = response.headers.get("Alt-Svc")
        if alt_svc and "h3" in alt_svc:
            return alt_svc
        else:
            return None
    except requests.RequestException:
        return None

Below is a list of some of the publicly available test HTTP/3 endpoints:

Expanding from the above I made this Burp extension to passively monitor for HTTP/3 endpoints that are advertised in the ‘Alt-Svc’ header in proxied requests:

https://github.com/dtmsecurity/http3/blob/main/burp_passive_http3.py

Adoption

Browsers such as Chrome have been doing QUIC behind the scenes for some time. Typically browsers start off with a HTTP/2 connection, realise the availability of HTTP/3, then start making use them from that point forward. If you visit a test page in a browser you will probably find that you have to visit the page and refresh before you see that it has been loaded over QUIC. Many well known sites and CDNs actually offer HTTP/3 and it’s adoption is growing.

Understanding a client?

A great primer to start with is definitely the book HTTP/3 Explained by Daniel Stenberg. This gives an in depth introduction. Having read through this I played around with the example client provided by aioquic in Python. This example client is full featured so I was keen to create something from scratch / strip down this to the minimum required logic (still using aioquic as a dependency) in order to be able to do HTTP/3 GET/POST requests.

We need to build an asynchronous client that makes a connection to the desired endpoint and then implement a protocol handler to handle both the sending of the request and handling of both QUIC and HTTP/3 events.

Here is the client I ended up with:

https://github.com/dtmsecurity/http3/blob/main/minimal_http3_client.py

Let’s run this without debugging.

python3 http3.py https://quic.rocks:4433
:status: 200
content-type: text/html; charset=UTF-8
x-original-url: https://quic.rocks/
alt-svc: h3=":4433"; ma=3600, h3-29=":4433"; ma=3600

<!doctype html>
<html>
<head><title>quic.rocks</title></head>
<body>
<h1>quic.rocks</h1>
<p>You have successfully loaded quic.rocks using QUIC!</p>
</body>
</html>

You can see we successfully get the HTTP response headers and body and it looks all too familiar. If we enable debugging in the client you will see that there’s actually quite a lot going on:

http3.py https://quic.rocks:4433 --debug
[DEBUG] QUIC event: ProtocolNegotiated
[DEBUG] QUIC event: HandshakeCompleted
[DEBUG] QUIC event: StreamDataReceived
[DEBUG] Stream: 3 Data: b'\x00\x04\x1f\x01\x80\x01\x00\x00\x06\x80\x00@\x00\x07@d\x08\x01\xc0\x00\x00\x08\xa1\x8f\xf5\xc9\xc0\x00\x00\x00\xed"K\x11\xc0\x00\x00\x03\x91\xa11\xf2\x030\xf4\xae'...
[DEBUG] QUIC event: ConnectionIdIssued
[DEBUG] Connection ID: b'\x12d\xd0\xcee\xb6\x1d#'
[DEBUG] QUIC event: StreamDataReceived
[DEBUG] Stream: 11 Data: b'\x02?\xe1\x1f\xec\x92I|\xa5\x89\xd3M\x1fj\x12q\xd8\x82\xa6\x0e\x1b\xf0\xac\xf7k\xf2\xb1\xec4\xc6\xa8t-m\x94\x7f\x8e\x9d)\xad\x17\x18c\xb5\xa6"\xf6\x1c\x9dP\xc7\xff\x14\xa3\x9d\x98?\x9b\x8d4\xcb?\xcf\xda\x94\x8e\x06\\\x00>\x94\x9d\x95\x84\xfc\x1f\xcd\xc6\x9ae\x9f\xe7\xedJG\x03.\x00\x1f'...
[DEBUG] QUIC event: StreamDataReceived
[DEBUG] Stream: 0 Data: b'\x01\x06\x04\x00\xd9\x82\x81\x80\x00@\xa4<!doctype html>\n<html>\n<head><title>quic.rocks</title></head>\n<body>\n<h1>quic.rocks</h1>\n'...
[DEBUG] QUIC event: ConnectionTerminated
:status: 200
content-type: text/html; charset=UTF-8
x-original-url: https://quic.rocks/
alt-svc: h3=":4433"; ma=3600, h3-29=":4433"; ma=3600

<!doctype html>
<html>
<head><title>quic.rocks</title></head>
<body>
<h1>quic.rocks</h1>
<p>You have successfully loaded quic.rocks using QUIC!</p>
</body>
</html>

The below diagram roughly shows this graphically:

To summarise:

  • We establish a connection
  • We start receiving and handling QUIC events, such as ProtocolNegotiated, HandshakeCompleted. aioquic does most of the heavy lifting but its useful to see what’s going on.
  • We make our request by acquiring the next available stream ID with get_next_available_stream_id() and then proceeding to send the headers and request body if required. We also need some logic to keep track of and wait for requests to complete. In our case we are sending the request on Stream ID 0.
  • We will start receiving data across various QUIC streams with the StreamDataReceived event. Some of these events are HTTP/3 events - for these we hand them over from the QUIC layer to the HTTP/3 layer.
  • We handle HTTP/3 events HeadersReceived, DataReceived, we store the headers and append any HTTP/3 response data to a buffer to be returned by our client. We also keep an eye out for when the stream has ended denoting the end of receiving our response body.

Upgrading proxied traffic to HTTP/3

Having played around with the above client, I wanted to take it further and try to use it to forcefully upgrade requests that I was sending via an intercepting HTTP proxy.

I came up with a quick proof of concept that looked like this:

  • Web Browser proxied via Burp
  • Burp configured with an upsteam HTTP proxy that is mitmproxy
  • mitmproxy with a custom flow script

What the script does is:

  • For every HTTP GET request, it attempts to make it over HTTP/3.
  • If it succeeds it returns the headers and response received over HTTP/3. It also adds an extra header 'HTTP3':'Loaded-over-HTTP3' so I can apply a Burp filter later to see what worked over HTTP/3.
  • If it fails within a fixed timeout (2 seconds by default) it doesn't change the response at all so it doesn't break HTTP1/2 only requests.

You can see this in action in the above screenshot, successfully loading quic.rocks.

You can find the script here:
https://github.com/dtmsecurity/http3/blob/main/try_http3_proxy.py
It loads the minimal HTTP/3 client in the same repsository and referenced above.

I ran this with mitmproxy using the following command:
mitmdump --quiet --listen-port=8081 -s try_http3_proxy.py

This is just a POC and has limitaitons at present such as only currently doing GET requests and also it currently doesn't pass through request headers.

Its worth noting that mitmproxy itself does support HTTP/3 inspection, however to my knowledge you can't force upgrades from HTTP/1.1-2 to HTTP/3 as above.

Conclusion

We have successfully made some lower-level requests using the aioquic python library and showed some of the things going on. There’s a bunch more capabilities that we have not yet explored (such as HTTP pushes) and other features but we have something that should work at a basic level. We have also highlighted how HTTP/3 services are advertised and hopefully made it a bit easier to spot them passively with a simple Burp plugin. Finally I showed a proof of concept which forcefully upgrades requests sent via Burp/mitmproxy.

Check out the following GitHub repo for the code snippets from the article:
https://github.com/dtmsecurity/http3