Cloud posture
IAM privilege escalation: the 8 patterns found in almost every SMB
iam:PassRole, CreatePolicyVersion, UpdateAssumeRolePolicy: 8 common IAM escalation paths, how to block them (SCP, conditions, Access Analyzer in CI) and how to prove it to an auditor.
In an IAM review, the line that rarely worries teams is a developer with “only” lambda:CreateFunction and iam:PassRole. Neither action looks dangerous on its own. Together, they let you run code under any role in the account, including AdministratorAccess.
An IAM privilege escalation is a path that lets an identity grant itself rights it was never given, without exploiting any vulnerability: only legitimate permissions, badly combined. It is the step that turns a stolen developer access key or a compromised CI workflow into admin access. Most of these paths have been documented for years (Rhino Security Labs, Hacking The Cloud) and tools like Pacu automate them. Yet they are still present in almost every account I review on engagements.
This article lists 8 patterns, then gives the control (SCP, conditions, CI check) and the evidence for each.
The threat: 8 recurring patterns
In every case, the attacker starts from a low-privilege identity (IAM user, CI role, developer role).
| # | Starting permissions | Result |
|---|---|---|
| 1 | iam:PassRole on * + lambda:CreateFunction + lambda:InvokeFunction | Code runs under any role, including admin |
| 2 | iam:PassRole on * + ec2:RunInstances | Instance with an admin profile, credentials via IMDS |
| 3 | iam:CreatePolicyVersion | Rewrites a managed policy already attached to oneself (--set-as-default) |
| 4 | iam:AttachUserPolicy, AttachRolePolicy, PutUserPolicy, PutRolePolicy | Attaches AdministratorAccess to oneself |
| 5 | iam:UpdateAssumeRolePolicy + sts:AssumeRole | Edits an admin role’s trust policy to add oneself |
| 6 | Trust policy arn:aws:iam::<account>:root without conditions, or "AWS": "*" | Any identity in the account (or worse) can assume the role |
| 7 | iam:CreateAccessKey, iam:CreateLoginProfile, iam:UpdateLoginProfile on other users | Takes over a more privileged user |
| 8 | lambda:UpdateFunctionCode (or ecs:RegisterTaskDefinition, glue:UpdateDevEndpoint…) on a resource running with an admin role | Hijacks an existing workload instead of creating one |
Two field notes:
- Patterns 1 and 2 are the most frequent because
iam:PassRolewith"Resource": "*"gets written “to make it work” during a first deployment and is never tightened. The AWS documentation explicitly recommends limiting which roles can be passed and to which service. - Pattern 8 is the most underestimated: a “maintenance” Lambda with a broad role, editable by the whole team, is an admin role in disguise.
A detail on pattern 3: a managed policy holds at most 5 versions; an attacker with iam:CreatePolicyVersion can create a new one with Allow *:* and make it the default. iam:SetDefaultPolicyVersion alone is enough if an older permissive version exists.
Control 1: limit who can write to IAM (SCP)
The most effective rule is also the simplest: only one or two identified roles can modify IAM. Developers and application CI don’t need it; the IaC that manages IAM goes through a dedicated role.
SCP to attach to workload OUs (not the root until tested, and never expected to apply to the management account, which SCPs don’t affect):
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyIamPrivilegeWritesExceptPlatform",
"Effect": "Deny",
"Action": [
"iam:CreatePolicyVersion",
"iam:SetDefaultPolicyVersion",
"iam:AttachUserPolicy",
"iam:AttachRolePolicy",
"iam:AttachGroupPolicy",
"iam:PutUserPolicy",
"iam:PutRolePolicy",
"iam:PutGroupPolicy",
"iam:UpdateAssumeRolePolicy",
"iam:CreateAccessKey",
"iam:CreateLoginProfile",
"iam:UpdateLoginProfile",
"iam:AddUserToGroup",
"iam:DeleteRolePermissionsBoundary",
"iam:DeleteUserPermissionsBoundary"
],
"Resource": "*",
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": [
"arn:aws:iam::*:role/PlatformIamAdmin",
"arn:aws:iam::*:role/TerraformIamDeploy"
]
}
}
}
]
}
Adapt the two ARNs to your roles. If your users go through IAM Identity Center, add the admin SSO role (arn:aws:iam::*:role/aws-reserved/sso.amazonaws.com/*/AWSReservedSSO_PlatformAdmin_*) and test in a sandbox account first: an SCP that is too broad and also locks out the platform team is an outage, not a control. SCPs grant no permissions; they only set a ceiling.
This SCP covers patterns 3, 4, 5 and 7. PassRole and trust policies remain.
Control 2: tighten iam:PassRole
iam:PassRole can’t be removed (CI needs it to deploy Lambdas and ECS tasks). It is constrained: a list of roles by prefix, and a target service.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::123456789012:role/app-lambda-*",
"Condition": {
"StringEquals": {
"iam:PassedToService": "lambda.amazonaws.com"
}
}
}
]
}
With this rule, the pattern 1 attacker can only pass app-lambda-* roles to Lambda. In return, those roles must never carry admin rights, and the team must not have free iam:CreateRole (otherwise they create an app-lambda-admin role). Along the same lines, a permissions boundary required on every role developers create caps what a created role can do.
For trust policies (pattern 6), the safe default for cross-account access inside an organization is to condition on the organization:
{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::210987654321:role/DeployFromTooling" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": { "aws:PrincipalOrgID": "o-exampleorgid" }
}
}
Prefer a precise principal (a role) over :root, which delegates the decision to the remote account’s administrator.
Control 3: block the problem before merge (Access Analyzer in CI)
SCPs protect runtime; you also need to stop a dangerous policy from being proposed. IAM Access Analyzer offers custom policy checks based on automated reasoning: CheckAccessNotGranted answers PASS/FAIL to “does this policy grant any of these actions?” (AWS announcement, June 2024). Check Access Analyzer pricing before calling it on every pull request of a large monorepo.
CI step on the Terraform plan already converted to policy documents (one JSON file per policy):
#!/usr/bin/env bash
set -euo pipefail
FORBIDDEN='[{"actions":[
"iam:CreatePolicyVersion","iam:SetDefaultPolicyVersion",
"iam:AttachUserPolicy","iam:AttachRolePolicy",
"iam:PutUserPolicy","iam:PutRolePolicy",
"iam:UpdateAssumeRolePolicy","iam:CreateAccessKey",
"iam:CreateLoginProfile","iam:UpdateLoginProfile"
]},{"actions":["iam:PassRole"],"resources":["*"]}]'
fail=0
for f in policies/*.json; do
result=$(aws accessanalyzer check-access-not-granted \
--policy-document "file://$f" \
--policy-type IDENTITY_POLICY \
--access "$FORBIDDEN" \
--query result --output text)
if [ "$result" != "PASS" ]; then
echo "::error file=$f::Policy grants a forbidden IAM action"
fail=1
fi
done
exit $fail
The CI role running this script only needs access-analyzer:CheckAccessNotGranted: provide it through OIDC, with no static key. For policies where these actions are legitimate (the PlatformIamAdmin role), keep them outside the checked folder, with mandatory CODEOWNERS review.
Control 4: detect attempts
Even when blocked, attempts are a signal. IAM events are global and show up in the us-east-1 region: the EventBridge rule must be created there.
resource "aws_cloudwatch_event_rule" "iam_privesc" {
provider = aws.us_east_1
name = "iam-privesc-attempts"
description = "Sensitive IAM writes or denied attempts"
event_pattern = jsonencode({
source = ["aws.iam"]
"detail-type" = ["AWS API Call via CloudTrail"]
detail = {
eventName = [
"CreatePolicyVersion", "SetDefaultPolicyVersion",
"AttachUserPolicy", "AttachRolePolicy",
"PutUserPolicy", "PutRolePolicy",
"UpdateAssumeRolePolicy", "CreateAccessKey",
"CreateLoginProfile", "UpdateLoginProfile"
]
}
})
}
resource "aws_cloudwatch_event_target" "iam_privesc_sns" {
provider = aws.us_east_1
rule = aws_cloudwatch_event_rule.iam_privesc.name
arn = aws_sns_topic.security_alerts.arn
}
These calls should be rare and attributable to PlatformIamAdmin or the Terraform role. Any other identity showing up, even with errorCode = AccessDenied, deserves a question.
The evidence: what you show an auditor
For SOC 2 (CC6.1, CC6.3), ISO 27001 (A.8.2, A.8.3) or a customer questionnaire, the demonstration comes down to four items:
- The SCP attached to the OUs, exported (
aws organizations describe-policy) and versioned in Git. - The
CheckAccessNotGrantedresult on every pull request, kept as a CI artefact: it is continuous proof the control works, not just that it once existed. - The inventory of roles able to write to IAM (two, named), obtained from the policy simulator or Access Analyzer.
- A dated negative test: an
aws iam attach-role-policyattempt from a developer role, returning an explicit SCPAccessDenied, with the matching CloudTrail event.
The last item is the most convincing. A control that has never been tested to failure is only an intention.
Where to start
In order of return on effort for an SMB:
- List identities with
iam:PassRoleon*and constrain them (half a day). - Deploy the IAM-write SCP on a test account, then on workload OUs.
- Add the Access Analyzer check to the Terraform CI.
- Enable the detection rule.
- Review service roles (Lambda, ECS, Glue) that have more rights than needed, using Access Analyzer’s unused access findings.
These five steps are the core of a cloud posture audit. If you want us to run it on your organization, that is the purpose of the Cloud Security Audit; a retainer then keeps the controls in place as the team and accounts grow.
Found this useful? Share it.
Go further
A topic, a project, a question?
Distribuée supports demanding SMBs on cloud security, CI/CD supply chain and AI security.
Book 15 min