Squid was a Server-Side Web challenge with just 3 solves in the end (not counting the several removed LLM-assisted solves 😄). One rule that made L3akCTF 2026 stand out was its zero-tolerance LLM policy, which made challenges much more rewarding to solve.
The start of this challenge was relatively simple, but we'll jump to internal hosts and learn a generic technique that allows reading files with 0 size like /proc/.../environ in the send_file() function from Flask (and likely more, so be prepared in your next CTF 😉).
Let's get into the challenge!
Mass Assignment
We get a public_app.py that, as the name suggests, is publicly available on http://localhost:13337 after starting the Docker stack with docker compose up --build. At /register, we can register with any credentials, but our default role is "user".
With the "user" role, we cannot access is_staff()-protected endpoints, which are basically all the interesting ones.
return ==
return , 403
...
return , 403
...
So the first step is likely to become "admin". There are no predefined USERS we could attack, but there is some weird logic in the /api/register endpoint:
=
=
return , 400
return , 400
return , 403
=
=
=
=
=
=
return
The client-side js/app.js script sets only username and password values:
;
But the endpoint also tries to read a role key, defaulting to "user". If we only change:
To:
We hit another check:
self-service accounts cannot request the admin role
json.loads() parses our request body and denies it when the role key is "admin". However, the code later switches to ujson.loads() to read the record and actually save the role.
json vs. ujson Parser Differential
The two JSON parsers json and ujson may parse the input slightly differently. Because the check is separated from the use, all we need is to obfuscate the role key somehow for json, while ujson still reads it correctly.
Luckily, there is some prior research on this. In an article by Jake Miller at BishopFox ("An Exploration of JSON Interoperability Vulnerabilities"), they describe various common JSON parser ambiguities. We can just go through them one by one and see if any work:
--
>>>
>>>
>>>
>>>
>>>
: \escape: line 1 column 21 (char 20)
>>>
:
>>>
>>>
Success! Duplicate keys resolve to the last for both libraries, \x0d isn't recognizes as a valid escape code by both, but a difference lies in handling dangling surrogates (\ud800). json sees the extra character and makes a new key for it, while ujson ignores it and overwrites the previous key, making the resulting role "admin".
Note: This differential only works on ujson < 5.4.0, it ended up being treated as a real security vulnerability (GHSA-wpqr-jcpx-745r)
This allows us to register as a real admin and access the other functionality in this app:
HTTP/1.1
File Read
Passing is_staff() checks now, this unlocks the /download/:target endpoint. It joins our path variable with a static directory and sends back the content of the file at this location:
return , 403
=
return
return , 404
There is an obvious path traversal vulnerability here. By prefixing our target path with ../ sequences, we can read any file on the filesystem. If we try to request http://localhost:13337/download/..%2F..%2F..%2F..%2Fetc%2Fpasswd, however, we get an unexpected response from Nginx:
400 Bad Request
Nginx has a sanity check during its internal URL normalization step to count the depth of the requested path vs. the number of ../ sequences. If you path-traverse more than there are existing path segments before it, you receive a "400 Bad Request" error instead. Because we only have one segment (/download/), we can only path-traverse once outside of the WORK directory. But we're lucky that it is set to only /work, no deeper path. So one traversal is enough to access the whole filesystem!
http://localhost:13337/download/..%2Fetc%2Fpasswd
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
...
This is a nice primitive! But for now we can't do much with it as it's running as the ctf user while the flag is stored in /secrets/flag owned and readable only by the root user.
Trying to read /proc/self/environ for environment variables also fails:
; filename=environ
It successfully finds the file indicated by the 200 OK, but due to the size of the file being reported as 0 by the OS, 0 bytes are sent to us. We'll get back to this in the Flask send_file() size Race Condition section.
SSRF via urllib vs. requests
Another endpoint opens up from is_staff() too:
=
return , 403
= or
=
return , 400
= .
return , 403
=
return , 502
return
This gets url from our request body and parses it with urllib.parse.urlparse() to check if the hostname is allowed, before requesting and returning the content at this URL. From the allowed TRUSTED_MIRRORS, none resolve. To get anything from this function we need to get it to request a different hostname that isn't allowed. Time for another parser differential!
Let's see if requests even uses a different parser than urllib. If it also just imports that, we're in bad luck. Just tracing where the url parameter flows into through the library, we end up here in models.py:
...
...
, , , , , , =
urllib3 is used. We previously saw urllib which is in the Python standard library, but requests internally uses urllib3 which is a re-implementation of it with different features, and importantly, a new parser.
Prior research by Thomas Chauchefoin at SonarSource ("Security Implications of URL Parsing Differentials") shows an example of these two parsers already.
They highlight one specific vector that causes a lot of confusion between parsers: http://a.tld\@b.tld
>>>
>>>
>>> .
>>>
It works perfectly for our case! If we set a URL like https://example.com\@manifests.buildfarm.internal, the 1st parser sees manifests.buildfarm.internal, which is allowed through. Then, the 2nd parser sees https://example.com with just a bit of a strange path.
As for what to reach with this, there is a second server on port 8000 only listening on localhost. We can now reach it:
= or
...
It handles any path and is configured by a GET query parameter ?spec=. This doesn't change our parser differential, so we can just append it. We can schedule a job through this internal server as follows:
HTTP/1.1
Interacting with worker.py
Now the question is what can we do with this new server. Well, the nice part is that as opposed to the public_app.py this server is running as root. See below the entrypoint.sh where /app/worker.py (localhost:8000) is run directly in a background process (stays root), and the public app is wrapped with gosu ctf, setting the user to ctf:
&
That means it can access the flag and does so here:
=
=
return
=
return in or or
=
continue
continue
=
=
return
This function filters environment variables and, while doing so, uses string.Template to allow values to reference the vault variables (FLAG). The syntax for this library is pretty basic, just $FLAG or ${FLAG}, no format specifiers or anything.
Where is materialise_env() used, you may ask? It is in the handler that we can call now via the SSRF:
=
= or
=
...
=
=
, =
= or
=
=
It spawns a subprocess runner.py with arguments build /work/RANDOM_HEX 60.0. In the spec parameter we provide, environment variables to the process, which are restricted and templated via the function from before. Now environment variables are pretty powerful, so the restrictions better be sound.
The runner.py file itself isn't too interesting. It just prints some static strings to a build.log file and waits for the number of seconds we specify in ttl.
return
=
=
=
...
= +
We can quickly test with ltrace where getenv() calls are made to read specific environment variables, then evaluate what happens if we control them.
) =
) =
)
Unfortunately, that seems quite limited. PATH is already blocked by the program, leaving us with LC_ALL, which is only used for some small localization details. This isn't the end of the story, however, because apart from calling a function such as getenv, a program can also just read from the environment variables already in memory and access them like a dictionary in Python (os.environ).
At the start of runner.py, we can insert a small snippet to hook these environment variable accesses and run runner.py:
# https://noamlerner.com/posts/python_environment_hook/
=
return
=
But unfortunately, again this also doesn't find any environment variable uses. If we look deeper into it, this is mainly because -I is set, which doesn't do us many favors:
-Ioption can be used to run the script in isolated mode wheresys.pathcontains neither the current directory nor the user’s site-packages directory. AllPYTHON*environment variables are ignored, too.
So if no variable affects the execution of the runner.py script, why do we have control over it? How can we do something with the flag value?
Remember: we can set the flag in the value of any environment variable, and we have a file read primitive. So, is anything stored on disk while executing that we missed?
I went for fuzzing. Cloned cpython, searched for environment variable-like words and gave them all a recognizable value like j0r1an that wouldn't normally appear on the system:
| |
|
PY_STDLIB_MOD=j0r1an_PY_STDLIB_MOD
AC_CHECK_MEMBERS=j0r1an_AC_CHECK_MEMBERS
RC_BAD_VENV_CFG=j0r1an_RC_BAD_VENV_CFG
_DO_CALL=j0r1an__DO_CALL
BDEB=j0r1an_BDEB
Then, run the runner with this packed environment and search the whole filesystem for any traces of the canary string j0r1an:
Sure enough, after some time we find results:
;
The cmdline is a false positive as it is matching the grep command itself. But in /proc/16998/environ it found content containing the environment values, which is not very surprising. We already tried to read /proc/self/environ with the file read earlier, but due to its 0 size, Flask can't return any of its bytes.
Flask send_file() size Race Condition
This is the last and most interesting part of the challenge. While digging into the source code of Flask's send_file() function and following where its first argument path_or_file is used. This lands us in werkzeug/utils.py. The send_file function here first calls os.stat(path) for the Content-Length header, then later on open(path) to actually read the content:
=
=
=
...
=
=
=
=
The weird thing is that /proc/self/environ reports a size of 0 when running os.stat on it, because it is a special dynamically generated file by the OS.
) ) )
But werkzeug still opens the file and successfully reads its data. What's stopping it is the gunicorn layer explicitly stopping with sending in http/wsgi.py if the Content-Length: is reached.
...
...
=
...
# Never write more than self.response_length bytes
return
=
=
# Sending an empty chunk signals the end of the
# response and prematurely closes the response
return
+=
And because our file reports a size of 0, it will always send 0 bytes back to us. It seems impossible, until we factor in time. Because there are actually two operations on our file at two different moments in time:
os.stat()open()
If during step 1, at the path there is some large file with a real size >0, which we can quickly swap for a symlink to /proc/self/environ before step 2, at step 2 the environment is read again but with a Content-Length: reporting a much larger size. gunicorn would let the data pass, and we can read the environment variables.
This requires RCE on the machine; creating and moving symlinks isn't realistic. Right? Right?!
Then I suddenly remembered a trick from a past CTF: Every open() call secretly "writes" a symlink to /proc/self/fd/3 (or a higher file descriptor) pointing to the open file:
>>> =
And these file descriptors are re-used. If I close the previous and open another one, we see it changed:
>>>
>>> =
This is effectively the symlink swap gadget we were looking for! If we had pointed a send_file() at /proc/452/fd/3 with the correct timing, it could read the size of /etc/passwd with the content of /proc/self/environ!
To be clearer, the following steps should happen:
- Trigger a long-running job via the SSRF to get
FLAGinto/proc/$RUNNER_PID/environ - Request
send_file("/etc/passwd"), creating/proc/self/fd/13 -> /etc/passwd - Request
send_file("/proc/self/fd/13").os.statfollows the symlink to/etc/passwdand gets back 967 as the size. Now waits - Step 2 is finished, closing file descriptor 13
- Request
send_file("/proc/$RUNNER_PID/environ"), creating/proc/self/fd/13 -> /proc/$RUNNER_PID/environ - Step 3 continues to
open()and follows the symlink now pointing to/proc/$RUNNER_PID/environ. Reads its content and returns it to gunicorn - gunicorn gets
Content-Length: 967with a body being the runner's environment variables. It reads the first 967 bytes of that content and sends those back to the client
There are a few unknowns, such as the exact timing (which we can fix by just spamming many attempts) and the runner PID. Note that we don't need to know the app PID because self refers to it.
Finding the PID isn't hard either. Using our file read, we can iterate through specific /proc files. We can't check /proc/.../cmdline for the same reason we also can't read environ, but the process has a very unique cwd (current working directory):
Our runner.py starts in /work/c7729b1b067f47e982202d03ae9ae6eb. We can follow it to read the build.log it writes, for example:
A simple Python loop finds the ID:
=
=
return None
assert ,
return
# Login
=
pid=24
Now all that's left is to launch the job that sets the environment variable and spam the 3 file reads we came up with until it returns the flag.
We should make the fake size (passwd) smaller than the real size (environ), because otherwise, gunicorn infinitely waits on the rest. Currently, the environment is ~137 bytes, while the passwd is 967 bytes. We could choose a smaller file for the fake size, but since we have control over the format string, we can also just repeat $FLAG$FLAG$FLAG... a couple times to reach the size.
=
=
return
=
break
Now there are the two symlink-swapping file reads (with the found PID "24" of the environ we want to read):
And finally, the command that reads the swapping symlink, at some point returning the flag. Using -mr FLAG we can match for the regex "FLAG" in the body, and -od ffuf as the output debug directory, where all matching responses are written into a directory (for us to read the flag when it hit).
Running all 3 commands at the same time on our target (while the runner job is still running), we eventually get some matches in the 3rd ffuf instance:
[Status: , Size: , Words: , Lines: , Duration: 313ms]
| RES | 03c23f286ff385424ad86870de6ffa90
* FUZZ:
Reading the written response, we find the flag!
;
Conclusion
This challenge started off with some well-known parser differentials, and ended spectacularly with a race condition inside werkzeug. If your file read primitive is fast, this is a generic technique for reading special files with send_file(). I'm sure the same /fd idea will also help exploit other functionalities vulnerable to race conditions.
Thanks to @caarab for creating the challenge!