Chapter 39 — Federated identity attacks: SAML and advanced OAuth/OIDC
In one sentence
SSO concentrates trust in the IdP and the assertion, so the attacks are on the
signature (SAML XML signature wrapping, Golden SAML), on the parsing (XXE,
comment injection), and on OAuth/OIDC flow details (redirect_uri, state, iss
mix-up, JWKS/kid games, account-linking) — and a single flaw is usually full
account takeover.
Where we are
G1 Ch 46 taught OAuth 2.0 authorization-code + PKCE and OIDC basics. This chapter is SAML (which G1 skipped) and the advanced OAuth/OIDC attack classes.
How it works
SAML attacks:
XML SIGNATURE WRAPPING (XSW)
The SAML Response contains a signed element (the Assertion, or the
Response). The attacker adds a SECOND, unsigned, malicious Assertion
and re-arranges the XML so:
- the signature-verification code still finds and validates the
ORIGINAL signed element (signature checks out), BUT
- the application's business logic (a different XPath / a
`getElementsByTagName("Assertion")[0]` / a different DOM walk)
reads the INJECTED assertion -> attacker becomes admin@victim.
Root cause: "what is verified" != "what is consumed". 8 canonical
XSW patterns (position of the injected element, referencing tricks).
GOLDEN SAML (CyberArk, 2017)
Steal the IdP's TOKEN-SIGNING PRIVATE KEY (e.g. from ADFS:
`ADFS/PrivateKey`, or the signing cert). Then forge ARBITRARY valid
assertions offline -- any user, any SP, any groups, any validity --
with NO interaction with the IdP and NO log there. Persistent until
the key is rotated. Used post-SolarWinds (UNC2452) for cloud
persistence.
SIGNATURE EXCLUSION / STRIPPING
SP accepts an assertion with NO signature, or `WantAssertionsSigned=
false`, or only checks the Response signature while consuming an
unsigned Assertion.
COMMENT / CANONICALIZATION INJECTION
NameID = `admin@victim.com<!---->.attacker.com`. If the SP's XML text
extraction stops at the comment (canonicalization quirk) it reads
`admin@victim.com` -> takeover. (Duo/Uber/GitHub/others, 2018 --
"The road to hell is paved with SAML assertions".)
XXE in the SAML parser (Ch 32).
REPLAY
No one-time `Assertion ID` tracking, missing/!checked `NotOnOrAfter`,
`NotBefore`, `InResponseTo` -> replay a captured assertion.
AUDIENCE / RECIPIENT / DESTINATION not validated
An assertion minted for SP-A is accepted by SP-B.
RelayState -> open redirect / reflected XSS.
IdP-initiated SSO -- no `InResponseTo` to bind, CSRF-like; prefer
SP-initiated.
Library CVEs: `ruby-saml` (multiple critical XSW/DoS, 2024-2025 --
GitLab/others), `python3-saml`, `xmlsec`, many vendor SDKs.
Advanced OAuth/OIDC:
redirect_uri
- must be EXACT-match against a registered value, on the AUTH request
AND validated again on the TOKEN request. Attacks: an open redirect
on the client's allowed domain, path/`../`/`%2e` tricks,
`?`/`#`/`@`/`\` parser confusion, `localhost` or wildcard
subdomains allowed -> steal the `code`.
state
- missing / not validated -> login CSRF, forced login, "code
injection" (attacker gets a code for THEIR account, delivers the
callback URL to the victim -> victim's app session is now linked to
the attacker's IdP identity -> account takeover; or victim is
logged into the attacker's account -> data harvested).
OAuth MIX-UP (RFC 9207 fixes it)
Client supports multiple IdPs. A malicious/compromised IdP tricks the
client into sending the `code` (or accepting a token) meant for
IdP-A to IdP-B (the attacker). Mitigation: the `iss` parameter in the
authorization response, validated; a distinct redirect_uri per IdP.
ID TOKEN validation
- not checking signature (`alg:none`; JWKS spoofing via `jku`/`jwk`/
`kid` header injection; RS256->HS256 key confusion, G1 Ch 46),
`iss`, `aud`, `exp`, `nonce`. Using an ACCESS token as proof of
identity (no `aud` for your client, may be opaque).
kid / jku games
- `kid` used in a file path / SQL / command -> injection.
- `jku` pointing at an attacker JWKS if not pinned/allowlisted.
- `jwk` header (embedded key) trusted.
PKCE downgrade
- if the AS doesn't bind "started with PKCE" to "must finish with a
verifier", an attacker strips PKCE and replays an intercepted code.
Refresh tokens
- not rotated, no reuse detection, not sender-constrained (DPoP /
mTLS) -> a stolen refresh token = indefinite access.
Scope / consent
- scope escalation on refresh; "illicit consent grant" / OAuth
consent phishing -- a malicious app requests broad scopes
(`Mail.Read`, `offline_access`) and a tricked user consents ->
persistent mailbox access with no password involved (a major M365/
Google Workspace attack pattern).
ACCOUNT LINKING / PRE-ACCOUNT-TAKEOVER
- the app links a "Sign in with Google" identity to a local account
by EMAIL without verifying control -> attacker pre-registers the
victim's email locally; victim later federates in and is merged
into the attacker's account (or vice versa). Require verified email
+ re-auth before linking.
Device code flow phishing; dynamic client registration abuse;
front/back-channel LOGOUT not implemented (session survives IdP
logout).
Defence (both):
SAML
[ ] Use a maintained library, CURRENT version. Track its CVEs.
[ ] Require signed assertions; validate that the signature COVERS the
exact element you consume (schema-aware validation, reference
checking) -- or sign the whole Response and consume only from the
verified tree. Reject multiple assertions.
[ ] Validate Audience, Recipient, Destination, NotBefore/NotOnOrAfter
(with small skew), InResponseTo, and enforce ONE-TIME use of the
Assertion ID (a replay cache).
[ ] Disable DTD processing in the SAML XML parser (XXE).
[ ] Canonicalization-safe NameID/attribute extraction (no
comment-truncation).
[ ] Prefer SP-initiated; treat IdP-initiated as higher risk or
disable.
[ ] Protect the IdP signing key like a root CA (HSM); monitor IdP
issuance vs SP consumption to catch Golden SAML; rotate on
suspicion.
OAUTH/OIDC
[ ] OAuth 2.1 / RFC 9700: authorization-code + PKCE for ALL clients,
no implicit, no ROPC, EXACT redirect_uri matching (auth AND
token request).
[ ] Enforce `state` (CSRF) and, in multi-IdP, the `iss` response
parameter (RFC 9207).
[ ] Validate every ID-token claim + `nonce`; pin JWKS or allowlist
`jku`; ignore `jwk`; pin the expected `alg`.
[ ] Rotate refresh tokens with reuse detection; consider DPoP /
mTLS-bound tokens for high value.
[ ] Minimise requested scopes; admin-review third-party OAuth apps in
the tenant (consent-grant governance); alert on new grants of
sensitive scopes.
[ ] Require verified email + step-up auth before account linking.
[ ] Implement back-channel logout.
Worked example
XML Signature Wrapping to admin, and the reference-checking fix.
The SP validates the SAML Response like this (pseudocode):
doc = parseXml(samlResponse) // DTDs off, good
sigEl = doc.xpath("//ds:Signature")[0]
signedEl = resolveReference(sigEl) // -> Assertion#A1
verify(signedEl, sigEl, idpCert) // PASSES
assertion = doc.getElementsByTagName("Assertion")[0] // <-- BUG
user = assertion.xpath("//saml:NameID/text()")
groups = assertion.xpath("//saml:Attribute[@Name='groups']/...")
ATTACK: take a legit Response for `bob@securesh.op` (Assertion id=A1,
signed). Wrap it: keep A1 (with its valid signature) but move it inside
an unused element (e.g. an `<Extensions>` or a second `<Object>`), and
ADD a new unsigned Assertion id=EVIL as the FIRST child of Response
with NameID `admin@securesh.op`, groups `superadmin`.
- `resolveReference` still finds A1 by its Id -> `verify` passes.
- `getElementsByTagName("Assertion")[0]` returns EVIL -> the SP logs
the attacker in as admin.
FIX:
- After verification, CONSUME ONLY from the verified subtree:
assertion = signedEl // the exact element the signature covered
(and assert signedEl is an <Assertion> directly under <Response>).
- Require the signature to reference an Assertion (or the Response
root), reject if the reference URI is empty, points outside the
document, or resolves to an unexpected element.
- Reject Responses containing more than one <Assertion>.
- Validate Audience/Recipient/Destination/timestamps/InResponseTo and
one-time Assertion ID.
- Better: use a library that does schema-aware, reference-checked
validation (and is current) rather than hand-rolled XPath.
Practice (90 min)
- PortSwigger Academy "OAuth 2.0 authentication vulnerabilities" and
"JWT attacks" tracks (redirect_uri, state/CSRF,
alg:none, JWKS injection,kidtraversal/SQLi, key confusion). Do the OAuth account-linking and "authentication bypass via flawed CSRF protection" labs. - SAML: use SAMLRaider (Burp) or
samling+ a deliberately-vulnerable SP (e.g. an oldpython3-saml/ruby-samlversion in a container). Perform an XSW attack (try several of the 8 patterns); a signature-stripping attack; comment injection on the NameID; XXE in the SAML parser. Then upgrade the library / add reference-checked validation and re-test. - Build the pre-account-takeover via unverified email linking in a toy app; fix with verified-email + re-auth.
- Reproduce OAuth mix-up conceptually with two mock IdPs and a client that
ignores
iss; addissvalidation (RFC 9207) and distinct redirect_uris. - Consent-grant: in an Entra/Google Workspace dev tenant, register an app requesting broad scopes, "consent" as a test user, then show the offline access; then apply admin-consent-required + a review workflow.
Common mistakes
- Verifying one element, consuming another (SAML). The XSW root cause. Consume only from the verified subtree; reject multiple assertions.
WantAssertionsSigned=false/ accepting unsigned assertions.- Not validating Audience/Recipient/Destination/timestamps/InResponseTo, or no one-time Assertion ID. Replay and cross-SP reuse.
- Hand-rolled SAML XML handling / an outdated SAML library. This library class has a steady stream of critical CVEs. Use a maintained one, keep it current.
- Non-exact
redirect_uri, or validating it only on the auth request. - Missing
state; treating an access token as identity; not checkingnonce/aud/iss; trustingjku/jwk/kidblindly. - Account linking by email without verifying control. Pre-account-takeover.
- Non-rotating refresh tokens; no consent-grant governance. Persistent access from one theft or one tricked click.
- IdP-initiated SSO enabled by default. Harder to secure; prefer SP-initiated.
Check yourself
- Explain XML Signature Wrapping in one sentence — what's the mismatch it exploits?
- What is Golden SAML, what does the attacker need, and why is it so persistent?
- Name four SAML validations beyond "the signature is valid".
- What is the OAuth mix-up attack and what parameter (which RFC) mitigates it?
- How does a pre-account-takeover via SSO account-linking work, and the fix?
- Why is "consume only from the verified subtree" the correct SAML fix?
Further reading
- SAML: "On Breaking SAML: Be Whoever You Want to Be" (Somorovsky et al.,
USENIX 2012 — the XSW paper); "The road to hell is paved with SAML Assertions"
(Duo, 2018 — comment injection); CyberArk "Golden SAML" (2017); the
ruby-saml2024-2025 advisories. - OAuth/OIDC: RFC 9700 (Security BCP), RFC 9207 (
issparameter), OAuth 2.1 draft, FAPI 2.0; PortSwigger "OAuth 2.0 authentication vulnerabilities" and the "OAuth security" write-ups; "Common OAuth Vulnerabilities" (Doyensec). - Tools: SAMLRaider,
samling,python-saml/ruby-samltest suites; Burp JWT Editor;oidc-attack. - Consent phishing: Microsoft "Illicit consent grant attack" docs; the "app consent governance" guidance.