Cloud Security

AWS IAM Privilege Escalation: Attack Paths and How to Block Them

A technical deep-dive into AWS IAM privilege escalation attack paths — iam:PassRole, CreatePolicyVersion, AttachUserPolicy — and how to detect and prevent them with IAM Access Analyzer, permission boundaries, and SCPs.

October 15, 20257 min readShipSafer Team

Privilege escalation in AWS doesn't require exploiting a zero-day. It requires finding a combination of permissions that, when used together, allow a lower-privileged user or role to gain higher-privileged access. These attack paths are entirely valid from AWS's perspective — the permissions were granted. The problem is that the combined effect wasn't intended.

This post catalogs the most common privilege escalation paths in AWS IAM and shows how to systematically detect and block them.

How IAM Privilege Escalation Works

Unlike traditional Unix privilege escalation (exploiting a setuid binary), AWS IAM privilege escalation typically involves:

  1. Misuse of allowed permissions to create new IAM resources (policies, roles, users)
  2. Exploiting trust relationships between roles to assume a more powerful one
  3. Chaining together multiple individually harmless permissions that together grant admin access

Most of these attacks are silent — they generate CloudTrail events but don't trigger the same alert fatigue as port scans or failed logins.

Critical Privilege Escalation Paths

Path 1: iam:CreatePolicyVersion

A user with iam:CreatePolicyVersion can create a new version of any managed policy. If they can modify a policy attached to a more privileged role or user, they can grant themselves admin access.

import boto3

iam = boto3.client('iam')

# Find a policy attached to an admin role
# Create a new version of that policy that adds sts:AssumeRole and admin permissions
iam.create_policy_version(
    PolicyArn='arn:aws:iam::123456789012:policy/SomeManagedPolicy',
    PolicyDocument=json.dumps({
        "Version": "2012-10-17",
        "Statement": [{"Effect": "Allow", "Action": "*", "Resource": "*"}]
    }),
    SetAsDefault=True
)

Block it: The policy must be in the same account. Restricting iam:CreatePolicyVersion to specific policy ARNs using conditions, or removing it entirely, eliminates this path.

Path 2: iam:AttachUserPolicy / iam:AttachRolePolicy

A user with iam:AttachUserPolicy can attach AdministratorAccess or any other policy to their own user:

iam.attach_user_policy(
    UserName='attacker',
    PolicyArn='arn:aws:iam::aws:policy/AdministratorAccess'
)

Block it: Use Permission Boundaries (described below). A permission boundary prevents a principal from having effective permissions beyond what the boundary allows, even if they successfully attach AdministratorAccess to themselves.

Path 3: iam:PassRole + ec2:RunInstances

This is one of the most commonly exploited paths. iam:PassRole allows a user to associate an IAM role with an AWS service. ec2:RunInstances allows launching EC2 instances. Together:

# Launch an instance with an admin IAM role attached
ec2.run_instances(
    ImageId='ami-12345',
    InstanceType='t2.micro',
    MinCount=1,
    MaxCount=1,
    IamInstanceProfile={'Name': 'AdminInstanceProfile'}  # Role with admin access
)
# Then SSH to the instance and curl the metadata service to get admin credentials

The instance's IAM credentials are accessible from inside the instance via the metadata service at 169.254.169.254.

Block it: Scope iam:PassRole to specific roles using conditions:

{
  "Effect": "Allow",
  "Action": "iam:PassRole",
  "Resource": "arn:aws:iam::123456789012:role/AllowedEC2Role",
  "Condition": {
    "StringEquals": {"iam:PassedToService": "ec2.amazonaws.com"}
  }
}

Never allow iam:PassRole with Resource: "*".

Path 4: iam:PassRole + lambda:CreateFunction + lambda:InvokeFunction

Similar to the EC2 path but using Lambda:

