REF · RANDE-COREACCESS · UNLOGGED
← write-ups

Signed, Not Verified

A Test-Drive SSO feature accepts a signed SAML assertion for any identity and JIT-provisions a session for it instantly — no invitation, no verification, no click, and no prior relationship between attacker and target required.

impact

An attacker who owns nothing but a throwaway trial signup and a self-signed certificate can mint a fully authenticated session carrying an arbitrary target's email — zero-click, no password, no MFA challenge, no notification to the target of any kind. The session is scoped to the attacker's own workspace: it does not, on its own, demonstrate entry into the target's separate pre-existing account elsewhere on the platform.

Every SSO integration is built on one sentence nobody writes down: whoever holds the signing key gets to say who you are. The service provider’s whole job is to check that sentence — verify the signature, check the expiry, hand back a session. It never asks the harder question underneath it: who decided what name went into the assertion in the first place?

That gap is usually invisible, because in the normal case the two questions have the same answer. The identity provider is run by the company. The company controls who gets to sign in. The assertion’s subject and the person sitting at the keyboard are, by every reasonable assumption, the same entity.

The interesting part starts when you’re allowed to be the identity provider yourself.

[[diagram:saml-trust-assumption]]

A feature built for exactly one moment of trust

The platform in question — a multi-tenant SaaS console, the kind where every customer gets an isolated workspace with its own membership, roles, and billing — lets an organization admin configure SSO before committing to it. It’s a sensible feature: point the app at your identity provider, run a Test-Drive login, confirm the assertion comes back clean, then flip the switch that forces the whole org through it. Nothing gets enforced during the test. It exists purely so an admin can find out if the plumbing works before anyone’s password stops mattering.

Which means, for one narrow purpose, it will accept a signed assertion from any IdP you hand it. That’s the point of the feature. It has to trust an IdP it has never seen before, because the entire reason you’re testing is that you haven’t turned trust on yet.

So the first real question isn’t “can I get the platform to trust my IdP” — it will, by design, for exactly as long as I need it to. The question is what happens once it does.

Establishing a baseline

Minimal-mutation testing means you don’t reach for the interesting payload first. You reach for the boring one, so you know what “normal” looks like before you go looking for what “wrong” looks like.

Standing up a throwaway IdP is a few lines: a self-signed certificate, a small service that builds and signs a SAML assertion, a sign-on URL pointed at it from the platform’s SSO settings. First test, deliberately uninteresting: sign an assertion whose NameID is my own email — the account that configured the connection in the first place. Run it. It logs in. Fine — that’s supposed to work; I’m both the IdP operator and the subject of the assertion.

Second test, one variable changed: NameID becomes a different member of the same org — someone I don’t otherwise have credentials for, on an account with MFA turned on. Sign it with the same key, same IdP, same everything except that one field.

POST /trust/saml2/acs/{account-id} HTTP/1.1
Host: [platform-host]
Content-Type: application/x-www-form-urlencoded

SAMLResponse=<base64>
  ...
  <saml:Subject>
    <saml:NameID>teammate@target-org.example</saml:NameID>
  </saml:Subject>
  ...
HTTP/1.1 302 Found
Set-Cookie: ldso=●●●●●●●●●●●●●●●●; HttpOnly; Secure
Location: /settings/security

A live session, minted for someone else’s identity, with a signature that checks out perfectly — because it’s my signature, from my key, and nothing downstream ever asks whether the name inside the envelope was mine to write.

No MFA challenge. The account had it enabled. It never came up, because the login pathway that “you control the IdP” opens doesn’t route through it at all — MFA guards the password form, and this isn’t the password form.

The question that actually matters

At this point the obvious triage response writes itself, and it’s not wrong: you configured that connection. Of course you can log in as your own org’s members — that’s what an admin with SSO configured is able to do everywhere. Trusting your own identity provider to assert identities inside your own org is, definitionally, what SSO is for. That’s not a bypass. That’s the feature working correctly, and no serious write-up should pretend otherwise.

So the real test isn’t “can I become someone already in my org.” It’s the one variable that actually decides whether this is a bug: does the platform check that the name in the assertion belongs to this org at all — or even exists yet?

POST /trust/saml2/acs/{account-id} HTTP/1.1
Host: [platform-host]
Content-Type: application/x-www-form-urlencoded

SAMLResponse=<base64>
  ...
  <saml:Subject>
    <saml:NameID>t.holloway@unrelated-inbox.example</saml:NameID>
  </saml:Subject>
  ...

That address had never touched this org. Never been invited, never seen an email from it, never heard of it. It was chosen for one property only: it was a real, deliverable inbox I controlled, so the result could be checked without guessing.

