TechEarl

XInclude Attacks: The XXE-Adjacent Variant Audits Routinely Miss

Ishan Karunaratne⏱️ 14 min readUpdated
Share thisCopied
XInclude attack inlining file content via xi:include

XInclude is the variant of the XXE story that does not look like XXE. It does not need a DOCTYPE. It does not declare an entity. It does not match any of the regex patterns a code review or a WAF rule is grepping for, because the only thing on the wire is an xi:include element bound to a specific W3C namespace. The application has to opt into XInclude processing through a separate libxml entry point, which sounds defensive until you discover the opt-in lives one line away from the "render the parsed XML" path in a legacy XSLT pipeline that nobody has touched in six years. This article is the deep dive on the XInclude variant: what the spec is, why it sidesteps every XXE defence built around entity resolution, the minimal exploit, and the per-stack flags that actually block it.

This is the variant companion to the XML External Entity (XXE) deep dive, which covers the broader XXE family (in-band file read, out-of-band exfil, billion laughs). Read that first for the entity-based attacks; this article picks up where it stops, with the XInclude code path that the entity-based defences leave wide open.

In short: what XInclude attacks are

XInclude is a W3C specification, separate from XML's built-in entity system, that lets one XML document include the contents of another via the xi:include element bound to the http://www.w3.org/2001/XInclude namespace. When an application calls a parser's XInclude processor on attacker-controlled XML, an attacker can supply an xi:include element whose href attribute points at a local file or an internal HTTP URL, and the parser substitutes the fetched bytes into the document tree before the application renders it. The attack does not need a DOCTYPE, does not declare any entities, and does not trip any defence configured around "disable external entities" or "disallow DOCTYPE". The fix is to refuse to call the XInclude processor on untrusted input, or to constrain href to an allow-list of safe URIs; the per-stack flags below are the actual switches. XInclude bugs are routinely classified under CWE-611 or under "XXE" in CVE descriptions, which understates how common they are in legacy SOAP, SAML, and XSLT pipelines.

What XInclude is

XInclude (XML Inclusions) is published by the W3C as XInclude 1.0, with a separate 1.1 draft that did not see wide adoption. It defines an element, <xi:include>, in the namespace http://www.w3.org/2001/XInclude, that asks the processor to replace itself with the parsed (or text) content of a referenced resource. The canonical legitimate use case is composing a large XML document from reusable fragments without inventing a custom include mechanism per format: a DocBook manual that imports each chapter as a separate file, a SOAP envelope that pulls a shared header fragment, an Ant build that includes a common target file.

The element has two interesting attributes:

  • href is the URI to include. It can be file:///etc/hostname, http://internal-service/admin, or a relative path that resolves against the parsing document's base URI.
  • parse is either xml (the default, parse the included resource as XML and graft its root into the host document) or text (read the bytes as text and substitute them as a text node).

That is the whole surface area an attacker needs. Everything else (the xpointer attribute, the fallback child, the accept and accept-language HTTP hints) is decoration on top of "fetch this URL, paste it in".

Why this matters for security audits

The XXE remediation playbook every team eventually adopts is built around the entity system: disable external general entities, disable external parameter entities, disallow the DOCTYPE declaration outright. PHP's safe defaults (no LIBXML_NOENT, no LIBXML_DTDLOAD), Java's disallow-doctype-decl feature, .NET's DtdProcessing.Prohibit, Python's defusedxml. All of those defences fire on the entity code path inside the parser.

XInclude is not on that code path. It is processed by a separate function (xmlXIncludeProcess in libxml C, DOMDocument::xinclude in PHP, the XIncludeAware factory flag in Java) that takes the already-parsed tree and walks it looking for elements in the XInclude namespace. The parser does not have to load a DTD. It does not have to substitute an entity. It only has to call the include processor.

