Łukasz Makuch

Łukasz Makuch

Are XML Signatures secure?

A sealed envelope

The short answer is that in practice XML signatures are insecure and shouldn't be relied upon.

Now, I know that's quite a statement, but please hear me out.

What are digital signatures, again?

Digital signatures are a way to ensure that a piece of data has been released by a particular party and that it hasn't been modified by a third party.

The premise is simple: if we have the public key of the sender, we can check if some data has been signed by that sender. To sign data, you need to be in possession of the private key and that's why only the legitimate sender can sign documents.

It's a wonderfully simple concept.

For example, you can sign a document and pass it from one system to another through some front channel, such as a web browser, and if the data is correctly signed, you can be sure that no malicious actor modified it.

What makes signing XML tricky?

As I mentioned earlier, signatures are simple. But add XML to the equation and you get an incredibly complex, tricky and practically insecure mechanism.

Just have a look at this XML document describing a classic pizza:

<pizza Id="_0">
  <name>Margherita</name>
  <details>
    <ingredients>
      <ingredient>mozzarella</ingredient>
      <ingredient>tomatoes</ingredient>
      <ingredient>basil</ingredient>
    </ingredients>
  </details>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo><CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><Reference URI="#_0"><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><DigestValue>IaSUjZiQc6CtB9EjeSS6R96bIWo=</DigestValue></Reference></SignedInfo><SignatureValue>ReJkW/mCaIbX+UZBoZRl+uoqXFQeN7/Tm4gGG9tpqVtvIHTkZTFk7/K/EG9IS6dmqM46qCNRq+yymm8MvLJu2/Iey4A0ZGyZ5etY4jHiIju2L5gfF/orHjfXkhbpT7qbJ3+5rtKed/p3hrX1wdY8gikCC6XgNC3LKsi9C1NhkQ4YhyFnGrnXKoGeEkBFsyaJWiRY7CFVm5aMDzSF+bO7WrdF0wtuayIBR0+fz6bbt7PVWWypEPhOzu8DsZnSXocy51ASY7LW2f7HyPVhsui9XMMcdTT4mGIJnGJeM8wVmCi40QWrQuIEXtAfISDjFz2U7eiP2jLKPQg88Wxhao0ymQ==</SignatureValue></Signature></pizza>

The goal of the code consuming this document is simple: verify the signature and read the list of ingredients.

I decided to use JavaScript to illustrate the examples just because it's popular, but judging from my experience in the security industry you can run into such errors in any language.

Here's an implementation based on the documentation of the library used to verify the signature:

// Imports:
const select = require('xml-crypto').xpath
const dom = require('@xmldom/xmldom').DOMParser;
const SignedXml = require('xml-crypto').SignedXml;
const FileKeyInfo = require('xml-crypto').FileKeyInfo;
const fs = require('fs');

// Loading data:
const xml = fs.readFileSync("signed.xml").toString();
const doc = new dom().parseFromString(xml);
const signature = select(doc, "//*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']")[0]
const sig = new SignedXml()
sig.keyInfoProvider = new FileKeyInfo("cert.pem")
sig.loadSignature(signature)

// Verifying the signature:
const correctlySigned = sig.checkSignature(xml);
if (correctlySigned) {
  // Making sure the element we work with is signed. Based on the docs.
  const pizza = select(doc, "/pizza")[0];
  const uri = sig.references[0].uri;
  const id = (uri[0] === '#') ? uri.substring(1) : uri;
  if (pizza.getAttribute('ID') != id && pizza.getAttribute('Id') != id && pizza.getAttribute('id') != id)
    throw new Error('the interesting element was not the one verified by the signature')
  // Reading ingredients:
  const ingredients = select(pizza, "//ingredient").map(node => node.textContent);
  console.log(ingredients);
} else {
  console.error(sig.validationErrors) 
}

This is what we get if we run it:

➜  node read.js
[ 'mozzarella', 'tomatoes', 'basil' ]

Let's see what happens when we change basil to oregano:

<pizza Id="_0">
  <name>Margherita</name>
  <details>
    <ingredients>
      <ingredient>mozzarella</ingredient>
      <ingredient>tomatoes</ingredient>
      <ingredient>oregano</ingredient>
    </ingredients>
  </details>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo><CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><Reference URI="#_0"><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><DigestValue>IaSUjZiQc6CtB9EjeSS6R96bIWo=</DigestValue></Reference></SignedInfo><SignatureValue>ReJkW/mCaIbX+UZBoZRl+uoqXFQeN7/Tm4gGG9tpqVtvIHTkZTFk7/K/EG9IS6dmqM46qCNRq+yymm8MvLJu2/Iey4A0ZGyZ5etY4jHiIju2L5gfF/orHjfXkhbpT7qbJ3+5rtKed/p3hrX1wdY8gikCC6XgNC3LKsi9C1NhkQ4YhyFnGrnXKoGeEkBFsyaJWiRY7CFVm5aMDzSF+bO7WrdF0wtuayIBR0+fz6bbt7PVWWypEPhOzu8DsZnSXocy51ASY7LW2f7HyPVhsui9XMMcdTT4mGIJnGJeM8wVmCi40QWrQuIEXtAfISDjFz2U7eiP2jLKPQg88Wxhao0ymQ==</SignatureValue></Signature></pizza>
