DEV Community

Shieldly
Shieldly

Posted on

AWS IAM Privilege Escalation: Real Attack Paths DevOps Engineers Need to Know

You've locked down your AWS accounts. MFA is enforced, no wildcard IAM policies in production, and you've got a service control policy (SCP) boundary that's tighter than a production change freeze. Feels safe, right?

Then someone on your team grants iam:PassRole to a Lambda function, and a junior engineer accidentally leaves iam:CreatePolicyVersion on a developer role. Two harmless-looking permissions. One breach.

IAM privilege escalation in AWS rarely comes from obvious admin access. It comes from chains — individually reasonable permissions that, when combined, let an attacker with a foothold pivot to full account compromise. Here are the escalation paths I've seen pop up in real AWS environments, what they look like in practice, and how to shut them down without breaking your team's workflow.


Path 1: iam:PassRole → Lambda → sts:AssumeRole

This is the most common escalation I encounter during reviews. It's deceptively simple.

The dangerous permission set:

{
  "Effect": "Allow",
  "Action": ["iam:PassRole", "lambda:CreateFunction", "lambda:InvokeFunction"],
  "Resource": "*"
}
Enter fullscreen mode Exit fullscreen mode

On its own, iam:PassRole lets an IAM principal attach an existing IAM role to an AWS service. It doesn't grant any permissions on that role's policies — just the ability to attach it. Engineers add this to Lambda execution roles all the time so CI/CD pipelines can deploy functions.

The escalation:

An attacker who compromises a principal with those three permissions can:

  1. Find a high-privilege IAM role in the account (e.g., AdminRole, DeploymentRole, BreakGlassRole).
  2. Create a Lambda function with that role attached via iam:PassRole.
  3. Write a one-liner in the function that calls sts:AssumeRole on itself — or, more destructively, calls iam:CreateAccessKey on an admin user.
  4. Invoke the function, extract the credentials, and own the role.
# payload.py — deployed as Lambda, attached to the victim role
import boto3, json

def handler(event, context):
    sts = boto3.client('sts')
    # Just long enough to exfil
    creds = sts.get_session_token(DurationSeconds=900)
    return {'status': 'done', 'access': creds['Credentials']['AccessKeyId']}
Enter fullscreen mode Exit fullscreen mode

The mitigation isn't "never use iam:PassRole" — that's impractical. The fix is resource constraints on iam:PassRole to limit what roles can be passed, and lambda:CreateFunction to restrict what functions can be created. Pair this with a permissions boundary on Lambda execution roles, and the path collapses.

{
  "Effect": "Allow",
  "Action": "iam:PassRole",
  "Resource": "arn:aws:iam::*:role/ci-cd-*",
  "Condition": {
    "StringEquals": {"iam:PassedToService": "lambda.amazonaws.com"}
  }
}
Enter fullscreen mode Exit fullscreen mode

Path 2: EC2 Instance Profile Chaining

EC2 instance profiles are the original IAM escalation vector, and they're still widespread. The mechanics are different from the Lambda path but the endgame is the same: hijacking an attached role's identity.

The setup:

Your CI/CD runners live on EC2. Each runner gets an instance profile role — say EC2-CI-Runner — with permissions to pull from ECR, write to CloudWatch, and access an S3 bucket for build artifacts. Standard stuff.

Someone adds ec2:AssociateIamInstanceProfile to a developer role so the team can attach profiles to test instances during development. That's the crack in the door.

The escalation:

An attacker with ec2:AssociateIamInstanceProfile (and ec2:RunInstances or access to an existing instance) can:

  1. Launch a new EC2 instance (or hijack an existing stopped one they can ec2:StartInstances).
  2. Detach the low-privilege instance profile from the target instance.
  3. Associate a high-privilege instance profile — one attached to a production service or a data pipeline — onto their instance.
  4. SSH in (via SSM or a key they control) and retrieve the instance metadata credentials at http://169.254.169.254/latest/meta-data/iam/security-credentials/.

One curl command later, they have AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN for the production role.

# On the attacker-controlled EC2 instance
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" \
  -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
  http://169.254.169.254/latest/meta-data/iam/security-credentials/
