Security Advisory Tobias Klein TKADV2026-001 www.trapkit.de ModSecurity v3 XML Request Body Processor Uninitialized Pointer Dereference Vulnerability Revision: 1.0 Release Date: 2026-10-17 Last Modified: 2026-10-17 Date Reported: 2026-07-03 Affected Software: ModSecurity v3.0.15, v3.0.16, and the v3/master branch prior to commit e979a73. Earlier v3.0.x releases and ModSecurity v2.x are not affected. Vendor URL: https://modsecurity.org/ Solution Status: Fixed (a patched release v3.0.17 is available) Time-to-fix: 89 days CVE ID: CVE-2026-73857 Weakness: CWE-824 (Access of Uninitialized Pointer), CWE-123 (Write-what-where Condition) Impact: Remote code execution, memory disclosure and denial of service, each demonstrated with a working proof of concept Severity: 8.1 High (CVSS v3.1) Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H 1 Vulnerability Description ModSecurity v3 (libmodsecurity) is prone to a remotely triggerable uninitialized pointer dereference vulnerability in its XML request-body processor, resulting in wild-pointer reads and writes as well as in indirect function calls through that uninitialized pointer. A remote, unauthenticated attacker can, with a single crafted HTTP request, cause a web server worker process that runs ModSecurity to dereference an uninitialized pointer. Several values that the attacker knows but cannot choose are then written through that pointer, each at a fixed offset from it. Because the attacker can influence the pointer, these become controlled writes: known values written to attacker-chosen addresses, a restricted write-what-where primitive. Which values are written, and at which offsets, depends on the version of the underlying XML parser (libxml2). The degree of control over the pointer value ranges from partial to full, depending on the worker process's heap allocator and heap state. Some libxml2 versions additionally read a function pointer through the uninitialized pointer and call it. If the uninitialized pointer is made to point into attacker-controlled memory, for example the attacker's own request body, both the address called and the argument passed to it are the attacker's, which yields remote code execution. Although the most visible symptom is the worker process terminating and being automatically restarted, this denial of service is the least severe consequence. The same dereference was demonstrated to escalate beyond a crash to memory disclosure and to remote code execution in the worker process's user context. Each of these outcomes was reproduced remotely with a working proof of concept. The defect requires SecParseXmlIntoArgs set to On or OnlyArgs and a configured SecArgumentsLimit. The former is a non-default setting, the latter part of ModSecurity's recommended configuration. 2 Technical Details ModSecurity's rules inspect incoming requests through named collections of variables. One central collection is ARGS, which holds a request's parameters (for example query-string and HTML form fields), so that rules can match against them. By default ARGS is populated only from such conventional parameters. When the SecParseXmlIntoArgs directive is enabled, ModSecurity additionally parses an XML request body and adds each XML leaf element's path and text value into ARGS. The purpose is to let existing rules inspect XML payloads the same way they inspect ordinary parameters, rather than treating the body as opaque. This XML parsing is performed with a libxml2 SAX parser, which invokes application callbacks as it walks the document. ModSecurity passes to these callbacks a user-data object that accumulates the current element path and value, and holds the parser state. That object is an instance of class XMLNodes. modsecurity-v3.0.16/src/request_body_processor/xml.h: .. 48 /* 49 * XMLNodes for parsing XML into args 50 */ 51 class XMLNodes { 52 public: 53 std::vector> nodes; 54 unsigned long int node_depth; 55 std::string currpath; 56 std::string currval; 57 bool currval_is_set; 58 Transaction *m_transaction; 59 // need to store context - this is the same as in xml_data 60 // need to stop parsing if the number of arguments reached the limit 61 xmlParserCtxtPtr parsing_ctx_arg; 62 63 explicit XMLNodes(Transaction *transaction); 64 ~XMLNodes(); 65 }; .. The XMLNodes constructor initializes every member except parsing_ctx_arg. modsecurity-v3.0.16/src/request_body_processor/xml.cc: .. 40 XMLNodes::XMLNodes(Transaction *transaction) 41 : nodes{}, 42 node_depth(0), 43 currpath(""), 44 currval(""), 45 currval_is_set(false), 46 m_transaction(transaction) 47 {} .. The XMLNodes object is allocated on the heap, and the allocator hands out memory without clearing it first. Since the constructor never assigns parsing_ctx_arg, the member keeps whatever data previously occupied that memory: an uninitialized pointer whose value depends on the process's prior heap activity. modsecurity-v3.0.16/src/request_body_processor/xml.cc: .. 51 /* 52 * SAX handler for parsing XML into args 53 */ 54 class MSCSAXHandler { 55 public: .. 82 void onEndElement(void * ctx, const xmlChar *localname) { .. 90 if (xml_data->m_transaction->addArgument("XML", xml_data->currpath, xml_data->currval, 0) == false) { 91 xmlStopParser(xml_data->parsing_ctx_arg); 92 } .. The uninitialized parsing_ctx_arg member is read in the SAX end-element callback MSCSAXHandler::onEndElement(). For each XML leaf element (an element without child elements), this callback adds one argument to the ARGS collection. On line 90 it checks, via addArgument()'s return value, whether the configured argument limit has been reached. addArgument() returns false once the running argument count reaches SecArgumentsLimit (1000 in the shipped modsecurity.conf-recommended), so a request body that contains more leaf elements than the configured limit reaches line 91 with addArgument() == false. On line 91 parsing_ctx_arg is passed to libxml2's xmlStopParser(). For this call to work, parsing_ctx_arg must point to the active XML parser. MSCSAXHandler::onEndElement() does not set this pointer but only uses it. The pointer is meant to be set earlier, in XML::processChunk(). Inside XML::processChunk(), the XMLNodes member parsing_ctx_arg is assigned in only one place, and only on the path that handles a body delivered in several chunks ("Not a first invocation"): modsecurity-v3.0.16/src/request_body_processor/xml.cc: .. 217 bool XML::processChunk(const char *buf, unsigned int size, 218 std::string *error) { .. 223 if (m_data.parsing_ctx == NULL && m_data.parsing_ctx_arg == NULL) { 224 /* First invocation. */ .. 275 } 276 277 /* Not a first invocation. */ 278 if (m_data.parsing_ctx != NULL && .. 282 m_data.xml_parser_state->parsing_ctx_arg = m_data.parsing_ctx_arg; .. Two distinct members share the name parsing_ctx_arg, and telling them apart is essential to understanding the defect. The first, m_data.parsing_ctx_arg, is the live libxml2 parser context. It is created on the first invocation and is therefore set correctly. The second, m_data.xml_parser_state->parsing_ctx_arg, is the XMLNodes member that onEndElement() later reads. Line 282 above copies the first into the second. Only this second member, the one in XMLNodes, is ever left uninitialized. In the remainder of this advisory, every reference to parsing_ctx_arg being "never set" means this XMLNodes member. ModSecurity hands the parser the complete body in one call: XML::processChunk() is invoked once (not in a loop), with the whole buffer (m_requestBody.str().c_str()) and its full length (m_requestBody.str().size()), immediately followed by complete(), as illustrated in the following code snippet. Because there is only this one call, the check at line 223 is always true and only the "First invocation" path runs. The "Not a first invocation" path, which holds the sole assignment at line 282, is never taken. The XMLNodes member parsing_ctx_arg is therefore never set and keeps the leftover bytes from the freshly allocated heap memory. modsecurity-v3.0.16/src/transaction.cc: .. 674 int Transaction::processRequestBody() { .. 718 m_xml->processChunk(m_requestBody.str().c_str(), 719 m_requestBody.str().size(), 720 &error); 721 m_xml->complete(&error); .. When the argument limit is hit, xmlStopParser() is called on the uninitialized pointer (see line 91 above). What xmlStopParser() does internally depends on the libxml2 version. The following walk-through uses libxml2 2.9.13 as a representative example. That is the version Red Hat Enterprise Linux 9 and Rocky Linux 9 shipped when this advisory was written. In this version, xmlStopParser() forwards to xmlHaltParser(), which performs the first dereference of the uninitialized parsing_ctx_arg pointer: libxml2-2.9.13/parser.c: .. 12545 xmlHaltParser(xmlParserCtxtPtr ctxt) { 12546 if (ctxt == NULL) 12547 return; 12548 ctxt->instate = XML_PARSER_EOF; 12549 ctxt->disableSAX = 1; .. 12552 if (ctxt->input != NULL) { .. 12557 if (ctxt->input->free != NULL) { 12558 ctxt->input->free((xmlChar *) ctxt->input->base); .. 12579 xmlStopParser(xmlParserCtxtPtr ctxt) { 12580 if (ctxt == NULL) 12581 return; 12582 xmlHaltParser(ctxt); 12583 ctxt->errNo = XML_ERR_USER_STOP; 12584 } .. The NULL checks on lines 12546 and 12580 do not help: the uninitialized parsing_ctx_arg holds leftover heap data rather than a clean NULL, so it passes both checks and the store on line 12548 executes. That store, ctxt->instate = XML_PARSER_EOF, writes a fixed 32-bit constant at a fixed offset from the uninitialized parsing_ctx_arg pointer. Because the attacker can influence the value of this uninitialized pointer, the result is a fixed value written to an attacker-chosen address: the restricted write-what-where primitive described in Section 1. xmlHaltParser() continues past this store. On line 12552 it loads ctxt->input from the memory the uninitialized pointer points to, and on line 12558 it calls the function pointer ctxt->input->free with ctxt->input->base as its argument. If the uninitialized pointer is made to point into attacker-controlled memory, both the address called and the argument passed to it are the attacker's. That is the step behind the code-execution impact described in Section 1. The code that frees the parser input, lines 12552 to 12558 above, is present up to and including libxml2 2.12.10. From 2.13.0 on, the stop path frees nothing: it writes its constants through the uninitialized pointer and returns, with no indirect call. Those installations are therefore exposed to the write-based effects but not to the indirect call that libxml2's own stop path performs. Even there, the writes alone were shown to redirect a worker's execution flow, though the step to code execution was not demonstrated with a working proof of concept. To summarize: by sending a single well-formed XML request body with more leaf elements than the configured argument limit, an attacker drives the argument count to SecArgumentsLimit. From then on addArgument() returns false, and the SAX end-element callback passes the XMLNodes member parsing_ctx_arg to xmlStopParser(), which forwards it to xmlHaltParser(). Because the body is delivered in one chunk, the only code path that sets the XMLNodes member (XML::processChunk(), line 282) never runs, so parsing_ctx_arg still holds leftover heap data. xmlHaltParser() and xmlStopParser() then write values the attacker knows but cannot choose, each at a fixed offset from this uninitialized pointer, so each lands at an address the attacker chooses. A denial of service, a memory disclosure and a redirect of a worker's execution flow were demonstrated this way; what such a write reaches beyond that depends on what occupies the chosen address. On libxml2 up to 2.12.10, xmlHaltParser() also calls a function pointer that it reads through the uninitialized pointer. If that pointer is made to point into attacker-controlled memory, the call can yield arbitrary code execution. 3 Solution Upgrade to a fixed release of ModSecurity (>= v3.0.17). As a workaround, the vulnerability can be avoided by ensuring the SecParseXmlIntoArgs directive is not set to On or OnlyArgs. This is the default state (Off), so configurations that do not explicitly enable XML-into-ARGS parsing are not affected. 4 Disclosure Timeline 2026-07-03 - Initial vendor notification 2026-07-03 - Initial vendor response 2026-07-16 - Fix committed to the v3/master branch (commit e979a73) 2026-09-29 - Patched release available (ModSecurity v3.0.17) 2026-09-30 - Vendor advisory published (GHSA-jx3r-phvx-2jmj) 2026-10-17 - Release date of this security advisory 5 Credit This vulnerability was discovered and documented by Tobias Klein. Contact and further advisories: https://www.trapkit.de/ 6 References ModSecurity advisory: https://github.com/owasp-modsecurity/ModSecurity/security/advisories/GHSA-jx3r-phvx-2jmj Fix commit: https://github.com/owasp-modsecurity/ModSecurity/commit/e979a73e89d60cefdf42b41bb38bf1e456d7909f Patched release: https://github.com/owasp-modsecurity/ModSecurity/releases/tag/v3.0.17 URL for this advisory: https://www.trapkit.de/advisories/TKADV2026-001.txt 7 Revision History Revision 1.0 - Initial public release 8 Disclaimer The information within this advisory may change without notice. Use of this information constitutes acceptance for use in an AS IS condition. There are no warranties, implied or express, with regard to this information. In no event shall the author be liable for any direct or indirect damages whatsoever arising out of or in connection with the use or spread of this information. Any use of this information is at the user's own risk. Copyright 2026 Tobias Klein. All rights reserved.