The Three API Calls That Changed My View of IAM
A penetration test found that a developer IAM user with iam:PassRole could escalate to admin in 3 API calls. The tester did not steal an administrator password, bypass MFA, or exploit some obscure AWS edge case. They created a Lambda function, passed an overpowered execution role, invoked the function, and used the resulting permissions to prove administrative access.
I was on the manufacturing facility’s IT security team that had approved the IAM pattern. Our environment was a mix of production AWS accounts, plant-floor integrations, FortiGate firewalls running FortiOS 7.4.3, Ubuntu 22.04 jump hosts, and automation maintained in Python 3.11. We were used to thinking about segmentation, remote access, EDR alerts, and firewall rules. IAM felt cleaner than that, almost mathematical. That confidence was misplaced.
My first wrong assumption was that the developer user was low risk because it had no direct AdministratorAccess attachment. I looked at the visible policy name, not the permission graph. The iam:PassRole permission was granted without restricting which roles could be passed, a well-documented but frequently missed escalation path, and that single miss turned a limited account into a launch point.
That stung.
The lesson I took from that test was blunt: effective AWS security review has to evaluate what a principal can become, not only what it can do right now. In my opinion, any IAM review that stops at attached policies is unfinished work.
The Escalation Paths I See Most Often
In our audits, the common paths were rarely exotic. They were permissions that made operational life easier during a project and then stayed in place after the project moved on. A user could update a policy version. A CI role could pass broad roles into compute services. A support role could attach managed policies. A deployment principal could create access keys for another user. Each permission looked defensible in isolation, but together they created a path.
iam:PassRolewithout tight role and service conditionsiam:CreatePolicyVersionwith permission to set the new version as defaultiam:AttachUserPolicy,iam:AttachRolePolicy, oriam:PutRolePolicyon broad resourcessts:AssumeRoleinto roles with weak trust policieslambda:CreateFunction,ecs:RunTask, orglue:CreateJobpaired with role passingiam:CreateAccessKeyfor users other than the caller
I care less about whether a permission sounds administrative and more about whether it lets a principal alter identity, attach identity, or run code under a stronger identity. That mental model has caught more real risk for my team than any color-coded severity label.
Names lie.
One developer policy in our account was named ReadOnlyBuildSupport, but it could create a new default version of a customer-managed policy. Another role had sts:AssumeRole access into a shared tooling account where the trust policy allowed too much. We fixed both, but only after we stopped trusting naming conventions and started tracing escalation mechanics.
My opinion is that IAM privilege escalation is mainly a design hygiene problem, not a hacker magic problem.
Where iam:PassRole and Policy Versioning Hurt Us
The most dangerous case for us was iam:PassRole because it was easy to justify during normal engineering work. Teams needed to deploy Lambda functions, ECS tasks, Glue jobs, and CloudFormation stacks. The problem was not passing a role. The problem was passing any role. Once a principal can hand an administrator role to a service that runs attacker-controlled code, the permission boundary has already failed.
A safer pattern in our environment was to bind iam:PassRole to named deployment roles and approved services. We used role naming standards, permission boundaries, and conditions such as iam:PassedToService. That did not make IAM simple, but it made the intent reviewable by another engineer without reading every line of every pipeline.
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::111122223333:user/dev-build-user \
--action-names iam:PassRole iam:CreatePolicyVersion sts:AssumeRole \
--resource-arns arn:aws:iam::111122223333:role/production-admin
iam:CreatePolicyVersion was the path I underestimated most. If a principal can create a new policy version and mark it as default, it can rewrite its own permissions when the resource scope allows it. We found this on policies originally created for emergency deployment support, and nobody had gone back to remove it because the permission did not look as loud as iam:AttachUserPolicy.
What I didn’t expect was how often sts:AssumeRole issues came from trust policies rather than permission policies. The identity side would look narrow, but the destination role trusted more principals than it should have. In a multi-account setup, that gap is easy to miss unless someone reviews both sides of the relationship.
The trust policy is the door.
I now treat iam:PassRole, policy versioning, and role assumption as privileged workflows even when AWS does not display them that way in the console. My opinion is that these permissions deserve the same change-control scrutiny as firewall rule changes into production networks.
You may also find this useful: Check out our guide on Python Network Config Backup: Automating Multi-Vendor Device Snapshots for more practical tips.
Scanning Our Policies Without Pretending Tools Think for Us
After the pentest, we audited 340 IAM policies with a custom privilege escalation scanner. It combined AWS IAM data collection, open-source escalation checks, and a Python 3.11 rules layer that mapped principals to possible attack paths. After that audit, 23 escalation paths were found and remediated in 2 weeks. Before the audit, we had 23 known paths sitting in production. Afterward, we had 0 confirmed paths matching those checks.
We ran tooling from an Ubuntu 22.04 security workstation and exported IAM users, groups, roles, inline policies, managed policies, trust policies, permission boundaries, and service control policy context. I like tools such as PMapper, Cloudsplaining, Pacu, and Parliament, but I do not treat any single scanner as complete. The useful result is not a giant report. The useful result is a short list of principals that can become more powerful than intended.
Signal beats volume.
Our scanner flagged escalation chains, then we manually validated them with aws iam simulate-principal-policy and controlled proof steps in a non-production account. That mattered because false positives waste credibility fast with operations teams. At a plant, I cannot walk into a maintenance window with a theoretical finding and ask everyone to change deployment roles without showing the path clearly.
We also added scanner output to pull request review for Terraform changes. If a merge request introduced broad iam:PassRole, default policy version creation, or unrestricted role assumption, the pipeline failed and linked to the exact statement. That was more effective than another policy document in a wiki, and I will always prefer a guardrail that runs where engineers already work.
Build Least Privilege That Survives Real Operations
Least privilege failed for us when it was treated as a one-time cleanup exercise. Manufacturing IT does not sit still. Vendors need temporary access, engineers deploy fixes during production constraints, and cloud services get added because a line or warehouse system needs data quickly. The only pattern that survived was one that assumed permissions would drift unless we built friction in the right places.
We settled on a practical model. Deployment roles were separated by environment. Human users did not get broad role-passing rights. Break-glass access had ticket references, expiration, and CloudTrail review. Permission boundaries were mandatory for automation-created roles. Trust policies had named principals instead of account-wide trust unless there was a strong reason. Service control policies blocked the worst IAM mutations outside approved pipelines.
Small controls compound.
I also learned to write IAM review comments in operational language. Instead of saying, “This violates least privilege,” I say, “This lets the build user pass the production admin role into Lambda and run code as that role.” That phrasing changes the conversation. Engineers understand the blast radius, and managers understand why the change cannot wait until the next quarterly review.
The strongest pattern we adopted was ownership. Every privileged role had an application owner, a security owner, and a review date. If nobody could explain why a role needed iam:PassRole or iam:CreatePolicyVersion, the permission came out. Some teams complained at first, but the deployment process got cleaner because roles stopped being shared junk drawers.
My opinion after cleaning this up is simple: AWS IAM is manageable only when privilege escalation is reviewed as a path, not a permission name.

