
Lab Docker riproducibile + PoC: escalation da Keycloak manage-users a realm-admin tramite ruoli compositi (variante CVE-2026-94000). Convalidato su 26.8.1.
Un laboratorio Docker riproducibile e PoC per un percorso di escalation dei privilegi nell'API REST di amministrazione di Keycloak: un amministratore delegato che possiede solo manage-users può elevare se stesso (o qualsiasi utente o gruppo) a realm-admin completo mappando un ruolo composito il cui nome è ordinario ma i cui figli includono realm-management/realm-admin.
Confermato end-to-end sull'immagine ufficiale quay.io/keycloak/keycloak:26.8.1.
⚠️ Solo ricerca autorizzata. Tutto viene eseguito su un Keycloak usa e getta su
localhost. Non puntare il PoC verso sistemi che non sei esplicitamente autorizzato a testare.
Keycloak 26.8.1 ha rafforzato il group join (CVE-2026-94000): un amministratore con manage-users non può più aggiungere un utente a un gruppo che concede transitivamente ruoli di amministrazione realm-management. GroupUtils.checkAdminGroupRoles percorre i ruoli del gruppo ricorsivamente e chiama requireMapRole su ogni ruolo amministrativo annidato:
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));
}
La stessa ricorsione non è mai stata aggiunta agli endpoint di mappatura dei ruoli. Quando un ruolo viene mappato direttamente su un utente o un gruppo, il server controlla solo il ruolo inviato:
// 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);
}
E il controllo dei privilegi stesso si basa sul nome del ruolo, non sul suo contenuto. In
RolePermissionsV2.canMapRole, un ruolo è considerato privilegiato solo quando il suo container è
realm-management/master e il suo nome è presente in AdminRoles.ALL_ROLES:
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;
...
}
Quindi un composite chiamato sneaky-composite che racchiude realm-management/realm-admin supera
il controllo — il suo nome non è in ALL_ROLES, e i suoi figli non vengono mai percorsi. La lacuna
coinvolge tre endpoint:
RoleMapperResource.addRealmRoleMappings — utenti e gruppiClientRoleMappingsResource.addClientRoleMapping — ruoli client su qualsiasi clientEffetto netto: il payload privilegiato (un composite che racchiude realm-admin) deve solo
esistere — creato da un admin reale, da un'importazione di realm o da un processo precedente. manage-users
può quindi assegnarlo, cosa che il modello di minaccia di CVE-2026-94000 non aveva mai previsto.
| # | Variante | Perché funziona |
|---|---|---|
| A | Auto-mappare sneaky-composite → realm-admin | il controllo sul nome non intercetta il composite |
| B | Mappare outer-sneaky annidato (→ sneaky-composite → realm-admin) su un utente | la profondità non viene verificata |
| C | Mappare un ruolo client su un client non-admin (spa/hidden-ra → realm-admin) | ClientRoleMappingsResource usa lo stesso controllo |
| D | Mappare il composite su un gruppo che ha già membri | API diversa dal join; i membri esistenti escalano senza alcun join |
Requisiti: Docker + Docker Compose v2, e curl + jq sull'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
Il container one-shot bootstrap costruisce lo scenario tramite l'API REST di amministrazione, poi termina:
| Oggetto | Scopo |
|---|---|
limited-admin / limitedpass | Attaccante — possiede solo manage-users |
alice / bob / carol / dave | Utenti vittima |
sneaky-composite (ruolo realm) | Composito → realm-management/realm-admin |
outer-sneaky (ruolo realm) | Composito → sneaky-composite (un salto extra) |
client spa + ruolo spa/hidden-ra | Ruolo client non-admin → realm-admin |
client confidential (confsecret) | Obiettivo di impatto lettura-segreti |
gruppo helpdesk (alice è membro) | Obiettivo dell'attacco di mappatura dei gruppi |
Il PoC risolve gli ID dei ruoli nel modo in cui un chiamante con manage-users può effettivamente fare — tramite gli endpoint .../role-mappings/.../available — perché a quell'account è negato l'accesso a GET /roles/{name} e GET /clients (403) ma non alle liste available. Questo mantiene lo script una riproduzione onesta dal punto di vista di un attaccante a basso privilegio.
Punta il lab su una build corretta; un server corretto deve restituire 403 su ogni fase di mappatura pur consentendo ancora la normale mappatura dei ruoli non-admin:
KC_VERSION=<patched-tag> docker compose up -d --force-recreate
make check # expect: NOT vulnerable
Contro quay.io/keycloak/keycloak:26.8.1 (trascrizione completa in docs/demo.txt,
dettagli in VALIDATION.md):
| Passo | Azione | Risultato |
|---|---|---|
| Baseline | limited-admin POST /roles | 403 (privilegi effettivamente bassi) |
| check | mappare sneaky-composite su se stesso (poi rimuovere la mappatura) | 204 → VULNERABILE |
| A | self-map sneaky-composite → realm-admin → creare ruolo / estrarre email utenti | 204 / 201 / 200 |
| B | outer-sneaky annidato su bob → bob crea ruolo | 204 / 201 |
| C | spa/hidden-ra su carol → carol crea ruolo | 204 / 201 |
| D | mappare il wrapper su helpdesk → il membro esistente alice crea ruolo | 204 / 201 |
| Controllo D | manage-users aggiunge un nuovo membro a helpdesk | 403 (fix CVE-2026-94000 intatto) |
Il controllo D dimostra l'asimmetria: l'aggiunta a un gruppo è bloccata, ma la mappatura dello stesso composite sul gruppo (o su un utente/client) non viene percorsa e restituisce 204.