Nightjar

Namespace separator injection in libexpat (CVE-2022-25236)

Identifier
CVE-2022-25236
Software
libexpat
Fixed in
Expat 2.4.5 (18 Feb 2022); check relaxed to non-RFC-3986 separators
Reported by
Ivan Fratric, Google Project Zero
Disclosed
18 February 2022

bar\nbaz\nfoo. An XML parser handed an application that string, and the application's job is to split it on the newlines into a namespace, a tag name and a prefix. Three fields. Unless it is two fields and the first one happens to contain a newline, in which case the application is about to believe several things about this document that are not true.

Ivan Fratric of Google Project Zero put the question on a slide at Black Hat USA 2022, in a talk called XMPP Stanza Smuggling or How I Hacked Zoom. Two lines from fast_xml, the XML library the ejabberd chat server uses, an arrow pointing at the third argument, and the caption "What's this?":

c
state->parser = XML_ParserCreate_MM("UTF-8", &ms, "\n");
XML_SetReturnNSTriplet(state->parser, 1);

Answering that is most of CVE-2022-25236.

Expanded names#

Expat can do XML namespace processing. Turn it on and you have to tell it a namespace separator character, which is the third argument above. The separator decides how Expat glues a resolved name back together before handing it to you.

Normally your start-element handler gets the tag name as written. With namespace processing on, it gets an expanded name: the namespace URI, the separator, and the local name. So parsing

xml
<tag xmlns="namespace">

hands your handler the string namespace\ntag. And XML_SetReturnNSTriplet(parser, 1) asks for one more piece on the end, the prefix, so

xml
<prefix:tag xmlns:prefix="namespace">

gives you namespace\ntag\nprefix. That's the triplet. It's a convenience. Expat has done the namespace bookkeeping, and rather than invent a struct it packs three strings into one buffer with a delimiter you picked. You split them apart again on the other side.

The HTML API docs for XML_ParserCreateNS had warned about the general shape of the problem for over twenty years. They tell you to pick a separator that can't appear in a URI. \n is a reasonable choice by that standard. What nobody had checked was whether Expat itself enforced it.

It didn't. Namespace URIs in XML are just attribute values, and attribute values can contain character references. &#x0A; is a newline. So:

xml
<foo xmlns="bar&#x0A;baz">

Expat resolves that and hands the application bar\nbaz\nfoo. Three fields, one separator too many. Fratric's slide labels them the way an application would read them: namespace bar, tag name baz, prefix foo. Split from the other end and you get different answers. There is no right answer, which is the actual finding, and his slide says it in one line:

User has no way of differentiating a triplet from namespace containing a separator.

one buffer, two ways to cut it namespace URI, as Expat resolved it local name bar \n baz \n foo namespace tag name prefix the application splits on every \n and gets three fields this one came from &#x0A; inside the attribute value
The separator Expat wrote and the separator the attacker wrote look identical in the buffer.

Stanza smuggling in XMPP#

An ambiguous split is a bug. It becomes a security bug because of what the application does next.

&#x3C; and &#x3E; are < and >. So:

xml
<foo xmlns="bar&#x0A;baz&#x3C;xml&#x3E;">

produces bar\nbaz<xml>\nfoo, and the "tag name" the application now believes it is holding is baz<xml>. Angle brackets. In a tag name.

XMPP is the reason this matters. An XMPP server doesn't hand you a socket to another user. It parses each stanza a client sends, does its own bookkeeping, and then serialises the stanza back out to the recipient's connection. There are at least two XML parsers and one XML serialiser in the path. In Zoom's case they weren't the same code: ejabberd with fast_xml and Expat on the server, gloox on the client. Fratric calls the resulting bug class stanza smuggling. You send one stanza, the server agrees it is one stanza, and the receiving client sees two.

one stanza in, two stanzas out attacker's client sends one stanza xmlns="bar&#x0A;baz&#x3C;xml&#x3E;" ejabberd fast_xml + Expat element name = baz<xml> written out with < and > unescaped victim's client, gloox reads two stanzas
The element name carries the tag boundary that the server never meant to write.

