Skip to content
datahacks.dev
XML

XML Signature Wrapping in Plain Language With a Toy Example

Signature wrapping moves a signed element so the signature verifies while the app reads another. A toy HMAC example shows the gap and the fix.

6 min read
Summarize in AI
Flat illustration of paired angle-bracket tags wrapped around a document, with the XML logo.

XML signatures are used where one party must prove to another that a message was not changed: single sign-on assertions (SAML), signed invoices, government filings, SOAP security headers. The signing standard is flexible. It lets a signature cover any part of a document, named by a reference. That flexibility is also what makes signature wrapping possible, and it is the reason an application can verify a signature successfully and still act on forged data.

This is the idea in plain language, with a small toy that reproduces it. It is not a security review of any product, and it does not use the real standard.

The idea

A signature protects one thing: the exact content of the element it covers. It says nothing about where that element sits in the document, or what else the document contains. An application that treats "the signature is valid" as "everything I am about to read is trustworthy" has joined two separate facts that nothing connects.

Wrapping exploits the gap. The attacker takes a genuine signed message and rearranges it so that the signed element is still present and unchanged, so verification passes, but sits somewhere the application does not look. Then the attacker puts forged content where the application does look. The signature checks out and the data is the attacker's.

The toy

A message with one signed element, a payment with an id:

XML
<msg><pay id="p1"><amount>10</amount></pay></msg>

The signer computes a keyed hash over the canonical form of the pay element and attaches it. The verifier finds the element by its id and recomputes the hash:

wrap.pyPython
import hmac, hashlib
from lxml import etree

K = b"secret"
def sign(el):
    return hmac.new(K, etree.tostring(el, method="c14n"), hashlib.sha256).hexdigest()

def verify_by_id(root, pid):
    el = root.xpath(f'//*[@id="{pid}"]')[0]
    return hmac.compare_digest(sign(el), sig)

def read_amount_naive(root):
    return root.xpath("//amount")[0].text

The two functions encode two different questions. verify_by_id asks: is the element with id p1 the one that was signed? read_amount_naive asks: what is the first amount anywhere in the document? On the genuine message they have the same answer:

Text
original  : verified True | amount read 10

The attack

The attacker rewrites the document without touching the signed element's own content: the original pay goes inside a new wrap element, and a forged pay with a different id and a big amount takes its place at the top.

XML
<msg>
  <pay id="evil"><amount>9999</amount></pay>
  <wrap><pay id="p1"><amount>10</amount></pay></wrap>
</msg>
Text
wrapped   : verified True | naive amount 9999

Verification still passes, because the element with id p1 exists and is byte-for-byte what was signed (its canonical form does not depend on where it sits). The application reads the first amount in the document and gets 9999. Nothing in the signature was broken, and the attacker never learned the key. The failure is in the application, which verified one element and used another.

The fix

Make the use depend on the verification. Instead of searching the whole document for data, take the value from the exact node you verified, reached by the path you expect:

Python
def read_amount_safe(root, pid):
    return root.xpath(f'/msg/pay[@id="{pid}"]/amount')[0].text
Text
safe read : rejected, signed element is not where the application expects it

The signed pay in the attack is under /msg/wrap, not /msg, so the strict path finds nothing and the code rejects the message (the toy raises IndexError, which the demo prints as a rejection). The rule behind it, in general form:

  1. Verify, then use the verified node. Keep a reference to the element that passed verification and read the data from it, not from a second search of the document.

  2. Fix the structure. Validate the document against a schema before or after verification, so unexpected elements such as wrap make it invalid. A schema that forbids extra elements closes the "rearrange" space the attacker needs.

  3. Reject duplicates. If two elements can claim the same id, the lookup by id is ambiguous. Refuse a document where an id appears twice.

  4. Cover enough. Sign the whole document, or at least every element the application reads, and the position-dependent parts too. A signature over a fragment protects the fragment.

  5. Use a library that does these checks, with strict settings, and keep it updated. Real XMLDSig has references by id, by XPath and by URI, plus transforms, and each has produced vulnerabilities in real implementations. A hand-rolled verifier will have more.

How it looks in real systems

SAML responses are the most studied case. An identity provider signs the assertion or the response. A service provider that verifies the signature on one element and reads the user's name from another, found by a loose path, can be fed an assertion for a different user. The published attacks vary in detail: moving the signed assertion into an element that the processing ignores, adding a second assertion before it, putting the forged content in an extension element, or exploiting a library's id resolution. The common core is the toy's: two lookups that can disagree.

A related weakness is trusting the signature's own key information. If the verifier uses a certificate embedded in the document without checking that it is the one you trust, a forged message signed with the attacker's own key verifies. The key has to come from your configuration, not from the message.

None of this makes XML signatures unusable. It means the check is a procedure with several parts, and getting only the cryptographic part right is not enough.

Testing for it

You can test your own verification code with the toy's recipe: take a genuine signed message and build the wrapped variant; confirm that your code rejects it or reads the verified value. Keep that message in the test suite. Add the other rearrangements: a duplicate id, a signed element moved under an extensions element, a second element of the same name before the signed one. If your code reads values by loose paths such as //amount, the tests will fail, and that is the finding.

For the overview, see XML security problems to close before production, and for the parser-level attacks what an XML external entity attack is. Validation against a schema is covered in validating XML against an XSD schema. The standard is XML Signature Syntax and Processing, the canonical form is Canonical XML 1.0, and the OWASP guidance for SAML is in the SAML Security Cheat Sheet. Test a path against a sample in the XPath tester.

When a toy example is not a security review

You rely on XML signatures for authentication or payments. Get a review from someone who knows XMLDSig and your library, and test with real attack documents. This article gives you the vocabulary for that conversation.

You can avoid XML signatures. A message signed as a whole with a detached signature, or a JSON Web Token with a library that enforces its checks, has fewer degrees of freedom. If you control the protocol, choose the simpler design.

Your library is old. Wrapping fixes arrived in libraries over years. A verifier from a decade ago has known bypasses; update it.

The signature covers a document that is later transformed. Converting the verified XML to another form before use can move the verified data away from the verification.

Also inSecurity
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.