➜  node read.js
[ 'invalid signature: for uri #_0 calculated digest is kK2GwWhLPYK/zAa8MoEJ3FPIkGk= but the xml to validate supplies digest IaSUjZiQc6CtB9EjeSS6R96bIWo=' ]

We've got a validation error. That's good, right? Right! It means nobody can manipulate this data, right? Wrong!

You see, there are so many complex operations going on that it's really hard to fully understand it. The underlying signing algorithm is solid and libraries make it easy to use. It takes the data, the signature, and the key and tells us whether this key was used to generate this signature for this data. But our case is inherently more complex! We have an XML document which is a tree structure. The signature isn't provided as a separate argument, but is placed inside the signed document instead. And in order to read data, we use a query language (XPath) to query for <ingredient /> nodes. Moreover, we don't even query the same document that has been signed! More on that below.

When the document was being signed, it didn't have a signature. The signature is derived from the unsigned document and only then placed inside it. That's why before verifying it, the document is reverted to its initial form. It means removing the <Signature /> node from the tree. That's what the http://www.w3.org/2000/09/xmldsig#enveloped-signature transformation does. There are also other operations going on, like canonicalization, which helps to ignore the differences between things like <Node /> and <Node></Node>.

That's why even thought we're loading something like this:

<pizza>
  <field param="value"></field>
  <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">...</Signature>
</pizza>

we may be calculating the signature of something more like this:

<pizza><field param="value"/></pizza>

The fact that a huge chunk of the tree disappears is interesting from an attacker's perspective and terrifying from a developer's perspective.

When we're looking for <ingredient /> nodes, we're querying the signed document. It means that in it still has the <Signature /> subtree in it. But because the signature itself isn't signed, we can add there anything we want, even another <ingredient /> node:

<pizza Id="_0">
  <name>Margherita</name>
  <details>
    <ingredients>
      <ingredient>mozzarella</ingredient>
      <ingredient>tomatoes</ingredient>
      <ingredient>basil</ingredient>
    </ingredients>
  </details>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
  <ingredient xmlns="">pineapple</ingredient>
  <SignedInfo><CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><Reference URI="#_0"><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><DigestValue>IaSUjZiQc6CtB9EjeSS6R96bIWo=</DigestValue></Reference></SignedInfo><SignatureValue>ReJkW/mCaIbX+UZBoZRl+uoqXFQeN7/Tm4gGG9tpqVtvIHTkZTFk7/K/EG9IS6dmqM46qCNRq+yymm8MvLJu2/Iey4A0ZGyZ5etY4jHiIju2L5gfF/orHjfXkhbpT7qbJ3+5rtKed/p3hrX1wdY8gikCC6XgNC3LKsi9C1NhkQ4YhyFnGrnXKoGeEkBFsyaJWiRY7CFVm5aMDzSF+bO7WrdF0wtuayIBR0+fz6bbt7PVWWypEPhOzu8DsZnSXocy51ASY7LW2f7HyPVhsui9XMMcdTT4mGIJnGJeM8wVmCi40QWrQuIEXtAfISDjFz2U7eiP2jLKPQg88Wxhao0ymQ==</SignatureValue></Signature></pizza>

Even though we weren't able to forge the signature, when we run the code again, we get a little surprise:

➜ node read.js
[ 'mozzarella', 'tomatoes', 'basil', 'pineapple' ]

We've got pineapple on our pizza!

What happened is that even though the data was properly signed, this signed data was just a subset of the queried data. The malicious payload was placed in the unsigned yet queried portion of the document, which is the surprisingly hard to avoid root cause of attacks aimed at XML signatures.

How to make XML signatures more secure?

Does this mean it's impossible to properly validate an XML signature? No, it's technically possible. Some of the steps that may be taken in order to increase the trust in XML signatures include:

  • Applying strict XML schemas, even for the signature node.
  • Avoiding enveloped signatures by placing signatures outside the signed node.
  • Using precise selectors such as /pizza/details/ingredients/ingredient instead of relaxed queries like //ingredient.
  • Considering the library used to work with XML documents high-risk and always, and I mean always, keeping it up to date.

Should you rely on XML signatures?

In reality, thought, it's very hard to expect all these criteria to be met as it requires niche knowledge. And even if you have great specialists, they may still make mistakes, as did people at Microsoft and Atlassian. Similar issues were found in popular Java and PHP libraries.

I must say that when I first worked with XML signatures a couple of years ago it was a humbling experience. I don't know how big my ego would have to be for me to think that I can certainly get it right even though so many smart people got it wrong.

Even some protocols that started as dependent on XML signatures, such as SAML 2.0, later introduced solutions that don't require pristine signature validation. For example, SAML used to rely on signed XMLs being transmitted via the browser from one server to another but now it uses a kind of server to server communication (HTTP Artifact binding) where the sensitive data is sent in the response to the server and not in the request. Doing it this way significantly reduces the attack surface.

That's why even though in theory it's possible to securely verify an XML signature, I believe it's worth keeping in mind that the risk of failing to do so is very high.