Research/keycloak-manage-users-composite
0-dayNo CVEMediumPublic

Keycloak 26.8.1, manage-users maps a composite admin role

Keycloak 26.8.1 RolePermissionsV2.canMapRole does not walk composites. A manage-users admin can map a pre-built non-admin composite that includes manage-realm onto himself and then read the full realm document. Sibling of CVE-2026-94000 on the role-mapping POST. Labbed. No CVE yet.

Name
Keycloak 26.8.1, manage-users maps a composite admin role
Type
0-day analysis
CVE
n/a
CVE Risk
medium
Disclosure Status
public
Vendor
Red Hat
Affected
Keycloak 26.8.1 (24ed1dad). Image quay.io/keycloak/keycloak:26.8.1. Needs manage-users plus a pre-existing composite of manage-realm or realm-admin. FGAP v2 is the default evaluator.
Published
03 Nov 2026
Updated
03 Nov 2026
Tags
0-day, keycloak, auth, privilege-leftover, cwe-269, cwe-266, authenticated

The job title was not supposed to include the keys.

I am @abraxas_null. The proof of concept is on GitHub: abraxas/keycloak-manage-users-composite (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py. Authorized lab only. It talks to loopback.

This is Keycloak 26.8.1 (24ed1dadf3f246a555adb6d3d4a99e9b3003fb3b). No CVE yet. CWE-269 / CWE-266. 6.6 Medium (CVSS:3.1/AV:N/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:H). Remote auth leftover. Authenticated Admin REST. Same shape as CVE-2026-94000.

A help-desk account that is only supposed to reset passwords and create users can promote itself. Someone with Keycloak's manage-users role finds a leftover job-title in the realm (a composite role that quietly includes manage-realm), pins that title on their own account, and then reads the full realm document that was hidden from them a request earlier. They cannot mint that composite. A real realm administrator has to have built it first.

This is not anonymous. This is not host RCE. Direct assignment of a role named manage-realm is still refused.

Why this leftover, after that CVE

CVE-2026-94000 taught Keycloak to walk composites on group join. GroupUtils.checkAdminGroupRoles looks at nested admin roles before a manage-users admin can join a group that carries them. UsersResource.createUser does the same. The role-mapping POST does not call that walk.

I sat on RoleMapperResource.addRealmRoleMappings because that is the other way you attach a role to a user. If the group path learned to walk and the mapping path did not, the leftover is the mapping path.

Pinning 26.8.1

Same pin as the SAML leftover: 26.8.1, commit 24ed1dad, image quay.io/keycloak/keycloak:26.8.1. Fine-grained admin permissions v2 is Type.DEFAULT. Profile.isEnabledByDefault turns that type on. AdminPermissions.evaluator then uses MgmtPermissionsV2, whose roles() constructs RolePermissionsV2. You do not have to turn FGAP on. A default install is already on v2.

The skip list

Same public 26.8.1 CVE and weakness batch as the SAML post. I did not refile CVE-2026-94000. I did not refile #53417 (token role stripping). I did not treat realm-admin doing realm-admin work as a leftover.

canMapRole does not walk

RolePermissionsV2.canMapRole returns true for every role that fails isRealmAdminRole. It never walks composites. UserPermissionsV2.canMapRoles is true for manage-users. RoleMapperResource.addRealmRoleMappings then grantRole. The same check is ClientRoleMappingsResource.addClientRoleMapping.

The next admin request treats the caller as holding the nested admin role. UserAdapter.hasRole walks composites. security-admin-console and admin-cli are created with full scope, so ClientAdapter.hasScope is true and KeycloakIdentity.hasClientRole trusts the user model.

He cannot create that composite himself. canMapComposite on an admin role still calls checkAdminRoles. Direct assignment of a role whose name is in AdminRoles.ALL_ROLES on realm-management is still refused. RolePermissionsV2.isRealmAdminRole is wider than the v1 helper. I wasted a pass on v1. v2 overrides it.

A stock realm does not ship the composite. Someone with real manage-realm has to have created it, the same setup the CVE-2026-94000 group test builds. Help-desk realms collect leftover titles. That is the precondition.

What an attacker can do

Needs an existing Keycloak account that already holds manage-users, and a realm or client role whose own name is not an admin role and whose composites include manage-realm or realm-admin.

Privilege Method / sink Recon / integrity / availability Lab-proven or inferred
Map a non-admin composite onto self POST /admin/realms/{realm}/users/{id}/role-mappings/realm Integrity (role grant) Lab-proven. HTTP 204. Witness kcr02m7q4
Same grant on a client role POST /admin/realms/{realm}/users/{id}/role-mappings/clients/{client} Integrity Source. Same canMapRole
Read the full realm document on the same access token GET /admin/realms/{realm} after the mapping Recon Lab-proven. Before: HTTP 200 partial (no attributes, no accessTokenLifespan). After: HTTP 200 full, planted attribute present
Use nested manage-realm on later admin calls UserAdapter.hasRole walks composites Integrity Inferred from source. Lab oracle is the full realm GET
Nested realm-admin when that is the composite Same walk Integrity Inferred. Lab used manage-realm
Negative control A second manage-users user who does not map the composite n/a Lab-proven. Control GET stays partial

Stays closed on this leftover

Axis Method / sink Why it is closed
Integrity Direct map of manage-realm / realm-admin isRealmAdminRole still refuses
Integrity Create the composite that includes an admin role canMapComposite still calls checkAdminRoles
Integrity Join a group whose composite includes an admin role CVE-2026-94000
Integrity Create a user already in that group Same walk on UsersResource.createUser
Recon Anonymous Admin REST Needs a manage-users token
Integrity Host RCE Out of class

GET /admin/realms/{realm} is not HTTP 403 for manage-users. That role is itself an admin role, so the server returns 200 with a partial document. I expected 403. Live result is the partial 200. The leftover is the fields that appear only after the composite is mapped: attributes, accessTokenLifespan, the planted witness.

The lab

Stock quay.io/keycloak/keycloak:26.8.1 on 127.0.0.1:18082. Bootstrap admin creates realm lab with a planted attribute, creates sneaky-composite whose only composite is realm-management / manage-realm, and grants two users only manage-users. The limited user POSTs the composite onto himself. HTTP 204. The same access token then reads the full realm document. The control user stays on the partial document. Image label and serverinfo are both 26.8.1.

Plain text
IOC image=quay.io/keycloak/keycloak:26.8.1 label-version=26.8.1
IOC serverinfo-version=26.8.1
IOC admin-composite=manage-realm
IOC limited-direct=manage-users
IOC before-status=200 witness=0 full=0
IOC available=sneaky-composite
IOC post-status=204
IOC after-get attempt=1 status=200 witness=1 full=1
IOC control-status=200 witness=0 full=0 same-token=1
SUCCESS KC-H02-ROLEMAP kcr02m7q4

Wrong turns already recorded: treating v1 isRealmAdminRole as the live check; expecting HTTP 403 on the realm GET before the grant; re-filing CVE-2026-94000; a reverse shell. Theatre.

The fix

Walk composites in RolePermissionsV2.canMapRole the way GroupUtils.checkAdminGroupRoles already does for group join. A role that includes manage-realm or realm-admin is an admin role even when its own name is not.

Until then, do not leave a composite of manage-realm sitting in a realm that has help-desk manage-users accounts. The group path learned. The mapping path did not.

I am @abraxas_null. Site abraxaslabs.tech. GitHub abraxas. Mail abraxas.null@proton.me.