Just for fun, I signed up for the DEF CON CTF 2020 Qualifiers this weekend. I didn’t successfully solve any challenges besides the (deliberately easy) welcome challenges. But I spent a while working on “uploooadit,” a web challenge focusing on a Flask app. This post is a write-up of my unsuccessful attempts at solving the challenge.

The Challenge

The challenge links to a simple website and provides the source code, written in Python with the web framework Flask:

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

The site exposes an endpoint to upload files and a way to view the uploaded files. It requires that the uploads have certain headers (the correct Content-Type, and a UUID to be used as a file name).

Since I’ve worked with Python and Flask before, I thought I might have a shot at figuring out this challenge.

My attempts

Regex?

The first thing that stood out to me was the regular expression GUID_RE. Maybe something was wrong with it? Reading through it, it looks pretty normal. It just asks for hex digits (lowercase) in groups of 8, 4, 4, 4, and 12, and separated by hyphens. The only curious bits to me were the \A at the start and the \Z at the end. But when I looked up these special sequences, I found: > \A > > Matches only at the start of the string. > > \Z > > Matches only at the end of the string.

So, this just seems to be an alternate way of matching the start and end of the string, which is more commonly done with ^ and $.

The other thing I considered was that perhaps one of the hyphens in the regex could be misinterpreted, as the hyphen is used in regular expressions to specify a range of characters. But I plugged it into regex101 and it parsed as expected.

I couldn’t find any way that this regex would be exploitable, so I moved on.

MAX_CONTENT_LENGTH

I noticed that the script sets a maximum content length of 512 bytes with the line app.config["MAX_CONTENT_LENGTH"] = 512. I tried sending input longer than this, and it was just rejected with 413 Client Error: REQUEST ENTITY TOO LARGE.

Curiously, in that case, the add_file() route was still called. It wasn’t until request.data was accessed that the request was aborted. I ran the site locally and modified the function:

@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)

    print('execution reaches here')
    request.data
    print('never reached')

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

The output in the server logs from submitting too-long content is

execution reaches here
127.0.0.1 - - [16/May/2020 20:29:43] "POST /files/ HTTP/1.1" 413 -

This makes sense — I suppose Flask doesn’t know the length of the content until it is read. But how can this be exploited? We will never call filestore.save() when the request data is too long, because request.data will first be evaluated, which throws the exception about content being too long. Just to confirm this, I modified app.py again:

@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)

    try:
        filestore.save(guid, request.data)
    except:
        import traceback
        traceback.print_exc()
    return "", 201

Sure enough, the traceback shows that we never call filestore.save() when the request body is too large:

Traceback (most recent call last):
  File "app.py", line 30, in add_file
    filestore.save(guid, request.data)
  File "/Users/jarhill/Library/Python/3.7/lib/python/site-packages/werkzeug/local.py", line 347, in __getattr__
    return getattr(self._get_current_object(), name)
  File "/Users/jarhill/Library/Python/3.7/lib/python/site-packages/werkzeug/utils.py", line 90, in __get__
    value = self.func(obj)
  File "/Users/jarhill/Library/Python/3.7/lib/python/site-packages/werkzeug/wrappers/base_request.py", line 426, in data
    return self.get_data(parse_form_data=True)
  File "/Users/jarhill/Library/Python/3.7/lib/python/site-packages/werkzeug/wrappers/base_request.py", line 456, in get_data
    self._load_form_data()
  File "/Users/jarhill/Library/Python/3.7/lib/python/site-packages/flask/wrappers.py", line 88, in _load_form_data
    RequestBase._load_form_data(self)
  File "/Users/jarhill/Library/Python/3.7/lib/python/site-packages/werkzeug/wrappers/base_request.py", line 319, in _load_form_data
    self._get_stream_for_parsing(), mimetype, content_length, options
  File "/Users/jarhill/Library/Python/3.7/lib/python/site-packages/werkzeug/formparser.py", line 225, in parse
    raise exceptions.RequestEntityTooLarge()
werkzeug.exceptions.RequestEntityTooLarge: 413 Request Entity Too Large: The data value transmitted exceeds the capacity limit.

So, I don’t think we can abuse the content length either.

Flask route registrations

Flask enables you to specify variable parts in the path for a route, described in the documentation. This site uses this feature in the get_file endpoint:

@app.route("/files/<guid>", methods=["GET"])
def get_file(guid):

The site’s users can access a URL like /files/00000000-0000-0000-0000-000000000000 (where the end part is a valid UUID) and Flask will call the get_file() endpoint with the appropriate (string) value of guid. Flask does allow site developers to specify a data type to convert a variable part to, such as int or float. In that case, Flask will take care of responding with an error if the URL cannot be converted to the desired form. Flask even has a uuid type, but for some reason this code doesn’t use it.

There’s no way that I could find or think of that this feature of Flask could be used to exploit the web app. When the guid parameter comes in, it’s immediately checked against the regex before anything else is allowed to happen. So, any value that we put there must be a valid UUID. This too feels like a dead end.

OPTIONS and HEAD

I tried sending HTTP OPTIONS and HEAD requests to the add_file() and get_file() endpoints. They variously responded with “Ok” and “wrong method!” but didn’t seem to do anything interesting.

Messing with the content type

I considered whether the content type could somehow be set or read incorrectly in a way that would be advantageous to an attacker. Nothing stood out.

I also tried sending weird data, like non-ASCII bytes and a NUL byte. Nothing interesting happened.

Conclusions

That’s pretty much everything I considered and tried. It’s just a 46-line file written in a language I’m familiar with, using a framework I’m familiar with. I know that I’m missing something, or multiple things, because as I write this, 89 teams have solved the challenge. I’m curious to find out exactly what I missed once writeups are published.

What’s more, I’m not even sure what I’m looking for. Am I looking for a particular UUID that contains the flag? Is one of these UUIDs the flag itself (how do I know which of the 2^128 possibilities?)? Am I supposed to exploit the server somehow to get it to do something bad? I don’t even know how I’m supposed to find the flag.

This was a fun attempt, but I know that I was far from the solution, whatever it was.