# Create a Lambda function with an admin role
lambda_client.create_function(
    FunctionName='escalation-lambda',
    Runtime='python3.11',
    Role='arn:aws:iam::123456789012:role/AdminRole',
    Code={'ZipFile': malicious_code},
    Handler='index.handler'
)

# Invoke it to run arbitrary code with admin permissions
lambda_client.invoke(FunctionName='escalation-lambda')

This is particularly dangerous because the Lambda execution leaves fewer traces than an EC2 instance and can be deleted immediately after exploitation.

Path 5: iam:CreateAccessKey

A user with iam:CreateAccessKey can generate access keys for other users, including administrators:

response = iam.create_access_key(UserName='admin-user')
# Now they have admin credentials

Block it: Restrict iam:CreateAccessKey to Resource: "arn:aws:iam::*:user/${aws:username}" (only allows creating keys for yourself).

Path 6: sts:AssumeRole with Weak Trust Policies

A role trust policy that uses overly broad conditions (or no conditions) can be assumed by unintended principals. Common patterns:

// Dangerous: any principal in the account can assume this role
{
  "Effect": "Allow",
  "Principal": {"AWS": "arn:aws:iam::123456789012:root"},
  "Action": "sts:AssumeRole"
}

The root principal in a trust policy means any IAM principal (user or role) in that account with sts:AssumeRole permission can assume it — not just root.

Block it: Always specify the exact principal ARN in trust policies. Add aws:PrincipalArn conditions when needed:

{
  "Effect": "Allow",
  "Principal": {"AWS": "arn:aws:iam::123456789012:root"},
  "Action": "sts:AssumeRole",
  "Condition": {
    "ArnLike": {
      "aws:PrincipalArn": "arn:aws:iam::123456789012:role/AllowedRole"
    }
  }
}

Path 7: iam:UpdateAssumeRolePolicy

A user with this permission can modify any role's trust policy:

iam.update_assume_role_policy(
    RoleName='AdminRole',
    PolicyDocument=json.dumps({
        "Version": "2012-10-17",
        "Statement": [{
            "Effect": "Allow",
            "Principal": {"AWS": "arn:aws:iam::123456789012:user/attacker"},
            "Action": "sts:AssumeRole"
        }]
    })
)
# Now assume the admin role
sts.assume_role(RoleArn='arn:aws:iam::123456789012:role/AdminRole', RoleSessionName='escalation')

Path 8: iam:SetDefaultPolicyVersion

If previous versions of a policy were more permissive, this permission allows setting an older version as default:

# List policy versions to find a more permissive one
versions = iam.list_policy_versions(PolicyArn='arn:aws:iam::123456789012:policy/MyPolicy')

# Set the most permissive version as default
iam.set_default_policy_version(
    PolicyArn='arn:aws:iam::123456789012:policy/MyPolicy',
    VersionId='v1'  # The original, more permissive version
)

Automated Discovery with PMapper

Principal Mapper (PMapper) is an open-source tool that builds a graph of all IAM principals and their privilege escalation paths:

pip install principalmapper

# Collect IAM data
pmapper --profile default graph create

# Identify privilege escalation paths to admin
pmapper --profile default analysis --output-type text

# Query: who can escalate to AdministratorAccess?
pmapper --profile default query "who can do * with *"

# Visualize the attack graph
pmapper --profile default visualize --output-type svg --output-file iam-graph.svg

PMapper checks over 20 escalation techniques and identifies transitive escalation paths (A can assume B, B can assume C, C is admin — therefore A has an escalation path to admin).

Run PMapper quarterly or after significant IAM changes.

IAM Access Analyzer

AWS IAM Access Analyzer uses formal verification to identify privilege escalation paths and overly permissive policies:

# Validate a policy for security issues
aws accessanalyzer validate-policy \
  --policy-document file://my-policy.json \
  --policy-type IDENTITY_POLICY

