Ruby-SAML pwned by XML signature wrapping attacks
CVE-2024-45409 was published on September 10, 2024. It’s yet another XML signature wrapping attack, this time affecting the main Ruby implementation of SAML. The vuln allows an attacker log in as any arbitrary user of the affected system.
This attack keeps coming up again and again, and it keeps affecting huge swaths of the internet — this time, GitLab and much of the Ruby ecosystem — at a time.
Here’s what this issue is, why it keeps happening, and what we can do about it.
XML Signature Wrapping
XML signatures are the year 2000’s answer to JWTs. In 2024, JWTs are a very common answer to “I need to sign some data and send it over the internet”. It’s not a perfect spec, but it’s workable.
XML signatures do the same thing, but every conceivable step is much more complicated.
All an XML signature does is let you cryptographically sign an XML document.
Same thing as what JWTs do with alg: "RS256"
(no ES256
, because remember:
the year is 2000).
There is exactly one sane way to cryptographically sign data:
- You take your message, and convert it to bytes
- You sign the bytes, which produces some more bytes
- You transmit two things: the message-bytes from (1), and the signature-bytes from (2)
- Don’t get cute.
Steps 1-3 is what JWT does. It does step (3) by separating the message and the
signature with a period (.
), which works because it also base64s the message
and the signature, and .
can’t appear in base64. People hate on JWT because it
forgot about (4); it’s part of an overarching attempt to standardize all of
crypto under something called
JOSE (and
COSE), a bad idea. But overall
they’re pretty good. They work. You can mostly ignore the overreach on the part
of the spec authors.
XML Signatures takes a different approach. Instead, it:
- Lets you sign subsets of a message,
- Or none of the message at all,
- Because instead of sending a signature accompanying your message, you edit the very message you’re signing to sprinkle in some
<ds:Signature>
elements, - Each of which sign a different
subset of the message. A
ds:Signature
uses a URI to point to other parts of the message, and say “here’s a signature for that part of the message”
If you ignore the XML-ness of it all, it’s the equivalent of signing {"email": "bob@company.com"}
by modifying it to be:
{
"email": "bob@company.com",
"__sig": {
"uri": "/email",
"sig": "... signature for the string bob@company.com ..."
}
}
This is a bad idea. It introduces the need for some “signature discovery” step,
where you search through the document for signatures (__sig
in my JSON
example; <ds:Signature>
elements in XML signatures). It basically begs
engineers to do this:
def validate_xml(xml_document):
for signature in find_xml_signature_elements(xml_document):
element = resolve_uri(signature["SignedInfo"]["Reference"]["URI"], xml_document)
verify_signature(element, signature["SignatureValue"]
Note that this doesn’t actually do anything if the document doesn’t have any signature at all.
People write a ton of bugs related to this “signature discovery” step. Every such bug is what they call a “XML Signature Wrapping” attack; someone wrote some tricky XML that makes your code lose track of what’s actually being signed.
To anticipate the obvious suggestion: no, sadly you can’t just check whether the top-level XML document is signed, because that’s not how SAML does it (more later), and SAML is the only reason people do XML Signatures (again, more later).
So you instead in practice have people adapting their previous code to make sure that the part of the message they care about is, in fact, signed:
def validate_xml(xml_document, uri_checklist):
checked_uris = []
for signature in find_xml_signature_elements(xml_document):
element = resolve_uri(signature["SignedInfo"]["Reference"]["URI"], xml_document)
verify_signature(element, signature["SignatureValue"]
checked_uris.append(signature["SignedInfo"]["Reference"]["URI"])
for uri in uri_checklist:
if uri not in checked_uris:
throw MissingSignatureError()
There are a million ways this goes wrong, but here’s the one ruby-saml
ran
into: there’s nothing that guarantees the URI
the signature points to is
unique.
So what you could do is take a legitimate message, and just stick some other
stuff into the message, reusing the same URI
. And then hope your victim’s code
will get confused as to what it just signed.
Something like this:
<Document>
<!-- a faked message; this is never actually signed -->
<ImportantStuff>
<Message id="dead[...]beef">
<Email>eve@evil.com</Email>
</Message>
</ImportantStuff>
<!-- attacker-supplied message, copied out of the ImportantStuff from a legit message -->
<Message id="dead[...]beef">
<Email>alice@customer.com</Email>
</Message>
<!-- a signature for alice@customer.com -->
<Signature>
<SignedInfo>
<Reference URI="dead[...]beef" />
</SignedInfo>
<SignatureValue>... the correct signature, but for alice@customer.com ...</SignatureValue>
</Signature>
</Document>
The victim code finds the signature, resolves the uri to the top-level
Message
, but then later on would process ImportantStuff
instead of
Message
, if that’s where the Message
is normally placed.
It’s a mess. Ruby-SAML patched this by throwing if resolve_uri
finds more
than one
match.
But Ruby-SAML has no reliable way of validating which parts of an XML document
were signed, and so I wouldn’t be surprised if there were more issues in that
codebase lurking.
SAML is why this matters
Nobody cares about XML Signatures anymore, except in the context of SAML. SAML is what people mean by “enterprise single-sign-on”, and it works by having an Identity Provider (Okta, Microsoft Entra, Google Workspace, etc.) send a signed XML message to a Service Provider (a B2B SaaS product). That message typically just contains the logging in user’s email address. It’s an elaborate email address transmission protocol.
Concretely, SAML requests are a POST from your user’s browser containing:
<?xml version="1.0" encoding="UTF-8"?>
<saml2p:Response>
<saml2:Assertion ID="id2829877824622019702853127">
<saml2:Issuer>
http://www.okta.com/exkig8gdo63cjI4OD5d7
</saml2:Issuer>
<ds:Signature>
<ds:SignedInfo>
<ds:Reference URI="#id2829877824622019702853127">
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue>
n744L/[...]mDruC1H9E0Lz7sbZg==
</ds:SignatureValue>
</ds:Signature>
<saml2:Subject>
<saml2:NameID>
ulysse.carion@ssoready.com
</saml2:NameID>
</saml2:Subject>
</saml2:Assertion>
</saml2p:Response>
And “implementing SAML” mostly consists of:
- “Validating” the message, with all the fraught gotchas that entails
- Logging in the user as
ulysse.carion@ssoready.com
.
Notice how this is roughly the same shape of message that I laid out in the
previous example. I won’t disclose it here because that’d be irresponsible, but
you can do the same attack. You could get me to validate the message in step
(1), but then have some other thing in the message, also with
ID="id2829877824622019702853127"
, that I use in step (2).
To repeat it again, the core dumb idea here is that you’re signing a message, instead of signing bytes. XML Signatures interweaves cryptography and the XML tree model into a messy knot. It’s really hard to make sure the message you’re processing is the same as the bytes you verified.
How to fix this: disregard the spec
SAML library authors need to stop being so credulous about the spec.
When a specification is a collection of security flaws, responsible engineers disregard the specification. Responsible engineers should disregard what the SAML and XML Signatures spec authors wrote down, and instead implement the secure thing at its core.
Put another way, the reason XML wrapping attacks keep coming up is that people architect their code like this:
saml-ruby
calls into somexml-signatures-ruby
dependency or submodule
This seems like good, sane composition. But XML Signatures is insane. It cannot be composed. XML Signatures can’t say “yes this message is valid”. What it can say is “these N potentially-overlapping subsets of this XML document are correctly signed” (It’s actually considerably worse than that, but whatever). There’s no building on top of XML Signatures.
So here’s the sane thing to do instead:
- Notice that every Identity Provider in the world has practically agreed to shape their SAML payloads in the same shape.
- Assume all messages shaped otherwise are invalid.
- Disregard the
URI
in XML signatures. - Presume in advance that they’re signing exactly the subset of the SAML payload they should be signing, which is also the subset of the payload you’ll be processing later.
- Verify that signature, and only that signature.
In other words: forget XML signatures. Treat it as some weird relic. Just look at the SAML payload, and implement the de-facto protocol that has emerged.
Ignore Postel. When it comes to processing cryptographic signatures, loosey-goosey isn’t “liberal”, it’s libertine.