
Exploit de prueba de concepto e informe técnico para CVE-2023-6553, una vulnerabilidad de inclusión de archivos PHP no autenticada que permite la ejecución remota de código en el plugin Backup Migration de WordPress <=1.3.7.
Plugin: Backup Migration (backup-backup) ≤ 1.3.7
CVSS: 9.8 (Crítico)
CWE: CWE-98 — Control inadecuado del nombre de archivo para la declaración Include/Require
Requisito de autenticación: Ninguno
Impacto: Ejecución remota de código
Backup Migration es un plugin de WordPress bastante popular (~90.000+ instalaciones activas) que ayuda a los usuarios a crear copias de seguridad. Durante el proceso de copia de seguridad, el plugin tiene un archivo llamado backup-heart.php ejecutándose en segundo plano: recibe información de configuración mediante cabeceras HTTP para saber qué directorio debe respaldarse, dónde se encuentra el archivo de configuración, etc.
El problema radica en que este archivo confía plenamente en las cabeceras HTTP enviadas por el cliente, toma el valor de la cabecera directamente hacia la ruta del archivo y luego usa require_once() para cargar el archivo desde esa ruta. Un atacante solo necesita enviar la cabecera Content-Dir apuntando a un directorio que contenga código PHP malicioso → el servidor automáticamente lo incluye y ejecuta.
Cabe destacar que el archivo backup-heart.php no requiere autenticación — solo comprueba si el método de solicitud es POST, sin verificar ningún nonce ni privilegios de usuario. Cualquier persona en internet puede enviarle una solicitud.
⇒ Esta es una vulnerabilidad zero-click.
| Atributo | Valor |
|---|---|
| ID CVE | CVE-2023-6553 |
| Puntuación CVSS | 9.8 (Crítico) |
| Plugin | backup-backup (Backup Migration) ≤ 1.3.7 |
| Autenticación | No requerida |
| Interacción del usuario | Ninguna (zero-click) |
| Corregido | Versión 1.3.8 |
PHP tiene funciones como include(), require(), require_once() que se usan para incluir otros archivos PHP en el programa en ejecución. Cuando la ruta de archivo pasada a estas funciones proviene de la entrada del usuario sin validación, un atacante puede forzar al servidor a incluir cualquier archivo que desee:
allow_url_include=On (normalmente deshabilitado por defecto).Este CVE se clasifica como LFI — el atacante controla la ruta pasada a require_once() apuntando a un archivo PHP que el atacante ha logrado escribir en el servidor.
Muchos desarrolladores piensan que las cabeceras HTTP son metadatos "internos" conocidos solo por el servidor y el cliente. En realidad, los atacantes controlan el 100% del contenido de las cabeceras — pueden establecer cualquier nombre y valor de cabecera. Confiar en las cabeceras es igual que confiar en la entrada de un formulario: debe validarse.
define() y las constantes de PHPdefine('NAME', $value) crea una constante utilizada en toda la aplicación. Una vez defineda, el valor no se puede cambiar. Si $value proviene de un atacante, todos los lugares que usan esa constante se ven afectados.
Empecé usando grep para buscar todas las declaraciones require e include en todo el plugin:
grep -rn "require\|include" includes/

Los resultados de la búsqueda devolvieron muchas llamadas require/include. Revisándolas, la mayoría eran llamadas include_once en banner/misc.php y banner/views/index.php — pertenecen al código de renderizado de la interfaz de administración con rutas codificadas, lo que las hace no explotables.
Sin embargo, 2 líneas en backup-heart.php llamaron mi atención:
includes/backup-heart.php:64: define('BMI_INCLUDES', BMI_ROOT_DIR . 'includes');
includes/backup-heart.php:118: require_once BMI_INCLUDES . '/bypasser.php';
La línea 118 usa require_once con la constante BMI_INCLUDES — si esta constante estuviera codificada, sería segura. Pero mirando hacia arriba, en la línea 64, vi que BMI_INCLUDES se construye a partir de otra constante, BMI_ROOT_DIR. Así que necesitamos rastrear más allá: ¿dónde se le asigna su valor a BMI_ROOT_DIR?
Usé grep nuevamente para rastrearlo:
grep -n "BMI_ROOT_DIR" includes/backup-heart.php
Resultados:

Line 62: define('BMI_ROOT_DIR', $fields['content-dir']);
Line 64: define('BMI_INCLUDES', BMI_ROOT_DIR . 'includes');
La línea 62 muestra que BMI_ROOT_DIR toma su valor de $fields['content-dir']. Esta es una variable, no un valor fijo — necesitamos abrir el archivo y ver qué contiene $fields.
$fields?Abrí backup-heart.php en VS Code en la línea 62:
// Line 62
define('BMI_ROOT_DIR', $fields['content-dir']);
// Line 64
define('BMI_INCLUDES', BMI_ROOT_DIR. 'includes');

Es claramente visible que $fields['content-dir'] va directamente a define(). Ahora necesitamos determinar dónde se asigna la variable $fields:
// 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;
}


En este punto, la causa raíz queda completamente clara: $fields contiene todas las cabeceras HTTP obtenidas mediante getallheaders() — completamente controladas por el cliente. No hay wp_verify_nonce(), ni current_user_can(), ni comprobación de ruta válida — simplemente verifica el método POST y lee las cabeceras directamente.
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
Resumen de la causa raíz: Cabecera HTTP → define() → require_once(), con cero pasos de validación en el medio.
Usé Xdebug + VS Code para confirmar visualmente el flujo del ataque. Establecí 2 puntos de interrupción en las líneas 62 y 118 de backup-heart.php y luego envié la solicitud de explotación usando curl.
Punto de interrupción 1 — Línea 62:
El depurador se detuvo justo en define('BMI_ROOT_DIR', $fields['content-dir']). Al expandir la variable $fields en el panel de Variables se mostró un array de 22 elementos — que contenía todas las cabeceras HTTP enviadas por el cliente. Específicamente: