Details
-
Bug
-
Resolution: Invalid
-
Major
-
None
-
None
-
- **Application:** XWiki with `xwiki-contrib/authenticator-saml`
- **XWiki version:** 18.5.0
- **Runtime package:** official Docker image `xwiki:18.5.0-mariadb-tomcat`
- **SAML authenticator revision tested:** `cb806403d75ec287f3ce83e25fd548c442c92590` (`2014-12-19`)
- **Database:** MariaDB 11.8, `xwikistrings.XWS_VALUE` collation `utf8mb4_unicode_ci`
- **XWiki DB version:** `180500000`- **Application:** XWiki with `xwiki-contrib/authenticator-saml` - **XWiki version:** 18.5.0 - **Runtime package:** official Docker image `xwiki:18.5.0-mariadb-tomcat` - **SAML authenticator revision tested:** `cb806403d75ec287f3ce83e25fd548c442c92590` (`2014-12-19`) - **Database:** MariaDB 11.8, `xwikistrings.XWS_VALUE` collation `utf8mb4_unicode_ci` - **XWiki DB version:** `180500000`
-
Unknown
-
N/A
-
N/A
-
Description
- SAML identifier collation bypass in XWiki `SAMLAuthClass.nameid` leading to ATO
-
- Preface
The XWiki SAML authenticator stores the configured SAML user identifier in the custom `XWiki.SAMLAuthClass` object property `nameid`. XWiki string properties are stored in `xwikistrings.XWS_VALUE`; in the tested MariaDB deployment this column used `utf8mb4_unicode_ci`, which is case-insensitive and accent-insensitive. A signed SAML assertion whose configured `uid` attribute was `xwíki1805a0702` therefore resolved to the existing local XWiki account whose stored SAML identifier was `xwiki1805a0702`.
```text
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N 8.1
```
-
- Server Info
- *Application:* XWiki with `xwiki-contrib/authenticator-saml`
- *XWiki version:* 18.5.0
- *Runtime package:* official Docker image `xwiki:18.5.0-mariadb-tomcat`
- *SAML authenticator revision tested:* `cb806403d75ec287f3ce83e25fd548c442c92590` (`2014-12-19`)
- *Database:* MariaDB 11.8, `xwikistrings.XWS_VALUE` collation `utf8mb4_unicode_ci`
- *XWiki DB version:* `180500000`
- *IdP:* Keycloak realm `saml-collation-poc`
- *Access permissions:* Any user able to register or control an account on the connected IdP
- *Test date:* 2026-07-02
*Affected entry point:*
```text
POST /bin/<action>/<space>/<page>
```
The authenticator reads a `SAMLResponse` parameter on the originally requested XWiki URL. In the PoC this was:
```text
POST /bin/admin/XWiki/XWikiPreferences
```
Relevant request properties:
- Authentication: valid SAML assertion signed by the configured IdP
- Trigger: SP-initiated SAML SSO
- Vulnerable field: configured SAML identifier field, tested with the `uid` attribute
- Tested SP issuer: `xwiki-poc`
- Tested ACS URL: `http://xwikipoc.test:18400/bin/admin/XWiki/XWikiPreferences`
-
- Root Cause Analysis
-
-
- part0 — `SAMLAuthClass.nameid` is stored as a weakly-collated XWiki string property
-
The authenticator README requires administrators to create a custom XWiki class:
```text
XWiki.SAMLAuthClass
with field nameid as a string
```
`targets/xwiki-authenticator-saml/readme.txt:4`
The authenticator defines that class and property in code:
```java
private static final String SAML_ID_XPROPERTY_NAME = "nameid";
...
private static final EntityReference SAML_XCLASS = new EntityReference("SAMLAuthClass", EntityType.DOCUMENT,
new EntityReference(XWiki.SYSTEM_SPACE, EntityType.SPACE));
```
`targets/xwiki-authenticator-saml/src/main/java/com/xwiki/authentication/saml/XWikiSAMLAuthenticator.java:160`
On the live XWiki 18.5.0 test instance, that string property was stored in:
```text
Field Type Collation
XWS_VALUE varchar(768) utf8mb4_unicode_ci
```
The database confirmed that the tested identifiers collide under the column collation but not under byte-exact comparison:
```text
weak_equal binary_equal
1 0
```
for:
```text
victim: xwiki1805a0702
attacker: xwíki1805a0702
```
-
-
- part1 — The signed SAML identifier flows into the local lookup
-
The authenticator reads the `SAMLResponse`, base64-decodes it, validates it, and extracts SAML attributes:
```java
String samlResponse = request.getParameter("SAMLResponse");
...
samlResponse = new String(Base64.decode(samlResponse), XWiki.DEFAULT_ENCODING);
...
if (!validateResponse(response, (String) request.getSession().getAttribute(REQUEST_ID_SESSION_KEY)))
...
attributes.put(att.getName(), ((XSStringImpl) val).getValue());
```
`targets/xwiki-authenticator-saml/src/main/java/com/xwiki/authentication/saml/XWikiSAMLAuthenticator.java:438`
The configured identifier field is then selected from the signed SAML attributes and passed to `getLocalUsername()`:
```java
String nameID = attributes.get(getIdFieldName(context));
...
DocumentReference userReference = getLocalUsername(nameID, userData, context);
```
`targets/xwiki-authenticator-saml/src/main/java/com/xwiki/authentication/saml/XWikiSAMLAuthenticator.java:480`
In the PoC configuration:
```text
xwiki.authentication.saml.id_field=uid
```
so Keycloak's signed `uid` attribute became the value stored and later looked up in `XWiki.SAMLAuthClass.nameid`.
-
-
- part2 — Existing users are resolved by SQL equality under the weak collation
-
`getLocalUsername()` searches for an existing SAML binding with a normal equality predicate on `StringProperty.value`:
```java
String sql = "select distinct obj.name from BaseObject as obj, StringProperty as nameidprop "
+ "where obj.className=? and obj.id=nameidprop.id.id and nameidprop.id.name=? and nameidprop.value=?";
List<String> list = context.getWiki().getStore().search(sql, 1, 0,
Arrays.asList(this.compactStringEntityReferenceSerializer.serialize(SAML_XCLASS),
SAML_ID_XPROPERTY_NAME, nameID), context);
```
`targets/xwiki-authenticator-saml/src/main/java/com/xwiki/authentication/saml/XWikiSAMLAuthenticator.java:609`
There is no binary comparison and no byte-exact Java post-filter after the database returns a row. If the database returns a weak-collation match, the first returned document name becomes the authenticated local user:
```java
if (list.size() == 0)
else
{ validUserName = list.get(0); }...
return this.currentMixedDocumentReferenceResolver.resolve(validUserName, PROFILE_PARENT);
```
`targets/xwiki-authenticator-saml/src/main/java/com/xwiki/authentication/saml/XWikiSAMLAuthenticator.java:616`
-
-
- part3 — The matched row becomes the session user
-
For a new victim, the authenticator creates the local XWiki user and stores the SAML identifier:
```java
int result = context.getWiki().createUser(userReference.getName(), userData, PROFILE_PARENT,
content, syntax, "edit", context);
...
BaseObject obj = userDoc.newXObject(SAML_XCLASS, context);
obj.set(SAML_ID_XPROPERTY_NAME, nameID, context);
context.getWiki().saveDocument(userDoc, context);
```
`targets/xwiki-authenticator-saml/src/main/java/com/xwiki/authentication/saml/XWikiSAMLAuthenticator.java:509`
For an existing match, the returned `userReference` is used to update the profile and set the authenticated user in the HTTP session:
```java
context.getWiki().saveDocument(userDoc, context);
...
context.getRequest().getSession().setAttribute(getAuthFieldName(context),
this.compactStringEntityReferenceSerializer.serialize(userReference));
```
`targets/xwiki-authenticator-saml/src/main/java/com/xwiki/authentication/saml/XWikiSAMLAuthenticator.java:537`
Because the lookup accepted a weak-collation match for `xwíki1805a0702`, the attacker was logged in as the victim-created local user `XWiki.user_2`.
-
- Security Impact
An attacker who can register or control an IdP account with a SAML identifier that collides under the database collation can obtain an XWiki session for the victim-created local account without knowing the victim's IdP password.
The impact is account takeover of the matched XWiki user. Profile fields mapped from SAML can also be overwritten during the attacker's login. In the PoC, the local `XWiki.user_2` email changed from the victim email to the attacker email.
-
- Reproduction
-
-
- 1. Start XWiki and configure SAML
-
The PoC used Docker Compose in `poc/compose/xwiki.yml`:
```text
XWiki image: xwiki:18.5.0-mariadb-tomcat
Database image: mariadb:11.8
Host URL: http://xwikipoc.test:18400
IdP SSO URL: http://45.77.20.255:8080/realms/saml-collation-poc/protocol/saml
SP issuer: xwiki-poc
```
The old authenticator was built from `targets/xwiki-authenticator-saml` and installed into `WEB-INF/lib` with its OpenSAML 2 runtime dependencies. The XWiki setup account used for creating the required class was:
```text
username: superadmin
password: XWikiSuperAdminPassw0rd!
```
The required SAML class was created through XWiki's class editor:
```text
document: XWiki.SAMLAuthClass
property: nameid
type: String
```
The SAML settings used by the PoC were:
```text
xwiki.authentication.authclass=com.xwiki.authentication.saml.XWikiSAMLAuthenticator
xwiki.authentication.saml.cert=/WEB-INF/keycloak-saml-collation-poc.crt
xwiki.authentication.saml.authurl=http://45.77.20.255:8080/realms/saml-collation-poc/protocol/saml
xwiki.authentication.saml.issuer=xwiki-poc
xwiki.authentication.saml.namequalifier=xwiki-poc
xwiki.authentication.saml.id_field=uid
xwiki.authentication.saml.fields_mapping=email=email,first_name=firstName,last_name=lastName
xwiki.authentication.saml.xwiki_user_rule=first_name,last_name
xwiki.authentication.saml.xwiki_user_rule_capitalize=0
```
Compatibility note: this old authenticator uses the same `authurl` value for the IdP redirect target and for `AuthnRequest.AssertionConsumerServiceURL`:
```java
authRequest.setAssertionConsumerServiceURL(getSAMLAuthenticatorURL(context));
...
return context.getWiki().Param(CONFIG_KEY_IDP_URL);
```
`targets/xwiki-authenticator-saml/src/main/java/com/xwiki/authentication/saml/XWikiSAMLAuthenticator.java:427`
To make the unmodified old SP interoperate with Keycloak in the lab, the test helper rewrote the unsigned AuthnRequest ACS from the IdP URL to the XWiki callback URL before Keycloak issued the signed SAML response. This did not modify the XWiki lookup/storage code or the signed SAML attributes used for the exploit.
-
-
- 2. Register and bind the victim account
-
Victim account registered through the Keycloak public registration flow:
```text
username: xwiki1805a0702
password: XWikiVictimIdpPassw0rd4!
email: xwiki1805a0702@example.test
```
The signed SAML response contained:
```text
NameID: G-6f21f2ca-a9fe-4ef8-9799-40245846c548
uid attribute: xwiki1805a0702
username attribute: xwiki1805a0702
email attribute: xwiki1805a0702@example.test
```
After the callback, XWiki showed an authenticated session as local user `user_2` on XWiki 18.5.0. The database stored the binding:
```text
XWO_NAME nameid
XWiki.user_2 xwiki1805a0702
```
-
-
- 3. Confirm the attacker's identifier collides but is not byte-identical
-
The database comparison result was:
```text
weak_equal binary_equal
1 0
```
for:
```text
'xwiki1805a0702' = 'xwíki1805a0702'
BINARY 'xwiki1805a0702' = BINARY 'xwíki1805a0702'
```
Before the attacker login, there was no exact attacker `nameid` row.
-
-
- 4. Register the attacker account and log in to XWiki
-
Attacker account registered through the same Keycloak public registration flow:
```text
username: xwíki1805a0702
password: XWikiHackerIdpPassw0rd4!
email: xwikiattacker1805a0702@example.test
```
The signed SAML response contained the attacker's distinct identifier:
```text
NameID: G-a6b9bdad-2a76-49f6-aa0e-36e0fb085ef6
uid attribute: xwíki1805a0702
username attribute: xwíki1805a0702
email attribute: xwikiattacker1805a0702@example.test
```
After the SAML callback, XWiki again showed an authenticated session as local user `user_2` on XWiki 18.5.0.
The post-attack database state confirmed that no byte-exact attacker row existed, but the weak lookup for the attacker's identifier returned the victim-created binding:
```text
exact_attacker_nameid_rows
0
XWO_NAME matched_by_attacker_lookup
XWiki.user_2 xwiki1805a0702
```
The local victim row was also updated with attacker-controlled mapped profile data:
```text
XWO_NAME XWO_CLASSNAME XWS_NAME XWS_VALUE
XWiki.user_2 XWiki.SAMLAuthClass nameid xwiki1805a0702
XWiki.user_2 XWiki.XWikiUsers email xwikiattacker1805a0702@example.test
```
This proves the attacker account did not create or authenticate as a separate local XWiki user. It authenticated into the victim-created row through the accent-insensitive `SAMLAuthClass.nameid` comparison.
-
- Recommended Fix
Identity mapping values must use byte-preserving equality. For this authenticator, the safest design is to store and query a byte-stable external identity key, such as a binary digest of `(issuer, id_field, id_value)`, rather than relying on XWiki's general string-property storage collation.
At minimum, `getLocalUsername()` must not trust the first database row returned by SQL equality under a weak collation. It should either:
- query the backing storage with a binary collation/comparison for the `nameid` value, or
- fetch all candidate rows and keep only rows whose stored `nameid` is exactly equal to the SAML identifier with Java `String.equals()`.
The fix should also enforce exact uniqueness of the SAML binding so that two byte-distinct IdP identifiers cannot resolve to the same local XWiki account.
-
- Acknowledge / Credit
whale120 (@whale120_tw), working with DEVCORE Internship Program