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.