A customer-managed policy that grants iam:PassRole on * with no condition lets a user hand any IAM role to an AWS service, which is a direct path to privilege escalation. Scope the Resource to specific role ARNs and add an iam:PassedToService condition.
The iam:PassRole permission is one of the quietest privilege escalation vectors in AWS. It rarely shows up in threat models because, on its own, it does nothing dramatic. It does not read data, delete buckets, or launch instances. What it does is allow a principal to attach an existing IAM role to a resource they create. Combine that with a service that runs code, and a low-privilege user can suddenly operate with the permissions of a far more powerful role.
This Lensix check, account_iampassrole, flags any customer-managed IAM policy that grants iam:PassRole on all resources ("Resource": "*") without a condition narrowing where the role can be passed.
What this check detects
The check scans your customer-managed IAM policies and looks for statements shaped like this:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "*"
}
]
}
Three conditions have to be true for the check to fire:
- The policy is customer-managed (not an AWS-managed policy you cannot edit).
- It allows
iam:PassRole, either directly or via a wildcard likeiam:*oriam:Pass*. - The
Resourceis*and there is noConditionblock restricting the target role or the service receiving it.
Note: iam:PassRole is not an action that "does" anything by itself. AWS evaluates it when a service tries to assume a role on your behalf, for example when you launch an EC2 instance with an instance profile or create a Lambda function with an execution role. The API call you make (like ec2:RunInstances) checks whether you are allowed to pass that specific role to that specific service.
Why it matters
Unrestricted PassRole breaks the boundary between what a user has and what a user can get. Here is the classic escalation path.
Imagine a developer with this policy attached. Their own permissions are modest, but the account also contains an admin role used by automation, say arn:aws:iam::123456789012:role/AdminAutomation, that trusts the EC2 service. The developer cannot assume that role directly, because the trust policy only allows EC2. But they have iam:PassRole on everything plus ec2:RunInstances. So they do this:
- Launch an EC2 instance and attach the
AdminAutomationinstance profile. - SSH or SSM into that instance.
- Query the instance metadata endpoint and pull temporary credentials for
AdminAutomation. - Use those credentials to do anything an admin can do.
The same trick works through other vectors:
- Lambda: create a function with a powerful execution role and invoke code that abuses it.
- CloudFormation: pass a high-privilege role as the stack service role and deploy resources with it.
- Glue, SageMaker, CodeBuild: any service that runs your code under an assumed role.
Warning: This is why granting iam:PassRole on * alongside even a single compute-launch permission is effectively granting access to every role in the account that those services can assume. Auditors and attackers both look for exactly this combination.
From a business standpoint, the impact is the loss of least privilege as a meaningful control. You may have carefully scoped a hundred roles, but if one widely attached policy lets people pass any of them, the scoping no longer constrains the blast radius of a compromised credential.
How to fix it
The fix is to scope two things: which roles can be passed, and which service they can be passed to.
Step 1: Find the offending policy
List your customer-managed policies and inspect the ones related to compute or deployment:
aws iam list-policies --scope Local --query \
'Policies[].{Name:PolicyName,Arn:Arn}' --output table
Pull the default version of a suspect policy to see the document:
POLICY_ARN="arn:aws:iam::123456789012:policy/dev-compute-policy"
VERSION=$(aws iam get-policy --policy-arn "$POLICY_ARN" \
--query 'Policy.DefaultVersionId' --output text)
aws iam get-policy-version --policy-arn "$POLICY_ARN" \
--version-id "$VERSION" --query 'PolicyVersion.Document'
Step 2: Rewrite the statement
Replace the wildcard resource with the specific role ARNs that this policy legitimately needs to pass, and add an iam:PassedToService condition so the role can only be handed to the intended service.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": [
"arn:aws:iam::123456789012:role/app-ec2-runtime",
"arn:aws:iam::123456789012:role/app-lambda-exec"
],
"Condition": {
"StringEquals": {
"iam:PassedToService": [
"ec2.amazonaws.com",
"lambda.amazonaws.com"
]
}
}
}
]
}
If you genuinely do not know which roles are passed, audit usage in CloudTrail first. Look for the PassRole events that AWS records when services consume the permission, then build your allow list from what you actually observe.
Step 3: Publish the new version
Danger: Editing a policy that is attached to live principals takes effect immediately. If you remove a role ARN that an active workflow still needs to pass, that workflow will start failing with AccessDenied. Validate your allow list against CloudTrail before publishing, and roll out in a non-production account first.
Create the new version and set it as default in one call:
aws iam create-policy-version \
--policy-arn "$POLICY_ARN" \
--policy-document file://scoped-passrole.json \
--set-as-default
IAM keeps up to five versions per managed policy. If you hit that limit, delete the oldest non-default version first:
aws iam delete-policy-version \
--policy-arn "$POLICY_ARN" --version-id v1
Terraform equivalent
If you manage policies as code, the same scoping looks like this:
data "aws_iam_policy_document" "compute" {
statement {
sid = "ScopedPassRole"
effect = "Allow"
actions = ["iam:PassRole"]
resources = [
aws_iam_role.app_ec2_runtime.arn,
aws_iam_role.app_lambda_exec.arn,
]
condition {
test = "StringEquals"
variable = "iam:PassedToService"
values = ["ec2.amazonaws.com", "lambda.amazonaws.com"]
}
}
}
How to prevent it from happening again
One-off fixes do not hold. The same wildcard creeps back the next time someone copies a policy off a forum to unblock a deployment. Put guardrails in the path that policies travel through.
Catch it in CI with policy-as-code
If your IAM lives in Terraform or CloudFormation, scan it before it merges. A simple Open Policy Agent rule can reject any PassRole statement with a wildcard resource:
package iam.passrole
deny[msg] {
some stmt in input.Statement
stmt.Effect == "Allow"
contains_passrole(stmt.Action)
stmt.Resource == "*"
not stmt.Condition
msg := "iam:PassRole on '*' without a condition is not allowed"
}
contains_passrole(action) {
action == "iam:PassRole"
}
contains_passrole(actions) {
some a in actions
a == "iam:PassRole"
}
Tip: AWS IAM Access Analyzer has built-in policy validation that flags overly broad PassRole grants. Run aws accessanalyzer validate-policy in your pipeline against every policy document and fail the build on any finding above a chosen severity. It needs no custom rules to maintain.
Block it at the org level
A Service Control Policy can stop anyone in an account from attaching a policy that allows unrestricted PassRole, but a simpler and stronger move is to use SCPs to deny passing your most sensitive roles entirely except through approved paths. Pair that with permission boundaries on developer roles so that even if they create new policies, the boundary caps what those policies can effectively grant.
Run the Lensix check on a schedule
Continuous scanning catches drift between deploys, including policies created by hand in the console or by tooling outside your IaC. The account_iampassrole check runs against every account you connect, so a re-introduced wildcard surfaces on the next scan rather than at the next audit.
Best practices
- Always scope
ResourceonPassRole. Name the exact role ARNs. A wildcard here is almost never justified. - Always add
iam:PassedToService. Even with scoped role ARNs, the condition stops a role meant for Lambda from being passed to EC2 or CodeBuild. - Treat
PassRole+ a launch permission as a privileged combination. Review them together, not separately. Either one alone is benign; together they escalate. - Tighten role trust policies. If a role only ever needs to be assumed by Lambda, do not let its trust policy also include EC2. Narrow trust limits what
PassRolecan exploit. - Prefer specific actions over
iam:*. Wildcard IAM actions hidePassRoleinside them and make policies impossible to reason about. - Audit with CloudTrail before tightening. Build your allow list from observed usage so you scope down without breaking live workflows.
The goal is simple: a user should only ever be able to pass the roles their job requires, to the services those roles were built for. Once PassRole is scoped that tightly, the privilege escalation path closes and your carefully designed least-privilege roles actually mean something.

