
재현 가능한 Docker 랩 + PoC: 복합 역할을 통한 Keycloak manage-users -> realm-admin 권한 상승 (CVE-2026-94000 변종). 26.8.1에서 검증됨.
Keycloak의 관리자 REST API에서 권한 상승 경로에 대한 재현 가능한 Docker 랩 및 PoC: 오직 manage-users 만 보유한 위임된 관리자가 이름은 평범하지만 자식에 realm-management/realm-admin이 포함된 복합 역할을 매핑함으로써 자기 자신(또는 임의의 사용자나 그룹)을 완전한 realm-admin 으로 승격시킬 수 있습니다.
공식 이미지 quay.io/keycloak/keycloak:26.8.1에 대해 엔드투엔드로 확인되었습니다.
⚠️ 승인된 연구 전용입니다. 모든 것은
localhost의 일회용 Keycloak에 대해 실행됩니다. 명시적으로 테스트 권한을 받지 않은 시스템에 PoC를 향하게 하지 마십시오.
Keycloak 26.8.1은 그룹 가입을 강화했습니다(CVE-2026-94000): manage-users 관리자는 이제 전이적으로 realm-management 관리자 역할을 부여하는 그룹에 사용자를 추가할 수 없습니다.
GroupUtils.checkAdminGroupRoles는 그룹의 역할을 재귀적으로 순회하며 중첩된 모든 관리자 역할에 대해 requireMapRole을 호출합니다:
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));
}
동일한 재귀적 탐색이 역할 매핑 엔드포인트에는 추가되지 않았습니다. 역할이 사용자나 그룹에 직접 매핑될 때, 서버는 게시된 역할만 확인합니다:
// 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);
}
그리고 권한 게이트 자체는 역할의 이름을 기준으로 판단하며, 그 내용물을 기준으로 하지 않습니다. RolePermissionsV2.canMapRole에서 역할은 그 컨테이너가 realm-management/master이고 그 이름이 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;
...
}
그래서 realm-management/realm-admin을 감싸는 sneaky-composite라는 이름의 컴포지트는 게이트를 통과합니다. 그 이름은 ALL_ROLES에 없고, 그 자식들은 결코 순회되지 않습니다. 이 격차는 세 개의 엔드포인트에 걸쳐 있습니다:
RoleMapperResource.addRealmRoleMappings — 사용자 및 그룹ClientRoleMappingsResource.addClientRoleMapping — 모든 클라이언트의 클라이언트 역할순 효과: 권한 있는 페이로드(realm-admin을 감싸는 컴포지트)는 단지 존재하기만 하면 됩니다 — 실제 관리자, realm 가져오기, 또는 이전 프로세스에 의해 생성된 것입니다. 그런 다음 manage-users가 이를 할당할 수 있으며, 이는 CVE-2026-94000 위협 모델이 결코 의도하지 않은 것입니다.
| # | 변형 | 작동하는 이유 |
|---|---|---|
| A | sneaky-composite → realm-admin 자기 매핑 | 이름 게이트가 컴포지트를 놓침 |
| B | 중첩된 outer-sneaky(→ sneaky-composite → realm-admin)를 사용자에 매핑 | 깊이가 확인되지 않음 |
| C | 비관리자 클라이언트의 클라이언트 역할 매핑(spa/hidden-ra → realm-admin) | ClientRoleMappingsResource가 동일한 게이트를 사용함 |
| D | 이미 구성원이 있는 그룹에 컴포지트를 매핑 | join과 다른 API; 기존 구성원은 join 없이 권한 상승 |
요구 사항: Docker + Docker Compose v2, 그리고 호스트의 curl + jq.
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
원샷 bootstrap 컨테이너는 admin REST API를 통해 시나리오를 구축한 뒤 종료됩니다:
| 객체 | 용도 |
|---|---|
limited-admin / limitedpass | 공격자 — manage-users만 보유 |
alice / bob / carol / dave | 피해 사용자 |
sneaky-composite (realm 역할) | 컴포지트 → realm-management/realm-admin |
outer-sneaky (realm 역할) | 컴포지트 → sneaky-composite (한 단계 추가) |
spa 클라이언트 + spa/hidden-ra 역할 | 비관리자 클라이언트 역할 → realm-admin |
confidential 클라이언트 (confsecret) | 시크릿 읽기 영향 대상 |
helpdesk 그룹 (alice가 구성원) | 그룹 매핑 공격 대상 |
PoC는 manage-users 호출자가 실제로 할 수 있는 방식으로 역할 ID를 확인합니다 — 즉
.../role-mappings/.../available 엔드포인트를 통해 — 해당 계정은
GET /roles/{name} 및 GET /clients가 거부(403)되지만 available 목록은 그렇지 않기 때문입니다. 이는
스크립트를 정직한 저권한 공격자 관점의 재현으로 유지합니다.
랩을 패치된 빌드로 지정하세요; 수정된 서버는 일반적인 비관리자 역할 매핑은 여전히 허용하면서 모든 매핑 단계에서 403을 반환해야 합니다:
KC_VERSION=<patched-tag> docker compose up -d --force-recreate
make check # expect: NOT vulnerable
quay.io/keycloak/keycloak:26.8.1 대상 (전체 기록은 docs/demo.txt, 세부 사항은 VALIDATION.md):
| 단계 | 동작 | 결과 |
|---|---|---|
| 기준선 | limited-admin POST /roles | 403 (실제로 낮은 권한) |
| check | sneaky-composite를 자기 자신에 매핑 (이후 매핑 해제) | 204 → 취약 |
| A | sneaky-composite를 자기 자신에 매핑 → realm-admin → 역할 생성 / 사용자 이메일 덤프 | 204 / 201 / 200 |
| B | outer-sneaky를 bob에 중첩 → bob이 역할 생성 | 204 / 201 |
| C | spa/hidden-ra를 carol에 매핑 → carol이 역할 생성 | 204 / 201 |
| D | 래퍼를 helpdesk에 매핑 → 기존 멤버 alice가 역할 생성 | 204 / 201 |
| D 대조 | manage-users가 새 멤버를 helpdesk에 가입시킴 | 403 (CVE-2026-94000 수정 유지) |
D 대조는 비대칭성을 입증한다: 그룹 가입은 차단되지만, 동일한 컴포지트를 그룹(또는 사용자/클라이언트)에 매핑하는 것은 순회되지 않고 204를 반환한다.
RolePermissionsV2.canMapRole / RolePermissions.checkAdminRoles에서 호출자가 모든 중첩된 관리자 역할을 매핑할 수 있는 경우가 아니라면 AdminRoles.isAdminRoleOrComposite(role)이 true일 때 역할을 거부하도록 한다 — GroupUtils.requireMapRoleRecursive를 그대로 따른다.RoleMapperResource.addRealmRoleMappings (사용자 및 그룹)와 ClientRoleMappingsResource.addClientRoleMapping에 대해 해당 순회를 실행한다.manage-users가 sneaky-composite, 중첩 래퍼, realm-admin을 컴포지트하는 비관리자 클라이언트 역할, 또는 멤버가 있는 그룹에 래퍼를 매핑할 수 없음을 검증하는 테스트 커버리지를 추가한다. 기존 그룹 가입 테스트만으로는 충분하지 않다.전체 기술 보고서: report/report.md.
docker-compose.yml Keycloak + one-shot bootstrap container
bootstrap/setup-realm.sh Builds the vulnerable realm via the admin REST API
poc/kc_composite_maprole_poc.sh Detection + exploitation + cleanup PoC (curl/jq)
report/report.md Full technical write-up
VALIDATION.md / docs/demo.txt Verified run evidence
MIT — LICENSE 참조.