Back to blog
AWSBest PracticesCloud SecurityIdentity & AccessOperations & Compliance

IAM Policy Allows Unrestricted PassRole

An IAM policy granting iam:PassRole on all resources is a privilege escalation path. Learn how to detect, scope, and prevent unrestricted PassRole in AWS.

TL;DR

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 like iam:* or iam:Pass*.
  • The Resource is * and there is no Condition block 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:

  1. Launch an EC2 instance and attach the AdminAutomation instance profile.
  2. SSH or SSM into that instance.
  3. Query the instance metadata endpoint and pull temporary credentials for AdminAutomation.
  4. 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 Resource on PassRole. 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 PassRole can exploit.
  • Prefer specific actions over iam:*. Wildcard IAM actions hide PassRole inside 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.