HTTP/1.1 302 Found
Set-Cookie: ldso=●●●●●●●●●●●●●●●●; HttpOnly; Secure
Location: /settings/security

Same 302. Same cookie. GET /internal/profile on the resulting session reports that identity back, fully formed, sitting in the org as a member — provisioned the instant the assertion was accepted, no invitation, no confirmation link, no pending state of any kind. The inbox never received anything. There was nothing to click, because nothing was ever sent.

[[diagram:saml-boundary-crossing]]

That’s the boundary. Not “I trust my own IdP” — everyone does, and has to. The boundary is that the platform never asks whether the identity being asserted has any relationship to the org receiving the assertion, or to the browser presenting it. A signature proves the envelope wasn’t tampered with in transit. It says nothing about who was allowed to write the name on the front of it — and nobody downstream ever asks that second question.

What “zero-click” means when you say it out loud

It’s worth being precise here, because “account takeover” gets used loosely and this deserves a specific claim, not a dramatic one.

Nothing about this requires the targeted person to do anything. No phishing page, no OAuth consent screen, no “click here to confirm,” no malicious attachment. The entire interaction happens between the attacker’s browser and the platform’s own login endpoint; the only externally-visible fact needed about the target is an email address, and even that address doesn’t need to be real in the sense of belonging to someone who’ll ever find out — it just needs to be a string the attacker chooses. The victim is not a participant in this flow. They’re an input to it.

That’s a meaningfully different shape of risk than most “takeover” findings, which still route through something the victim has to touch — a reset link, a session token left in a log, a cookie stolen off a shared machine. Here the attacker’s only prerequisites are an account of their own (any trial signup gets you admin of a fresh org) and one line of Python to sign an XML document. Everything past that point is fully attacker-controlled and fully offline from the target’s perspective.

What the resulting session is actually worth

[[diagram:saml-privilege-funnel]]

The setup cost is a one-time, admin-only action — configuring the connection. Everything after that is cheap, and it doesn’t stay admin-only. The _testRedirectUrl used to drive this only requires Reader-level access to fetch; any authenticated member of the org that configured the connection can go get one and set name_id to whatever they like. The privileged step and the exploitable step are not the same step, and only one of them is gated.

Once a session exists under an assumed identity, it’s a completely ordinary authenticated session — which is exactly the problem. GET /api/v2/caller-identity and GET /internal/profile both report the target’s identity, not the attacker’s. A few concrete things that’s worth in practice:

  • Cross-org existence leakage. The platform will tell you which other organizations a given email belongs to, even when neither of those orgs has any relationship to the one you’re standing in. Knowing that someone has an account somewhere is itself information the address’s owner never agreed to disclose.
  • Defeating self-review controls. The platform has a real, working control that blocks an admin from approving their own change request. Assuming a second, real member’s identity and approving the same request from that session sails through it — the control checks whose session this is, and the session is, as far as the platform can tell, telling the truth.
  • Attribution that actively misleads. Actions taken through the normal API are logged against the acting admin — name, email, the works. The identical action taken through an assumed identity logs as member: none/system, or under the impersonated person’s own name. A security team reviewing “why did this account suddenly do that” has nothing pointing back the right direction.

None of that requires a role change, a privilege escalation, or any interaction with the target at all. It requires knowing an email address and being willing to write it into a field the platform was never checking.

Where this actually sits

Some precision matters, because it’s tempting to round this up to something bigger than what’s proven. The identity minted this way is scoped to the org the attacker controls — this is not, on its own, evidence that it reaches into a target’s separate, pre-existing account somewhere else on the platform. What’s demonstrated is narrower and still serious: an attacker who owns nothing but a throwaway trial signup can, using only a target’s email address, obtain a fully authenticated session that the platform’s own APIs report as that person’s identity — with the target never notified, never prompted, and never involved. Whether that identity can be walked back out to a target’s real account elsewhere on the platform is a distinct question, and one this testing didn’t establish; it shouldn’t be claimed here.

What’s established is enough on its own: a signature check was mistaken for an identity check, and the difference between them turned out to be the entire vulnerability.

The fix isn’t “watch Test-Drive mode more closely”

The tempting remediation is procedural — restrict who can configure SSO, log Test-Drive usage more aggressively, require a second approval before a connection goes live. All reasonable, none of them close the actual gap. The gap is that the service provider accepts an assertion’s subject as fact the moment the signature validates, without ever confirming that the browser presenting it has any independent claim to that identity — a first login tied to a verified channel, a domain check, anything that makes the assertion’s subject provable rather than merely well-formed.

Signed only tells you the envelope wasn’t opened in transit. It was never supposed to be the whole answer to who’s inside.

Reported through the platform’s official bug bounty program. Details of the affected product and organization have been withheld; the technical mechanism above is described exactly as tested.