Attacker-controlled characters in a tag name that gets serialised is exactly the primitive you need for that. The server writes out what it thinks is an element name. The bytes < and > go on the wire unescaped, and the client's parser reads a tag boundary that the server never intended to create.

The sibling bug in the same Expat release, CVE-2022-25235, does the same job by a different route. It is worth thirty seconds because it's so tidy. 0xEB starts a three-byte UTF-8 sequence, and the two bytes that follow are supposed to have their high bit set. 0x3C and 0x3E do not. Expat accepted 0xEB 0x3C 0x3E as one malformed three-byte character and passed it through. gloox saw three characters, the second and third being < and >. Same outcome, no character references required. The Expat changelog puts the check where it belongs:

validation was not their job but Expat's. Exploits with code execution are known to exist.

the same three bytes, counted twice Expat: one malformed character, passed through 0xEB 0x3C 0x3E byte 1 < > gloox: three characters, two of them a tag boundary
A high bit that should have been set is the only thing separating one character from three.

Fratric found both by fuzzing. His harness fed a candidate stanza through fast_xml's reparse path and then into a gloox parser, and flagged anything gloox managed to parse. His starting corpus contained neither &#xA; nor the attribute name xmlns, which is a nice reminder that coverage feedback was doing real work here.

The rest of the Zoom chain runs through a custom <stream:error> handler in Zoom's gloox fork. From there: a revoke-token element carrying a web-domain attribute, then a /clusterswitch request against a domain of the attacker's choosing. Then a /releasenotes response with the update download URLs replaced, and an older, properly signed Zoom installer. Zero clicks. That part of the talk is worth reading in full; it is not about XML.

What the patch does#

Here is the fix, in addBinding in xmlparse.c, comment included:

c
// NOTE: While Expat does not validate namespace URIs against RFC 3986,
//       we have to at least make sure that the XML processor on top of
//       Expat (that is splitting tag names by namespace separator into
//       2- or 3-tuples (uri-local or uri-local-prefix)) cannot be confused
//       by an attacker putting additional namespace separator characters
//       into namespace declarations.  That would be ambiguous and not to
//       be expected.
if (parser->m_ns && (uri[len] == parser->m_namespaceSeparator)) {
  return XML_ERROR_SYNTAX;
}

Three lines and a test with two cases: <doc xmlns='one_two' /> parses, <doc xmlns='one&#x0A;two' /> errors. That shipped in Expat 2.4.5 on 18 February 2022, along with three unrelated CVEs and a credit line naming Fratric, Samanta Navarro, Google Project Zero and JetBrains.

Two weeks later it had to be walked back a bit. Some applications pass : as their namespace separator, which is precisely the character the twenty-year-old documentation advises against, but there they are. Rejecting every URI containing the separator broke them. So 2.4.7 added is_rfc3986_uri_char, an eighty-five-case switch statement over A-Za-z0-9, %, -._~, :/?#[]@ and !$&'()*+,;=. The check narrowed to this:

c
if (parser->m_ns && (uri[len] == parser->m_namespaceSeparator)
    && ! is_rfc3986_uri_char(uri[len])) {

If you chose a separator that can legally appear in a URI, you keep your old behaviour and your old problem. If you chose one that can't, Expat now guarantees you won't see it. The same release added a paragraph to expat.h. Splitting an expanded name back into pieces "may end up vulnerable" for a badly chosen separator, and "sane choices for a namespace separator are e.g. '\n' (line feed) and '|' (pipe)." Which was already true when fast_xml picked \n. It just wasn't yet enforced anywhere but the documentation.

Sources

  1. 1Expat Changes file: per-release changelog with CVE fix historyraw.githubusercontent.com
  2. 2XMPP Stanza Smuggling, or How I Hacked Zoom (Black Hat USA 2022)i.blackhat.com
  3. 3oss-security: Expat 2.4.5 released, includes 5 security fixesopenwall.com