
Reproduzierbares Docker-Lab + PoC: Keycloak manage-users -> realm-admin-Eskalation über zusammengesetzte Rollen (CVE-2026-94000-Variante). Validiert auf 26.8.1.
Ein reproduzierbares Docker-Lab und PoC für einen Privilegieneskalationspfad in Keycloaks Admin-REST-API: Ein delegierter Admin, der nur manage-users besitzt, kann sich selbst (oder jeden beliebigen Benutzer oder jede beliebige Gruppe) zu vollem realm-admin eskalieren, indem er eine zusammengesetzte Rolle zuweist, deren Name gewöhnlich ist, deren Kinder jedoch realm-management/realm-admin enthalten.
End-to-End bestätigt gegen das offizielle Image quay.io/keycloak/keycloak:26.8.1.
⚠️ Nur autorisierte Forschung. Alles läuft gegen ein Wegwerf-Keycloak auf
localhost. Richte den PoC nicht auf Systeme, für deren Test du nicht ausdrücklich autorisiert bist.
Keycloak 26.8.1 hat Group Join gehärtet (CVE-2026-94000): Ein manage-users-Admin kann einen Benutzer nicht mehr zu einer Gruppe hinzufügen, die transitiv realm-management-Adminrollen gewährt. GroupUtils.checkAdminGroupRoles durchläuft die Rollen der Gruppe rekursiv und ruft requireMapRole für jede verschachtelte Adminrolle auf:
private static void requireMapRoleRecursive(RoleModel role, AdminPermissionEvaluator auth, Set<String> visited) {
if (!visited.add(role.getId())) return;
if (AdminRoles.isAdminRole(role)) auth.roles().requireMapRole(role); // checks nested admin roles
if (role.isComposite()) role.getCompositesStream().forEach(child -> requireMapRoleRecursive(child, auth, visited));
}
Derselbe rekursive Durchlauf wurde nie zu den Rollenzuordnungs-Endpunkten hinzugefügt. Wenn eine Rolle direkt einem Benutzer oder einer Gruppe zugeordnet wird, prüft der Server nur die übermittelte Rolle:
// RoleMapperResource.addRealmRoleMappings — mounted on BOTH users and groups
for (RoleRepresentation role : roles) {
RoleModel roleModel = realm.getRole(role.getName());
auth.roles().requireMapRole(roleModel); // posted role only — children never inspected
roleMapper.grantRole(roleModel);
}
Und das Privileg-Gate selbst richtet sich nach dem Namen der Rolle, nicht nach ihrem Inhalt. In
RolePermissionsV2.canMapRole gilt eine Rolle nur dann als privilegiert, wenn ihr Container
realm-management/master ist und ihr Name in AdminRoles.ALL_ROLES enthalten ist:
public boolean canMapRole(RoleModel role) {
if (isRealmAdminRole(role)) { ... } // isRealmAdminRole = container + ALL_ROLES.contains(name)
if (root.hasOneAdminRole(AdminRoles.MANAGE_USERS)) // manage-users may map ANY non-admin-named role
return true;
...
}
Eine zusammengesetzte Rolle namens sneaky-composite, die realm-management/realm-admin umschließt, passiert also die Prüfung — ihr Name steht nicht in ALL_ROLES, und ihre untergeordneten Rollen werden nie durchlaufen. Die Lücke erstreckt sich über drei Endpunkte:
RoleMapperResource.addRealmRoleMappings — Benutzer und GruppenClientRoleMappingsResource.addClientRoleMapping — Client-Rollen bei jedem ClientNettoeffekt: Die privilegierte Nutzlast (eine zusammengesetzte Rolle, die realm-admin umschließt) muss nur existieren — erstellt von einem echten Administrator, einem Realm-Import oder einem früheren Prozess. manage-users kann sie dann zuweisen, was das Bedrohungsmodell von CVE-2026-94000 nie vorgesehen hat.
| # | Variante | Warum sie funktioniert |
|---|---|---|
| A | Selbstzuordnung von sneaky-composite → realm-admin | die Namensprüfung übersieht die zusammengesetzte Rolle |
| B | Zuordnung der verschachtelten Rolle outer-sneaky (→ sneaky-composite → realm-admin) zu einem Benutzer | die Tiefe wird nicht geprüft |
| C | Zuordnung einer Client-Rolle bei einem Nicht-Admin-Client (spa/hidden-ra → realm-admin) | ClientRoleMappingsResource verwendet dieselbe Prüfung |
| D | Zuordnung der zusammengesetzten Rolle zu einer Gruppe, die bereits Mitglieder hat | andere API als beim Beitritt; bestehende Mitglieder werden ohne jeden Beitritt eskaliert |
Voraussetzungen: Docker + Docker Compose v2 sowie curl + jq auf dem Host.
cp .env.example .env # optional: pin KC_VERSION (default 26.8.1)
make up # start Keycloak + auto-bootstrap the vulnerable realm `app`
make logs # wait for "Lab realm 'app' is ready"
make check # non-destructive detection (maps, then immediately unmaps)
make exploit # Attack A: self-escalate limited-admin -> realm-admin
make all # full chain: check + A + B + C + D
make reset # undo every escalation (keep the lab running)
make clean # tear everything down
Der One-Shot-Container bootstrap baut das Szenario über die Admin-REST-API auf und beendet sich dann:
| Objekt | Zweck |
|---|---|
limited-admin / limitedpass | Angreifer — besitzt nur manage-users |
alice / bob / carol / dave | Opfer-Benutzer |
sneaky-composite (Realm-Rolle) | Composite → realm-management/realm-admin |
outer-sneaky (Realm-Rolle) | Composite → sneaky-composite (ein zusätzlicher Hop) |
spa-Client + spa/hidden-ra-Rolle | Nicht-Admin-Client-Rolle → realm-admin |
confidential-Client (confsecret) | Ziel für die Auswirkung „Secret lesen“ |
helpdesk-Gruppe (alice ist Mitglied) | Ziel des Gruppen-Mapping-Angriffs |
Das PoC löst Rollen-IDs so auf, wie es ein manage-users-Aufrufer tatsächlich kann — über die
.../role-mappings/.../available-Endpunkte —, weil diesem Konto GET /roles/{name} und
GET /clients verweigert werden (403), die Available-Listen jedoch nicht. Dadurch bleibt
das Skript eine ehrliche Reproduktion aus der Sicht eines Angreifers mit geringen Rechten.
Richte das Lab auf einen gepatchten Build aus; ein gefixter Server muss bei jedem Mapping-Schritt 403 zurückgeben, während gewöhnliches Nicht-Admin-Rollen-Mapping weiterhin erlaubt ist:
KC_VERSION=<patched-tag> docker compose up -d --force-recreate
make check # expect: NOT vulnerable
Gegen quay.io/keycloak/keycloak:26.8.1 (vollständiges Transkript in docs/demo.txt,
Details in VALIDATION.md):
| Schritt | Aktion | Ergebnis |
|---|---|---|
| Baseline | limited-admin POST /roles | 403 (tatsächlich geringe Rechte) |
| check | sneaky-composite auf sich selbst abbilden (dann aufheben) | 204 → ANFÄLLIG |
| A | Selbstzuordnung sneaky-composite → realm-admin → Rolle erstellen / Benutzer-E-Mails ausgeben | 204 / 201 / 200 |
| B | verschachteltes outer-sneaky auf bob → bob erstellt Rolle | 204 / 201 |
| C | spa/hidden-ra auf carol → carol erstellt Rolle | 204 / 201 |
| D | Wrapper auf helpdesk abbilden → bestehendes Mitglied alice erstellt Rolle | 204 / 201 |
| D-Kontrolle | manage-users fügt ein neues Mitglied zu helpdesk hinzu | 403 (CVE-2026-94000-Fix intakt) |
Die D-Kontrolle beweist die Asymmetrie: Das Hinzufügen zu einer Gruppe ist blockiert, aber das Abbilden desselben Composites auf die Gruppe (oder auf einen Benutzer/Client) wird nicht durchlaufen und liefert 204.