← Back to list

SAML Username Collision Leading to Full ATO

I was testing an application that supported both local accounts and SAML-based SSO. Nothing looked obviously broken. Signatures were valid…

M0n3m · 2026-05-29 17:06 · 111 claps · 3.9 min read
#bug-bounty #aml #sso #web-security #pentesting
Open on Medium ↗
Wiki topics: 🔒 · Cybersecurity 🏔️ · Outdoor & Adventure

SAML Username Collision Leading to Full ATO

I was testing an application that supported both local accounts and SAML-based SSO. Nothing looked obviously broken. Signatures were valid. Tokens were properly scoped. The IdP was correctly configured.

And yet, I could log in as the administrator without knowing the password, without triggering MFA, and without touching a single signing key.

Here’s how.

Background: How SAML Authentication Works

SAML is a federated identity protocol. When a user logs in via SSO, the flow looks roughly like this:

  1. User clicks “Login with SSO”
  2. Application redirects them to the Identity Provider (IdP) — Okta, Azure AD, Google Workspace, etc.
  3. IdP authenticates the user and issues a signed XML assertion: ”I confirm this user is uid=bob”
  4. Application receives the assertion, validates the signature, extracts the username attribute, and creates a session

The signature validation step is what most people focus on. If the signature is valid and the IdP is trusted, the assertion is accepted. That part worked correctly here.

The problem was in step 4 — specifically, what happened after the assertion was accepted.

The Vulnerability: Missing Identity Binding

Most applications that support both local accounts and SAML maintain two separate identity stores running side by side:

-Local accounts — created directly in the application, with their own passwords and MFA settings

  • Federated accounts — provisioned through SSO, authenticated by the IdP

When SAML authentication succeeds, the application needs to figure out which local account to create a session for. The vulnerable implementation did this:

extract username attribute from SAML assertion → query local accounts WHERE username = extracted_value → create session for whatever account matches

No check that the local account was provisioned from SAML. No check that it was explicitly linked to the active SAML directory. Just a raw username string lookup.

This means: if a federated user’s SSO username matches a pre-existing local account name, the application creates a session for that local account — regardless of whether the two identities have any relationship to each other.

Exploitation

The application had a default local administrator account. Its username was Admin — the default on most platforms, left intact after SSO was enabled.

The attack was straightforward:

Step 1: Authenticate through a trusted IdP with a SAML assertion carrying uid=Admin

Step 2: The application validates the signature — passes, the IdP is trusted

Step 3: The application extracts uid=Admin and queries local accounts

Step 4: It finds the local Admin account and creates a fully active session for it

The PoC that demonstrated this:

attr_statement = etree.SubElement(assertion, f"{{{NSA}}}AttributeStatement")
attr = etree.SubElement(attr_statement, f"{{{NSA}}}Attribute", Name="uid")
etree.SubElement(attr, f"{{{NSA}}}AttributeValue").text = "Admin"
signer = XMLSigner(method=methods.enveloped, c14n_algorithm="http://www.w3.org/2001/10/xml-exc-c14n#")
signed_assertion = signer.sign(assertion, key=key, cert=cert, reference_uri=f"#{assertion_id}")

Result:

[+] Success. [+] Redirect: dashboard.view [+] Session Cookie: eyJzZXNzaW9uaWQiOiIz…

Full authenticated session for the local administrator. No password entered. No MFA challenge. Nothing abnormal in the logs from the application’s perspective — the SAML assertion was valid.

The MFA Bypass

This is where it got worse.

The normal username/password login path looked like this:

POST /login → validate credentials → MFA pending state → redirect to /mfa → issue session

The SAML login path looked like this:

POST /acs → validate assertion → CUser::loginByUsername() → issue session

The loginByUsername() function called by the SAML flow created a fully active session immediately. It did not check whether the account had MFA enabled. It did not create an MFA-pending state. It did not redirect to the MFA page.

So even if the target local account was protected by MFA, the SAML login path bypassed it entirely. The MFA protection only existed on the local password login flow — and we never touched that flow.

Why This Happens

The root cause is an architectural assumption that breaks down when two identity stores coexist.

The application trusted the IdP completely — which is correct. When the IdP says ”this user authenticated as uid=Admin”, that statement is cryptographically verified and should be believed.

But the application then made a second assumption that was never verified: ”the local account named Admin is the same identity as the federated user uid=Admin.”

Those are two different things. The IdP proved a user authenticated as a federated identity. It said nothing about the pre-existing local account with the same name. The missing question was:

Is the local account I’m about to create a session for actually linked to this SAML directory?

It wasn’t. And the application never asked.

The Fix

The correct fix is to bind federated identities to local accounts using a stable, immutable, directory-scoped identifier — not a mutable username string.

Before creating a session, the application should verify:

  1. The resolved local account was provisioned from the active SAML directory, or has an explicit link to it
  2. The account was not created through a different authentication method with no SAML association
  3. If a collision occurs with an unlinked local account, authentication is rejected — not silently granted

Practically speaking:

# Vulnerable
session = db.find_user(username=saml_assertion.uid)
# Correct
session = db.find_user(
 username=saml_assertion.uid,
 auth_source="saml",
 saml_directory_id=current_idp.id
)
if not session:
 reject()

Additionally: if local accounts are no longer needed after SSO is enabled, they should be disabled or deleted — not left dormant as silent takeover targets.

Key Takeaway

SAML vulnerabilities rarely live in the cryptography.

They almost always live in what happens after the signature is validated — the logic that maps a trusted assertion to a local identity. That post-validation logic is where the interesting bugs are, and it’s where this one was hiding.

If you’re reviewing SSO implementations, the question to ask is not just ”is the signature valid?” but ”is the identity being created a session for actually the same identity that authenticated?”

Those are not the same question. And the gap between them is where accounts get taken over.


메타데이터
post_id
b1f3595e1cc8
slug
saml-username-collision-leading-to-full-ato-b1f3595e1cc8
url
https://medium.com/@m0n3m/saml-username-collision-leading-to-full-ato-b1f3595e1cc8
canonical_url
https://medium.com/@m0n3m/saml-username-collision-leading-to-full-ato-b1f3595e1cc8
author_url
https://medium.com/@m0n3m
status
ok
fetched_at
2026-06-13 12:55:53