DownUnderCTF
sodium: three parser disagreements, one flag
The challenge has a customer app, an RPC service, and a Pound proxy in front of them. The RPC service holds the flag, but it checks both an auth key and the caller's IP address.
I used three bugs to reach it: a URL parsing mismatch to read the customer app's key, a request smuggling bug to add my IP to the RPC allowlist, and a second format pass on the RPC logs to read the flag.
Read the customer app's config
Pound picks an app based on the Host header. The customer app is at dev.customer.ductf. If the challenge gives you one target address, use Burp to set that Host header, or map the name to the target in your local hosts file.
The customer app has normal register and login pages. Create an account, log in, and use the URL scan form on the home page. Enter this URL with the leading space, then click Scan:
file:///home/customerapp/customer_app/.env
The space is the trick. The app checks the URL with urllib.parse.urlparse, then fetches it with urllib.request.urlopen:
scheme = urlparse(url).scheme
if scheme in ['file', 'gopher', 'dict']:
return False
preview = urlopen(url).read()
The Dockerfile uses Python 3.11.3. In that version, urlparse leaves the leading space in place and sees no scheme, so the blacklist does not match. urlopen strips the space before opening the URL, so it reads the local file. This is the URL parsing issue described in CVE-2023-24329; the trimming step is in Python's urllib.request.
The scan result includes the file contents. Read the AUTHENTICATION_KEY value from the preview. We now have the key the RPC service expects, but our IP is still blocked.
Learn the RPC checks
Set Host: dev.rpc-service.ductf to reach the RPC service. Its initial allowlist contains only 127.0.0.1. The service checks the proxy's X-Forwarded-For value against that list before it checks Auth-Key.
We need to add the address Pound reports for our connection. In Burp Repeater, send a request to /ping with the RPC Host header but no Auth-Key. It returns 403, and the response body includes the address it received in X-Forwarded-For. The solver reads the same address from this error message.
The service has an authenticated /update_allowlist?ip=... route. To use it, we need a request with the leaked key and X-Forwarded-For: 127.0.0.1. Pound normally sets the forwarded address to ours, so I used request smuggling to make Pound and the RPC service disagree about where one request ends.
Smuggle the allowlist update
The RPC service uses h11 to parse HTTP. Its chunk reader discards the two bytes after each chunk without checking that they are CRLF; the code is in h11 0.15.0's reader. Pound 4.15 reads through the next line ending instead. That difference lets each side see a different request boundary, as described in the h11 request-smuggling advisory.
The solver builds a hidden request first:
hidden = (
f"GET /update_allowlist?ip={caller_ip} HTTP/1.1\r\n"
"Host: dev.rpc-service.ductf\r\n"
f"Auth-Key: {auth}\r\n"
"X-Forwarded-For: 127.0.0.1\r\n"
"Transfer-Encoding: chunked\r\n"
"\r\n"
"0\r\n\r\n"
)
It then places that request after a malformed chunked body:
request = (
"GET /ping HTTP/1.1\r\n"
"Host: dev.rpc-service.ductf\r\n"
"Connection: keep-alive\r\n"
"Transfer-Encoding: chunked\r\n"
"\r\n"
"5\r\n"
"AAAAAXX2\r\n"
f"{len(hidden) + 4:X}\r\n"
"0\r\n\r\n"
f"{hidden}"
).encode()
The 5 says the first chunk is five bytes long: AAAAA. After those bytes, XX takes the place of the required CRLF. h11 accepts it, then reads the 2 as the next chunk size. It consumes the two digits of the calculated hex length as that chunk's data, then sees the 0 chunk and finishes /ping before the hidden GET. Pound reads the same bytes differently and treats the hidden request as part of the outer request body. The hidden GET is still in the stream when h11 looks for its next request.
The hidden request passes the IP check with X-Forwarded-For: 127.0.0.1, then uses the leaked key to add caller_ip to the allowlist. The request must be sent as exact HTTP/1.1 bytes; a browser or regular HTTP client may change the chunk framing. The included solver uses a raw TCP socket for this part. A 403 response to the outer /ping is expected and does not show whether the hidden update worked. The later stats request confirms that the update went through.
Read the flag from the logs
The stats route logs each query parameter before it uses the parameters to call build_stats_page. That gives us a place to store a format string:
/stats?x={config.__init__.__globals__[FLAG]}
Send this in Burp Repeater with Host: dev.rpc-service.ductf, Auth-Key: <leaked key>, and Connection: keep-alive. The unknown x argument makes build_stats_page fail and the request returns 500, but the service has already written the parameter to debug.log.
The stats page has two formatting passes. First it inserts the log text into a template. Then it formats the whole template again to add the config:
template = "...{logs}".format(logs=get_logs())
template += "...{config}"
return template.format(config=config)
The second pass treats braces inside the log as format fields. In our field, config.__init__ gets the config object's initializer, .__globals__ reaches that function's global variables, and [FLAG] selects the flag.
Now send a second request:
GET /stats?get_log=1&get_config=1 HTTP/1.1
Host: dev.rpc-service.ductf
Auth-Key: <leaked key>
Connection: keep-alive
The Connection: keep-alive header matters. The included solver notes that asking to close the connection makes h11 log an error containing braces, which can interfere with the format pass. The solver closes its own socket after reading the response instead.
Solver and output
solve.py automates the chain. It uses a normal HTTP session for registration, login, and the scanner. It uses raw TCP for the RPC requests so the smuggled bytes stay intact. It also reads the caller IP from the denied /ping response, checks for the expected 500 after placing the format string, and prints the flag from the final stats response.
The final response contains DUCTF{th3y_s33_m3_smuggl1ng_4nd_ch41n1ng}. The included src.zip is the challenge handout, with placeholder values for the auth key and flag.