
Laboratorio Docker reproducible + PoC: escalada de Keycloak manage-users -> realm-admin mediante roles compuestos (variante de CVE-2026-94000). Validado en 26.8.1.
Un laboratorio Docker reproducible y PoC para una ruta de escalada de privilegios en la API REST de administración de Keycloak: un administrador delegado que posee solo manage-users puede escalar sus propios privilegios (o los de cualquier usuario o grupo) a realm-admin completo mediante la asignación de un rol compuesto cuyo nombre es ordinario pero cuyos hijos incluyen realm-management/realm-admin.
Confirmado de extremo a extremo contra la imagen oficial quay.io/keycloak/keycloak:26.8.1.
⚠️ Solo investigación autorizada. Todo se ejecuta contra un Keycloak desechable en
localhost. No apuntes el PoC a sistemas que no estés explícitamente autorizado a probar.
Keycloak 26.8.1 endureció la unión a grupos (CVE-2026-94000): un administrador con manage-users
ya no puede añadir un usuario a un grupo que otorgue de forma transitiva roles de administración de realm-management.
GroupUtils.checkAdminGroupRoles recorre los roles del grupo recursivamente y llama a
requireMapRole en cada rol administrativo anidado:
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));
}
El mismo recorrido recursivo nunca se añadió a los endpoints de mapeo de roles. Cuando un rol se mapea directamente a un usuario o grupo, el servidor solo verifica el rol enviado:
// 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);
}
Y la comprobación de privilegios en sí se basa en el nombre del rol, no en su contenido. En
RolePermissionsV2.canMapRole, un rol se considera privilegiado solo cuando su contenedor es
realm-management/master y su nombre está en 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;
...
}
Así que un compuesto llamado sneaky-composite que envuelve realm-management/realm-admin pasa la puerta — su nombre no está en ALL_ROLES, y sus hijos nunca son recorridos. La brecha abarca tres endpoints:
RoleMapperResource.addRealmRoleMappings — usuarios y gruposClientRoleMappingsResource.addClientRoleMapping — roles de cliente en cualquier clienteEfecto neto: la carga útil privilegiada (un compuesto que envuelve realm-admin) solo necesita existir — creada por un administrador real, una importación de realm o un proceso anterior. manage-users puede entonces asignarla, lo que el modelo de amenazas de CVE-2026-94000 nunca contempló.
| # | Variante | Por qué funciona |
|---|---|---|
| A | Auto-mapear sneaky-composite → realm-admin | la puerta por nombre no detecta el compuesto |
| B | Mapear outer-sneaky anidado (→ sneaky-composite → realm-admin) sobre un usuario | no se comprueba la profundidad |
| C | Mapear un rol de cliente en un cliente no administrativo (spa/hidden-ra → realm-admin) | ClientRoleMappingsResource usa la misma puerta |
| D | Mapear el compuesto sobre un grupo que ya tiene miembros | API distinta a la de unión; los miembros existentes escalan sin ninguna unión |
Requisitos: Docker + Docker Compose v2, y curl + jq en el 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
El contenedor bootstrap de una sola ejecución construye el escenario a través de la API REST de administración y luego finaliza:
| Objeto | Propósito |
|---|---|
limited-admin / limitedpass | Atacante — solo posee manage-users |
alice / bob / carol / dave | Usuarios víctima |
sneaky-composite (rol de realm) | Compuesto → realm-management/realm-admin |
outer-sneaky (rol de realm) | Compuesto → sneaky-composite (un salto adicional) |
Cliente spa + rol spa/hidden-ra | Rol de cliente no administrador → realm-admin |
Cliente confidential (confsecret) | Objetivo de impacto de lectura de secretos |
Grupo helpdesk (alice es miembro) | Objetivo del ataque de mapeo de grupos |
La PoC resuelve los IDs de rol de la forma en que un llamador con manage-users realmente puede hacerlo — a través de los endpoints .../role-mappings/.../available — porque a esa cuenta se le deniega GET /roles/{name} y GET /clients (403) pero no las listas de disponibles. Esto mantiene el script como una reproducción honesta desde la perspectiva de un atacante con bajos privilegios.
Apunte el laboratorio a una compilación parcheada; un servidor corregido debe devolver 403 en cada paso de mapeo mientras sigue permitiendo el mapeo ordinario de roles no administradores:
KC_VERSION=<patched-tag> docker compose up -d --force-recreate
make check # expect: NOT vulnerable
Contra quay.io/keycloak/keycloak:26.8.1 (transcripción completa en docs/demo.txt,
detalles en VALIDATION.md):
| Paso | Acción | Resultado |
|---|---|---|
| Línea base | limited-admin POST /roles | 403 (genuinamente de bajo privilegio) |
| check | mapear sneaky-composite sobre sí mismo (luego desmapear) | 204 → VULNERABLE |
| A | automapeo de sneaky-composite → realm-admin → crear rol / volcar correos de usuarios | 204 / 201 / 200 |
| B | outer-sneaky anidado sobre bob → bob crea rol | 204 / 201 |
| C | spa/hidden-ra sobre carol → carol crea rol | 204 / 201 |
| D | mapear el wrapper sobre helpdesk → el miembro existente alice crea rol | 204 / 201 |
| Control D | manage-users une un nuevo miembro a helpdesk | 403 (corrección de CVE-2026-94000 intacta) |
El control D demuestra la asimetría: la unión a un grupo está bloqueada, pero mapear el mismo compuesto sobre el grupo (o sobre un usuario/cliente) no se recorre y devuelve 204.