SOAP picoCTF 2023 Solution

Published: July 19, 2023

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"> ]>
<stockCheck><productId>&xxe;</productId></stockCheck>
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"> ]>
<stockCheck><productId>&xxe;</productId></stockCheck>

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 1
    Set up Burp and intercept the request
    Observation
    I noticed the challenge involved a SOAP endpoint that accepted XML input, which meant modifying the raw request body was necessary to inject a crafted DOCTYPE payload, and an interception proxy like Burp Suite is the standard tool for pausing and editing HTTP requests before they reach 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 <stockCheck><productId>...</productId></stockCheck> 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 2
    Sanity-check XXE with /etc/hostname
    Observation
    I noticed the SOAP body used XML and there was no visible entity sanitization, which suggested the parser might resolve external SYSTEM entities; testing with /etc/hostname first confirmed the XXE primitive without risking a noisy read of a sensitive file like /etc/passwd before the attack vector was proven.
    Replace the productId 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"> ]>
    <stockCheck><productId>&xxe;</productId></stockCheck>
    What didn't work first

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

    The parser receives a malformed document with two root-level structures: the original stockCheck plus the new DOCTYPE. Most XML parsers reject the document at the syntax level and return a parse error rather than resolving the entity. Replacing the entire body with a clean, well-formed XML document is the reliable approach.

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

    PUBLIC requires two arguments - a public identifier string and a system URI. Supplying only a file path where the public identifier should go causes a syntax error and no file read occurs. SYSTEM takes exactly one URI and is the correct keyword when you control the fetch target directly.

    Learn more

    The full body replacement matters: paste the XML declaration, the DOCTYPE with the entity definition, and the stockCheck root with &xxe; as the productId 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 productId 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 Product not found, XXE is either filtered or the response does not echo the parsed entity.

  3. Step 3
    Swap to /etc/passwd for the flag
    Observation
    I noticed the /etc/hostname sanity check returned data successfully, confirming the parser resolves SYSTEM file URIs; since the challenge description hinted at reading /etc/passwd and CTF authors commonly append flags there because it is world-readable, swapping the URI was the direct next step.
    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"> ]>
    <stockCheck><productId>&xxe;</productId></stockCheck>

    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

    Skipping the sanity-check makes it impossible to distinguish between XXE being filtered and /etc/passwd simply being empty or returning no flag. If /etc/hostname echoes successfully but /etc/passwd does not, you know the flag is not there rather than XXE being blocked - the two-step approach gives you actionable diagnostic information.

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

    /etc/shadow is readable only by root and shadow group members. The challenge process almost certainly runs as an unprivileged user and gets a permission-denied error rather than file contents. The challenge authors place the flag in /etc/passwd precisely because it is world-readable and guaranteed to succeed.

    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 productId 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.

Flag

Reveal flag

picoCTF{XML_3xtern@l_3nt1t1ty_4db...}

Any file path works, but /etc/passwd proves the XXE and includes the picoCTF token in the response.

Key takeaway

XML External Entity (XXE) injection occurs when an XML parser is configured to resolve external entity references, allowing an attacker to supply a SYSTEM URI that points to local files or internal network resources. Any application that accepts XML input and uses a parser with external entity resolution enabled is potentially vulnerable, including SOAP web services, file upload endpoints, and document processors. The fix is always parser-level: disabling DTD processing or external entity resolution entirely, since input sanitization alone cannot reliably block all XXE vectors.

Related reading

Want more picoCTF 2023 writeups?

Useful tools for Web Exploitation

What to try next