← mohdarif.com · All notes
Web Security · Cloud

SSRF on AWS: From a URL Parameter to Instance Credentials

How a simple "download this image" feature can leak AWS credentials, why three common fixes don't work, and what does.

Server-side request forgery (SSRF) is easy to describe and easy to get wrong. This post follows one real-looking endpoint through three attempts to fix it. The pattern is almost always the same: the fix checks one thing, and the server then does something slightly different.

1. The setup

An image service runs on an EC2 instance. You send it a URL, it downloads the image, processes it, and saves the result to S3.

POST /api/v1/process-image

{ "image_url": "https://example.com/photo.jpg" }

In a security review you find that the server requests whatever URL you give it. The developer says:

"We check that the URL starts with http:// or https://, and we only accept JPEG and PNG files. So it's secure."

2. What's wrong

The user decides where the server connects. That is the whole bug. The server sits inside your network, so internal services and the EC2 metadata service trust requests from it. An attacker can use the server to reach things they can't reach from the internet.

OUR EC2 INSTANCE AND NETWORKAttackerPOST image_urlImage servicefetches the URLPublic internetexample.com/photo.jpgInternal service10.x or localhost adminMetadata service169.254.169.2541 intended2 SSRF3 SSRFIMDSv1 returns the role credentialsto a plain GET requestAttackeruses stolen role keysonly if the response or an error leaks it
Figure 1. The request comes from the trusted server, not from the attacker. The credentials reach the attacker only if the response, or an error message, exposes them.

The developer's two checks don't help:

Also keep two problems apart. SSRF is about the destination. A malicious image that crashes a decoder is about file handling. They need different fixes.

3. Can they really steal AWS credentials?

Not automatically. Reaching the metadata address is only the first step. Ask these questions:

What IMDSv2 changes

With IMDSv2 you first need a token, and you get it with a PUT request plus a special header. Then you send the token in a header on the next request:

# step 1: PUT to get a token
TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" \
        -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")

# step 2: use the token as a header
curl -H "X-aws-ec2-metadata-token: $TOKEN" \
     http://169.254.169.254/latest/meta-data/iam/security-credentials/

A "fetch this URL" feature only makes a plain GET and doesn't let the attacker set headers, so it can't do step 1. AWS also rejects the token request if it carries an X-Forwarded-For header, and by default the token response can't travel past the instance (hop limit 1).

IMDSv2 makes this attack harder. It does not fix SSRF. A feature that lets the attacker choose the method and headers and read the reply would still work. And IMDSv2 does nothing to protect other internal services.

This isn't just theory. The 2019 Capital One breach, as publicly reported, involved SSRF against the metadata service. That was before IMDSv2.

4. Three attempts to fix it

Attempt 1: check the scheme and the file type

This is what the developer proposed. As shown above, it doesn't control where the server connects, so it fixes nothing.

Attempt 2: resolve the hostname and block private IPs

def fetch_image(url):
    parsed = urlparse(url)
    if parsed.scheme not in ["http", "https"]:
        raise ValueError("Invalid scheme")

    ip = socket.gethostbyname(parsed.hostname)     # lookup 1: used for the check
    if not ipaddress.ip_address(ip).is_global:
        raise ValueError("Internal IP blocked")

    return requests.get(url, timeout=5,            # lookup 2: used to connect
                        allow_redirects=True).content

This is much better, and the IP check itself is correct. But it checks one address and then connects to another. There are four ways that goes wrong.

1. The name is looked up twice. The check uses the first lookup. requests does its own lookup when it connects. If the attacker controls the DNS for their hostname, they can answer the first lookup with a public IP and the second with 169.254.169.254. The check passed, and the connection still went inside. This is called DNS rebinding. The root cause is that the checked address is never tied to the connection.

2. Redirects. Only the first URL is checked, but redirects are followed. The attacker's public server simply replies 302 with Location: http://169.254.169.254/... and the client follows it with no check.

