Nantes, France
Enzo Peigné — Portfolio · Ingénieur Cybersécurité —
Projet · Développement Sécurisé

Wargame
CTF & Pentest

Projet collaboratif de création d'une application web volontairement vulnérable, suivie d'un exercice de test d'intrusion complet.

Concevoir une application volontairement vulnérable, puis la remettre à des équipes adverses pour qu’elles l’attaquent et attaquer celles des autres en retour. L’exercice force une double posture rarement pratiquée en formation : penser simultanément comme développeur qui construit et comme attaquant qui cherche.

Le contexte est le wargame inter-groupes du M2 Cybersécurité de l’ISEN Nantes. Chaque groupe de deux ou trois étudiants produit une cible et en reçoit une. Les vulnérabilités doivent appartenir à des catégories définies par le règlement, être exploitables, et récompenser les attaquants par un flag.

Contexte

L’application est un portail restaurant fictif, intitulé “Vulnfood”, permettant de créer un compte, se connecter, consulter le menu, le télécharger en PDF, et modifier ses informations personnelles. La surface est délibérément banale : authentification, gestion de profil, téléchargement de fichier. Ce sont exactement les fonctionnalités où les développeurs font confiance aux données qu’ils ont eux-mêmes stockées.

Le règlement impose au moins trois vulnérabilités de catégories distinctes. Nous en avons implémenté quatre : trois portent un flag, la quatrième est une fragilité structurelle sans récompense directe.

Objectifs

Le projet se déroule en deux phases.

Phase de développement : concevoir et développer une application fonctionnelle dont les vulnérabilités sont réalistes, pas caricaturales. Chaque faille doit ressembler à une erreur de développeur ordinaire plutôt qu’à un piège évident. Les flags sont cachés dans la base de données et dans le système de fichiers.

Phase red team (pentest) : recevoir l’application d’un autre groupe, mener un test d’intrusion structuré, documenter les vecteurs exploités dans un rapport de pentest avec preuves et recommandations de remédiation.

Architecture

L’application est conteneurisée via Docker Compose avec deux services : un conteneur PHP 8.1/Apache servant le frontend HTML/CSS/JS et le backend PHP, et un conteneur PostgreSQL 15 pour la persistance. Le docker-compose.yml monte flag.txt dans /etc/flag.txt à l’intérieur du conteneur, accessible uniquement par path traversal.

Le schéma de base initialise trois tables (utilisateurs, produits, commandes) et pré-insère trois comptes : le premier avec toutes les colonnes remplies par Flag{2nd_0rder_SQLi_1s_fun}, le deuxième avec des identifiants par défaut connus, le troisième un compte de test normal.

Les quatre vulnérabilités

1. SQL Injection de second ordre · Flag{2nd_0rder_SQLi_1s_fun}

C’est la vulnérabilité la plus subtile. Le vecteur se déroule en deux temps.

À l’inscription, le mot de passe est correctement hashé via password_hash() et tous les champs passent par des requêtes préparées avec bindValue(). La fonction registerAccount() dans database.php est propre :

$sql = "INSERT INTO utilisateurs (prenom, nom, email, password) 
        VALUES (:firstname, :lastname, :email, :password)";
$stmt = $conn->prepare($sql);
$stmt->bindValue(':email', $email);
// ...

Mais plus loin dans le même fichier, la fonction getUserProfile() construit sa requête par concaténation directe de l’email récupéré depuis la base :

function getUserProfile($conn, $email) {
    $sql = "SELECT * FROM utilisateurs WHERE email = '$email'";
    $stmt = $conn->query($sql);
    // ...
}

Le flux d’exploitation : s’inscrire avec l’email ' OR '1'='1, se connecter, accéder à la page de profil. Le backend récupère cet email depuis la base (considéré “sûr” car déjà stocké), le concatène dans la requête de getUserProfile(), et retourne l’ensemble de la table, y compris le compte fictif dont toutes les colonnes valent Flag{2nd_0rder_SQLi_1s_fun}.

La faille est invisible à une revue de code centrée sur les entrées utilisateur directes. C’est la donnée en base qui devient le vecteur, après avoir passé la validation d’entrée.

2. Default Credentials · Flag{D3fault_@dmin_Cr3ds}

