Skip to content
All posts
webrevised February 13, 202611 min read

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/passwd is 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 8080

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

FormExample for 127.0.0.1Why it slips through
Alternate loopback127.1, 127.0.1, 0.0.0.0, 0Shortened dotted-quad forms are expanded by the resolver, not by the filter
Decimal (dword)2130706433The 32-bit integer form; inet_aton accepts it, a regex for dots does not
Octal0177.0.0.01Leading zeros mean base 8 to the C resolver
Hex0x7f.0x0.0x0.0x1 or 0x7f000001Same address, no decimal digits to match on
Mixed base0177.0.0.0x1Defeats 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 DNSlocaltest.me, or an A record you set to 127.0.0.1The string is a public domain; only the resolution is local
RedirectA 302 from your host to http://127.0.0.1/The submitted URL contains nothing banned
Compute the dword form as a·256³ + b·256² + c·256 + d. For 127.0.0.1 that is 2130706433.

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()
A DNS rebinding setup does the same job against filters that resolve before connecting, at the cost of needing a rebinding DNS server and some patience.

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 trying
Every line contains the allowed domain. None of them go there.

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

  1. 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.
  2. `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, and file:///app/flag are all one request each.
  3. Cloud instance metadata. http://169.254.169.254/latest/meta-data/ on AWS, and http://metadata.google.internal/computeMetadata/v1/ on GCP - the latter demanding a Metadata-Flavor: Google header, which is exactly why it is a good challenge: you need an SSRF primitive that can set headers.
  4. 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.
  5. The network itself. Sweep 10.0.0.0/24 or 172.17.0.0/24 a 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%0A
The underscore after the port is consumed as the gopher item type, so the payload starts at the character after it. CRLF is %0D%0A, and it must be exact.

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

ObservationUsual meaning
200 vs 500 across addressesThe app is surfacing connect success and failure - the cleanest oracle you can get
Identical status, different error stringsRead the strings; a connection-refused message and a parse-error message mean opposite things
Identical everything, different timingFirewalled or unrouted addresses hang; refused ones fail instantly
Identical everything, alwaysEither 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.

  1. A public app has an avatar-by-URL feature, and rejects anything resolving into a private range.
  2. A second in-scope host has an open redirect - a ?next= parameter that does not validate its destination.
  3. The avatar fetcher follows redirects, so https://second-host/?next=http://127.0.0.1:8000/ passes validation and lands internally.
  4. Port 8000 is an internal admin API with no authentication, because it is only reachable from inside.
  5. Its /debug route 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

  1. Inventory every feature that could fetch a URL, including PDF renderers and XML parsers.
  2. Confirm with a listener you control, and read the User-Agent to identify the client.
  3. 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).
  4. Ask for loopback ports, file://, cloud metadata, and Compose service names, in that order.
  5. If you need a protocol rather than a page, try gopher:// - and check first whether the client speaks it.
  6. If it is blind, build a baseline and read status, error text, and timing as your oracle.