Enter fullscreen mode Exit fullscreen mode

The fix:

Never grant ec2:AssociateIamInstanceProfile without a resource constraint on the instance profile ARN, not just the instance. Lock it down to specific profiles:

{
  "Effect": "Allow",
  "Action": "ec2:AssociateIamInstanceProfile",
  "Resource": [
    "arn:aws:ec2:*:*:instance/*",
    "arn:aws:iam::*:instance-profile/development-*"
  ]
}
Enter fullscreen mode Exit fullscreen mode

Better yet, add a Condition key checking that the profile isn't tagged as production, or apply a service control policy that blocks instance profile association for non-admin roles entirely.


Path 3: iam:CreatePolicyVersion — The Nearly Silent Takeover

This one flies under the radar because it's one permission — no chaining required.

iam:CreatePolicyVersion lets you create a new version of an existing customer-managed policy. If the policy is attached to a user, group, or role, the new version can be set as default, instantly granting new permissions to every principal under that policy.

The scenario:

A security-conscious team grants iam:CreatePolicyVersion to a handful of trusted engineers so they can iterate on policy changes without going through a full deployment pipeline. The policy in question, SupportEngineerPolicy, is attached to 40 support engineers who need read-only access to CloudWatch and limited EC2 describe permissions.

An attacker compromises one support engineer's credentials — phishing, leaked token, compromised workstation — and discovers they can create policy versions. They craft a new version of SupportEngineerPolicy that adds "Effect": "Allow", "Action": "*", "Resource": "*" and set it as default.

# One API call to own the environment
aws iam create-policy-version \
  --policy-arn arn:aws:iam::123456789012:policy/SupportEngineerPolicy \
  --policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}' \
  --set-as-default
Enter fullscreen mode Exit fullscreen mode

Every principal attached to that policy — 40 engineers — now has full administrative access. The attacker exfiltrates data, creates backdoor users, and the blast radius is enormous. The worst part? No new IAM users or roles were created. No obvious anomalies in CloudTrail if you're not watching CreatePolicyVersion events.

The mitigation:

Block iam:CreatePolicyVersion with an SCP for all non-admin roles. Use iam:SetDefaultPolicyVersion as a sensitive action that triggers an alert in your SIEM. Treat policy version management the same way you treat IAM user creation — it's a privileged operation, not a convenience feature.

{
  "Effect": "Deny",
  "Action": [
    "iam:CreatePolicyVersion",
    "iam:SetDefaultPolicyVersion",
    "iam:DeletePolicyVersion"
  ],
  "Resource": "*",
  "Condition": {
    "ArnNotLike": {
      "aws:PrincipalARN": "arn:aws:iam::*:role/admin-*"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Putting It Together: Defense in Depth for IAM

If there's one lesson from years of AWS IR, it's this: IAM permissions are directional. You can't audit them in isolation. A permission that's harmless in role A becomes critical when paired with role B's pass-role target or role C's policy attachment.

What works in practice:

  1. SCP boundaries first. Deny iam:PassRole for cross-account roles, deny iam:CreatePolicyVersion broadly, and restrict instance profile associations at the organization level. You can't accidentally grant what SCPs already block.

  2. Resource-level constraints on everything. "Resource": "*" on pass-role or create-function is a risk acceptance, not a policy. Scope every actionable IAM permission to specific ARN patterns.

  3. Alert on the escalation primitives. CloudWatch Events on CreatePolicyVersion, PassRole to non-standard services, and AssociateIamInstanceProfile on production-tagged instances. Don't wait for a breach to find out your monitoring had a gap.

  4. Audit with an adversarial mindset. Don't ask "does this role have too many permissions?" Ask "if this role is compromised, what can the attacker reach from here?" Map the chains, not just the leaf permissions.

  5. Use permissions boundaries. Every role your team creates should have a permissions boundary that caps its effective permissions regardless of what policies get attached later. It's the IAM equivalent of a circuit breaker.

IAM privilege escalation isn't a vulnerability in AWS — it's a vulnerability in how we compose IAM permissions day to day. The tools themselves are fine. The chains we build with them are the problem.

Know your chains. Test them before an attacker does.

Top comments (0)