一个可复现的 Docker 实验环境和 PoC,用于演示 Keycloak 管理 REST API 中的一条权限提升路径:一个仅持有 manage-users 的委派管理员可以通过映射一个复合角色,将自身(或任意用户或组)提升为完整的 realm-admin,该复合角色的名称很普通,但其子角色包含 realm-management/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;
...
}
因此,一个名为 sneaky-composite、包装了 realm-management/realm-admin 的复合角色能够通过这道校验——它的名称不在 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 | 将该复合角色映射到一个已有成员的组上 | 与加入操作使用不同的 API;现有成员无需任何加入操作即可提权 |
要求: 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(确为低权限) |
| 检查 | 将 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。