Security recipes · 03
SAML signature wrapping
- The threat
- Inject a forged assertion into a validly-signed SAML message so the signature verifies against one element while the SP consumes another.
- Sentinel's counter
- Pinned-certificate verification plus a reference-equality check that the signed element IS the consumed element, with algorithm/transform allowlists and XXE prohibition before any trust decision.
- Capability
- /docs/enterprise/

The attack
XML-DSig signs a referenced element, not the document — and therein lies fifteen
years of SAML CVEs. The classic wrapping move: take a legitimately signed response,
relocate the signed assertion into a wrapper the SP won’t look at (an extension block, a
copied Object), and insert a forged assertion — attacker as admin — where the SP’s
XPath/getElementsByTagName will find it. The signature still verifies: the signed
bytes are untouched, merely elsewhere. The SP checks “is there a valid signature?”
(yes), then consumes “the assertion” (the forged one). Variants swap KeyInfo
certificates, exploit permissive transforms, or smuggle DOCTYPE entity tricks in before
validation even starts.
The root cause is a gap between two questions that must have the same answer: what did the signature cover? and what am I about to trust?
How Sentinel counters it
All verification funnels through one method —
SamlSignatures.VerifyEnveloped(document, element, pinnedCertificate) — which layers
five defenses, each killing a variant class:
-
Keys come only from the pinned per-connection certificate. The
SamlIdpConnection/SamlSpConnectionrecord carries the certificate; the document’sKeyInfois never consulted (CheckSignature(cert, verifySignatureOnly: true)). Certificate-substitution wrapping — “here’s my own cert, and a signature that matches it” — verifies against nothing (Cert_substitution_is_rejected_against_the_pinned_certificate). -
Algorithm allowlist. RSA-SHA256/384/512 signatures, SHA-256/384/512 digests. SHA-1 and HMAC are rejected — the HMAC downgrade (verifying an asymmetric signature as an HMAC keyed with the public cert bytes) dies here.
-
Exactly one same-document reference. One
<Reference>, whose URI must be a#idpointer into this document — empty URIs (whole-document ambiguity) and external URIs are refused. -
Transform allowlist. Enveloped-signature and (exclusive) canonicalization only. XPath and XSLT transforms — the programmable “ignore the part I forged” machinery — are rejected outright.
-
The wrapping killer: reference equality. After the cryptography passes, the element the signature’s URI resolves to must be the very same object the caller is about to consume:
var signedElement = signedXml.GetIdElement(document, uri[1..]); return signedElement is not null && ReferenceEquals(signedElement, element) ? SamlSignatureResult.Valid : SamlSignatureResult.Invalid;Not same-id, not equal-content —
ReferenceEquals. A relocated signed assertion plus an injected impostor cannot pass, because the impostor is a different node. The testSignature_wrapping_injected_assertion_is_not_consumedstages exactly the classic attack and asserts the forged assertion is never consumed.
Before any of that, the parser refuses to play: SamlXml.TryLoadDocument prohibits
DTDs (DtdProcessing.Prohibit, null resolver, entity limits zeroed, size-capped) — a
DOCTYPE payload is hard-rejected before any trust decision
(Doctype_payload_is_hard_rejected_before_any_trust_decision). Element navigation
matches on local name and namespace and returns null on ambiguity — duplicate-element
smuggling fails closed. Redirect-binding detached signatures get their own path
(VerifyRedirectBinding over the raw percent-encoded bytes, same pinned key).
And the signature pipeline is only step five of the SP’s validation ladder: single
top-level-assertion rule, issuer, destination, conditions windows with bounded clock
skew, audience restriction, bearer subject confirmation, InResponseTo, and an
assertion-id replay cache. IdP-initiated SSO is off by default. The
SamlSecurityTests suite covers each rung — tampered assertions, unsigned responses,
expired conditions, wrong audiences, replayed assertions and replayed RelayState.
What your app must still do
- Guard the certificate lifecycle. The pinned certificate is the trust anchor; updating a connection’s cert is a privileged admin mutation — treat it like a key ceremony, and let the admin audit chain record it.
- Verify metadata out-of-band. When a customer sends their IdP metadata, confirm the certificate fingerprint through a second channel. Sentinel validates what’s configured; it can’t know the configuration itself was socially engineered.
- Leave IdP-initiated SSO off unless a customer’s IdP genuinely cannot do
SP-initiated. Unsolicited assertions discard
InResponseToprotection by nature — that’s why the default is off. - Patch the platform. Sentinel’s pipeline hardens its own consumption path;
underlying
System.Security.Cryptography.Xmlfixes still arrive via .NET updates.