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:
- Spring Boot Actuator over-exposure + an nginx path-ACL bypass (
/.;/) → leaks admin credentials and the first flag. - 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.
| # | Technique | Flag |
|---|---|---|
| 1 | Spring Actuator /mappings + nginx /.;/ ACL bypass | THM{REDACTED} |
| 2 | HTTP/2 H2.CL request smuggling — capture victim request | THM{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.
| |
| |
Two things jumped out immediately:
- Port 80 is
ssl/http. Read that again. It’s HTTPS on port 80 with a customServer: El Bandito Serverbanner. This bites you later — if you lazilycurl https://target/you’ll hit port 443, which is a different (decoy) service. You must explicitly targethttps://target:80/. - Port 8080 is plain nginx fronting something. The response header
X-Application-Context: application:8081is a dead giveaway for a Spring Boot app, with nginx reverse-proxying to an internal Spring instance on8081.
The 8080 root is a Vite/React SPA called “Bandit-Coin”:
| |
| |
Pulling the bundled JS and grepping for routes surfaced two static pages:
| |
/burn.html is a “Token Burn” form that loads /app.js. But requesting /app.js returns a Spring Boot 404 JSON:
| |
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:
| |
| |
/mappings dumps every route the Spring app knows about. Most are boilerplate, but four are clearly bespoke (net.thm.websocket.config.AdminController):
| |
So the app defines /admin-creds and /admin-flag. Let’s just ask for them:
| |
| |
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
| |
/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:
| |
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:
| |
- nginx sees the literal string
/.;/admin-creds. It does not match thelocation /admin-credsdeny rule, so it happily proxies the request through. - Spring receives
/.;/admin-creds, strips the;-parameter segment and collapses the/.→ it routes to/admin-credsand 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:
| |
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:
| |
/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:
| |
/;/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:
| |
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 POSTto/with headercontent-length: 0, but a DATA frame carrying a real, non-empty body. The front-end usesEND_STREAMto 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:
| |
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
| |
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
| |
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:
| |
The pseudo-headers declare content-length: 0; the DATA frame carries the real (non-empty) smuggled prefix with END_STREAM:
| |
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
| |
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:
| |
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.1and was hitting/access— a strong hint that an internal-only/XFF-gated endpoint is the next objective on this box.
7. Conclusion / Flag
| |
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: 0at 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./mappingshanded us the entire attack surface;/heapdumpwould have handed us memory. - When terminating HTTP/2 in front of an HTTP/1.1 backend, the front-end must recompute
Content-Lengthfrom the real body and reject H2 requests whosecontent-lengthheader contradicts their framing. Copying the client-supplied value is the whole vulnerability.
Happy hunting, and mind the desync. 🤠