# Check for findings in Access Analyzer
aws accessanalyzer list-findings \
  --analyzer-arn arn:aws:accessanalyzer:us-east-1:123456789012:analyzer/my-analyzer \
  --filter '{"findingType":{"eq":["EXTERNAL_ACCESS"]}}'

Access Analyzer also generates least-privilege policies based on CloudTrail activity — use it to replace wildcard policies with scoped ones.

Permission Boundaries: The Key Defense

Permission boundaries are the most powerful and underused defense against IAM privilege escalation. A permission boundary is a managed policy attached to an IAM entity that defines the maximum permissions it can have, regardless of what identity-based policies allow.

# User has AdministratorAccess but is constrained by the boundary
# Effective permissions = intersection of AdministratorAccess AND DeveloperBoundary

# Create boundary that allows most developer actions but not IAM modification
boundary_policy = {
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ec2:*",
                "s3:*",
                "lambda:*",
                "logs:*"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Deny",
            "Action": [
                "iam:CreateUser",
                "iam:AttachUserPolicy",
                "iam:AttachRolePolicy",
                "iam:CreatePolicyVersion",
                "iam:SetDefaultPolicyVersion",
                "iam:PassRole",
                "iam:UpdateAssumeRolePolicy"
            ],
            "Resource": "*"
        }
    ]
}

iam.create_policy(
    PolicyName='DeveloperBoundary',
    PolicyDocument=json.dumps(boundary_policy)
)

# Apply boundary when creating roles or users
iam.create_role(
    RoleName='DeveloperRole',
    PermissionsBoundary='arn:aws:iam::123456789012:policy/DeveloperBoundary',
    AssumeRolePolicyDocument=...
)

A critical constraint: if developers can create new roles/users, they should only be able to do so when attaching the same or more restrictive boundary. Otherwise they create an unbounded role and assign it to themselves.

Service Control Policies for Org-Wide Guardrails

SCPs provide guardrails that even account administrators cannot override:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PreventPrivilegeEscalation",
      "Effect": "Deny",
      "Action": [
        "iam:CreatePolicyVersion",
        "iam:SetDefaultPolicyVersion",
        "iam:AttachUserPolicy",
        "iam:AttachRolePolicy",
        "iam:AttachGroupPolicy",
        "iam:PutUserPolicy",
        "iam:PutRolePolicy",
        "iam:PutGroupPolicy"
      ],
      "Resource": "*",
      "Condition": {
        "ArnNotLike": {
          "aws:PrincipalARN": [
            "arn:aws:iam::*:role/BreakGlassRole",
            "arn:aws:iam::*:role/IAMAdminRole"
          ]
        }
      }
    }
  ]
}

This SCP prevents any principal except explicitly named admin roles from modifying IAM policies, regardless of what their identity-based policies say.

Detection: CloudTrail Alerts for Escalation

Monitor CloudTrail for the events that indicate privilege escalation attempts:

# CloudWatch Logs Insights query for IAM escalation events
fields @timestamp, userIdentity.arn, eventName, requestParameters
| filter eventName in [
    "CreatePolicyVersion",
    "SetDefaultPolicyVersion",
    "AttachUserPolicy",
    "AttachRolePolicy",
    "PutUserPolicy",
    "PutRolePolicy",
    "CreateAccessKey",
    "UpdateAssumeRolePolicy",
    "PassRole"
  ]
| filter userIdentity.type != "AssumedRole" or
    not like(userIdentity.arn, "*:assumed-role/AllowedIAMRole*")
| sort @timestamp desc
| limit 50

Set up a CloudWatch alarm on this metric filter to notify your security team within minutes of a potential escalation attempt.

The combination of permission boundaries (tactical constraint), SCPs (strategic constraint), PMapper scans (discovery), and CloudTrail alerting (detection) creates a layered defense that makes IAM privilege escalation significantly harder to execute and easier to detect.

AWS
IAM
privilege escalation
PMapper
permission boundaries
IAM Access Analyzer

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.