SekaiCTF 2025/Web/My Flask App
Challenge Overview
While competing in Sekai CTF 2025, I came across an interesting web challenge called My Flask App. Initially, it seemed like your typical Flask application with a file reading vulnerability, but there was an interesting twist that made it more engaging than expected. Let me walk you through my approach to solving this one.
Initial Analysis
Whenever I tackle web challenges, my first step is always to examine the Dockerfile. This usually reveals important details about how the application is configured and deployed.
Here’s what I found:
FROM python:3.11-slim
RUN pip install --no-cache-dir flask==3.1.1
WORKDIR /app
COPY app .
RUN mv flag.txt /flag-$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1).txt && \
chown -R nobody:nogroup /app
USER nobody
EXPOSE 5000
CMD ["python", "app.py"]
The interesting part here was the flag handling. Instead of leaving it at a predictable location like /flag.txt
, the setup script renames it to something like /flag-VenUXnNXjh9MJxOH6m8xHvAR2oG9cmmG.txt
— a completely random 32-character alphanumeric string appended to the filename.
This immediately told me that while exploiting the application might be straightforward, discovering the actual flag location would require some creative thinking. Brute-forcing 32 random characters? Not happening.
Examining the Application Code
Next, I dove into app.py
to understand what we’re working with:
from flask import Flask, request, render_template
app = Flask(__name__)
@app.route('/')
def hello_world():
return render_template('index.html')
@app.route('/view')
def view():
filename = request.args.get('filename')
if not filename:
return "Filename is required", 400
try:
with open(filename, 'r') as file:
content = file.read()
return content, 200
except FileNotFoundError:
return "File not found", 404
except Exception as e:
return f"Error: {str(e)}", 500
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
The application was remarkably simple:
- The root endpoint (
/
) just serves a basic HTML page - The
/view
endpoint accepts afilename
parameter and reads its contents - Most importantly: no input validation whatsoever
- I noticed
debug=True
was enabled, but at first I didn’t think much of it — figured it was just left in during development
This was clearly a Local File Inclusion (LFI) vulnerability. I could read any file on the system that the nobody
user had permissions for. The vulnerability itself was trivial to exploit.
The real challenge? Finding where that randomly-named flag file was hiding.
Hunting for the Flag
With unrestricted file read access, I had plenty of options to explore. In Linux containers, the /proc
filesystem is a goldmine of runtime information about the system and running processes.
I started testing various paths:
/proc/self/cmdline
- showed the command used to start the process/proc/self/environ
- environment variables/proc/self/cwd
- current working directory
Then I remembered something about how Linux tracks filesystem mounts. Every mounted filesystem gets logged by the kernel, and this information is accessible through /proc/mounts
.
I made a request to:
/view?filename=/proc/mounts
And there it was, clear as day:
/dev/nvme0n1p1 /flag-VenUXnNXjh9MJxOH6m8xHvAR2oG9cmmG.txt ext4 ro,...
The complete path to the flag file, including the random suffix, just sitting there in the mount table!
Understanding the Mount Leak
Initially, I wondered why the randomized filename would even appear in /proc/mounts
. After thinking about it, I realized what was happening.
The challenge infrastructure wasn’t just copying the flag into the container — it was using a bind mount to make the flag file available inside the container.
In Linux, a bind mount creates an additional reference to an existing file or directory at a different location in the filesystem tree. It’s essentially aliasing one path to another. Since the kernel needs to track all mounted filesystems (including bind mounts), they all get recorded in /proc/mounts
.
This meant the container was essentially exposing its own flag location through standard Linux filesystem metadata.
Additional Note: You could also use
/proc/self/mountinfo
for even more detailed information. The main difference is that/proc/mounts
provides a simpler, traditional view of mounted filesystems, while/proc/self/mountinfo
gives you more comprehensive kernel-level details including mount IDs and propagation settings.
Getting the Flag
Once I had the exact path, retrieving the flag was straightforward:
curl "http://server/view?filename=/flag-VenUXnNXjh9MJxOH6m8xHvAR2oG9cmmG.txt"
And just like that, the flag appeared:
SEKAI{!s-+h1s_3VEN_<all3d_a_cv3}
Success!
Alternative Solution - The Intended Path
After solving it, I checked the official writeup and discovered there was actually a much more complex intended solution that I had completely missed.
Remember that debug=True
setting I glossed over? Turns out that was the key to the intended exploit chain.
Here’s how the intended solution worked:
Information Gathering via LFI
Using the file read vulnerability, you could extract specific system information:
- MAC address from
/sys/class/net/eth0/address
- Boot ID from
/proc/sys/kernel/random/boot_id
- Other system identifiers
- MAC address from
Werkzeug PIN Generation
Flask’s debug console is protected by a PIN that’s generated using specific system values. By collecting the “public bits” (like username, module name, and app path) and “private bits” (MAC address and boot ID), you could recreate Werkzeug’s PIN generation algorithm and compute the correct PIN.
Bypassing Access Restrictions
The debug console typically restricts access to localhost only. However, by manipulating the
Host
header to127.0.0.1
, you could trick the application into treating your request as local, which would expose aSECRET
value from the/console
endpoint.Console Authentication
With both the calculated PIN and the leaked SECRET, you could call the
pinauth
endpoint to obtain a valid session cookie for the debug console.Achieving Remote Code Execution
Once authenticated to the debug console, you’d have full Python code execution capabilities, allowing you to run arbitrary commands and read the flag directly.
The complete intended exploit chain was: LFI → System info collection → PIN calculation → SECRET extraction → Console authentication → RCE → Flag retrieval.
You can find the official solution script here.
In retrospect, I completely underestimated the significance of that debug=True
line. It was hiding in plain sight the entire time.
Closing Thoughts
What made My Flask App memorable for me was how it subverted typical CTF patterns. On the surface, it looked like just another Flask LFI challenge, but the randomized flag path added an interesting layer that forced you to think beyond simple path traversal.
I particularly appreciated that there were two valid approaches:
- My solution - exploiting the bind-mount configuration leak through
/proc/mounts
to discover the randomized filename - The intended solution - leveraging
debug=True
to reconstruct the Werkzeug PIN and gain RCE through the Flask debugger
Both approaches highlighted an important lesson: even simple vulnerabilities like arbitrary file reads or forgotten debug flags can lead to complete system compromise when combined with deep knowledge of the underlying platform.
What I took away from this challenge wasn’t about memorizing payloads or following scripts — it was about careful observation, understanding how systems work at a fundamental level, and using that knowledge to find creative solutions. Sometimes the simplest path forward is the one hidden in the system’s own design.