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