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.
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."
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.
The developer's two checks don't help:
file://), but not who it talks to. The metadata service and most internal services are plain HTTP.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.
Not automatically. Reaching the metadata address is only the first step. Ask these questions:
169.254.169.254 (and [fd00:ec2::254] if the IPv6 endpoint is on). It can be disabled or blocked.AdministratorAccess is a big one.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.
This is what the developer proposed. As shown above, it doesn't control where the server connects, so it fixes nothing.
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.
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.
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:
getaddrinfo to see IPv4 and IPv6. Refuse the request if any address is private. Then connect to the checked IP, but keep the original hostname for the Host header, TLS and certificate checks. (Just swapping the IP into an HTTPS URL breaks certificate checks, so do it at the connection level or use an egress proxy such as Smokescreen.)PutObject on one prefix and nothing else.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).
169.254.169.254 for the real connection.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.
302 to an internal address, and the client follows it unchecked.Problem 3: IPv6.
::1. The check saw only IPv4, the connection used IPv6.getaddrinfo returns both IPv4 and IPv6, and we refuse if any of them is not public.Problem 4: two parsers read the URL differently.
http://example.com\@evil.com where the checker sees one host and the HTTP library connects to another.user@ section, reject it.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.
iptables -A OUTPUT -d 169.254.169.254 -j REJECT (AWS's own example limits it per user with -m owner --uid-owner; a plain rule like this one blocks every process on the host, which is fine for a fetcher with no role) (and fd00:ec2::254 with ip6tables if the IPv6 endpoint is on). Security groups can only allow, so they can't express "everything except private addresses". AWS also says neither security groups nor network ACLs filter metadata traffic, so the metadata address needs the host rule. An egress proxy such as Smokescreen is another way: the fetcher can only talk to the proxy, which does the lookup, check and connection itself.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.
Only test systems you own or are allowed to test.
127.0.0.1, a 10.x address, 169.254.169.254, [::1], [fd00:ec2::254]. All should be rejected before any request is made.user@host, #, strange encodings.curl a private address and the metadata address. They should fail even if the app has a bug.aws ec2 describe-instances and look at MetadataOptions (HttpTokens, HttpPutResponseHopLimit, HttpEndpoint).Decide by how easy it is to exploit, how bad it is, and how much work the fix takes.
getaddrinfo, check all addresses and connect to the checked one. Add size and time limits, generic errors and tests.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.