Four AWS attack paths, and the one change that breaks each

Each of these four paths needs nothing exotic. Every hop is a setting that ships in real accounts, and every one can be cut with a single change. Account IDs, role names and bucket names are made up.

An attack path is a chain of real access, so the useful way to learn them is to read some. Below are four, written the way you would see them in an AWS account: the exact setting, the chain an attacker walks, and the one change that ends it. If you want the concept first, read attack path analysis and what an attack path is. Otherwise, start at the first example.

One note on method. Each path is described from AWS's own documented behaviour, and the fix is the smallest change that removes a hop. Where a fix leaves a related risk in place, the post says so.

Path 1: the web server that hands out its own credentials

The setup. An EC2 instance runs a public web application. The application has a feature that fetches a URL on the user's behalf, such as an image importer or a webhook tester. The instance still allows the original version of the instance metadata service, and its instance profile role can read a customer export bucket.

Role: orders-api-role
Allows: s3:GetObject on arn:aws:s3:::customer-exports-prod/*
Instance metadata: HttpTokens = optional   (IMDSv1 still answers)

The chain.

  1. The attacker asks the application to fetch http://169.254.169.254/latest/meta-data/iam/security-credentials/. The request is made from the instance, so the metadata service answers it.
  2. The response names the role. A second request to the same path plus the role name returns an access key, a secret key and a session token.
  3. Those temporary credentials work from anywhere unless a policy condition ties them to a network. The attacker configures them locally and runs aws s3 cp against the bucket.

The wider pattern, a server that can be talked into handing over role credentials, has a famous public case. The US Department of Justice describes the 2019 Capital One intrusion as occurring through a misconfigured web application firewall that let an outsider's commands run, which obtained security credentials for particular roles, which were then used to list and copy data from storage buckets. The details differ from the example here, and the DOJ's account does not name the metadata service. The lesson is the same: credentials that a server can reach are credentials an attacker can reach through that server.

The one change. Require IMDSv2 on the instance:

aws ec2 modify-instance-metadata-options \
  --instance-id i-0abc1234def567890 \
  --http-tokens required \
  --http-endpoint enabled

IMDSv2 asks the caller to first request a session token with an HTTP PUT and a custom header, then present that token on every read. A plain forwarded GET, which is all most request-forgery bugs can produce, no longer returns credentials. The hop from the application bug to the role disappears.

Newer defaults help, but not for what is already running. Amazon Linux 2023 requires IMDSv2 by default, and AWS lets you set an account-level default so new launches require it. Neither changes an existing instance that was launched with IMDSv1 allowed, so check the ones you already have.

What it does not fix. An attacker who can run code on the instance can still make the PUT. So the second change is to shrink the role. If the service never needs the export bucket, remove the grant and the target is no longer on the path at all.

Path 2: two permissions that add up to administrator

The setup. A developer role can create and run Lambda functions, and it can pass roles. Somewhere else in the account sits a role built for a data pipeline, with broad permissions and a trust policy that lets the Lambda service assume it.

Developer role policy:
  lambda:CreateFunction, lambda:InvokeFunction
  iam:PassRole  on  Resource: "*"

Role: data-pipeline-admin
  Permissions: AdministratorAccess
  Trust: Service lambda.amazonaws.com

The chain.

  1. The attacker obtains developer credentials. A key committed to a repository or lifted from a laptop is enough.
  2. They create a function and pass data-pipeline-admin as its execution role. iam:PassRole on every resource allows exactly that.
  3. They invoke the function. Its code runs with the administrator role, so it can create an access key, add a user or read anything in the account.

No individual permission here looks alarming. Creating functions is what developers do. The escalation exists only because iam:PassRole was not scoped, which is the control AWS documents for exactly this reason.

The one change. Scope the PassRole grant to the roles developers legitimately use, and name the service it may pass them to:

{
  "Effect": "Allow",
  "Action": "iam:PassRole",
  "Resource": "arn:aws:iam::111122223333:role/dev-lambda-*",
  "Condition": {
    "StringEquals": { "iam:PassedToService": "lambda.amazonaws.com" }
  }
}

With that policy the developer can still deploy functions. They can no longer attach the administrator role to one. The path is cut at the second hop, and nothing else about their work changes.

Path 3: the CI role that trusts every repository you own

The setup. A deployment role is assumed by GitHub Actions through OpenID Connect, so there are no long-lived keys. The trust policy checks that the token comes from GitHub, then uses a wildcard on the subject claim so that any repository in the organisation can use the role.

"Condition": {
  "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" },
  "StringLike":   { "token.actions.githubusercontent.com:sub": "repo:acme-corp/*" }
}

The chain.

  1. Someone with write access to any repository in acme-corp adds a workflow. That could be a hackathon repository, a forgotten prototype or a repository a contractor can push to.
  2. The workflow requests an OIDC token and calls sts:AssumeRoleWithWebIdentity for the deployment role. The subject claim looks like repo:acme-corp/hackathon-2024:ref:refs/heads/main, and the wildcard matches it.
  3. AWS issues credentials for the deployment role. The attacker now holds whatever production deployment can do.

The role was meant to serve one pipeline. The wildcard widened it to everyone who can push a workflow anywhere in the organisation. This pattern is common enough that the free frontdoor tool checks for it. The longer treatment is in who can walk into your AWS account from outside.

The one change. Pin the subject to the repository, and ideally to an environment or branch, instead of a wildcard:

"StringEquals": {
  "token.actions.githubusercontent.com:sub": "repo:acme-corp/payments-service:environment:production"
}

Now only runs of that repository that target its production environment can assume the role. GitHub lets you require approvals on an environment, so the trust boundary becomes something you can review.

One detail to watch: GitHub changed the default subject format for repositories created after 15 July 2026 to include immutable owner and repository IDs, so pin the subject in whichever format your repository actually emits. AWS also now refuses to save a GitHub trust policy whose subject condition is missing or only a bare wildcard, which closes the laziest version of this mistake and leaves the prefix wildcard shown above.

Path 4: the trust policy that says "anyone in this account"

The setup. A production administrator role trusts the account root as its principal. Separately, a developer policy lets developers call sts:AssumeRole on any resource, because someone once found it simpler than listing the roles.

Role: prod-admin
  Trust: Principal AWS arn:aws:iam::111122223333:root
Developer policy:
  sts:AssumeRole on Resource: "*"

The chain.

  1. An attacker gets any developer credential. The least careful place a key lives is the one most often found.
  2. They list the account's roles and find prod-admin.
  3. They call aws sts assume-role for it. A trust policy that names the account root delegates the decision to the account's own IAM policies, and the developer policy says yes.

Trusting the account root is not wrong on its face. It means access is decided by the permissions each identity in the account carries. The mistake is combining it with a blanket sts:AssumeRole, which makes every identity in the account a candidate administrator.

The one change. Replace the wildcard in the developer policy with the specific role ARNs developers need:

"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::111122223333:role/dev-sandbox-*"

Developers keep the roles they use. prod-admin is no longer reachable from their credentials. Adding a multi-factor condition on the admin role's trust policy is a good second layer, but the scoped policy is what removes the hop.

What the four have in common

Look at where each path was cut. Paths 2 and 4 were fixed with a change to an identity policy, path 3 with a change to a trust condition, and only path 1 was a host setting. In each case the repair was a line in a policy, not a patch and not a new product. That is the usual outcome. In a cloud account, the cheapest edge to cut is more often a permission than a package.

Notice also that no scanner rule was needed to describe any of them. Each hop on its own is a setting someone chose for a reason. The path exists only when you read them together, which is the point made in toxic combinations. Finding them across a real account, with the right ones ranked first, is the job attack path analysis automates.

Checking your own account

You can look for all four without buying anything. Query for instances where HttpTokens is still optional, and for roles attached to instances that face the internet. Search policies for iam:PassRole with a * resource. List trust policies that use a wildcard in the GitHub subject claim. And search for roles that trust an account root next to identities holding sts:AssumeRole on every resource. Then ask the question the paths are built to answer: if this identity were stolen, what could it reach?

Quick answers

The questions below are the ones that come up most when these paths are first read.

FAQ

Is requiring IMDSv2 enough to stop path 1?

It stops the common case, where a request-forgery bug forwards a simple GET. It does not stop an attacker who can already run code on the instance, so also remove permissions the role does not need.

Why is iam:PassRole dangerous on its own?

It decides which roles a principal may hand to a service. With a wildcard resource, a principal that can create a function or instance can attach any role in the account that the service is allowed to assume.

Does an OIDC wildcard mean anyone on the internet can assume the role?

No. Whoever can run a workflow in a matching repository can. For a wildcard over an organisation, that means anyone with write access to any of its repositories.

Do these paths need a vulnerability to work?

Only the first one needs a bug, and only to get the initial request through. The other three use permissions that exist exactly as configured.

Secorvia builds paths like these from read-only configuration data on its Starter plan and above. The pillar page on attack path analysis explains how they are ranked.

Keep reading
Fundamentals · 6 min read

What is an attack path in cloud security?

Prioritisation · 6 min read

Why a CVSS score cannot prioritise your cloud risk

Identity · 6 min read

Who can walk into your AWS account from outside?

Try it against your own cloud account.