UofT 2025 CTF (Web)
Table of Contents
UofT CTF 2025 hosted by University of Toronto was a blast. I played with my team PwnSec and we ranked 33 despite missing out three categories.
As usual, I focused on web; so let’s get started 😉
CodeDB⌗
A pretty cool website that allows you to search a database of files like GitHub.
It came with a list of vibrant code samples written in various languages:
Search operations are exposed through the /search
endpoint and run on a Node.js worker thread
The worker thread:
Notice how we do have full RegEx search capabilities, pretty cool!
We have three locations where file visibility is checked.
First in app.js
:
And twice is searchWorker.js
, where it is first set and removed from results before handing them over through parentPort
.
Doing npm audit
we notice that it’s vulnerable to Regular Expression Denial of Service (ReDoS). A rather theoretical timing attack that can be used to leak information under certain conditions - as well as getting abuse for DoS.
So I wrote this lovely script with binary search:
uoftctf{why_15_my_4pp_l4661n6_50_b4dly??}
References:
- https://portswigger.net/daily-swig/blind-regex-injection-theoretical-exploit-offers-new-way-to-force-web-apps-to-spill-secrets
- https://diary.shift-js.info/blind-regular-expression-injection/
Prepared 1⌗
We have a custom prepared statement generator called QueryBuilder:
Queries are prepared using this query builder before getting sent to the database:
I think we have to abuse the recursive, sequential nature of the placeholder logic. Using username = {password!a}
and password
Database query failed: 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near 'as'' AND password = 'as'' at line 1
We need to find different gadgets to produce what we need, ideally any ASCII character. We can abuse format and do username = {password.__class__}
and we get: Sanitized query: SELECT * FROM users WHERE username = '<class 'str'>' AND password = 'a'
Let’s further understand what str.format_map
does:
The function in the code is:
- If
k
is a string in ourformat_map
(That’s the dictionary, different fromstr.format_map
method), it’s sent as is… - If
k
is something else, presumablyDirtyString
, then we callformat_map[k]
with two arguments""
andk
. In the presumed case, that would lead to creating aDirtyString
instance whose value is""
.
I see this as a function call gadget, if we can reach an important object or function, we can call it this way… I daresay we can achieve a call chain.
Using something like:
We get Login successful.
We can easily grab strings from any valid python string, like ''.__doc__
for example! Writing a little custom encoder and a blind injector gives us the flag:
Running our script:
uoftctf{r3m3mb3r_70_c173_y0ur_50urc35_1n_5ql_f0rm47}
References:
Timeless⌗
A blog website where you can add new posts, upload a profile picture and write your bio. Posts can be public or not public. An RCE is required.
I immediately recognize an unsafe path concatenation with user-controlled input in the upload functionality, I think this is integral to the challenge.
Even though we can’t have ..
or .
in the filename, by creating a username of /tmp/passwd
, we get an arbitrary file write:
$ ls /tmp/passwd
3f33712d6754403bb475ca9eb8a0e40d.jpg
We identify a /status
endpoint allowing us to get the time:
In config.py
, we have predictable secrets:
Still… How does that lead to an RCE? Do we need an RCE?
- No
TEMPLATES_AUTO_RELOAD
, we can’t overwrite a template for easy RCE - Sessions are permanent though, and they reside on the filesystem.
- We cannot upload a file with extension, we don’t need to though, we will just overwrite a session with deserialization ;)
- We find explanation for these constants here, notice that tokens are serialized using a weird function: https://flask-session.readthedocs.io/en/latest/config.html#SESSION_SERIALIZATION_FORMAT
We will look at these later, lets review the allowed_file
logic:
Translates to:
There could be a differential between how the file is checked here, and the actual file saved:
To generate a similar uuidv1
secret, we need the server’s MAC address, we need an arbitrary file read, looking around:
Notice the file save logic, below which allows us to configure the path even if file save fails by incorrectly commiting the results in the finally
clause:
We have full control over the username, as well as the profile photo name as long as it does not have an extension. We can easily upload any file by controlling the username, profile picture does not matter/should trigger an error. Eventually, we get it right and we exfiltrate the MAC address from /sys/network
On flask-session
’s website, we find the following notice (Our app uses Flask-Session 0.8.0
):
Having the secret key, and file upload in hand, and knowing about the pickle deserialization vulnerability, we want to take this further. However, how do we identify the stored session identifier which stores our session?
In flask-session
docs:
class flask_session.filesystem.FileSystemSessionInterface(app: Flask, key_prefix: str = 'session:', use_signer: bool = False, permanent: bool = True, sid_length: int = 32, serialization_format: str = 'msgpack', cache_dir: str = '/home/docs/checkouts/readthedocs.org/user_builds/flask-session/checkouts/latest/docs/flask_session', threshold: int = 500, mode: int = 384)
Uses the cachelib.file.FileSystemCache as a session storage.
Parameters:
key_prefix – A prefix that is added to storage keys.
use_signer – Whether to sign the session id cookie or not.
permanent – Whether to use permanent session or not.
sid_length – The length of the generated session id in bytes.
serialization_format – The serialization format to use for the session data.
cache_dir – the directory where session files are stored.
threshold – the maximum number of items the session stores before it
mode – the file mode wanted for the session files, default 0600
flask-session
’s filesystem storage uses FileSystemCache
from cachelib
.
- Flask Session’s
FileSystemSessionInterface
: https://github.com/pallets-eco/flask-session/blob/bc2fe67958bff5e46023c4807b5e75ca350554eb/src/flask_session/filesystem/filesystem.py#L17 - Cachelib’s
FileSystemCache
(Indeed using pickle internally): https://github.com/pallets-eco/cachelib/blob/9a4de4df1bce035d27c93a34608a8af4413d5b59/src/cachelib/file.py#L218C5-L233C20
Grabbing remote’s secret key:
This is how sessions are saved in flask session by calling _upsert_session
:
Which in turn calls cachelib
’s set
.
To get the filename from session ID, we do use the following where the session ID is the prefix before the .
in the signed flask-session token:
After sometime spent on this challenge, I realized that the author, intentionally, swapped argument order between function definition and function call which left me wondering why is my md5 “not working”. Notice:
vs:
I only noticed the discrepancy after plastering the source code with prints.
Anyways below is my complete solver.
My Solver
Producing output like this:
Prismatic Blog⌗
We know it’s a Prisma injection, but how?
The flag is stored in a post with a random ID that does not belong to any user. We have an injection point in name.
npx prisma generate
npx prisma db push
We have two endpoints:
My teammate @moha09 solved it using binary search with a payload similar to these:
/api/posts?author[name]=Bob&AND[0][author][password][lt]=8AXCgMish5Zn59rSXjM
/api/posts?author[name]=White&AND[0][author][password][lt]=3pCtWJfabwPlo6qNgGS1P4
Conclusion⌗
It was a great CTF with lots of learning. I could notice growth in my code review skills as auditing lots of code became more natural with less friction.
Generally, I felt less intimidated to jump into open-source dependencies or python’s stdlib to get definitive answers on the behaviors of certain constructs.