3. IPv6. Python's docs say gethostbyname() doesn't support IPv6 and recommend getaddrinfo(). The HTTP client looks up both IPv4 and IPv6 and tries every address. A host with a harmless IPv4 record and an IPv6 record pointing to ::1 passes the check and gets connected over IPv6.

4. URL parsing differences. The check reads the host with urlparse. The actual request is parsed by different code inside requests. If two parsers read the same string differently (OWASP's example is http://example.com\@evil.com), the host you checked isn't the host you connect to. OWASP's advice: reject the URL if parsers disagree.

What is not a bypass here: localhost, 0.0.0.0 or writing 127.0.0.1 as 2130706433. This code checks the resolved address, and these all resolve to loopback or similar, which is_global rejects. Odd IP spellings only beat checks that look at the URL text. Only call something a bypass if you can explain why the logic fails.

Attempt 3: turn off redirects and require IMDSv2

This fixes problem 2 and the plain-GET route to credentials. It does not fix problems 1, 3 or 4. Attackers can still use DNS rebinding to reach other internal services, such as an admin page on a private address.

So the honest answer is: SSRF still exists, and this specific credential theft is now much harder. Say both.

5. What a safe version looks like

First ask: does the service really need to fetch any URL? If users can upload the file, accept an upload. If the images come from known sites, use an allowlist, and build the request in your code from the matching entry. That removes the problem instead of managing it.

If you must fetch arbitrary public URLs, contain the risk:

API backendvalidates requestFetcher serviceresolve once, check all addressesconnect to the checked IP onlyno redirects, no roleEgress controlnetwork levelPublic internet80 and 443 onlyDeniedprivate, loopback, link-local, unique-local,multicast and metadata addressesblockedIsolated decodersize limits, re-encode, no credentialsthen PutObject to one prefixbounded bytes
Figure 2. The application check and the network control are independent. If the fetcher is fooled, the network still refuses the connection, and the fetcher has no valuable credentials.

How the safe version fixes each problem

Here is every problem from section 4, what the attacker does, what we change, and why the attack stops working.

Problem 1: the name is looked up twice (DNS rebinding).

infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)   # the only lookup
ips = [i[4][0] for i in infos]

if not all(ipaddress.ip_address(ip).is_global for ip in ips):
    raise Rejected()

sock = socket.create_connection((ips[0], port), timeout=5)        # the IP we checked
tls = ctx.wrap_socket(sock, server_hostname=host)                 # HTTPS only: certificate checked against the name
tls.sendall(b"GET " + path + b" HTTP/1.1\r\nHost: " + host.encode() + b"\r\n\r\n")

The name is still used for the Host header and for TLS, so websites and certificates work normally. (For plain http skip the TLS line.)

Problem 2: redirects.

Problem 3: IPv6.

Problem 4: two parsers read the URL differently.

Problem 5: a bug in the app code still lets a request through. The four fixes above live in our code, and code can have bugs. So we add a second wall that doesn't depend on it.

Problem 6: the credentials are there to be stolen.

Problem 7: the image itself is hostile.

Problem 8: a stolen key is still too powerful. Give the S3 writer one permission:

{ "Effect": "Allow",
  "Action": "s3:PutObject",
  "Resource": "arn:aws:s3:::image-bucket/processed/*" }

Now a leaked key can write into one prefix. It can't read other buckets, list objects or call other services, so the damage is small even in the worst case.

Why all of them? Each one covers a failure of another. If the IP check has a bug, the network rule stops the connection. If the network rule is wrong, the fetcher has no role to steal. If a role is stolen anyway, it can only write to one prefix. No single control has to be perfect.

6. How to test it

Only test systems you own or are allowed to test.

7. If the release is in 30 minutes

Decide by how easy it is to exploit, how bad it is, and how much work the fix takes.

My rule: don't ship arbitrary-URL fetching while its destination checks can still be bypassed. Ship with it off or on an allowlist, and add the full design afterwards. Odd URL spellings can wait, because the check runs on the resolved address. The connection problem can't.

8. Things that sound safe but aren't

Summary

References