
Lab Docker reproductible + PoC : escalade de privilèges Keycloak manage-users -> realm-admin via rôles composites (variante CVE-2026-94000). Validé sur 26.8.1.
Un laboratoire Docker reproductible et une PoC pour un chemin d'élévation de privilèges dans l'API REST d'administration de Keycloak : un administrateur délégué disposant uniquement de manage-users peut s'élever lui-même (ou n'importe quel utilisateur ou groupe) au statut complet de realm-admin en mappant un rôle composite dont le nom est ordinaire mais dont les enfants incluent realm-management/realm-admin.
Confirmé de bout en bout sur l'image officielle quay.io/keycloak/keycloak:26.8.1.
⚠️ Recherche autorisée uniquement. Tout s'exécute contre un Keycloak jetable sur
localhost. Ne pointez pas la PoC vers des systèmes que vous n'êtes pas explicitement autorisé à tester.
Keycloak 26.8.1 a durci l'adhésion aux groupes (CVE-2026-94000) : un administrateur disposant de manage-users ne peut plus ajouter un utilisateur à un groupe qui accorde de manière transitive des rôles d'administration realm-management. GroupUtils.checkAdminGroupRoles parcourt les rôles du groupe récursivement et appelle requireMapRole sur chaque rôle d'administration imbriqué :
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 même traversée récursive n'a jamais été ajoutée aux endpoints de mappage de rôles. Lorsqu'un rôle est mappé directement sur un utilisateur ou un groupe, le serveur ne vérifie que le rôle soumis :
// 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);
}
Et la barrière de privilèges elle-même se base sur le nom du rôle, et non sur son contenu. Dans
RolePermissionsV2.canMapRole, un rôle n'est considéré comme privilégié que lorsque son conteneur est
realm-management/master et que son nom figure dans 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;
...
}
Ainsi, un composite nommé sneaky-composite qui englobe realm-management/realm-admin franchit
le filtre — son nom n'est pas dans ALL_ROLES, et ses enfants ne sont jamais parcourus. La faille
s'étend sur trois points de terminaison :
RoleMapperResource.addRealmRoleMappings — utilisateurs et groupesClientRoleMappingsResource.addClientRoleMapping — rôles client sur n'importe quel clientEffet net : la charge utile privilégiée (un composite englobant realm-admin) doit seulement
exister — créée par un véritable administrateur, un import de realm, ou un processus antérieur. manage-users
peut alors l'attribuer, ce que le modèle de menace CVE-2026-94000 n'a jamais prévu.
| # | Variante | Pourquoi cela fonctionne |
|---|---|---|
| A | Auto-mapper sneaky-composite → realm-admin | le filtre par nom ne détecte pas le composite |
| B | Mapper outer-sneaky imbriqué (→ sneaky-composite → realm-admin) sur un utilisateur | la profondeur n'est pas vérifiée |
| C | Mapper un rôle client sur un client non-admin (spa/hidden-ra → realm-admin) | ClientRoleMappingsResource utilise le même filtre |
| D | Mapper le composite sur un groupe qui a déjà des membres | API différente de join ; les membres existants sont escaladés sans aucun join |
Prérequis : Docker + Docker Compose v2, ainsi que curl + jq sur l'hôte.
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
Le conteneur bootstrap one-shot construit le scénario via l'API REST d'administration, puis se termine :
| Objet | Objectif |
|---|---|
limited-admin / limitedpass | Attaquant — ne détient que manage-users |
alice / bob / carol / dave | Utilisateurs victimes |
sneaky-composite (rôle de realm) | Composite → realm-management/realm-admin |
outer-sneaky (rôle de realm) | Composite → sneaky-composite (un saut supplémentaire) |
client spa + rôle spa/hidden-ra | Rôle de client non-admin → realm-admin |
client confidential (confsecret) | Cible d'impact de lecture de secret |
groupe helpdesk (alice en est membre) | Cible d'attaque par mappage de groupe |
Le PoC résout les ID de rôles comme un appelant disposant de manage-users le peut réellement — via les
points de terminaison .../role-mappings/.../available — car ce compte se voit refuser
GET /roles/{name} et GET /clients (403) mais pas les listes disponibles. Cela maintient
le script comme une reproduction honnête du point de vue d'un attaquant à faibles privilèges.
Pointer le lab vers une build corrigée ; un serveur corrigé doit renvoyer 403 à chaque étape de mappage tout en autorisant toujours le mappage ordinaire de rôles non-admin :
KC_VERSION=<patched-tag> docker compose up -d --force-recreate
make check # expect: NOT vulnerable
Contre quay.io/keycloak/keycloak:26.8.1 (transcription complète dans docs/demo.txt,
détails dans VALIDATION.md) :
| Étape | Action | Résultat |
|---|---|---|
| Référence | limited-admin POST /roles | 403 (réellement peu privilégié) |
| check | mapper sneaky-composite sur lui-même (puis démapper) | 204 → VULNÉRABLE |
| A | auto-mapper sneaky-composite → realm-admin → créer un rôle / extraire les e-mails des utilisateurs | 204 / 201 / 200 |
| B | outer-sneaky imbriqué sur bob → bob crée un rôle | 204 / 201 |
| C | spa/hidden-ra sur carol → carol crée un rôle | 204 / 201 |
| D | mapper le wrapper sur helpdesk → le membre existant alice crée un rôle | 204 / 201 |
| Contrôle D | manage-users ajoute un nouveau membre à helpdesk | 403 (correctif CVE-2026-94000 intact) |
Le contrôle D prouve l'asymétrie : l'ajout à un groupe est bloqué, mais le mappage du même composite sur le groupe (ou sur un utilisateur/client) n'est pas parcouru et renvoie 204.