SAML RelayState and the trust you inherit from an IdP
RelayState as an open-redirect and phishing vector through a trusted identity-provider domain, plus the assertion-signing pitfalls that turn a redirect into a full SSO bypass.
SAML is old enough that people trust the parts they should be checking. Two of
those parts are easy to test and high-value: where RelayState is allowed to
point, and whether the service provider really verifies what it claims to verify
on the assertion.
RelayState is an open redirect wearing a trusted domain
RelayState is the deep-link the service provider wants the user returned to after
authentication. The SP is supposed to keep it opaque, validate it against an
allowlist, or store it server-side keyed by a token. Plenty do none of that and
echo it straight into a Location header.
GET /sso/login?RelayState=https://app.example.com/dashboard HTTP/1.1
Host: idp.example.comSwap the value and watch where you land:
RelayState=https://attacker.com/harvest
RelayState=//attacker.com
RelayState=/\attacker.com
RelayState=https://app.example.com.attacker.comIf the post-authentication bounce follows that to an attacker origin, you have an
open redirect, and an unusually dangerous one. The redirect is initiated from
idp.example.com, a domain users and their security teams are trained to trust for
login. A phishing link that starts at the real IdP, authenticates the user for real,
and then lands them on a credential-harvesting clone is far more convincing than a
cold phishing page, because every early signal is genuine.
The assertion is the whole game
RelayState is the appetiser. The assertion is where SSO bypass lives, because the
assertion is the SP’s only evidence of who you are. Two failures recur.
Unsigned or partially-signed assertions. SAML lets you sign the response, the
assertion, both, or neither, and the SP decides what it requires. A surprising number
accept a response whose assertion is unsigned, or sign-check the outer response but
read identity from an inner unsigned assertion. If the SP does not strictly require a
valid signature over the exact assertion it trusts for identity, you craft an
assertion naming any NameID you like:
<saml:Assertion>
<saml:Subject>
<saml:NameID>admin@example.com</saml:NameID>
</saml:Subject>
<!-- no Signature, or a signature the SP never actually verifies -->
</saml:Assertion>If that logs you in, the SP was trusting the document’s contents rather than its signature. That is a complete authentication bypass.
XML Signature Wrapping, conceptually. XML signatures cover a specific element,
identified by reference. The trick, at a high level, is the gap between which
element was signed and which element the SP reads for identity. An attacker keeps
a legitimately signed assertion intact so signature verification passes, but
restructures the document so the SP’s parser pulls the NameID from a second,
injected, unsigned assertion instead. The signature is valid, just not over the data
that actually decides who you are. The defence is that signature verification and
identity extraction must operate on the same element, by the same reference, with no
ambiguity about which assertion is authoritative. I am deliberately not giving a
copy-paste payload here, the point is the class: verify-here, read-there is the flaw,
and you test it by checking whether the SP’s notion of “the signed assertion” can
ever differ from “the assertion I read identity from.”
How I approach an SSO integration
- Map both legs: IdP-initiated and SP-initiated. They often validate differently.
- Hit
RelayStateeverywhere it appears for redirect handling, and weight any finding by the trust of the host doing the bounce. - On the ACS endpoint, test what the SP actually requires: strip the signature,
swap the
NameID, and see what it accepts. The answer to “what does this SP trust, the signature or the bytes?” is the entire severity.
RelayState gets you a high-trust phishing redirect. The assertion, when its signing is loose, gets you anyone’s account. Test both, and do not stop at the redirect when the assertion is right there.