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.

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:

  1. Keys come only from the pinned per-connection certificate. The SamlIdpConnection / SamlSpConnection record carries the certificate; the document’s KeyInfo is 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).

  2. 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.

  3. Exactly one same-document reference. One <Reference>, whose URI must be a #id pointer into this document — empty URIs (whole-document ambiguity) and external URIs are refused.

  4. Transform allowlist. Enveloped-signature and (exclusive) canonicalization only. XPath and XSLT transforms — the programmable “ignore the part I forged” machinery — are rejected outright.

  5. 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 test Signature_wrapping_injected_assertion_is_not_consumed stages 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 InResponseTo protection by nature — that’s why the default is off.
  • Patch the platform. Sentinel’s pipeline hardens its own consumption path; underlying System.Security.Cryptography.Xml fixes still arrive via .NET updates.