Le script d’initialisation init_db.sql insère un compte administrateur avec l’email Vulnfood et un mot de passe hashé connu. L’interface de connexion n’est pas liée depuis l’interface principale, mais l’URL login.html est accessible directement. Tenter les identifiants par défaut du nom de l’application suffit.

Le flag est la valeur de la colonne nom de ce compte, retournée lors de la connexion réussie. Aucune exploitation technique, juste la reconnaissance que des comptes de démo qui ne sont pas supprimés avant la mise en production représentent un risque de sécurité conséquent.

3. Path Traversal · Flag{S3cure_y0ur_d0wnl0ads}

Le endpoint de téléchargement download.php construit le chemin du fichier par concaténation directe du paramètre file sans aucune validation ni normalisation :

$file = $_GET['file'];
$filePath = '/var/www/html/files/' . $file;

if (file_exists($filePath)) {
    readfile($filePath);
}

Le docker-compose.yml monte flag.txt dans /etc/flag.txt. La requête GET /php/download.php?file=../../../etc/flag.txt traverse les répertoires parents et atteint le fichier. Apache sert le contenu sans restriction.

La correction évidente est une normalisation du chemin (realpath()) suivie d’une vérification que le chemin résolu commence bien par le répertoire autorisé.

4. Stored XSS (sans flag)

Le champ de mise à jour du prénom et du nom (personal_info.html) envoie les valeurs au backend via profile.php/updateProfile, qui les stocke sans échappement. À l’affichage, personal_info.js injecte le contenu directement dans le DOM via .innerHTML :

document.getElementById('nom').innerHTML = profile['nom'];
document.getElementById('prenom').innerHTML = profile['prenom'];

Un payload <img src=x onerror=alert(1)> stocké en base s’exécute pour tout utilisateur consultant la page de profil. La “protection” côté client, une simple vérification includes("<script>") dans le JS, est trivialement contournée par n’importe quelle balise alternative.

Cette vulnérabilité n’est pas récompensée par un flag mais démontre la différence entre validation côté client (cosmétique) et protection réelle côté serveur (htmlspecialchars() en PHP ou encodage au rendu).

Stack technique détaillée

PHP 8.1 / Apache : backend. Routing HTTP manuel via PATH_INFO, gestion de session par cookie USERSESSION (token SHA-256), requêtes PDO (préparées pour les opérations sûres, concaténées là où la faille réside).

PostgreSQL 15 : persistance. Schéma initialisé par script SQL au démarrage du conteneur via docker-entrypoint-initdb.d.

HTML / CSS / JavaScript (jQuery + Ajax) : frontend. Appels Ajax vers les endpoints PHP, injection DOM directe via innerHTML (vecteur XSS).

Docker Compose : déploiement reproductible. Un docker-compose up --build suffit à déployer l’environnement complet, identique sur toutes les machines des équipes adverses.

Ce que j’en retire

Construire une vulnérabilité intentionnelle et la voir exploitée par des équipes adverses est une expérience difficile à reproduire autrement. Elle force à se mettre dans la peau de l’attaquant pendant qu’on écrit le code défensif et révèle à quel point les deux raisonnements sont distincts.

La SQLi de second ordre a été le cas le plus formateur. Elle respecte la règle fondamentale “utiliser des requêtes préparées pour les entrées utilisateur”, mais la donnée en base n’est plus traitée comme entrée utilisateur quand elle ressort. L’erreur ne vient pas d’une négligence : elle vient d’une confiance implicite dans la donnée stockée, que l’attaquant a contrôlée dès l’inscription. Comprendre ça en l’implémentant change la façon dont on relit du code par la suite.

Le path traversal illustre une leçon plus simple mais tout aussi récurrente : “le fichier demandé existe-t-il ?” n’est pas une vérification de sécurité. La question est “ce chemin normalisé est-il bien dans le répertoire autorisé ?”. Une ligne de realpath() + assertion de préfixe est nécessaire pour éviter les traversals.

La “protection” XSS côté client (includes("<script>")) mérite d’être mentionnée pour ce qu’elle enseigne : une validation JavaScript est une UX, pas une sécurité. Elle s’exécute sur la machine de l’utilisateur, peut être désactivée ou contournée en quelques secondes via les outils développeur, et ne protège rien côté serveur.