# Variant RCA Report — CVE-2026-11800 (Keycloak JWT Algorithm Confusion)

## Summary

A distinct **alternate trigger** (not a bypass) for CVE-2026-11800 was found and
reproduced end-to-end. The original CVE is JWT algorithm confusion in the **JWT
Authorization Grant** flow (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer` +
`assertion`), gated by `AbstractBaseJWTValidator.validateSignatureAlgorithm()` (the fix's
Layer 1) and `HardcodedPublicKeyLoader` (Layer 2). The variant reaches the **same root
cause / sink** — `HardcodedPublicKeyLoader`'s HMAC branch on a hardcoded RSA public key —
from a **different entry point that the fix's Layer 1 does not cover**: the **OIDC
external-internal token exchange** (RFC 8693,
`grant_type=urn:ietf:params:oauth:grant-type:token-exchange` with
`subject_token_type=urn:ietf:params:oauth:token-type:jwt` and `subject_issuer=<oidc-idp>`).
An attacker who knows an OIDC Identity Provider's RSA public key (inherently public) forges
an HS256 `subject_token` signed with the RSA public key DER bytes as the HMAC secret and
exchanges it for a Keycloak access token for any federated user linked to that IdP. Tested
against `quay.io/keycloak/keycloak:26.6.3` (vulnerable — HTTP 200, access token issued for
the impersonated federated user `basic-user`) and `quay.io/keycloak/keycloak:26.6.4`
(fixed — HTTP 400 `invalid_token`). The variant is **covered by the fix's Layer 2 only**
(the `HardcodedPublicKeyLoader` HMAC-branch removal); the fix's primary algorithm-type gate
(Layer 1) is never invoked on this path. This is therefore an alternate trigger, **not a
bypass**: the released fix (26.6.4) rejects the forged token.

## Fix Coverage / Assumptions

- **Invariant the fix relies on:** attacker-controlled JWTs verified against an IdP's
  hardcoded public key either (a) pass through `AbstractBaseJWTValidator.validateSignatureAlgorithm()`
  which rejects symmetric/`none` algorithms, or (b) are verified by a key loader that can
  no longer produce an HMAC secret from a public-key config.
- **Paths explicitly covered (Layer 1):** JWT Authorization Grant
  (`DefaultJWTAuthorizationGrantValidator`) and all JWT client authenticators
  (`JWTClientValidator`, `JWTClientSecretValidator`, `FederatedJWTClientValidator`,
  `IDJWTAuthorizationGrantValidator`) — every `AbstractBaseJWTValidator` subclass.
- **What the fix does NOT cover (Layer 1):** `OIDCIdentityProvider.verifySignature()` and
  the external token-exchange / broker ID-token validation paths, which call
  `PublicKeyStorageManager.getIdentityProviderKeyWrapper()` → `HardcodedPublicKeyLoader`
  directly with the JWT header's algorithm and perform **no algorithm-type allow-list
  check**. These paths are defended **only** by Layer 2 (HMAC-branch removal).

## Variant / Alternate Trigger

**Entry point:** `POST /realms/{realm}/protocol/openid-connect/token` with
`grant_type=urn:ietf:params:oauth:grant-type:token-exchange`,
`subject_token_type=urn:ietf:params:oauth:token-type:jwt`,
`subject_issuer=<oidc-idp-alias>`, `requested_token_type=urn:ietf:params:oauth:token-type:access_token`,
`subject_token=<forged HS256 JWT>`, plus `client_id`/`client_secret` of a client authorized
(via v1 FGAP token-exchange permission) to exchange to that IdP.

**Code path:**
`TokenExchangeGrantType.process()` → `V1TokenExchangeProvider.tokenExchange()` →
`isExternalInternalTokenExchangeRequest()`/`exchangeExternalToken()` →
`OIDCIdentityProvider.exchangeExternal()` → `exchangeExternalImpl()` →
`validateJwt()` → `validateToken(subjectToken, ignoreAudience=true)` → `verify(jws)` →
`verifySignature(jws)` → `PublicKeyStorageManager.getIdentityProviderKeyWrapper()` →
`HardcodedPublicKeyLoader(kid, publicKeySignatureVerifier, alg=HS256)` →
(on vulnerable) HMAC branch: `Base64Url.decode(base64PubKey)` → RSA public key DER bytes →
HMAC `SecretKey` → `MacSignatureVerifierContext.verify()` succeeds.

Files / functions:
- `services/.../protocol/oidc/tokenexchange/V1TokenExchangeProvider.java` (`tokenExchange`,
  `exchangeExternalToken`)
- `services/.../broker/oidc/OIDCIdentityProvider.java` (`exchangeExternalImpl`,
  `validateJwt`, `validateToken`, `verify`, `verifySignature`)
- `services/.../keys/loader/PublicKeyStorageManager.java` (`getIdentityProviderKeyWrapper`)
- `services/.../keys/loader/HardcodedPublicKeyLoader.java` (removed HMAC branch = the fix)

**Other candidates considered and ruled out:**
- **Federated JWT client authentication** (`clientAuthenticatorType=federated-jwt`,
  `CLIENT_AUTH_FEDERATED` feature, default-enabled): same sink, but it goes through
  `FederatedJWTClientValidator` → `AbstractBaseJWTValidator.validateSignatureAlgorithm()`
  (Layer 1 covers it) and additionally its `DefaultClientAssertionStrategy` issuer/client
  lookup did not resolve in 26.6.3 dev mode in our test environment, so it could not be
  exercised end-to-end; regardless it is Layer-1-covered.
- **OIDC broker ID-token validation** with a hardcoded key: same `verifySignature` sink,
  but the ID token arrives server-to-server from the upstream IdP's token endpoint (not
  directly attacker-controlled), so it does not cross the relevant trust boundary for an
  external attacker and is not a practical variant.
- **`private_key_jwt` client auth** (`ClientPublicKeyLoader`): no HMAC branch ever existed,
  so not vulnerable to this confusion even pre-fix; and Layer 1 covers it post-fix.
- **JWKS-URL / JWKS-string key configs** (`OIDCIdentityProviderPublicKeyLoader`): the JWKS
  is admin/IdP-controlled, not attacker-controlled, so an `oct` key cannot be injected by
  the attacker; HS256 against an RSA JWKS key fails to verify on both versions.

## Impact

- **Package/component affected:** `org.keycloak:keycloak-services` —
  `OIDCIdentityProvider` (external token exchange), `HardcodedPublicKeyLoader`,
  `PublicKeyStorageManager`; same vulnerable sink as the original CVE.
- **Affected versions (as tested):** Red Hat Build of Keycloak / upstream Keycloak
  **26.6.3** (vulnerable). **26.6.4** rejects (fixed via Layer 2).
- **Risk level:** High (same class as the parent CVE; CVSS 8.1 base per GHSA-gqj5-2xp5-3qmp).
- **Consequences:** An attacker with valid client credentials for a client authorized to
  exchange to a vulnerable OIDC IdP (hardcoded public key, `validateSignature=true`) can
  forge an HS256 `subject_token` and obtain a valid Keycloak access token for **any
  federated user** linked to that IdP — federated-user impersonation / privilege
  escalation, identical impact class to the JWT Authorization Grant variant.

## Impact Parity

- **Disclosed/claimed maximum impact (parent CVE):** impersonate any federated user linked
  to the affected Identity Provider; unauthorized access / privilege escalation.
- **Reproduced impact from this variant run:** Full end-to-end — forged HS256
  `subject_token` submitted via external token exchange; vulnerable 26.6.3 returned HTTP 200
  with a Bearer `access_token` whose decoded payload is
  `sub=<basic-user uuid>`, `azp=te-client`, `iss=http://localhost:8080/realms/test-realm`,
  `preferred_username=basic-user` — i.e. a token minted for the impersonated federated user
  `basic-user`, issued for the attacker's client `te-client`.
- **Parity:** `full` — same authentication-bypass / federated-user-impersonation impact as
  the parent CVE, demonstrated through the real Keycloak token endpoint.
- **Not demonstrated:** No RCE/memory-corruption (not applicable to this class); no
  demonstration of post-impersonation lateral movement beyond token issuance.

## Root Cause

`HardcodedPublicKeyLoader` (vulnerable versions) contains an HMAC branch keyed on the JWT
header's attacker-controlled `alg`: when `alg` is HS256/HS384/HS512 and the IdP is
configured with `useJwksUrl=false` + `publicKeySignatureVerifier` set to the **base64
content** of an RSA public key (no PEM headers), `Base64Url.decode(encodedKey)` yields the
exact DER-encoded `SubjectPublicKeyInfo` bytes — the same bytes as
`RSAPublicKey.getEncoded()` — which become the HMAC `SecretKey`. Because the RSA public key
is inherently public, an attacker can sign an HS256 JWT with those bytes and the signature
verifies. The OIDC external token-exchange path feeds the attacker's `subject_token`
directly into `OIDCIdentityProvider.verifySignature()` → `HardcodedPublicKeyLoader` **with
no algorithm-type allow-list check** (Layer 1 is absent on this path), so on the vulnerable
version the forged token is accepted and a federated-user access token is minted.

Fix commit: `d343c7373dcc7bfeb9dc14de2309836299c81837`
(https://github.com/keycloak/keycloak/pull/50374). The fix removes the HMAC branch (Layer 2)
and adds the algorithm-type gate to `AbstractBaseJWTValidator` (Layer 1). Layer 2 closes
this variant on 26.6.4 (the loader returns `null` for HS256 → `verifySignature` fails →
`invalid_token`); Layer 1 does not run on this path.

## Reproduction Steps

1. **Reference:** `bundle/vuln_variant/reproduction_steps.sh` (driver) +
   `bundle/vuln_variant/exploit_variant.py` (embedded exploit).
2. **What the script does:**
   - Pulls `quay.io/keycloak/keycloak:26.6.3` (vulnerable) and `:26.6.4` (fixed).
   - Starts **both** Keycloak instances in dev mode with
     `--features=token-exchange,admin-fine-grained-authz:v1 --hostname=localhost --http-enabled=true`
     (`token-exchange` v1 enables external-internal exchange; `admin-fine-grained-authz:v1`
     enables the v1 FGAP token-exchange permission used to authorize the test client — a
     legitimate admin config, not a bypass).
   - Starts a Python client container (`requests` + `cryptography`).
   - For each Keycloak instance:
     - Creates `test-realm`; generates a 2048-bit RSA key pair; creates an **OIDC** IdP
       (`providerId=oidc`) with `useJwksUrl=false`,
       `publicKeySignatureVerifier=<base64 of RSA public key DER>`,
       `validateSignature=true`, `disableTypeClaimCheck=true`, `syncMode=FORCE`.
     - Creates a federated user `basic-user` linked to the IdP (`userId=basic-user-id`).
     - Creates a confidential client `te-client` and grants it the v1 FGAP
       `token-exchange` permission to the IdP (enable IdP management permissions → create a
       `client` policy listing `te-client` → bind it to the IdP `token-exchange` scope
       permission).
     - Forges an HS256 `subject_token` (`iss=https://external-idp-issuer`,
       `sub=basic-user-id`, `aud=https://external-idp-issuer`, `typ=Bearer`) signed with
       HMAC-SHA256 using the RSA public key DER bytes (294 bytes) as the secret.
     - POSTs the external token exchange and records the HTTP status/body and any
       `access_token`.
   - Captures Keycloak server logs, image build metadata, and writes
     `runtime_manifest.json`; cleans up containers/network.
3. **Expected evidence:**
   - `logs/vuln_variant/vulnerable/response_status.txt` = `HTTP 200`;
     `logs/vuln_variant/vulnerable/access_token.json` contains a Bearer access token whose
     decoded payload has `preferred_username=basic-user`, `azp=te-client`.
   - `logs/vuln_variant/fixed/response_status.txt` = `HTTP 400`;
     `logs/vuln_variant/fixed/response_body.json` =
     `{"error":"invalid_token","error_description":"invalid token"}`.
   - `logs/vuln_variant/verdict.txt` = `alternate_trigger`.

## Evidence

- `bundle/logs/vuln_variant/reproduction_steps.log` — driver log
- `bundle/logs/vuln_variant/exploit_run.log` — exploit stdout (both versions)
- `bundle/logs/vuln_variant/vulnerable/{exploit.log,forged_subject_token.txt,request.txt,response_status.txt,response_body.json,access_token.json}`
- `bundle/logs/vuln_variant/fixed/{exploit.log,forged_subject_token.txt,request.txt,response_status.txt,response_body.json}`
- `bundle/logs/vuln_variant/{vuln_keycloak.log,fixed_keycloak.log,image_metadata.txt}`

**Key excerpts (run 1):**
```
Vulnerable (26.6.3):
  Forged HS256 subject_token payload: {"iss":"https://external-idp-issuer","sub":"basic-user-id","aud":"https://external-idp-issuer","typ":"Bearer",...}
  HMAC secret = RSA public key DER bytes (294 bytes)
  Response status: 200
  *** vulnerable: forged HS256 subject_token ACCEPTED! Access token received. ***
  Token sub: 16391078-5822-47ec-8f53-d15bf771c5f6
  Token azp (client): te-client
  Token preferred_username: basic-user

Fixed (26.6.4):
  Response status: 400
  fixed: forged HS256 subject_token REJECTED. Error: invalid_token, Description: invalid token

VERDICT: alternate_trigger
```

**Environment:** Keycloak 26.6.3 / 26.6.4 Docker images (Quarkus, JVM 21),
`--hostname=localhost`, features `token-exchange,admin-fine-grained-authz:v1`; Python 3.12
client with `requests` + `cryptography`; RSA 2048-bit key generated per run; HMAC-SHA256
with RSA public key DER bytes (294) as secret. Docker-in-Docker with a custom bridge network
(container-to-container via container names).

## Recommendations / Next Steps

1. **Extend the algorithm-type gate to the broker `verifySignature` path.** Add an explicit
   allow-list check in `OIDCIdentityProvider.verifySignature()` (and the shared
   `AbstractOAuth2IdentityProvider`/broker verify path) mirroring
   `AbstractBaseJWTValidator.validateSignatureAlgorithm()`: reject `none` and reject
   symmetric algorithms (HS256/HS384/HS512) unless the IdP is explicitly configured to allow
   symmetric verification. This makes external token exchange and broker ID-token
   validation two-layer-defended (Layer 1 + Layer 2) like the JWT Authorization Grant,
   instead of relying solely on `HardcodedPublicKeyLoader` not reintroducing an HMAC branch.
2. **Defense in depth:** consider rejecting symmetric algorithms for any hardcoded-public-key
   IdP at the `PublicKeyStorageManager.getIdentityProviderKeyWrapper()` dispatch level,
   independent of the loader.
3. **Pin `jwtAuthorizationGrantAssertionSignatureAlg` / equivalent** on IdPs and prefer
   JWKS URLs over hardcoded keys where possible.
4. The fix is effective for this variant on 26.6.4 (Layer 2); the recommendation is about
   structural completeness / future-proofing, not an open bypass.

## Additional Notes

- **Bypass vs alternate trigger:** This is an **alternate trigger** (distinct entry point,
  same root cause/sink), **not a bypass**. The released fix (26.6.4) rejects the forged
  token (Layer 2). Per the stage spec, the reproduction script exits 1 (alternate trigger),
  not 0 (bypass).
- **Idempotency:** Verified by running `reproduction_steps.sh` twice consecutively; both
  runs produced identical results (vulnerable HTTP 200 + token; fixed HTTP 400
  `invalid_token`; verdict `alternate_trigger`).
- **Feature prerequisites:** The variant requires `token-exchange` (v1) and
  `admin-fine-grained-authz:v1` to be enabled. In the default 26.6.x image, `TOKEN_EXCHANGE`
  (v1) and `ADMIN_FINE_GRAINED_AUTHZ` (v1) are **disabled** by default (v2 FGAP is
  default-on and does not support token-exchange permission configuration); the script
  enables them explicitly. This is a deployment-config prerequisite (an admin who has
  turned on external token exchange for an IdP), analogous to the original CVE's
  `oauth2.jwt.authorization.grant.enabled` prerequisite — not a weakening of the fix.
- **Audience/type-claim handling:** the forged `subject_token` sets payload `typ=Bearer`
  (one of `OIDCIdentityProvider.SUPPORTED_TOKEN_TYPES`) and the IdP sets
  `disableTypeClaimCheck=true` to pass the post-signature type check; these are payload
  controls, not signature controls, and do not affect the algorithm-confusion root cause.
