Cloud & Container Security · intermediate · ~11 min
**What you will learn** - Identify the common places cloud credentials leak: source code, CI logs, container images, environment variables, and the instance metadata service. - Explain how Server-Side Request Forgery (SSRF) reaches the metadata endpoint `169.254.169.254` and steals an instance's IAM role credentials. - Describe the difference between IMDSv1 and IMDSv2 and why the session-token requirement of IMDSv2 blocks most SSRF credential theft. - Recognise the dangerous IAM permissions that enable privilege escalation: `iam:PassRole`, `iam:CreatePolicyVersion`, `iam:AttachUserPolicy`, and `sts:AssumeRole` chaining. - Apply layered defences: secrets managers, IMDSv2, least privilege, short-lived credentials, logging, and rotation. - Build a simple mental threat model that traces how a small foothold becomes full account compromise.
Most people picture a cloud breach as someone "hacking the server." In practice, the more common and more damaging pattern is quieter: an attacker finds a credential they were never supposed to see, then uses ordinary, documented cloud features to turn that credential into more access. No exotic exploit required — just leaked secrets and over-generous permissions.
This lesson builds directly on The cloud model and IAM. There you learned that every action in a cloud account is performed by a principal (a user, a role, or a service) and is allowed or denied by IAM policies. Here we look at what happens when an attacker gets to act as one of those principals — and how a principal with seemingly minor permissions can climb to administrator.
There are two halves to the story:
This is defensive material. The goal is to understand these paths well enough to close them in your own accounts, detect abuse in your logs, and reason about blast radius. Everything here applies only to systems you own or are explicitly authorised to test in an isolated lab.
Key terms you will meet: IMDS (Instance Metadata Service), IMDSv2 (the token-protected version), SSRF (Server-Side Request Forgery), STS (Security Token Service, which issues temporary credentials), PassRole (handing a role to a compute service), and least privilege (granting only the permissions actually needed).
Credential theft and IAM privilege escalation are how a small cloud foothold becomes full account compromise. An attacker rarely needs to break encryption or find a zero-day; they need one leaked key and one over-broad policy.
The single most common path in real assessments is stealing role credentials from the metadata service through an SSRF bug in a web application. The app is tricked into fetching http://169.254.169.254/..., the metadata service returns the instance's temporary credentials, and the attacker now acts as that instance — sometimes with far more power than the application ever needed.
These IAM escalation paths are the cloud equivalent of Active Directory path-finding. They are a core focus of cloud penetration tests and security reviews because they convert quietly: a developer grants iam:PassRole to make a deployment script work, and months later that single permission is the rung an attacker uses to reach admin. Understanding the paths is what lets defenders see the risk before an attacker does — and the cost of missing one is often the entire account: data exfiltration, resource abuse (crypto-mining on your bill), and persistence that is hard to evict.
Everything below describes attacker techniques so you can defend against them. Only ever test these ideas against accounts you own or have written authorization to assess, and only in an isolated lab (a personal sandbox account, a local emulator, or a CTF). Never point any of this at third-party systems, and never use real production credentials in examples.
Before the concepts, fix the picture of what we are protecting and where attacks enter.
ASSETS ENTRY POINTS IMPACT
------ ------------ ------
IAM role credentials <--SSRF-- web app (user input) act as the role
Secrets (DB pw, API) <--leak-- git repo / CI logs read/modify data
Admin permissions <--IAM-- over-broad policy full account
Trust boundary: the cloud control plane (the IAM/STS API)
+---------------------------------------------------------------+
| Inside: principals act via API calls, gated by IAM policy. |
| An attacker who can make authenticated API calls is INSIDE. |
+---------------------------------------------------------------+
The central idea: the trust boundary is the ability to make authenticated API calls. The moment an attacker holds working credentials, they are inside the control plane and limited only by what IAM allows that principal to do.
Definition. A credential leak is any path by which a secret (access key, token, password, connection string) becomes readable by someone who should not have it.
Plain language. Cloud credentials are short text strings. Text strings end up everywhere humans and machines store text: source files, commit history, build logs, container layers, process environments.
Where they hide:
| Location | How it leaks |
|---|---|
| Source code / config | Hardcoded keys committed to a repo |
| Git history | A key was removed in a later commit but still lives in history |
| CI/CD logs | A script echoes a secret; the log is world-readable |
| Container images | A secret baked into an early image layer survives even if a later layer deletes the file |
| Environment variables | Readable by any code in the process, and often dumped in error pages |
| Metadata service | Hands out role credentials to anything that can reach 169.254.169.254 |
How it works internally (container layers). A Docker image is a stack of layers. Deleting a file in a later layer hides it from the final filesystem but the earlier layer still contains it — anyone who pulls the image can read it.
Layer 3 (top): rm secrets.env <- file no longer visible
Layer 2: RUN build using key
Layer 1: COPY secrets.env . <- secret STILL stored here
When to worry / when not. Worry whenever a secret could touch version control, logs, or an image. You do not need to hide a value that is genuinely public (a public bucket URL, a published client ID). The pitfall is treating a long random-looking string as harmless because it "doesn't look like a password."
Knowledge check (find-the-bug): A teammate says "I committed an AWS key by accident but I deleted it in the next commit and force-pushed, so we're fine." Why are they probably not fine, and what is the only real fix?
Definition. IMDS is a link-local HTTP endpoint at 169.254.169.254 that a cloud VM can query to learn about itself — including, crucially, the temporary credentials for the IAM role attached to the instance.
Plain language. It is a vending machine inside every instance. Software running on the box asks it "what are my credentials?" and it answers — no authentication, because it assumes only the instance's own code can reach it. That assumption breaks when an application can be tricked into making the request on an attacker's behalf.
SSRF (Server-Side Request Forgery). An SSRF bug lets a user control a URL that the server fetches. If a feature like "enter an image URL and we'll fetch a preview" doesn't validate the URL, an attacker supplies http://169.254.169.254/latest/meta-data/iam/security-credentials/. The server fetches it and the response — temporary role credentials — comes back through the app.
Attacker -> Web app ("fetch this URL for me")
| url = http://169.254.169.254/.../security-credentials/ROLE
v
IMDS (169.254.169.254) -> returns AccessKeyId, SecretAccessKey, Token
|
v
Response flows back to attacker -> attacker now acts as ROLE
How IMDSv2 defends. IMDSv1 answers any GET request. IMDSv2 requires a two-step handshake: first a PUT to obtain a short-lived session token, then GET requests that include that token in a header. Most SSRF primitives can only issue simple GETs, cannot set custom headers, and cannot do the PUT — so IMDSv2 blocks them. Setting a low hop limit on the token response also stops it from being proxied off the host.
When to use / not. Always prefer IMDSv2 and least-privilege instance roles. The pitfall is leaving IMDSv1 enabled "for compatibility" while exposing a URL-fetching feature.
Knowledge check (predict-the-output): An app on an IMDSv2-only instance has an SSRF bug that can only send a plain
GETwith no custom headers. The attacker GETshttp://169.254.169.254/latest/meta-data/iam/security-credentials/. What does the metadata service return, and why?
Knowledge check (explain-in-your-own-words): Why is
169.254.169.254a link-local address rather than a public one, and how does that property both enable the convenience of IMDS and create the SSRF risk?
Definition. Privilege escalation is using the permissions you already have to obtain permissions you should not have.
Plain language. IAM policies describe who can do what. Some "what"s are special because they let a principal modify the rules themselves or borrow another identity. Hold one of those and you can often promote yourself to admin.
The classic dangerous permissions:
iam:PassRole + a compute service. PassRole lets you hand an existing role to a service (Lambda, EC2, ECS). If you can pass an admin role to a function you control, your code now runs with admin power. The danger is a wildcard PassRole (any role) combined with permission to create compute.iam:CreatePolicyVersion / iam:AttachUserPolicy / iam:PutUserPolicy. These let a principal edit or attach policies. An attacker simply writes themselves an Action: "*", Resource: "*" policy.sts:AssumeRole chaining. A role's trust policy says who may assume it. An over-trusting role ("anyone in the account may assume me") lets a low-privileged principal hop into a higher-privileged one, sometimes through several roles in a chain.low-priv user --PassRole--> [admin role] --attached to--> Lambda you create
| |
| v
+----------------- runs your code as admin <------------- invoke
How it works internally. None of this is a bug in the cloud provider. Each step is an allowed API call. The vulnerability is the combination of permissions a single principal holds — which is exactly why automated analyzers map permission graphs.
When to grant / not. Grant iam:* only to genuine administrators. Never use wildcard PassRole; scope it to the specific role ARN a workload needs. The common pitfall is attaching a broad managed policy (or iam:*) to a service account "to make deploys work," creating a silent escalation path.
Knowledge check (concept): A CI role has
iam:PassRoleon*and permission to create Lambda functions. There is also an unusedOrgAdminrole in the account. Describe, in defensive terms, the escalation path an attacker would take, and the one configuration change that closes it.
Definition. Defence in depth means no single control is the only thing standing between an attacker and the assets.
iam:*, no wildcard PassRole, scoped resources, narrow trust policies.Tools such as ScoutSuite, Prowler, and cloud-native IAM analyzers help defenders enumerate these paths before attackers do.
There is no programming syntax here, but the configuration and request shapes matter. The skeleton of an SSRF-driven metadata read versus the IMDSv2 handshake that defeats it:
# IMDSv1 (vulnerable to simple-GET SSRF) — single unauthenticated request
GET http://169.254.169.254/latest/meta-data/iam/security-credentials/ROLE
-> { "AccessKeyId": "...", "SecretAccessKey": "...", "Token": "..." }
# IMDSv2 (requires a token first — a plain GET alone fails)
PUT http://169.254.169.254/latest/api/token
Header: X-aws-ec2-metadata-token-ttl-seconds: 21600 # step 1: get token
GET http://169.254.169.254/latest/meta-data/iam/security-credentials/ROLE
Header: X-aws-ec2-metadata-token: <token-from-step-1> # step 2: use it
The defensive shape that matters most is a scoped IAM permission. Note the exact resource ARN instead of *:
{
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::123456789012:role/app-runtime-role"
}
The single difference between safe and dangerous here is Resource: a specific role ARN cannot be abused to pass an admin role; "Resource": "*" can.
Once inside a cloud environment, attackers hunt for credentials and abuse IAM to escalate. This is the cloud equivalent of privilege escalation on a single machine.
169.254.169.254). A compromised app, often via SSRF (see the web track), reads the instance's role credentials.These are like Active Directory attack paths, but for IAM policies. A principal with one seemingly minor permission can often reach admin.
iam:PassRole + a compute service lets an attacker run code as a more privileged role.iam:CreatePolicyVersion or AttachUserPolicy lets an attacker grant themselves admin.sts:AssumeRole abuses over-trusting roles.Tools such as ScoutSuite, Pacu, and cloud IAM analyzers enumerate these paths.
iam:*, no wildcard PassRole.Below is a defensive lab walkthrough: a tiny insecure URL-fetch endpoint that demonstrates the SSRF-to-metadata path, then the secure version, then how to verify the fix. Run only on localhost against a fake metadata server you control — never against a real cloud account.
WARNING: Intentionally vulnerable training example — use only in a local, isolated, authorized lab. Do not deploy.
# insecure_preview.py -- VULNERABLE TRAINING ONLY
import requests
from flask import Flask, request
app = Flask(__name__)
@app.route("/preview")
def preview():
# BUG: the user fully controls the URL the server will fetch.
url = request.args.get("url", "")
r = requests.get(url, timeout=2) # SSRF: no validation at all
return r.text
# An attacker calls:
# /preview?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
# and the server happily fetches internal credentials and returns them.
Why it is unsafe: the server fetches any URL the client supplies, including the link-local metadata address that only the host should ever reach. On an IMDSv1 instance this returns live role credentials.
Secure version — validate the destination against an allowlist, resolve the hostname, and refuse private/link-local IP ranges:
# safe_preview.py -- SECURE
import ipaddress
import socket
from urllib.parse import urlparse
import requests
from flask import Flask, request, abort
app = Flask(__name__)
ALLOWED_HOSTS = {"images.example-cdn.com"} # explicit allowlist
def is_blocked_ip(host: str) -> bool:
# Resolve and reject loopback, private, and link-local (169.254/16) addresses.
for info in socket.getaddrinfo(host, None):
ip = ipaddress.ip_address(info[4][0])
if ip.is_private or ip.is_loopback or ip.is_link_local:
return True
return False
@app.route("/preview")
def preview():
url = request.args.get("url", "")
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
abort(400, "scheme not allowed")
if parsed.hostname not in ALLOWED_HOSTS: # only known-good hosts
abort(400, "host not allowed")
if is_blocked_ip(parsed.hostname): # defence in depth vs DNS tricks
abort(400, "address not allowed")
r = requests.get(url, timeout=2, allow_redirects=False) # no redirect to bypass checks
return r.text
What it does / expected output. The insecure app returns whatever the supplied URL contains; pointed at a local fake-metadata server it returns the fake credential JSON, demonstrating the leak. The secure app returns the page only for images.example-cdn.com; any other host — including 169.254.169.254 — returns HTTP 400.
Edge cases to handle: redirects (an allowed host can 302 to the metadata IP — that is why allow_redirects=False), DNS rebinding (resolve and re-check at fetch time), IPv6 forms of link-local/loopback, and decimal/octal IP encodings of 169.254.169.254. An allowlist plus IP-range checks beats trying to blocklist every encoding.
Test the fix (mitigation verification): start a local server that imitates the metadata endpoint, then confirm the secure app refuses it:
# 1. Fake metadata server on localhost (lab only)
python3 -m http.server 8000
# 2. The secure app must REJECT it with HTTP 400:
curl -s -o /dev/null -w "%{http_code}\n" \
"http://localhost:5000/preview?url=http://127.0.0.1:8000/" # expect 400
Detection / logging guidance. Log the requested host and the resolved IP for every fetch, and alert when an internal app makes any outbound request to a link-local or private address. In the cloud, monitor IAM/STS calls (e.g. AssumeRole, CreatePolicyVersion, PassRole) and credential use from unexpected IPs. Never log the credential values, tokens, or secret strings themselves — log the event, not the secret.
Tracing the vulnerable-vs-secure /preview request:
| Step | Insecure app | Secure app |
|---|---|---|
1. Read url param |
Takes it as-is | Takes it, then validates |
| 2. Check scheme | (none) | Reject anything but http/https |
| 3. Check host | (none) | Must be in ALLOWED_HOSTS |
| 4. Resolve + check IP | (none) | getaddrinfo then reject private/loopback/link-local |
| 5. Fetch | requests.get(url) for any URL |
allow_redirects=False, only after checks pass |
| 6. Result for metadata URL | Returns role credentials | HTTP 400 |
Step by step for the attack request ?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/:
Insecure path. The handler reads the URL and immediately calls requests.get. The server — which does have network access to 169.254.169.254 — performs the GET. On an IMDSv1 instance the metadata service responds with JSON containing AccessKeyId, SecretAccessKey, and Token. That JSON is returned to the attacker. The attacker exports those three values and now makes cloud API calls as the instance role. From here the IAM escalation concepts apply: if the role can PassRole or edit policies, the foothold widens.
Secure path. urlparse extracts the scheme (http, allowed) and hostname (169.254.169.254). The hostname is not in ALLOWED_HOSTS, so the handler abort(400) before any fetch happens — the metadata service is never contacted. Even if an allowed host were used, is_blocked_ip resolves it and rejects link-local results, and allow_redirects=False stops an allowed host from bouncing the request to the metadata IP.
Why IMDSv2 matters in parallel. Independently of the app fix, an IMDSv2-only instance would also defeat the insecure app: the metadata service would reject the credential-less GET because no X-aws-ec2-metadata-token header is present. Two independent layers (app validation + IMDSv2) is the point of defence in depth.
Mistake 1 — "I deleted the committed key, so it's gone."
Mistake 2 — Blocklisting the string 169.254.169.254 to stop SSRF.
if "169.254.169.254" in url: reject. Attackers bypass it with 169.254.169.254 written in decimal/octal/hex, with DNS names that resolve to it, or via a redirect.http://2852039166/ (decimal form of the metadata IP) still succeeds.Mistake 3 — Wildcard PassRole to "make the deploy work."
Action: iam:PassRole, Resource: "*". Now the principal can pass any role, including admin, to a compute service it controls.Resource: arn:aws:iam::<acct>:role/app-runtime-role.Prowler/ScoutSuite) flags a privilege-escalation path from the principal to admin.Mistake 4 — Leaving IMDSv1 enabled because "an old SDK needs it."
HttpTokens: optional."My secure preview endpoint rejects a legitimate URL." Check the allowlist exact-match (subdomain vs apex, trailing dot), and confirm urlparse returns the hostname you expect — userinfo like http://169.254.169.254@evil.com/ parses with hostname evil.com, which is itself a reason to validate carefully.
"The metadata read returns nothing / 401 in my lab." On IMDSv2 a credential-less GET is supposed to fail (that's the defence). If you are reproducing the insecure case in a lab, point the app at your own local fake-metadata HTTP server, not a real endpoint.
"Stolen credentials don't work / are expired." Role credentials from IMDS are temporary and include a Token (session token) and an expiry. All three (AccessKeyId, SecretAccessKey, Token) are required, and they stop working after expiry — which is also why short-lived credentials limit blast radius.
Questions to ask when an SSRF mitigation "doesn't work":
Logic-error giveaways: an IAM "deny" you expected is actually allowed because a broader Allow (or a wildcard managed policy) wins — read the effective permissions, not just the policy you wrote. Use the provider's policy simulator to test before deploying.
This is security content, so the safety notes are about credentials and authorization rather than C memory.
Risks covered:
169.254.169.254 and leaking role credentials.PassRole, policy editing, and AssumeRole chaining turning a foothold into admin.Defensive practices (do these):
iam:* for workloads, no wildcard PassRole, narrow trust policies, scoped resource ARNs.Ethics/authorization: the offensive paths are described only to close them. Test exclusively on systems you own or are authorized to assess, in isolated labs (sandbox account, local emulator, CTF). Never use real targets or real production secrets — use placeholders like API_KEY=<development-placeholder>.
Robustness note: assume any credential that could have leaked has leaked, and design so its blast radius is small (least privilege + short lifetime). Defence in depth means the app fix and the IMDSv2 setting and the scoped IAM policy each independently reduce risk.
Concrete real-world use case. Multiple high-profile cloud breaches followed the same shape: a web application with an SSRF flaw was tricked into querying the instance metadata service, the attacker harvested the instance's IAM role credentials, and because that role was over-privileged, they reached storage and exfiltrated large volumes of customer data. The fix in each case is the same combination this lesson teaches: enforce IMDSv2, tighten the role to least privilege, validate server-side fetches, and monitor IAM/STS calls. This is also exactly what cloud penetration testers and security reviewers check first, and what tools like Prowler, ScoutSuite, and cloud-native access analyzers automate.
Professional best-practice habits.
Beginner rules:
Advanced habits:
CreatePolicyVersion, broad AttachUserPolicy, unexpected AssumeRole, and credential use from new IPs/ASNs.Beginner 1 — Secret-hunt checklist. Write a checklist of at least six places a cloud credential can leak (e.g. repo, git history, CI logs, image layers, env vars, metadata service). For each, note one detection method and one prevention. Concepts: credential leaks. Hint: one location can be both an entry point and a defence (the metadata service).
Beginner 2 — Classify the IAM permission. Given this list — s3:GetObject, iam:PassRole, ec2:DescribeInstances, iam:CreatePolicyVersion, sts:AssumeRole, logs:PutLogEvents — label each as "escalation-relevant" or "benign," and for the escalation-relevant ones write one sentence on how it could be abused. Concepts: IAM escalation. Constraint: no tooling; reason from the meaning of each action.
Intermediate 1 — Patch the SSRF. Take the insecure /preview endpoint from this lesson and harden it: scheme check, host allowlist, IP-range rejection (private/loopback/link-local), and no redirects. Input/output: ?url=http://images.example-cdn.com/x.png → 200; ?url=http://169.254.169.254/... → 400; ?url=http://2852039166/ (decimal metadata IP) → 400. Concepts: SSRF defence. Hint: resolve the hostname before deciding; don't string-match.
Intermediate 2 — IMDSv2 handshake explainer. Without writing exploit code, document the exact two requests IMDSv2 requires (the PUT for a token, then the GET with the token header) and explain, step by step, why a simple-GET-only SSRF cannot complete it. Include what a hop limit of 1 adds. Concepts: IMDS/IMDSv2. Constraint: describe requests at the protocol level, not as runnable attacks.
Challenge — Map and break an escalation path (lab only). In a personal sandbox account (or a local IAM emulator), create a low-privileged principal with iam:PassRole on * plus permission to create a Lambda, and an unused admin role. On paper, diagram the escalation path from the low-priv principal to admin. Then apply the single fix that closes it (scope PassRole to one non-admin role ARN) and re-diagram to show the path is gone. Concepts: PassRole escalation, least privilege, threat modelling. Constraints: your own account only; no real production roles; do not actually exfiltrate anything — the deliverable is the before/after diagram and a written explanation. Hint: the fix changes one field: Resource.
Main concepts. Cloud compromise usually has two stages: get a credential, then use IAM to escalate. Credentials leak through code, git history, CI logs, container image layers, environment variables, and especially the instance metadata service at 169.254.169.254, which an SSRF bug can reach to steal role credentials. Privilege escalation then exploits dangerous IAM permissions — iam:PassRole (with a compute service), iam:CreatePolicyVersion/AttachUserPolicy, and sts:AssumeRole chaining — none of which are provider bugs; they are over-broad permission combinations.
Most important configuration to remember. Enforce IMDSv2 (token handshake + hop limit) so a simple-GET SSRF can't harvest credentials, and scope IAM tightly — the difference between safe and dangerous PassRole is "Resource": "<specific-role-arn>" versus "Resource": "*".
Common mistakes. Thinking a deleted-then-force-pushed key is safe (rotate it — it isn't); blocklisting the metadata IP string instead of allowlisting hosts and rejecting IP ranges; granting wildcard PassRole; and leaving IMDSv1 enabled.
What to remember. The trust boundary is the ability to make authenticated API calls — once an attacker holds working credentials they are inside. Defend in depth: secrets managers, IMDSv2, least privilege, short-lived credentials, validated server-side fetches, and logging of IAM/STS activity (never the secrets themselves). All testing is defensive, authorized, and lab-only.