
Laboratório Docker reproduzível + PoC: escalonamento de Keycloak manage-users -> realm-admin via roles compostas (variante CVE-2026-94000). Validado na versão 26.8.1.
Um laboratório Docker reproduzível e PoC para um caminho de escalada de privilégios na API REST de administração do Keycloak: um administrador delegado que possui apenas manage-users pode escalar a si mesmo (ou qualquer usuário ou grupo) para realm-admin completo, mapeando um papel composto cujo nome é comum, mas cujos filhos incluem realm-management/realm-admin.
Confirmado de ponta a ponta com a imagem oficial quay.io/keycloak/keycloak:26.8.1.
⚠️ Apenas para pesquisa autorizada. Tudo é executado contra um Keycloak descartável em
localhost. Não aponte o PoC para sistemas que você não está explicitamente autorizado a testar.
O Keycloak 26.8.1 endureceu a entrada em grupos (CVE-2026-94000): um administrador com manage-users
não pode mais adicionar um usuário a um grupo que, de forma transitiva, concede papéis de administração de realm-management.
GroupUtils.checkAdminGroupRoles percorre os papéis do grupo recursivamente e chama
requireMapRole em cada papel administrativo aninhado:
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));
}
A mesma travessia recursiva nunca foi adicionada aos endpoints de mapeamento de funções. Quando uma função é mapeada diretamente para um usuário ou grupo, o servidor verifica apenas a função enviada:
// 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 o próprio filtro de privilégios baseia-se no nome da role, não no seu conteúdo. Em
RolePermissionsV2.canMapRole, uma role só é considerada privilegiada quando o seu container é
realm-management/master e o seu nome está em 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;
...
}
Assim, um composite chamado sneaky-composite que envolve realm-management/realm-admin passa
pelo filtro — seu nome não está em ALL_ROLES, e seus filhos nunca são percorridos. A lacuna
abrange três endpoints:
RoleMapperResource.addRealmRoleMappings — usuários e gruposClientRoleMappingsResource.addClientRoleMapping — roles de cliente em qualquer clienteEfeito líquido: o payload privilegiado (um composite que envolve realm-admin) só precisa
existir — criado por um admin real, uma importação de realm ou um processo anterior. manage-users
pode então atribuí-lo, o que o modelo de ameaça da CVE-2026-94000 nunca pretendeu.
| # | Variante | Por que funciona |
|---|---|---|
| A | Auto-mapear sneaky-composite → realm-admin | o filtro de nome não detecta o composite |
| B | Mapear outer-sneaky aninhado (→ sneaky-composite → realm-admin) em um usuário | a profundidade não é verificada |
| C | Mapear uma role de cliente em um cliente não-admin (spa/hidden-ra → realm-admin) | ClientRoleMappingsResource usa o mesmo filtro |
| D | Mapear o composite em um grupo que já possui membros | API diferente da de join; membros existentes escalam sem nenhum join |
Requisitos: Docker + Docker Compose v2, e curl + jq no 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
O contêiner one-shot bootstrap constrói o cenário via API REST de administração e, em seguida, encerra:
| Objeto | Finalidade |
|---|---|
limited-admin / limitedpass | Atacante — possui apenas manage-users |
alice / bob / carol / dave | Usuários vítimas |
sneaky-composite (role de realm) | Composta → realm-management/realm-admin |
outer-sneaky (role de realm) | Composta → sneaky-composite (um salto extra) |
cliente spa + role spa/hidden-ra | Role de cliente não-admin → realm-admin |
cliente confidential (confsecret) | Alvo de impacto de leitura de segredo |
grupo helpdesk (alice é membro) | Alvo do ataque de mapeamento de grupo |
O PoC resolve os IDs de role da forma como um chamador com manage-users realmente consegue — por meio dos endpoints .../role-mappings/.../available — porque essa conta tem acesso negado a GET /roles/{name} e GET /clients (403), mas não às listas de disponíveis. Isso mantém o script como uma reprodução honesta da perspectiva de um atacante com baixos privilégios.
Aponte o laboratório para uma build corrigida; um servidor corrigido deve retornar 403 em cada etapa de mapeamento, enquanto ainda permite o mapeamento comum de roles não-admin:
KC_VERSION=<patched-tag> docker compose up -d --force-recreate
make check # expect: NOT vulnerable
Contra quay.io/keycloak/keycloak:26.8.1 (transcrição completa em docs/demo.txt,
detalhes em VALIDATION.md):
| Passo | Ação | Resultado |
|---|---|---|
| Linha de base | limited-admin POST /roles | 403 (genuinamente de baixo privilégio) |
| verificação | mapear sneaky-composite sobre si mesmo (depois desmapear) | 204 → VULNERÁVEL |
| A | auto-mapear sneaky-composite → realm-admin → criar role / extrair e-mails de usuários | 204 / 201 / 200 |
| B | aninhar outer-sneaky sobre bob → bob cria role | 204 / 201 |
| C | spa/hidden-ra sobre carol → carol cria role | 204 / 201 |
| D | mapear wrapper sobre helpdesk → membro existente alice cria role | 204 / 201 |
| Controle D | manage-users adiciona um novo membro a helpdesk | 403 (correção do CVE-2026-94000 intacta) |
O controle D comprova a assimetria: a adição ao grupo é bloqueada, mas mapear o mesmo composite sobre o grupo (ou sobre um usuário/cliente) não é percorrido e retorna 204.