GymTok: Breaking TLS Using the Alt-Svc Header
A while ago, I watched Why TLS is better without STARTTLS at RuhrSec 2022, a talk on the dangers of opportunistic encryption. The researchers attacked TLS by abusing STARTTLS
on servers that use the same certificate for SMTP and HTTPS.
I found the idea of breaking TLS with cross-protocol attacks intriguing, so I tried to make a CTF challenge out of it. This is always a great motivation to dive into a topic for me, and I usually discover many interesting things along the way.
The original research required the attacker to be in a Man-in-the-Middle (MitM) position, which is not that common and makes it harder to host a CTF challenge. That’s why I connected it with the Alt-Svc
HTTP response header, which can allow you just what I needed: becoming a MitM!
The Challenge
My challenge was part of Hack.lu CTF 2024 and individual teams got close but nobody solved it in the end. The setup contains three services:
- HTTPS on port
443
(HTTP/1.1 only) - HTTP/2 on port
3444
- FTP on port
3021
All three of them were running on gymtok.social
and therefore used the same TLS certificate. The HTTP servers host a simple TikTok clone where visitors can scroll through the feed and log in, but there was no registration and players didn’t get credentials. The FTP server requires opportunistic encryption (equivalent to SMTP’s STARTTLS
) and user authentication. Again, players don’t have valid credentials, so they can’t do much with FTP yet.
There’s also an admin bot that visits the challenge page, inserts the flag into localStorage
, logs in, and then visits a player-supplied URL. The bot uses Firefox, which will come in handy later. There is no XSS in the entire application, so how can we steal the flag from the admin? The only thing we can really influence is the cdn
query parameter:
1 | app.use(async (ctx, next) => { |
It will be properly encoded and reflected into the Alt-Svc
header. The value used by the web app is h2=":3444"
which instructs the browser to communicate with the web server via HTTP/2 on port 3444 from now on. Let’s see what we can achieve with just this header!
From Web Attacker to MitM
As mentioned earlier, we need to become a MitM attacker to perform further attacks. Since all we control is the Alt-Svc
header, we’ll have to understand how it works and what it can do for us.
Alt-Svc Basics
I’ll give you the rundown, but I do recommend reading the Alt-Svc spec (RFC 7838) as it’s not too long and has an easy to follow structure. The basic syntax of the Alt-Svc
header is:
Alt-Svc: <protocol-id>=<alt-authority>; ma=<max-age>; persist=1 |
Its purpose is to tell the browser “hey, you can get the same content at location X”. In contrast to a redirect, the browser doesn’t have to use the alternative location. But a much bigger difference is that using the alternative is transparent to the user. Let’s look at the challenge’s header again:
Alt-Svc: h2=":3444" |
This tells the browser that the same content can be loaded via the HTTP/2 protocol from port 3444. If the browser wants, it can start connecting to that new port and use the newer/cooler/better HTTP/2 protocol. But the browser’s address bar (and even requests in the network tab) keep the old address (https://gymtok.social
) and don’t change to https://gymtok.social:3444
.
This is quite interesting! Could an attacker use this to make the browser load untrusted content from the attacker’s server while staying on the challenge origin?
Of course, the authors of the Alt-Svc spec thought about this. To prevent such an easy attack, the alternative service has to prove itself. In short, they do this by requiring the new service to use the original TLS certificate. This indicates that the old and new location are controlled by the same entity, suggesting that it’s okay to trust both equally.
Since an attacker doesn’t have access to the certificate’s private key, they can’t create legitimate responses. Or can they?
Passive MitM
Well, an attacker could just forward all traffic to the original server which obviously has the correct certificate. To do this, the attacker first injects their own address into the header:
Alt-Svc: h2="attacker.com:1337" |
When the browser starts sending requests here, the server at attacker.com:1337
opens a connection to gymtok.social:3444
(the valid HTTP/2 server) and pipes all bytes back and forth between the browser and the valid server. The browser won’t complain because it can establish a trusted TLS connection and the server uses the correct certificate for gymtok.social
.
The attacker is now in the MitM position, but they still can’t read or modify any traffic because TLS still protects the confidentiality and integrity of the data going through. All the attacker can see is the TLS handshake at the beginning and the TLS records afterward.
Compression Oracle
To start attacking the TLS connection, we have to read a little bit more of the challenge’s code. One thing that looks interesting in the context of TLS is this bit:
1 | const compress = require('koa-compress'); |
It activates the koa-compress middleware, configuring the application to use deflate compression for its responses. This was intended to ring some bells and make players remember the CRIME attack.
Time to Do Some CRIME
CRIME is a side-channel attack on TLS that uses the size of transmitted data as an oracle to see how much data was compressed. This can be used to incrementally leak authentication cookies by making the victim visit attacker-controlled paths. The more the path and the cookie value have in common, the more compressed (and therefore smaller) the requests becomes.
Since its discovery, CRIME has been mitigated at the TLS level, but HTTP-level compression still has the same effect. To launch the attack, we need to find an endpoint that responds with data we want to leak next to data that we control. The perfect API handler is /upload-config
:
1 | router.get('/upload-config', async (ctx) => { |
The response contains the user’s FTP credentials and a filename coming from the name
query parameter. Both are located close enough to each other that they fit into deflate’s compression window. This means that if the credentials and the filename have some part in common, the compression algorithm will recognise this duplication and replace both with a shorter symbol. This makes the response size a little bit shorter, creating an information side-channel.
Leaking FTP Credentials
In the challenge source code, we can see that the bot’s username is admin
. To perform the compression oracle attack, we’ll repeatedly send the admin bot to /upload-config?name=admin:<guess>
and try out all possible first characters for the password. Since we can see that the password has to be a 16-digit hexadecimal string based on the assert in api.js
, we have to try those 16 characters:
1 | admin:0 |
For a wrong guess, the server’s response looks like this:
{ "port":3021, "creds":"admin:5be9faa0e43dfd27", "file":"admin:0.mp4", }
The compression will still compress the ":"admin:
substring and replace it with a shorter symbol. However, the final size will be the same for all invalid guesses. When we reach the correct guess, the response has a little bit more duplication:
{ "port":3021, "creds":"admin:5be9faa0e43dfd27", "file":"admin:5.mp4", }
The compression will now be a little bit more efficient, replacing two more bytes with a shorter symbol than before. By correlating the requests with the observed TLS records, this size difference can be detected by the passive MitM attacker (example numbers):
admin:0 -> 1000 bytes
admin:1 -> 1000 bytes
...
admin:5 -> 998 bytes
...
admin:f -> 1000 bytes
Sometimes, the correct guess cannot be correctly detected because multiple TLS records differ in size. I’m not sure as to why this happens, but retrying the current position leads to success in 2-3 attempts. After finding the first password character, we just repeat the same process until we know the entire FTP password of the admin user.
Cross-Protocol Attack
With the leaked credentials, we can now finally interact with the FTP server! To perform another attack on TLS, let’s see how the FTP protocol works in passive mode.
FTP servers can transfer files in two different ways, active and passive, and the challenge’s server is configured in passive mode. When a client connects, this connection is considered the control connection where they send commands. After the client requests a file, the server opens a random port for the client to connect to in parallel. After the client opens the data connection to that port, the server sends the file content and closes the connection. Example:
The FTP server in the challenge supports opportunistic encryption using the AUTH TLS
command. After sending this command, the server acknowledges this and then both parties convert the existing TCP connection to a TLS connection by performing the TLS handshake. Once done on the control connection, the server will now also require TLS on data connections before sending or receiving file content.
Since the FTP server uses the same certificate as both HTTP servers, we can use this to control the data inside a legitimate TLS connection and fake an HTTP response! To achieve this, we would perform the following steps:
ALPN Gets in the Way
When trying this attack, you’ll notice that it fails because the browser aborts the TLS connection during the handshake. It turns out that the authors of the Alt-Svc spec have also anticipated such cross-protocol attacks.
To prevent them, the spec requires the browser to enforce the new protocol via the Application-Layer Protocol Negotiation (ALPN) TLS extension. During the handshake, both parties agree on the protocol that they will speak inside the TLS connection. When there’s no match, the TLS connection gets aborted with a fatal alert.
In the case of the challenge, the Alt-Svc header tells the browser to use HTTP/2. The browser includes this in the ALPN extension of its TLS ClientHello. Since the FTP server does not speak HTTP/2, the connection is dropped.
Tricking the Browser
While trying to bypass this restriction, I noticed that the browser only validates the ALPN-negotiated protocol during the first handshake. While still using the alternative service for the following connections, the browser accepts different protocols.
In order to bypass the browser’s check, we can forward the first incoming connection to the original server which negotiates the correct protocol via ALPN. After that, we close the underlying TCP connection to force the browser to establish a new one. Since the browser already marked the alternative service as trusted during the first connection, we can now forward all following connections to the FTP server:
The Alt-Svc spec does not explicitly mention if the ALPN check should only apply to the first connection or to all connections using the alternative service:
If the connection to the alternative service does not negotiate the expected protocol (for example, ALPN fails to negotiate h2, […]), the connection to the alternative service MUST be considered to have failed.
If the spec intends to enforce the check on all alternative connections, then Firefox’s behavior could be seen as a security vulnerability. Otherwise, it could be seen as a flaw in the spec because the ALPN check could always be bypassed by first forwarding the connection to a valid server as demonstrated.
I contacted freddyb from Mozilla about it and he asked me to report it to the Firefox team. They assessed this to be a vulnerability in Firefox, fixed the issue, and awarded me a $1000 bounty! Pretty cool outcome for creating a CTF challenge if you ask me :)
Putting Everything Together
To finally solve the challenge we perform the following attack steps:
- Become MitM via the Alt-Svc header injection
- Leak the admin’s FTP password via the compression oracle
- Upload an XSS payload response to the FTP server
- Forward the browser’s first request to the original HTTP/2 server
- Forward subsequent requests to an FTP data port
- The browser establishes a valid TLS connection with the FTP server and receives the malicious response
- Our XSS payload is executed in the browser, stealing the flag!
Closing Thoughts
It’s quite impressive what’s possible with just this one header injection! Of course, it’s not only the Alt-Svc injection that creates a vulnerability here. The HTTP compression and the certificate reuse between different protocols (HTTP and FTP) are also bad practices at least.
I’m not sure how realistic it is to pull off such an exploit in the wild. I expect injection into just the Alt-Svc header to be quite rare, and there’s other interesting headers that are easier to exploit for XSS. HTTP compression seems to be quite common, making CRIME-like attacks possible. I have no data how common certificate reuse across different protocols is, but I’m sure there’s some out there.
If anybody manages to exploit this in a real-world target, please let me know!