Tarek Cheikh
Founder & AWS Cloud Architect
A common first check in an IAM review is a policy that allows "Action": "*" on "Resource": "*". That is a reasonable check, and AWS Security Hub ships a control for it. But an identity can reach administrator-level permissions without ever holding that statement. All it needs is a permission that lets it change its own permissions, or borrow the permissions of something more powerful.
AWS documents the principle itself. In the IAM User Guide, on passing roles to services, AWS writes: "When setting the PassRole permission, you should make sure that a user doesn't pass a role where the role has more permissions than you want the user to have."
This article walks through twelve escalation paths that you can verify against AWS documentation. For each one you get the permissions involved, a command to reproduce it in a sandbox account you own, the CloudTrail event it leaves, and the control that blocks it. The method catalogue behind this topic comes from Rhino Security Labs, which lists 21 AWS IAM privilege escalation methods. This article covers 17 of them: the twelve rows in the table below, where rows 3, 4 and 7 each group several of Rhino's methods. The other four involve AWS Glue development endpoints, AWS Data Pipeline, and Lambda with a DynamoDB event source mapping. We did not review the AWS documentation for those services for this article, so we leave them out rather than describe them half-checked.
Use these commands only in an AWS account you own and control. They are here so you can confirm that your detections and guardrails work.
Whether a permission is dangerous depends on what it points at. iam:CreateAccessKey scoped to your own user name is routine. The same permission on Resource: "*" lets the holder create credentials for every user in the account, including the most privileged one. Each permission below can lead to administrator-level access, depending on its Resource element and on what the targets can do. Treat it as high risk unless the Resource element is narrow and the targets are low-privilege.
| # | Path | Permissions needed | CloudTrail eventName |
|---|---|---|---|
| 1 | Create a new policy version | iam:CreatePolicyVersion | CreatePolicyVersion |
| 2 | Switch to another policy version | iam:SetDefaultPolicyVersion | SetDefaultPolicyVersion |
| 3 | Attach a managed policy | iam:AttachUserPolicy, iam:AttachGroupPolicy, iam:AttachRolePolicy | Same as the action name |
| 4 | Write an inline policy | iam:PutUserPolicy, iam:PutGroupPolicy, iam:PutRolePolicy | Same as the action name |
| 5 | Join a more privileged group | iam:AddUserToGroup | AddUserToGroup |
| 6 | Create an access key for another user | iam:CreateAccessKey | CreateAccessKey |
| 7 | Set a console password for another user | iam:CreateLoginProfile, iam:UpdateLoginProfile | Same as the action name |
| 8 | Rewrite a role trust policy | iam:UpdateAssumeRolePolicy, then sts:AssumeRole | UpdateAssumeRolePolicy, then AssumeRole |
| 9 | Launch EC2 with a privileged role | iam:PassRole, ec2:RunInstances | RunInstances |
| 10 | Create and invoke a Lambda function with a privileged role | iam:PassRole, lambda:CreateFunction, lambda:InvokeFunction | CreateFunction20150331 |
| 11 | Replace the code of a Lambda function that has a privileged role | lambda:UpdateFunctionCode | UpdateFunctionCode20150331v2 |
| 12 | Hand a privileged role to CloudFormation | iam:PassRole, cloudformation:CreateStack | CreateStack |
On event names: CloudTrail defines eventName as "the requested action, which is one of the actions in the API for that service." For IAM, STS, EC2 and CloudFormation calls, that means the event name is the API action name. Lambda is different: some of its documented CloudTrail names carry a version suffix, including CreateFunction20150331 and UpdateFunctionCode20150331v2. Confirm the exact names in your own CloudTrail logs before you build alerts on them.
A customer managed policy can hold several versions, and only one is the default. Per the IAM API reference, CreatePolicyVersion creates a new version of a managed policy, and when SetAsDefault is true, "the new policy version becomes the operative version. That is, it becomes the version that is in effect for the IAM users, groups, and roles that the policy is attached to."
So a caller who holds iam:CreatePolicyVersion on a policy attached to its own identity can rewrite that policy in one call. A managed policy holds at most five versions, and AWS says you must delete an existing version with DeletePolicyVersion before creating a new one if five exist.
aws iam create-policy-version \
--policy-arn arn:aws:iam::123456789012:policy/MyPolicy \
--policy-document file://NewPolicyVersion.json \
--set-as-default
This path needs an older version of the policy that is more permissive than the current default. The API reference says SetDefaultPolicyVersion "affects all users, groups, and roles that the policy is attached to." If your policies keep old versions around after a cleanup, an old broad version is a loaded switch.
aws iam set-default-policy-version \
--policy-arn arn:aws:iam::123456789012:policy/MyPolicy \
--version-id v1
AttachUserPolicy, AttachGroupPolicy and AttachRolePolicy attach a managed policy to a user, group or role. The AWS API reference example for AttachUserPolicy uses arn:aws:iam::aws:policy/AdministratorAccess, which shows how direct the path is.
aws iam attach-user-policy \
--policy-arn arn:aws:iam::aws:policy/AdministratorAccess \
--user-name Alice
PutUserPolicy, PutGroupPolicy and PutRolePolicy each "adds or updates an inline policy document" on the user, group or role. An inline policy does not need to exist beforehand, and a policy document with a single allow-everything statement is enough.
aws iam put-user-policy \
--user-name Bob \
--policy-name ExamplePolicy \
--policy-document file://AdminPolicy.json
AddUserToGroup "adds the specified user to the specified group," per the API reference. If a group carries broad permissions and the caller may add users to it without a restriction on which group, the caller can add itself.
aws iam add-user-to-group --user-name Bob --group-name Admins
CreateAccessKey creates a new access key for "the specified user." If you do not give a user name, IAM uses the user that signed the request. With --user-name set to someone else, the caller receives working credentials for that user. The secret access key is only available at creation time, so the response is the moment the credentials are exposed. A user can hold two access keys at most, and that limit cannot be raised, so the call fails if the target already has two.
aws iam create-access-key --user-name Bob
AWS states that you can use the CLI, the API or the console "to create a password for any IAM user" and "to change the password for any IAM user." A console password turns a user that only had API access into one that can sign in to the console.
CreateLoginProfile fails with EntityAlreadyExists if the user already has a password, and the new password must meet the account password policy. When a profile already exists, UpdateLoginProfile is the path. In a sandbox, use a throwaway password: a password typed on the command line is stored in your shell history.
aws iam create-login-profile \
--user-name Bob \
--password 'PASSWORD_THAT_MEETS_YOUR_POLICY' \
--no-password-reset-required
aws iam update-login-profile --user-name Bob --password 'PASSWORD_THAT_MEETS_YOUR_POLICY'
A role has two policy types: a trust policy that says who can assume it, and permission policies that say what it can do. UpdateAssumeRolePolicy rewrites the trust policy. AWS documents that within one account, the trust policy acts as a resource-based policy: "When a resource-based policy grants access to a principal in the same account, no additional identity-based policy is required."
The path is two steps. First, rewrite the trust policy of a privileged role so that it trusts the caller. Second, assume the role. An SCP can still deny the iam:UpdateAssumeRolePolicy call itself, which is covered in the blocking section.
aws iam update-assume-role-policy \
--role-name Test-Role \
--policy-document file://Test-Role-Trust-Policy.json
To configure many AWS services, you pass an IAM role to the service so it can assume the role later and act on your behalf. The caller needs permission to pass the role. AWS also notes two facts that matter for detection. PassRole works only for services in the same AWS account, and "PassRole is not an API call. PassRole is a permission, meaning no CloudTrail logs are generated for IAM PassRole." To see which role was passed, you read the log of the call that created or modified the receiving resource, for example CreateFunction for Lambda.
The service must be able to assume the role, so the role's trust policy has to name that service as a principal. AWS's EC2 example uses "Principal": { "Service": "ec2.amazonaws.com" } with sts:AssumeRole. A PassRole grant on Resource: "*" means any role in the account can be passed. AWS says so directly for EC2: "If you specify the resource for iam:PassRole as *, this would grant access to pass any of your IAM roles to an instance."
An instance profile is the container that attaches an IAM role to an EC2 instance, and it holds one role. Applications on the instance "can access temporary credentials for the role through the instance profile metadata." A caller that can pass a privileged role and launch an instance can run its own code under that role. This path assumes an instance profile that already contains the privileged role. Our write-up of the Capital One breach covers why the instance metadata service is worth protecting, including IMDSv2.
aws ec2 run-instances \
--image-id AMI_ID \
--instance-type INSTANCE_TYPE \
--iam-instance-profile Name=INSTANCE_PROFILE_NAME \
--user-data file://user-data.sh
A new Lambda function takes an execution role. The caller creates a function whose code does what it wants, assigns the privileged role, and invokes it. The code runs with that role's permissions.
aws lambda create-function \
--function-name FUNCTION_NAME \
--runtime RUNTIME \
--role ROLE_ARN \
--handler HANDLER \
--zip-file fileb://function.zip
aws lambda invoke --function-name FUNCTION_NAME output.json
This path does not need iam:PassRole, because the role is already attached to the function. A caller that can update the code of a function whose role is privileged can make that function run different code. The AWS CLI reference notes one control: "If code signing is enabled for the function, the code package must be signed by a trusted publisher."
aws lambda update-function-code \
--function-name FUNCTION_NAME \
--zip-file fileb://function.zip
CloudFormation can create a stack using a service role. The AWS CLI reference says CloudFormation "always uses this role for all future operations on the stack" and uses it "even if the users don't have permission to pass it." The CloudFormation user guide states the risk plainly: other users who can operate on the stack can use the role regardless of iam:PassRole, and "If the role includes permissions that the user shouldn't have, you can unintentionally escalate a user's permissions."
A template that creates IAM resources must be acknowledged with a capability flag, and custom-named IAM resources need CAPABILITY_NAMED_IAM.
aws cloudformation create-stack \
--stack-name STACK_NAME \
--template-body file://template.json \
--capabilities CAPABILITY_NAMED_IAM \
--role-arn ROLE_ARN
CloudTrail captures all IAM and STS API calls, including calls from the console. Every path above leaves a management event, apart from the permission check itself. Four details decide whether you actually see them.
CreateFunction20150331 or RunInstances. For Lambda, AWS says the CreateFunction log shows the role that was passed.Invoke is a data event. AWS notes that "by default, CloudTrail doesn't log most data events," and Invoke is listed among the Lambda data events. Path 10 can therefore leave a CreateFunction20150331 record but no Invoke record unless you enabled data event logging.To look back over recent events, query the event names from the table. The loop below covers the IAM paths only. Query AssumeRole, RunInstances, CreateFunction20150331, UpdateFunctionCode20150331v2 and CreateStack the same way, in the Region where those calls were made. The lookup-events command accepts one attribute per call, is limited to two requests per second per account per Region, and IAM events are normally recorded in us-east-1.
for event in CreatePolicyVersion SetDefaultPolicyVersion AttachUserPolicy \
AttachGroupPolicy AttachRolePolicy PutUserPolicy PutGroupPolicy \
PutRolePolicy AddUserToGroup \
CreateAccessKey CreateLoginProfile UpdateLoginProfile \
UpdateAssumeRolePolicy; do
aws cloudtrail lookup-events \
--region us-east-1 \
--lookup-attributes AttributeKey=EventName,AttributeValue=$event
sleep 1
done
For continuous detection, send CloudTrail management events to Amazon EventBridge. EventBridge matches CloudTrail calls with the detail-type AWS API Call via CloudTrail, and AWS states that you must enable a CloudTrail trail with logging to record events with that detail-type. Take the exact source value for IAM from a sample event in your own account before you write the rule. The alert that matters is the combination: one of these event names, performed by a principal that is not on your short list of expected administrators or deployment roles.
AWS Security Hub CSPM control [IAM.1] IAM policies should not allow full "*" administrative privileges fails when the default version of a customer managed policy contains a statement with "Effect": "Allow" and "Action": "*" over "Resource": "*". AWS says it "does not check inline and AWS managed policies." Reading those two descriptions together, the control will not flag a policy that grants iam:PassRole or iam:CreatePolicyVersion on *, because those policies contain no full-wildcard action. The control also reads only the default version, so an old non-default version with broad permissions, the setup for path 2, is not examined. Use it, but do not treat a passing result as proof that no escalation path exists.
IAM Access Analyzer helps in two ways. Unused access findings highlight unused roles, unused access keys and unused passwords for IAM users, and for active identities they show unused services and actions. That is how you remove escalation permissions nobody uses. AWS bills unused access analysis per IAM role and user analyzed, per analyzer, per month. Custom policy checks run before you deploy a change. check-no-new-access compares an updated policy to the existing one, and returns PASS if no new access is allowed and FAIL if the updated policy might allow new access. It only detects increases against the existing policy, so it will not flag escalation permissions already present in it. check-access-not-granted checks a policy against a list of actions that must not be allowed, and returns FAIL if the policy might allow some or all of them.
aws accessanalyzer check-no-new-access \
--new-policy-document file://new-policy.json \
--existing-policy-document file://existing-policy.json \
--policy-type IDENTITY_POLICY
aws accessanalyzer check-access-not-granted \
--policy-document file://new-policy.json \
--access actions="iam:PassRole","iam:CreatePolicyVersion" \
--policy-type IDENTITY_POLICY
AWS documents three ways to scope PassRole. Limit it with the Resource element so a user can pass only approved roles. Add the iam:PassedToService condition key, which names the service principal a role can be passed to and applies only to the PassRole action. And add iam:AssociatedResourceArn, which restricts the resource the role is associated with at the destination service. AWS's own example allows passing any role to EC2, only for instances in one account. Read it as a template, not as a finished block: with Resource: "*", any role in the account can still be passed to EC2, which is path 9 for every role.
{
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "*",
"Condition": {
"StringEquals": {
"iam:PassedToService": "ec2.amazonaws.com"
},
"ArnLike": {
"iam:AssociatedResourceARN": [
"arn:aws:ec2:*:111122223333:instance/*"
]
}
}
}
Replace Resource: "*" with the ARNs of the specific roles the principal should pass. Two cautions from the AWS documentation. Some services do not support iam:PassedToService. And AWS says not to control who can pass a role by tagging the role and using the ResourceTag condition key with iam:PassRole, because "This approach does not have reliable results."
A permissions boundary sets the maximum permissions an identity-based policy can grant to a user or role. The effective permissions are the intersection of the boundary and the identity-based policies, and an explicit deny in either overrides an allow. A boundary does not grant permissions on its own.
The delegation pattern in the AWS documentation limits a delegated administrator so that the administrator can create or change users only when a specific boundary is attached. The condition key is iam:PermissionsBoundary. This statement is part of the boundary policy for that administrator, which AWS pairs with a separate permissions policy, and the example is written for users:
{
"Sid": "CreateOrChangeOnlyWithBoundary",
"Effect": "Allow",
"Action": [
"iam:AttachUserPolicy",
"iam:CreateUser",
"iam:DeleteUserPolicy",
"iam:DetachUserPolicy",
"iam:PutUserPermissionsBoundary",
"iam:PutUserPolicy"
],
"Resource": "*",
"Condition": {
"StringEquals": {
"iam:PermissionsBoundary": "arn:aws:iam::123456789012:policy/XCompanyBoundaries"
}
}
}
The same AWS example also contains two denies that protect the boundary itself. One denies iam:CreatePolicyVersion, iam:DeletePolicy, iam:DeletePolicyVersion and iam:SetDefaultPolicyVersion on the boundary policies themselves, which is paths 1 and 2 aimed at the boundary. The other denies iam:DeleteUserPermissionsBoundary on all resources. A boundary that its holder can edit or remove does not limit that holder. The same example still lets the delegated administrator create access keys and passwords for any user except one named exception, which are paths 6 and 7, so a boundary alone does not close every path. The AWS example is written for users only, so confirm the role equivalents against the IAM documentation before you rely on them.
A service control policy (SCP) limits what the IAM users and roles in member accounts can do, including the root user of a member account. SCPs have limits you must plan for. AWS states that "SCPs do not grant permissions," that they "don't affect users or roles in the management account," and that they do not affect service-linked roles. They also do not affect principals from accounts outside your organization. SCPs do not support the Principal and NotPrincipal elements, and a deny statement may name individual resource ARNs.
AWS's documented example protects a common administrative role from the role-changing actions that matter for the role variants of paths 3 and 4, and for path 8. Replace role-to-deny with the name of your role. It does not cover the user and group variants, or paths 1, 2, 5, 6 and 7.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyAccessToAdminRole",
"Effect": "Deny",
"Action": [
"iam:AttachRolePolicy",
"iam:DeleteRole",
"iam:DeleteRolePermissionsBoundary",
"iam:DeleteRolePolicy",
"iam:DetachRolePolicy",
"iam:PutRolePermissionsBoundary",
"iam:PutRolePolicy",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription"
],
"Resource": [
"arn:aws:iam::*:role/role-to-deny"
]
}
]
}
This blocks everyone in the affected accounts from changing that role, including your own administrators, so you must plan a break-glass process. AWS also states plainly: "AWS strongly recommends that you don't attach SCPs to the root of your organization without thoroughly testing the impact that the policy has on accounts."
cloudformation:RoleARN condition key controls "which service role users can use when they work with stacks or change sets."UpdateFunctionCode accepts only packages signed by a trusted publisher.iam:PassRole to the roles your instances are meant to use, as shown above."Action": "*". Pay attention to the Resource element on each.iam:PassRole grant with Resource: "*". Replace each with specific role ARNs and add iam:PassedToService.It does not cover the four Rhino methods noted at the start, and it does not cover every service that accepts a role. It also does not replace testing in your own environment: whether a given identity can actually escalate depends on its full set of policies, boundaries, SCPs and resource policies. That evaluation needs the full set of policies in your account.
Sources: IAM, STS, CloudTrail, EC2, Lambda, CloudFormation, EventBridge, Organizations, Access Analyzer and Security Hub behavior in this article is taken from the AWS documentation pages listed in the companion sources file. The method catalogue of 21 IAM privilege escalation methods is from Rhino Security Labs and its article on AWS IAM privilege escalation methods and mitigation. That page is a third-party catalogue and is used here for naming the methods, not as proof of AWS behavior.
This article is just the start. Get the full picture with our free whitepaper - 8 chapters covering IAM, S3, VPC, monitoring, agentic AI security, compliance, and a prioritized action plan with 50+ CLI commands.
A comprehensive review of your AWS environment to find misconfigurations, excessive access, and compliance gaps before they become incidents.
Authorized, hands-on testing of your AWS-hosted applications and infrastructure to find exploitable vulnerabilities before an attacker does.
KloudSec, built by the team behind Toc Consulting
What you just read is a manual, one-time check. KloudSec runs this and 400+ more automated checks across 90+ AWS services, continuously, agentless, live in about 5 minutes.
Toc Consulting: AWS Security & Cloud Architecture
Our team helps engineering teams secure and architect AWS the right way: assessment in week one, a prioritized action plan in week two.
How a misconfigured WAF, an unauthenticated metadata service, and an over-privileged IAM role combined to expose data on roughly 100 million people in the US and 6 million in Canada, and the specific checks that would have stopped it.
A single compromised AWS console login, no MFA, and a company was gone within 12 hours. The full story, and why "our backups are in AWS too" was the real failure.
A security company got breached through one exposed AWS API key on an internet-reachable instance. Even the people who build WAFs for a living can miss this.