The audit-side consequence: a code review that greps for <!ENTITY in source code, in WAF rules, in test fixtures, will find nothing. A test plan that confirms LIBXML_NOENT is unset will pass. A SAST tool tuned to flag DTD-related sinks will be silent. The only signal that the application is vulnerable is a call to the XInclude processor on input that has not been schema-validated against a schema that forbids the XInclude namespace, and that call is one line in a hundred-thousand-line codebase.

I have seen this pattern in three places consistently: legacy XSLT pipelines where someone enabled XInclude to support a single internal report and never disabled it; SOAP / WS-Security stacks where XInclude is part of the SOAP message-composition machinery; and SAML / WS-Federation parsers where the assertion-processing layer enabled XInclude for a now-forgotten interop reason. None of these systems show up in a typical "we hardened XXE" review.

The minimal exploit payload

The payload has no DOCTYPE and no <!ENTITY> declaration. Only the XInclude namespace and one xi:include element:

xml
<?xml version="1.0"?>
<bookmarks xmlns:xi="http://www.w3.org/2001/XInclude">
  <bookmark>
    <name><xi:include href="file:///etc/hostname" parse="text"/></name>
    <url>http://x</url>
  </bookmark>
</bookmarks>

The application parses the document with whatever its standard XML reader is. If that reader is configured safely against the entity-based attacks (no external entities, no DTD), parsing succeeds and produces a tree that still contains the literal xi:include element, because the regular XML parser does not interpret it. The vulnerability fires on the next line:

php
$dom->xinclude();

DOMDocument::xinclude walks the tree, finds every element in the XInclude namespace, fetches each referenced resource, and replaces the include element with the fetched content. parse="text" returns the raw bytes of /etc/hostname as a text node. The application then iterates <bookmark> nodes, renders each <name> into HTML, and the container's hostname lands in the response.

The same payload with parse="xml" (the default) would attempt to parse the included file as XML. That fails for non-XML files like /etc/hostname or /etc/passwd, but it opens a different door for any file that happens to be XML, including the application's own configuration files, and it lets a chained attack pull in a hostile DTD via the included document's own DOCTYPE if external-entity loading happens to be enabled on the secondary parse. parse="text" is the variant that just works against arbitrary files.

Why the application has to opt in (and why that does not save you)

XInclude processing is not automatic. The parser builds the tree, the application has to explicitly call the include processor on it. Across the major stacks:

  • PHP: $dom->xinclude() on a DOMDocument instance.
  • Java: DocumentBuilderFactory.setXIncludeAware(true), then the resulting parser processes XInclude as part of parse().
  • .NET: There is no first-class XInclude API in System.Xml. XInclude processing happens through XSLT pipelines or third-party libraries (Mvp.Xml) where the resolver settings matter.
  • Python: lxml.etree.ElementTree.xinclude() on a parsed tree, or via the XInclude class in lxml.ElementInclude.

That opt-in is exactly the window the vulnerability lives in. The application enabled XInclude because it had a legitimate reason: it composes its own XML responses from fragments, it supports an XSLT transform that uses XInclude for templating, it interoperates with a third-party system that ships XInclude-bearing payloads. Whoever wired up the opt-in did so for a known-internal, trusted-source flow. Then the same code path got reused for a user-facing XML upload, an inbound webhook, or an admin-only import that turned out to be reachable by an authenticated low-privilege user, and the opt-in stopped being safe.

The fix is never "find the call site and switch the flag off" without first understanding what depends on it. It is "constrain what XInclude can do": either schema-validate the input first so that any xi:include element raises an error before processing, or configure the parser's resource resolver to reject any href value that does not match an allow-list of trusted local fragments.

Walking a working chain against the xxe-basic lab

The techearl-labs xxe-basic lab parses XML with LIBXML_NOENT | LIBXML_DTDLOAD | LIBXML_NOCDATA and then explicitly calls $dom->xinclude() on the parsed tree before iterating <name> nodes for the response. That is the unsafe combination for this article.

Bring it up from the lab repo:

bash
docker compose up xxe-basic

The relevant endpoint is POST /import.php at http://localhost:8086. It reflects every <name> value back inside a <strong> element in the HTML response.

Save the XInclude payload from the previous section to xinclude.xml, then:

bash
curl -s -X POST --data-binary @xinclude.xml \
  -H 'Content-Type: application/xml' \
  http://localhost:8086/import.php

The response includes a bookmark whose <strong> contains the container's hostname (something like a3f7d2e8b1c4). The parser built a tree containing the literal xi:include element, the application called xinclude(), libxml fetched the contents of /etc/hostname from the container's filesystem, substituted them as a text node, and the application echoed them.

Switch the href to file:///etc/passwd and the same flow returns the container's /etc/passwd. Switch it to an internal HTTP URL (http://xxe-basic-collab/probe) and the parser issues an outbound HTTP request, which is the SSRF tier of the primitive: the same XInclude path reaches any HTTP endpoint the application's network position can reach. With parse="text" the response body is substituted into the document; with parse="xml" the response is parsed as XML and only the XML structure lands in the tree. The SSRF side-effect (the request being made) fires either way.

parse="text" vs parse="xml"

The two parse modes give the attacker different primitives.

parse="text" is the cleanest file-disclosure primitive. The included resource is read as bytes and substituted as a text node. Any file the parsing process can read becomes readable: /etc/passwd, /etc/hostname, /proc/self/environ, application configuration files, .env files, SSH private keys if the process happens to have them, depending on the deployment. Combined with the parser's URI scheme handlers, it can also read from non-file:// schemes if those handlers are enabled (libxml supports http, https, ftp, and on some builds more exotic schemes).

parse="xml" opens a deeper attack surface. The included resource is parsed as XML in its own right. That means the included document carries its own parsing context, including its own DOCTYPE declaration. An attacker who controls a URL the application will fetch (via parse="xml") can return an XML document that defines its own external entities, which the secondary parse may resolve depending on the parser configuration of the include processor. This is the path by which XInclude can be chained into out-of-band exfil even when the outer parse is configured safely: the safety flags applied to the top-level reader are not automatically applied to the included document's parse.

The defensive posture is the same in both cases (do not call XInclude on untrusted input), but the upside for the attacker is different. parse="text" is the SSRF / arbitrary-file-read primitive; parse="xml" is the chain into deeper XXE territory.

The fix per stack

PHP

Do not call $dom->xinclude() on attacker-controlled XML. That is the only reliable defence. If you have a legitimate need for XInclude (template composition, a documented import format), either restrict the call to inputs that came from a trusted source the application controls, or constrain the href values against an allow-list before invoking the include processor. The PHP docs for DOMDocument::xinclude note that the method accepts a libxml option bitmask; passing LIBXML_NONET (no network access) blocks http:// / https:// href values but does nothing to file://. Combine it with an allow-list pass over the tree first.

php
$dom = new DOMDocument();
$dom->loadXML($input);

foreach ($dom->getElementsByTagNameNS('http://www.w3.org/2001/XInclude', 'include') as $inc) {
    throw new RuntimeException('XInclude not permitted in this context');
}

$dom->xinclude(LIBXML_NONET);

The pre-check rejects any input that contains an xi:include element at all. If you must allow some, intersect the href attribute with an allow-list of safe local URIs before letting the include processor run.

Java

DocumentBuilderFactory.setXIncludeAware(false) is the default in current JDKs, but the default has been flipped on for individual factories in production codebases enough times that I would explicitly assert it rather than rely on it. Pair it with the secure-processing feature:

java
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setXIncludeAware(false);
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);

SAXParserFactory, XMLInputFactory, and TransformerFactory each have their own XInclude flag (or, for the transformer, a property on the underlying reader). The OWASP XXE Prevention Cheat Sheet enumerates them per factory; configure every factory the application instantiates, not just the one in the main XML parse path.

.NET

The System.Xml stack does not process XInclude as a first-class feature. The risk on .NET comes through XSLT pipelines (XslCompiledTransform) and third-party libraries like Mvp.Xml. The right defence is the same as the rest of the XXE story: set XmlResolver = null on the reader and the transform, refuse to resolve external resources, and require explicit opt-in for any included document.

csharp
var settings = new XmlReaderSettings
{
    DtdProcessing = DtdProcessing.Prohibit,
    XmlResolver = null
};
using var reader = XmlReader.Create(input, settings);

If a third-party library is doing XInclude processing in the request pipeline, that library has its own resolver, and that resolver is what you need to constrain. Audit any non-stdlib XML processing in the codebase before assuming the platform defaults cover you.

Python

The lxml library wraps libxml and exposes XInclude through tree.xinclude() and the lxml.ElementInclude module. Do not call either on untrusted input. The recommended posture for any code that parses untrusted XML is to use defusedxml, which wraps lxml, ElementTree, minidom, and pulldom, and raises on entity definitions, external entities, and XInclude by default. The defusedxml.lxml module specifically blocks XInclude processing in its parse and fromstring helpers.

python
from defusedxml.lxml import fromstring
tree = fromstring(untrusted_xml)

For code that has to use raw lxml and that genuinely needs XInclude in some controlled context, configure the parser to refuse network access and refuse DTD loading, and pre-walk the tree to enforce an href allow-list before calling xinclude():

python
from lxml import etree

parser = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False)
tree = etree.fromstring(untrusted_xml, parser)

XINCLUDE_NS = 'http://www.w3.org/2001/XInclude'
for inc in tree.iter(f'{{{XINCLUDE_NS}}}include'):
    raise ValueError('XInclude not permitted in this context')

tree.xinclude()

The standard library xml.etree.ElementTree is safe by default in modern Python (no XInclude support, no external entity resolution). The risk is concentrated in lxml and in older third-party packages that wrap it.

Real-world incidents

XInclude-specific CVEs are rarer than entity-based XXE because CVE descriptions tend to lump both under "XXE" or under CWE-611, which understates how often the underlying root cause is the XInclude code path rather than the entity one. A few illustrative incidents, with the standard caveat that you should pull the affected versions from the vendor advisory before quoting them in production:

  • Spring Framework's XInclude exposure in SAML / OXM components. Spring's OXM (Object/XML Mapping) layer has had a series of advisories over the years where the underlying XML reader was configured without setXIncludeAware(false) on at least one factory in the pipeline; the SAML extension carried similar findings tied to the SAML assertion parser. NEEDS-MANUAL-CHECK on specific CVE IDs.
  • Apache CXF WSS / WS-Security XInclude in SOAP attachments. The WS-Security stack has historically processed XInclude as part of envelope composition; older CXF releases shipped without the secure-processing feature consistently applied across all XML readers in the request pipeline. Worth checking the vendor advisory feed for the exact affected branches. NEEDS-MANUAL-CHECK on specific CVE IDs.
  • WS-Federation and SAML SSO parser disclosures. A recurring pattern across multiple identity-provider products: the assertion-processing layer enabled XInclude for a documented interop reason and then accepted assertions whose xi:include elements pointed at local files. NEEDS-MANUAL-CHECK on specific CVE IDs.

The general lesson is that XInclude findings cluster in three places: SOAP / WS-* stacks where envelope composition uses include semantics; SAML / federated-identity parsers where the assertion-processing layer reuses an internal XML reader configured for trusted input; and XSLT pipelines that enabled XInclude for a templating feature that outlived its original justification. If your application has any of those three components, the audit question is not "do we disable external entities" but "do we call the XInclude processor anywhere in the request path".

Where to go next

The parent article is the XML External Entity (XXE) deep dive, which covers the entity-based variants this article deliberately skips: in-band file read via <!ENTITY>, the recursive parameter-entity chain for out-of-band exfil, and the billion-laughs DoS. The sibling variants under the same hub are blind XXE OOB and billion laughs attack. For the wider surface area, back up to the web application security vulnerabilities taxonomy.

Sources

Authoritative references this article was fact-checked against.

Tagsxxexincludexml-securitylibxml

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts