Description
The SOAP stock-check endpoint fails to sanitize XML entities. Inject an external entity to have the backend disclose /etc/passwd.
Setup
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.
# 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># 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.
Step 1
Set up Burp and intercept the requestObservationI 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.Step 2
Sanity-check XXE with /etc/hostnameObservationI 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
stockCheckroot with&xxe;as theproductIdvalue. 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,
SYSTEMtakes a single URI and the parser fetches it directly.PUBLICtakes a public identifier plus a system identifier (the URI) for catalog-based resolution. For XXE you almost always useSYSTEMbecause 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.
Step 3
Swap to /etc/passwd for the flagObservationI 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 namedxxewhose 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_PROCESSINGin JAXP,resolve_entities=Falsein 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.