The Exploit
The attacker needs to be able to deliver a crafted XML document to an XML parser that uses the libexpat library (version < 2.8.2). No authentication is required if the parser is exposed publicly; in the worst case, the attacker simply sends a single HTTP request with a malicious XML body.
POST /xml/parse HTTP/1.1
Host: target.website.com
Content-Type: application/xml
<doc>Hello world!<![CDATA[<call_parser_free/>]]></doc>
When the character data handler callback (e.g., m_characterDataHandler) is triggered by the CDATA content, it calls XML_ParserFree(parser). On the vulnerable parser, this call succeeds, immediately freeing the parser structure while it is still executing the handler's stack frame. The result is a use-after-free condition: the XML parser continues accessing freed memory, typically causing a segmentation fault or allowing an attacker to redirect execution to controlled data.
What the Patch Did
Before (vulnerable code in xmlparse.c):
void XMLCALL
XML_ParserFree(XML_Parser parser) {
TAG *tagList;
if (parser == NULL)
return;
/* free m_tagStack and m_freeTagList */
// ... rest of free logic, no check for reentrancy
After (fixed code in xmlparse.c):
void XMLCALL
XML_ParserFree(XML_Parser parser) {
TAG *tagList;
if ((parser == NULL) || isCalledFromInsideHandler(parser))
return;
// ... rest of free logic now protected by reentrancy check
The patch added a call to isCalledFromInsideHandler() as a reentrancy guard. This function tracks whether the current execution is already inside a user-defined handler callback (like m_characterDataHandler, m_startElementHandler, etc.) using a depth counter incremented by beforeHandler() and decremented by afterHandler() wrappers. When the depth counter is non-zero, the guard blocks all dangerous reentrant API calls—XML_Parse, XML_ParseBuffer, XML_GetBuffer, XML_ParserFree, XML_ParserReset, and XML_ResumeParser—from within a handler.
Root Cause
CWE-416: Use-After-Free. The dataflow begins when an attacker-controlled XML document contains content (character data or start/end tags) that triggers a user-defined handler. In a handler like m_characterDataHandler, the callback has access to the XML_Parser pointer (often via XML_GetUserData()). The attacker, by design of the malicious XML, causes the handler to call XML_ParserFree() on that very parser. Because libexpat prior to 2.8.2 tracked no reentrancy state, XML_ParserFree() would immediately free the parser structure and its buffer pool. However, the handler's activation record and the parser's internal state machine (which called the handler) remain alive on the stack, leading to continued dereference of freed memory. The unbounded reentrancy—where a handler can call public API functions—crosses the trust boundary between "application code" and "library internals" without any validation that a handler is not recursively entering the library.
Why It Works
The single load-bearing line is isCalledFromInsideHandler(parser) in XML_ParserFree. If that check were removed, the bug would still be fully exploitable: an attacker would call XML_ParserFree from a handler and immediately free the parser while it is in use. The engineer added the same check to XML_Parse, XML_ParseBuffer, XML_GetBuffer, XML_ParserReset, and XML_ResumeParser primarily for defence-in-depth. While XML_ParserFree is the most dangerous (it destroys the parser), the other functions—like XML_Parse—could cause similar corruption by re-entering the parser's state machine while it is already mid-parse, triggering stack corruption, double-frees, or infinite recursion. The beforeHandler/afterHandler wrappers that maintain the depth counter are the supporting mechanism: without them, isCalledFromInsideHandler would never return XML_TRUE, so the guard itself would be useless. The patch thus enforces a complete ring: the counters track handler context, and every reentrant public API checks that context before proceeding.
Hardening Checklist
- Add a centralized reentrancy guard (
isCalledFromInsideHandlerequivalent) to all public API functions that modify or destroy internal state—especially parse, reset, free, and buffer operations—to prevent callback recursion. - Wrap every user-defined callback invocation with
beforeHandler()/afterHandler()that increments/decrements a thread-local or per-parser depth counter, ensuring the guard never misses handler entry/exit. - Use
assert()or static analysis annotations to verify that no code path from a handler can directly call the library's public API without the guard checking the counter—this catches regressions at compile or test time. - In languages with destructors (C++ RAII, Rust), tie the
isCalledFromInsideHandlercheck to a runtime panic/abort if a destructor is invoked while the parser is inside a handler, preventing silent corruption. - For parsers that accept untrusted input (XML, JSON, YAML), always sandbox the parser in a separate process or thread to limit the blast radius of such a memory-safety bug—use
fork()orsubprocessto isolate.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-50219