CyberNugget

L35 · Server-side request forgery (SSRF)

Module 5: Web Application Security · ~20 min · Lesson 35 of 71

🎯 What you'll learn

  • What SSRF is: an attacker choosing where your server sends a request, from inside your network
  • What an attacker can reach that way: internal APIs, admin ports, and the cloud metadata endpoint
  • How IMDSv2 and its hop limit protect EC2 credentials, and what that means for pods on EKS
  • The layered fix: accept IDs instead of URLs, allow-lists, resolved-IP checks, egress rules, small roles

🔥 Why this matters

Partners send a bank's statements service {"documentUrl": "https://..."} and it downloads the PDF. A pen tester sends http://169.254.169.254/latest/meta-data/iam/security-credentials/ instead. The error message names the worker node's IAM role, and the next request returns its temporary AWS keys. That pattern sits behind the 2019 Capital One breach of about 106 million people's records (L47 has the full story). SSRF was OWASP's A10 in 2021; the 2025 list folds it into A01 (L29).

1. Your server becomes the attacker's browser

Picture a building where visitors can't pass reception, but the receptionist will fetch any folder you name, including one from the locked records room. The receptionist is a confused deputy: someone with access who uses it on behalf of someone without it.

Server-side request forgery (SSRF) is a flaw where an attacker controls, fully or partly, the destination of a request your server makes. The request carries your server's network position and trust, so firewalls and "internal only" rules don't stop it.

 attacker             statements-svc          internal admin API
 (internet)           (public, via Kong)      10.20.3.7:8001
    │                        │                (no auth: "inside")
    │ 1. POST /import        │                         │
    │   {"documentUrl":      │                         │
    │    "http://10.20.3.7:  │                         │
    │     8001/"}            │                         │
    │───────────────────────▶│                         │
    │                        │ 2. GET http://          │
    │                        │    10.20.3.7:8001/      │
    │                        │────────────────────────▶│
    │                        │ 3. internal data        │
    │                        │◀────────────────────────│
    │ 4. data relayed in     │                         │
    │    the response        │                         │
    │◀───────────────────────│                         │
    │                                                  │
    └──── ✗ direct path blocked by firewall / VPC ─────┘

URLs reach your server in more places than an "import" field: webhook URLs, link previews, PDF generators rendering HTML (an <img src> is a fetch), XML external entities (XXE), and the JWT jku header (L25).

There are two flavors. In basic SSRF the attacker sees the response, as in step 4. In blind SSRF they don't, but can still map your network from timing and errors, or hit endpoints that change state on a GET.

2. What's on the other side

From inside your VPC, a server can reach things nobody meant to expose:

Target Example Why it hurts
Cloud metadata http://169.254.169.254/ Temporary credentials for the instance's IAM role
Loopback admin ports http://127.0.0.1:8001/ (Kong Admin API's default port) The Admin API can add routes and plugins; Kong's docs say to keep it off untrusted networks
Framework endpoints /actuator/heapdump on a Spring Boot service A heap dump holds secrets and tokens in memory (L38)
"Trusted" internal APIs http://accounts-svc.payments.svc.cluster.local/ Services that skip auth because "only the cluster can call us"
Other schemes file:///var/run/secrets/..., gopher:// Some HTTP clients (libcurl, older libraries) read local files or send raw bytes to non-HTTP services

3. The metadata endpoint: IMDSv1 vs IMDSv2

Every EC2 instance, including each EKS worker node, can reach the Instance Metadata Service (IMDS) at the link-local address 169.254.169.254. It serves instance data and, if the instance has an IAM role (its instance profile), temporary credentials for that role.

IMDSv1 is a plain unauthenticated GET:

GET /latest/meta-data/iam/security-credentials/
  → eks-node-role-example
GET /latest/meta-data/iam/security-credentials/eks-node-role-example
  → {"AccessKeyId": "ASIAEXAMPLE7Q2", "SecretAccessKey": "...",
     "Token": "...", "Expiration": "2026-11-07T18:12:09Z"}

Any SSRF that can send a GET and read the reply gets keys that work from anywhere until they expire.

IMDSv2 (November 2019) adds a session token. You first PUT /latest/api/token with the header X-aws-ec2-metadata-token-ttl-seconds: 21600, then send the returned token in an X-aws-ec2-metadata-token header on every GET. That defeats most SSRF in four ways:

  1. It needs a PUT. Most SSRF bugs can only make the server send a GET.
  2. It needs custom headers. Most SSRF bugs can't set request headers.
  3. It refuses proxied token requests. A PUT carrying X-Forwarded-For is rejected, so an open reverse proxy or misconfigured WAF can't relay it.
  4. The hop limit. The token reply's IP TTL is the instance's HttpPutResponseHopLimit (default 1, range 1 to 64). Each routing hop subtracts one; at zero the packet is dropped.

Point 4 matters for containers. An EKS pod has its own network namespace, one routed hop away from the node:

 ┌──────────────────── EC2 worker node ─────────────────────┐
 │  host network namespace        pod network namespace     │
 │  ┌───────────────────┐  veth   ┌────────────────────┐    │
 │  │ kubelet, aws-node │ ◀─────▶ │ statements-svc pod │    │
 │  │ hostNetwork pods  │ +1 hop  │                    │    │
 │  └─────────┬─────────┘         └────────────────────┘    │
 └────────────┼─────────────────────────────────────────────┘
              │ PUT /latest/api/token
              ▼
      IMDS 169.254.169.254
      token reply leaves with TTL = hop limit
        hop limit 1 → reaches the host, dropped before the pod
        hop limit 2 → reaches the pod too

So HttpTokens=required plus hop limit 1 means ordinary pods can't get the node's credentials, while the node (and hostNetwork pods) still can. Two caveats. If IMDSv1 is still allowed (HttpTokens=optional), the hop limit protects nothing: v1 has no token to drop. And defaults vary: in 2020 AWS set EKS managed node groups to hop limit 2 so pods could use IMDSv2, and newer AMIs and templates differ, so check the node group's launch template.

Pods that need AWS access should use their own role through IRSA or EKS Pod Identity (L50), which don't use the node's IMDS. Some add-ons read the region from IMDS; pass it as config first.

4. Defenses, in layers

Each layer covers gaps in the one above it:

 caller input
   │
   ▼  1. Don't accept URLs: accept a partner ID or document ID
   ▼  2. Allow-list exact scheme, host and port from config
   ▼  3. If you truly need arbitrary URLs: resolve DNS, reject
   │     private/loopback/link-local IPs, connect to the IP you
   │     checked, no redirects (Going deeper 1)
   ▼  4. Network: egress rules so the pod can't reach 169.254/16
   │     or admin ports, even if the code is wrong (L12)
   ▼  5. Cloud: IMDSv2 required, hop limit 1, small pod roles
         (L46), GuardDuty alerts on credential misuse (L52)

Prefer allow-lists to blocklists: a blocklist must predict every spelling of 169.254.169.254 (the Try it shows how that fails). Keep upstream details out of error messages, so a blind SSRF stays blind. On EKS, NetworkPolicy only works if something enforces it (the VPC CNI's network policy feature, or Calico).

🧪 Worked example: a document import, vulnerable vs fixed

Before. The URL goes straight from the request into the HTTP client:

@PostMapping("/v1/statements/import")
ImportResult importDoc(@RequestBody ImportRequest req) {
    byte[] pdf = restClient.get()
        .uri(req.documentUrl())          // caller picks the host
        .retrieve()
        .body(byte[].class);
    return importer.store(pdf);          // error echoes the body
}

What the tester sees (made-up values):

POST /v1/statements/import HTTP/1.1
Host: api.example.com
Content-Type: application/json

{"partnerId": "P-1042", "documentUrl":
 "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}

HTTP/1.1 422 Unprocessable Entity
{"error": "Not a PDF: 'eks-node-role-example'"}

The "helpful" error turned blind SSRF into basic SSRF, and the node allowed IMDSv1.

After. The caller names a partner and document; the URL comes from config:

// partners.yml (config, not input):
//   P-1042: https://docs.partner-a.example
private static final Pattern DOC_ID =
    Pattern.compile("[A-Z0-9-]{6,40}");

@PostMapping("/v1/statements/import")
ImportResult importDoc(@RequestBody ImportRequest req) {
    Partner p = partners.require(req.partnerId()); // 404 if unknown
    if (!DOC_ID.matcher(req.documentId()).matches())
        throw new BadRequestException("invalid documentId");
    URI uri = UriComponentsBuilder.fromUri(p.baseUri())
        .pathSegment("documents", req.documentId())
        .build().toUri();
    byte[] pdf = partnerClient.get().uri(uri)  // redirects off,
        .retrieve().body(byte[].class);        // 5 s timeout,
    return importer.store(pdf);                // 10 MB cap
}

Then the node layer (for managed node groups, set the same MetadataOptions in the launch template, since nodes get replaced):

aws ec2 modify-instance-metadata-options \
  --instance-id i-0abc123example \
  --http-tokens required \
  --http-put-response-hop-limit 1

Even if a future change brings back a URL parameter, the pod can't get a token, and its own IRSA role reaches only the one bucket it needs.

🛠 Try it (optional, ~5 min)

Compare a blocklist with a resolve-then-check filter. It runs locally and sends no requests. Save as ssrf_check.py:

import ipaddress, socket
from urllib.parse import urlsplit

BLOCKED = {"localhost", "127.0.0.1", "169.254.169.254"}

def naive_ok(url):
    return urlsplit(url).hostname not in BLOCKED

def resolved_ok(url):
    host = urlsplit(url).hostname
    try:
        infos = socket.getaddrinfo(host, 80)
    except socket.gaierror:
        return False
    for *_, addr in infos:
        ip = ipaddress.ip_address(addr[0].split("%")[0])
        if ip.version == 6 and ip.ipv4_mapped:
            ip = ip.ipv4_mapped
        if not ip.is_global:
            return False
    return True

TESTS = [
    "http://169.254.169.254/latest/meta-data/",
    "http://2852039166/latest/meta-data/",
    "http://0xA9FEA9FE/latest/meta-data/",
    "http://[::ffff:169.254.169.254]/",
    "http://127.1:8001/routes",
    "http://[::1]:8001/routes",
    "http://10.20.3.7:8001/routes",
]
for url in TESTS:
    print(f"{url:42} naive={'PASS' if naive_ok(url) else 'block':5}"
          f" resolved={'PASS' if resolved_ok(url) else 'block'}")

Real output from python3 -I ssrf_check.py on macOS:

http://169.254.169.254/latest/meta-data/   naive=block resolved=block
http://2852039166/latest/meta-data/        naive=PASS  resolved=block
http://0xA9FEA9FE/latest/meta-data/        naive=PASS  resolved=block
http://[::ffff:169.254.169.254]/           naive=PASS  resolved=block
http://127.1:8001/routes                   naive=PASS  resolved=block
http://[::1]:8001/routes                   naive=PASS  resolved=block
http://10.20.3.7:8001/routes               naive=PASS  resolved=block

Decimal, hex, short and IPv6 spellings all slip past the blocklist, because the OS treats them as the same address. Then delete the two ipv4_mapped lines and rerun: whether the mapped address is still blocked depends on your Python version, which is why you normalize explicitly. The resolver check still has a hole; Going deeper 1 shows it.

🧠 Key takeaways

  • SSRF lets an attacker send requests from your server's network position, with its trust.
  • The prize is usually 169.254.169.254: IMDSv1 hands out role credentials to any GET.
  • Require IMDSv2 and set hop limit 1 on EKS nodes; give pods their own roles through IRSA or Pod Identity.
  • Best fix: don't accept URLs at all. Next best: an allow-list from config. Blocklists lose.
  • Back the code with egress rules and generic errors, so one bug isn't game over.

📖 Words to know

Term Meaning
SSRF Server-side request forgery: the attacker controls where your server sends a request
Blind SSRF SSRF where the attacker can't see the response, only side effects or timing
Confused deputy A trusted component tricked into using its access on someone else's behalf
IMDS Instance Metadata Service at 169.254.169.254: instance data and role credentials
IMDSv2 Token-based IMDS: PUT for a session token, then GET with the token header
Hop limit HttpPutResponseHopLimit: the IP TTL on IMDSv2 token replies, 1 by default
Allow-list Only explicitly listed destinations are permitted; everything else is refused

⏭ Next time

L36: how turning bytes back into objects can run an attacker's code, and how one log line, ${jndi:ldap://...}, became the internet's worst week of December 2021.

Deep dive 1

SSRF filter bypasses: redirects, DNS rebinding and odd IP formats

The Try it showed that the OS accepts many spellings of one address. A filter that resolves the name and checks the IP beats those. But two classic tricks still get past it, because the check and the request are two separate events.

Trick 1: the redirect

The filter checks https://files.attacker.example/report.pdf, which resolves to a public IP. It passes. The attacker's server answers:

HTTP/1.1 302 Found
Location: http://169.254.169.254/latest/meta-data/

Most HTTP clients follow redirects by default (Python requests, curl with -L, Java's HttpClient only if you set followRedirects(NORMAL); defaults differ per library, so check yours). The second request is never checked. Fix: turn redirects off for user-supplied URLs, or follow them yourself, running the full check on every hop.

Trick 2: DNS rebinding

The attacker controls the DNS for rebind.attacker.example and sets a TTL of 0 seconds:

 your service                      attacker's DNS server
     │ 1. resolve rebind.attacker.example (filter)
     │ ─────────────────────────────────▶ │
     │ ◀── 203.0.113.10 (public, passes) ─│
     │                                    │
     │ 2. resolve again (HTTP client)     │
     │ ─────────────────────────────────▶ │
     │ ◀── 169.254.169.254 (TTL 0) ───────│
     │
     │ 3. GET http://169.254.169.254/... ──▶ IMDS

This is a time-of-check to time-of-use (TOCTOU) bug: the filter and the client each resolve the name, and nothing forces the two answers to match. Fix: resolve once, check that IP, then connect to that IP, sending the original host name in the Host header and TLS SNI. Most HTTP libraries can do this with a custom DNS resolver hook (OkHttp's Dns interface, Apache HttpClient's DnsResolver, a custom adapter in Python).

Worked example: a fetcher that pins the IP

Here is the shape in Python, using the standard library so every step is visible:

import http.client, ipaddress, socket, ssl
from urllib.parse import urlsplit

def safe_ip(host, port):
    infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
    ips = {ipaddress.ip_address(i[4][0].split("%")[0])
           for i in infos}
    for ip in ips:
        if ip.version == 6 and ip.ipv4_mapped:
            ip = ip.ipv4_mapped
        if not ip.is_global:
            raise ValueError(f"blocked address {ip}")
    return str(sorted(ips, key=str)[0])  # checked IP, used below

def fetch(url, timeout=5):
    u = urlsplit(url)
    if u.scheme != "https" or u.username or u.password:
        raise ValueError("https only, no credentials in URL")
    ip = safe_ip(u.hostname, u.port or 443)
    ctx = ssl.create_default_context()
    raw = socket.create_connection((ip, u.port or 443), timeout)
    tls = ctx.wrap_socket(raw, server_hostname=u.hostname)
    conn = http.client.HTTPSConnection(u.hostname, timeout=timeout)
    conn.sock = tls                       # connect to checked IP
    conn.request("GET", u.path or "/")    # Host header = hostname
    resp = conn.getresponse()
    if 300 <= resp.status < 400:
        raise ValueError("redirects not followed")
    return resp.read(10 * 1024 * 1024)    # size cap

What each part buys you: https only removes file://, gopher:// and plain-HTTP metadata; checking every resolved address stops a name that returns one public and one private IP; connecting to the checked IP kills rebinding; TLS verification against the real host name still works because server_hostname is set; redirects are refused; the size cap and timeout stop a slow or huge response from tying up your threads. In production, prefer a maintained library or an egress proxy (Going deeper 2) over hand-rolled code like this. It's here to show the moving parts.

Other spellings worth knowing

Input Why it's dangerous
http://169.254.169.254.nip.io/ Wildcard DNS services return the IP embedded in the name
http://user@169.254.169.254/ Naive string checks look at the wrong part of the URL
http://[fd00:ec2::254]/ IMDS over IPv6 on Nitro instances, if IPv6 endpoint is enabled
http://0.0.0.0:8001/ Often reaches services bound to localhost

ipaddress.is_global handles most of these once the name is resolved, which is why resolving first and judging the IP, not the string, matters.

🧠 Extra takeaways

  • Validate the IP you'll actually connect to, not the string the caller sent.
  • Redirects and DNS rebinding both exploit the gap between check and use; close it by pinning the IP.
  • Turn off automatic redirects for user-supplied URLs, and cap time and size.
  • Hand-rolled validators are fragile; prefer a vetted library or a dedicated egress proxy.
Deep dive 2

Webhooks and URL fetchers: designing a feature that calls customer URLs

Sometimes accepting URLs is the product. A payments platform lets each partner register a webhook URL to receive payment.settled events. You can't allow-list hosts you don't know yet. The answer is to design the feature so that even a successful SSRF reaches nothing interesting.

Put the fetcher somewhere boring

The core idea, used by companies that run webhooks at scale (Stripe open-sourced its egress proxy, Smokescreen, for this reason), is to route every outbound call to a customer URL through a dedicated egress proxy that refuses internal destinations, and to block those workloads from going anywhere else:

 ┌──────────── EKS: namespace webhooks ────────────┐
 │                                                 │
 │  webhook-sender pod                             │
 │   • no IAM role (no IRSA, no Pod Identity)      │
 │   • NetworkPolicy egress: ONLY the proxy        │
 │        │                                        │
 │        │ 1. CONNECT hooks.partner-b.example:443 │
 │        ▼                                        │
 │  egress proxy (Smokescreen-style)               │
 │   • resolves DNS itself, blocks private,        │
 │     loopback, link-local, metadata ranges       │
 │   • logs every destination with the partner ID  │
 └────────┼────────────────────────────────────────┘
          │ 2. public internet only, via NAT
          ▼
    hooks.partner-b.example

Now three independent things must fail for an attacker to reach IMDS or Kong's Admin API: the proxy's IP check, the NetworkPolicy, and IMDSv2 with hop limit 1. And even then, the sender pod has no role to steal and no internal service to call.

Rules for the feature itself

  • Validate at registration and at send time. DNS can change after a partner registers.
  • HTTPS only, standard ports only (443, maybe 8443). Port scanning through your sender gets boring fast.
  • Don't reflect responses. Store the status code and latency for the partner's dashboard, not the response body. That keeps a blind SSRF blind.
  • Sign what you send with an HMAC (L17), so partners can verify you, and a forged request can't impersonate you either.
  • Time-box and rate-limit per partner: 5 seconds, a few retries with back-off, and a circuit breaker for endpoints that keep failing.

Worked example: spotting blind SSRF in your logs

Blind SSRF often shows up first as odd destinations in the proxy log, before anything is stolen. A made-up log extract:

ts=2026-11-07T09:14:02Z partner=P-2207 dst=hooks.partner-b.example
  ip=203.0.113.44:443 status=200 ms=212
ts=2026-11-07T09:14:05Z partner=P-3391 dst=a1.rebind.example
  ip=10.20.3.7:443 action=DENY reason=private_ip
ts=2026-11-07T09:14:05Z partner=P-3391 dst=a2.rebind.example
  ip=10.20.3.8:443 action=DENY reason=private_ip
ts=2026-11-07T09:14:06Z partner=P-3391 dst=a3.rebind.example
  ip=169.254.169.254:443 action=DENY reason=link_local

One partner, many new host names, each resolving to a different internal address a second apart: that's someone mapping your network through the webhook feature. The proxy stopped it, and the log line tells you which partner account to suspend and investigate. An alert on "more than N denies per partner per hour" is cheap and catches it (detection engineering is L62).

🧠 Extra takeaways

  • When URLs are the product, assume SSRF will happen and make it worthless: no role, no internal reach.
  • Route customer-URL traffic through one egress proxy that does the IP checks, and block everything else.
  • Re-validate at send time, never echo response bodies, and sign outgoing payloads.
  • Proxy deny logs are an early-warning signal; alert on bursts of denies per caller.

The quiz for this lesson and the Tests section need JavaScript.