Skip to main content

SOAP picoCTF 2023 Solution

A web challenge testing a classic XML parsing vulnerability that can expose files on the server.

Published: July 19, 2023Updated: August 25, 2026

Description

The SOAP stock-check endpoint fails to sanitize XML entities. Inject an external entity to have the backend disclose /etc/passwd.

Install Burp Community, configure your browser to use the Burp proxy, and accept the Burp CA so HTTPS targets work.

In Burp Proxy, turn Intercept on. Click any Details button on the challenge site and the POST stops in your hands.

Right-click the intercepted request and Send to Repeater so you can iterate without re-clicking the page.

bash
# Sanity check first: file:///etc/hostname proves XXE works without touching sensitive files
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/hostname"> ]>
<data><ID>&xxe;</ID></data>
bash
# Once XXE is confirmed, swap to /etc/passwd to grab the flag
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<data><ID>&xxe;</ID></data>

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
XXE in a SOAP endpoint, classic web-app territory. The web challenges and real-world bug patterns guide covers XXE, SSRF, and the family of XML-driven server bugs in more depth.
  1. Step 1Set up Burp and intercept the request
    Observation
    The target is a SOAP endpoint that accepts XML, so the attack means editing the raw request body to add a crafted DOCTYPE. An interception proxy such as Burp is the standard way to pause a request and edit it before it reaches the server.
    Install Burp Community, point your browser proxy at 127.0.0.1:8080, install the Burp CA cert, then enable Proxy → Intercept → On. Clicking any Details button pauses the POST mid-flight.
    Learn more

    Burp Suite sits between your browser and the server, intercepting HTTP/HTTPS requests so you can read and modify them. Three pieces have to be in place before Intercept does anything useful: (1) Burp running and listening on 127.0.0.1:8080 by default, (2) your browser configured to use that as its HTTP/HTTPS proxy (FoxyProxy or system proxy settings), and (3) Burp's CA certificate installed in the browser's trust store so HTTPS targets do not trigger TLS errors.

    Once Intercept is on, every request the browser makes hits Burp first and waits for you. Click a Details button on the challenge page; in the Proxy tab a POST appears containing the SOAP <data><ID>1</ID></data> body. Right-click the request and pick Send to Repeater: that copies the full request (headers, cookies, body) into Burp's Repeater tab where you can edit and resend without going back through the browser. Forward the original from Intercept so the page does not stall.

  2. Step 2Sanity-check XXE with /etc/hostname
    Observation
    The SOAP body is XML with no visible entity sanitization, so the parser may resolve external SYSTEM entities. Test that with /etc/hostname first: it proves the primitive works before you go after anything sensitive.
    Replace the ID child with an XXE entity pointing at /etc/hostname first. Less sensitive, smaller response, and either it works or you know XXE is filtered before you waste time on /etc/passwd.
    bash
    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/hostname"> ]>
    <data><ID>&xxe;</ID></data>
    What didn't work first

    Tried: Prepend the DOCTYPE declaration to the existing XML body instead of replacing the whole payload

    That leaves the parser with two root-level structures, the original data element and the new DOCTYPE. Most parsers reject it on syntax and never resolve the entity. Replace the whole body with one clean, well-formed document instead.

    Tried: Use a PUBLIC entity declaration instead of SYSTEM for the /etc/hostname URI

    PUBLIC takes two arguments, a public identifier and a system URI. Give it only a file path and you get a syntax error with no read at all. SYSTEM takes one URI and is the right keyword when you control the target directly.

    Learn more

    The full body replacement matters: paste the XML declaration, the DOCTYPE with the entity definition, and the same root and child element names the intercepted request used (data and ID here) with &xxe; as the value. Just prepending the DOCTYPE to the existing body works only if the server is permissive about extra processing instructions. Replacing the whole thing is the dependable form.

    SYSTEM vs PUBLIC. In a DOCTYPE entity declaration, SYSTEM takes a single URI and the parser fetches it directly. PUBLIC takes a public identifier plus a system identifier (the URI) for catalog-based resolution. For XXE you almost always use SYSTEM because you control the URI and want the parser to fetch it verbatim.

    A successful response echoes the file contents inside the response body where the ID value would normally appear. /etc/hostname is one short line (the container hostname), so the answer is unmistakable: a single word printed back to you. If you see an XML parse error or the original lookup response unchanged, XXE is either filtered or the response does not echo the parsed entity.

  3. Step 3Swap to /etc/passwd for the flag
    Observation
    The hostname read came back, so the parser does resolve SYSTEM file URIs. The description points at /etc/passwd, and authors often hide flags there because it is world-readable, so swap the URI.
    Once /etc/hostname comes back, change file:///etc/hostname to file:///etc/passwd. The flag is appended to /etc/passwd by the challenge.
    bash
    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
    <data><ID>&xxe;</ID></data>

    Expected output

    root:x:0:0:root:/root:/bin/bash
    ...
    picoCTF{XML_3xtern@l_3nt1t1ty_4db...}
    What didn't work first

    Tried: Send the /etc/passwd entity payload directly without first confirming XXE via /etc/hostname

    Skip the sanity check and you cannot tell a filtered XXE from a /etc/passwd that simply holds no flag. If hostname echoes back and passwd does not, the vector works and the flag is elsewhere. Two steps buy you that distinction.

    Tried: Try fetching /etc/shadow instead of /etc/passwd to get password hashes

    Only root and the shadow group can read /etc/shadow. The challenge process runs unprivileged, so it gets permission denied instead of contents. The flag sits in /etc/passwd precisely because that file is world-readable.

    Learn more

    The DOCTYPE declaration <!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]> tells the parser: define an entity named xxe whose value is the contents of /etc/passwd. When the parser later resolves &xxe; in the document body, it substitutes the file contents. The server then includes that ID value in its response and you get the file back.

    /etc/passwd is world-readable on Linux: one line per user with username, UID, GID, home directory, and shell. CTF authors typically append the flag to that file for predictable XXE proofs. In real engagements the same primitive is used to read app config (/etc/nginx/nginx.conf), private keys, AWS credentials, or source code, so the technique generalises straight from CTF to production-impact bugs.

    The fix on the defender side is to disable external entity processing in the XML parser: FEATURE_SECURE_PROCESSING in JAXP, resolve_entities=False in lxml, etc. JSON APIs do not have an equivalent reference mechanism and avoid the whole class of bug.

Interactive tools
  • URL Encoder / DecoderEncode and decode URL-encoded (percent-encoded) strings. Useful for web exploitation challenges involving query parameters, form data, and HTTP headers.
  • Regex TesterTest regular expressions against a string with live match highlighting, flag toggles, and common CTF pattern shortcuts.

Flag

Reveal flag

picoCTF{XML_3xtern@l_3nt1t1ty_4db...}

The challenge appends the flag to /etc/passwd, so that is the file to read once the XXE primitive is confirmed.

Key takeaway

XXE injection happens when an XML parser resolves external entity references, letting an attacker point a SYSTEM URI at local files or internal network resources. Anything that accepts XML with that setting enabled is exposed: SOAP services, upload endpoints, document processors. The fix is at the parser, disabling DTD processing or external entities outright, because input sanitization cannot reliably block every vector.

Related reading

Useful tools for Web Exploitation

Where to go next