Uploaded image for project: 'XWiki Platform'
  1. XWiki Platform
  2. XWIKI-24561

SAML identifier collation bypass in XWiki `SAMLAuthClass.nameid` leading to ATO

    XMLWordPrintable

Details

    • Bug
    • Resolution: Invalid
    • Major
    • None
    • None
    • Authentication
    • Unknown
    • N/A
    • N/A

    Description

      1. SAML identifier collation bypass in XWiki `SAMLAuthClass.nameid` leading to ATO
        1. 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
      ```

        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:

        1. Root Cause Analysis
          1. 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
      ```

          1. 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)))

      {     return false; }

      ...
      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`.

          1. 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`

          1. 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`.

        1. 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.

        1. Reproduction
          1. 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.

          1. 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
      ```

          1. 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.

          1. 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.

        1. 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.

        1. Acknowledge / Credit

      whale120 (@whale120_tw), working with DEVCORE Internship Program

      Attachments

        Activity

          People

            surli Simon Urli
            whale120 LIN, CHIN-YU
            Votes:
            0 Vote for this issue
            Watchers:
            0 Start watching this issue

            Dates

              Created:
              Updated:
              Resolved: