In short: You prevent XXE in PHP by loading XML from foreign sources without the options LIBXML_NOENT, LIBXML_DTDLOAD and LIBXML_DTDVALID, rejecting documents with their own DTD and setting an entity loader that refuses external resources. Since libxml 2.9.0, PHP no longer substitutes entities by default, which is why libxml_disable_entity_loader() has been deprecated since PHP 8.0. From PHP 8.4 with libxml 2.13, LIBXML_NO_XXE is also available.
An Old Vulnerability That Does Not Go Away
XML external entities, XXE for short, are considered a solved topic by many developers. With modern libxml versions, PHP disabled the loading of external entities by default, and the old protective function was marked as deprecated with PHP 8.0. Nevertheless, XXE vulnerabilities keep turning up in reviews.
The reason is almost always one of three situations: someone set an option to solve an import problem and thereby switched entity substitution back on. A library does so internally. Or the application runs on a legacy system whose PHP was built with a very old libxml.
XXE is dangerous because the XML parser then loads content that is merely referenced in the document: local files or addresses on the network. Possible consequences are reading configuration files, requests to internal systems similar to SSRF, and overloading the server.
What Applies Since libxml 2.9 and PHP 8.0
The PHP manual describes the situation as follows: from libxml 2.9.0, the substitution of entities is disabled by default. External entities therefore no longer have to be switched off separately, unless internal entity references are to be resolved with certain options. This yields the following overview:
| Option | Effect | Risk with foreign XML |
|---|---|---|
LIBXML_NOENT |
substitutes entities | according to the manual, can facilitate XXE attacks |
LIBXML_DTDLOAD |
loads the external subset | enables loading of external entities |
LIBXML_DTDVALID |
validates against the DTD | can facilitate XXE attacks |
LIBXML_NONET |
forbids network access during loading | protective additional option |
LIBXML_NO_XXE |
disables external entities during substitution | protective, from PHP 8.4 and libxml 2.13.0 |
LIBXML_PARSEHUGE |
removes internal limits of the parser | facilitates overload, only for trusted data |
The misleading name of LIBXML_NOENT is a known trap: it sounds like "no entities" but does the opposite.
For developers this means: loading XML is not risky in itself, but every spot that deliberately changes the default behavior is. A search for these constants in the code is therefore the quickest way to a first assessment, before any elaborate review.
Defending Against XXE in PHP Securely: A Pattern for DOMDocument
The following example combines several layers of protection. It sets an entity loader that refuses every external resource, loads without risky options and rejects documents with their own DTD:
<?php
declare(strict_types=1);
function ladeFremdesXml(string $xml): DOMDocument
{
// Refuse external resources across the board
libxml_set_external_entity_loader(
static fn (?string $public, string $system, array $context) => null
);
$optionen = LIBXML_NONET;
if (defined('LIBXML_NO_XXE')) {
$optionen |= LIBXML_NO_XXE;
}
$vorher = libxml_use_internal_errors(true);
$dom = new DOMDocument();
$ok = $dom->loadXML($xml, $optionen);
libxml_use_internal_errors($vorher);
if (!$ok) {
throw new InvalidArgumentException('XML ungültig.');
}
if ($dom->doctype !== null) {
throw new InvalidArgumentException('Dokumente mit DTD werden nicht angenommen.');
}
return $dom;
}
According to the PHP manual, a return value of null in the loader blocks the loading of the external resource. OWASP points out that it is not enough to pass null directly to libxml_set_external_entity_loader(); it needs a function that returns null. The same options apply to SimpleXML and XMLReader. Wherever possible, OWASP recommends rejecting DTDs entirely.
If you can determine the data format yourself, JSON is usually the simpler choice for exchange between your own systems. For foreign formats such as SAML or Office files, XML remains unavoidable.
Finding Hidden XML Inputs
Many applications process more XML than their developers think. Go through this list:
- Interfaces: SOAP clients and servers, XML-RPC, payment and shipping services with XML responses.
- Login: Single sign-on with SAML is based on XML documents that come from outside.
- Imports: Product data, feeds, sitemaps, catalog formats.
- Uploads: SVG images are XML; Office formats such as DOCX and XLSX internally consist of XML files.
- Libraries: Composer packages for documents, spreadsheets or SAML that use libxml internally.
Two searches help in the code:
grep -rnE "LIBXML_(NOENT|DTDLOAD|DTDVALID|PARSEHUGE)" src/ vendor/ --include="*.php"
grep -rnE "(loadXML|simplexml_load_(string|file)|XMLReader|->load\()" src/ --include="*.php"
Matches in vendor/ are no cause for panic, but a reason to check the library's version and security advisories.
With SVG uploads, XXE is only one of several risks, because SVG files can also contain scripts. If your application does not absolutely need the format, do not allow it for uploads at all. Otherwise, process SVG only with the secure pattern above and deliver the files in such a way that the browser does not execute contained scripts.
Also watch out for error messages. If an application returns libxml's messages to users unfiltered, they may contain parts of the processed content. Errors belong in the log; the user receives a general message. In the example above, libxml_use_internal_errors(true) ensures that warnings do not end up in the output. Also limit the size of received XML documents before the parser even sees them.
Legacy Systems: When the Default Protection Is Missing
The protection from the default behavior depends on the libxml version PHP was built with. Which version is running is shown by php -r 'echo LIBXML_DOTTED_VERSION, PHP_EOL;' on the command line or the output of phpinfo() in a protected environment. Check the value for every PHP version on the server, because CLI and web server can use different builds. Container images bring their own libxml; there you check in the running container, not on the host.
Systems that already run on old PHP versions are particularly affected. For PHP 8.1 and older, the PHP project no longer provides official updates after PHP2Shell; what this means is described in PHP 8.1 and Older: What Applies Now. A legacy system with an outdated libxml and open interpreter vulnerabilities should not only be secured in code but migrated. Related risks when processing foreign data are described in Insecure Deserialization in PHP, fetching internal targets in Preventing Server-Side Request Forgery in PHP.
How does PHP SOS help?
- We find all places where your application and its libraries process XML from foreign sources.
- We remove risky parser options and build in a central, secured loading function.
- We check libxml and PHP versions on all servers and plan the migration for legacy systems.
- We test the changes with harmless test documents and document the result.
The review of your own software is described by the service Security Audit and Penetration Test.
Frequently asked questions
Are PHP applications automatically protected against XXE since PHP 8?
In the default case largely, because libxml no longer substitutes entities from version 2.9.0. The protection is lost, however, as soon as code sets options such as LIBXML_NOENT, LIBXML_DTDLOAD or LIBXML_DTDVALID, or PHP is built with a very old libxml version.
Should I still call libxml_disable_entity_loader?
No. The function has been deprecated since PHP 8.0 because loading external entities is disabled by default. For additional protection, the PHP manual recommends libxml_set_external_entity_loader and, from PHP 8.4 with a suitable libxml, the constant LIBXML_NO_XXE.
Where does my application process XML at all?
Often in unexpected places: SOAP interfaces, single sign-on with SAML, RSS feeds, sitemaps, product imports, SVG uploads and Office files, which internally consist of XML. Libraries can also process XML without it being visible in your own code.
What is the billion laughs attack?
A document with nested entities that grows enormously when resolved and consumes memory and computing time. It belongs to the same family as XXE. Protection comes from disabling entity substitution and rejecting documents with their own DTD.