PassRole & escalation paths
The permission combinations that become admin.
Here's a policy that would sail through most code reviews. No wildcards on the scary permissions, every line reads like something a deployment pipeline honestly needs, and it quietly turns whoever holds it into an account administrator. That's the shape of nearly every real privilege escalation in AWS (Amazon Web Services, the cloud platform this whole lesson lives in). Not one obviously broken permission. Just a few reasonable ones that click together into full admin.
The permission at the center of most of these is iam:PassRole. Two words of background first. IAM stands for Identity and Access Management, the part of AWS that decides who's allowed to do what. A role is a bundle of permissions that a person or a service can put on temporarily. 'Passing a role' means handing an AWS service a role and telling it: go do this job as this identity. You do it all the time without thinking about it. When you set up a small program to run on Lambda (AWS's service for running code without managing any servers) and tell it to use the data-reader role, you're passing that role to Lambda. PassRole is just the permission to do the handing-over.
Here's the same idea with car keys. You give the valet your keyring so he can go start the car. That's fine if the ring holds one car key. It's a real problem if it also holds your house key, your office key, and the key to the safe. PassRole is you handing over a keyring, and the only question that matters is which keys are on it.
On its own, PassRole does nothing. You could hold it for years and never touch a single resource, which is exactly why it slips through reviews. It grows teeth only when it's paired with a service that will run your code wearing the role you passed: lambda:CreateFunction, ec2:RunInstances, ecs:RunTask, glue:CreateJob, codebuild:CreateProject. Those services will happily execute whatever code you hand them, using whatever role you attach. So if a low-privilege account can bolt a powerful role onto a function it wrote and then run that function, it's now acting with the powerful role's permissions. It borrowed the keys by getting something else to drive the car.
A real escalation, start to finish
Meet ci-bot, an ordinary IAM user whose long-lived access key turned up in a public GitHub repository. (An access key is just a username-and-password pair for machines, so a program can log in without a human typing anything.) The first thing anyone does with a found key is ask what it can reach. Pull up the policy attached to it, and the two statements both read clean on their own.
cat > ci-bot-deploy.json <<'JSON'{"Version": "2012-10-17","Statement": [{ "Sid": "Deploy", "Effect": "Allow","Action": ["lambda:CreateFunction", "lambda:InvokeFunction", "lambda:UpdateFunctionCode"],"Resource": "*" },{ "Sid": "PassAnyRole", "Effect": "Allow","Action": "iam:PassRole","Resource": "*" }]}JSONaws iam create-policy \--policy-name ci-bot-deploy \--policy-document file://ci-bot-deploy.json{"Policy": {"PolicyName": "ci-bot-deploy","PolicyId": "ANPAI3EXAMPLE7XVXQ2K4","Arn": "arn:aws:iam::111122223333:policy/ci-bot-deploy","DefaultVersionId": "v1","AttachmentCount": 0,"IsAttachable": true,"CreateDate": "2026-05-12T09:41:07+00:00"}}
Nothing there trips an alarm on a fast read. The PassRole statement is scoped to Resource star (a wildcard meaning 'any role at all'), but reviewers see 'it's only PassRole' and scroll on. Sitting right next to lambda:CreateFunction and lambda:InvokeFunction, that star is the whole attack. The attacker's next move is to find a role worth stealing, and here's the detail most writeups skip: it has to be a role that Lambda is actually allowed to wear.
Passing a role to Lambda only works if the role's trust policy names lambda.amazonaws.com as an allowed user. A trust policy is the role's guest list: it spells out exactly who's allowed to put the role on. Plenty of admin roles trust only human login sessions or the account's root user, and trying to hand one of those to a function just fails. So the attacker hunts for the powerful-but-service-trusting role. In this account it's OrgAdminRole: it has AdministratorAccess attached, and its guest list still includes Lambda because someone wired up an automation two years ago and never tightened it. Attach it to a new function.
aws lambda create-function \--function-name reporting-helper \--runtime python3.13 \--role arn:aws:iam::111122223333:role/OrgAdminRole \--handler index.handler \--zip-file fileb://payload.zip \--timeout 30{"FunctionName": "reporting-helper","FunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:reporting-helper","Runtime": "python3.13","Role": "arn:aws:iam::111122223333:role/OrgAdminRole","Handler": "index.handler","CodeSize": 312,"State": "Pending","StateReason": "The function is being created.","StateReasonCode": "Creating","PackageType": "Zip","Architectures": ["x86_64"]}
AWS runs the PassRole check right here, at the moment the function is created, not later when it executes. Because ci-bot's policy allows passing any role, the check passes and the function comes up wired to full-admin credentials. All that's left is to run it. The handler can be three lines of Python that mint a brand-new access key on a real administrator account.
aws lambda invoke \--function-name reporting-helper \--cli-binary-format raw-in-base64-out \--payload '{"target_user": "backup-admin"}' \response.json{"StatusCode": 200,"ExecutedVersion": "$LATEST"}cat response.json{"ok": true, "target_user": "backup-admin", "AccessKeyId": "AKIA2XVXQ2K4BQZK7QEX", "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}
That access key belongs to a human admin, never expires, and works from any laptop on earth. ci-bot started the day able to deploy functions. It ended the day holding permanent administrator credentials, and every single step it took was individually authorized.
Close it: shrink the keyring, then prove it
Two changes fix this, and you need both. First, name the exact roles that may be passed in the Resource field, never a star. Second, add the iam:PassedToService condition so a role can only be handed to the one AWS service that's meant to use it. Do just one and the door stays open. A condition that says 'only pass to Lambda' means nothing if Resource is still a star, because Lambda is a service the attacker controls; they'll pass an admin role to their own function. Narrowing Resource without the service condition leaves a passed role reachable by services you never intended. Both together is what actually closes it.
cat > ci-bot-fixed.json <<'JSON'{"Version": "2012-10-17","Statement": [{ "Sid": "Deploy", "Effect": "Allow","Action": ["lambda:CreateFunction", "lambda:InvokeFunction", "lambda:UpdateFunctionCode"],"Resource": "arn:aws:lambda:us-east-1:111122223333:function:app-*" },{ "Sid": "PassOnlyAppRoleToLambda", "Effect": "Allow","Action": "iam:PassRole","Resource": "arn:aws:iam::111122223333:role/app-task-role","Condition": { "StringEquals": { "iam:PassedToService": "lambda.amazonaws.com" } } }]}JSONaws iam create-policy-version \--policy-arn arn:aws:iam::111122223333:policy/ci-bot-deploy \--policy-document file://ci-bot-fixed.json \--set-as-default{"PolicyVersion": {"VersionId": "v2","IsDefaultVersion": true,"CreateDate": "2026-07-16T14:22:51+00:00"}}
Never just trust that a policy does what you meant it to. Ask IAM directly. The policy simulator takes one specific request and runs it against the principal's real, attached policies, then tells you allow or deny without touching anything live. It's a dry run for permissions.
aws iam simulate-principal-policy \--policy-source-arn arn:aws:iam::111122223333:user/ci-bot \--action-names iam:PassRole \--resource-arns arn:aws:iam::111122223333:role/OrgAdminRole \--context-entries "ContextKeyName=iam:PassedToService,ContextKeyType=string,ContextKeyValues=lambda.amazonaws.com"{"EvaluationResults": [{"EvalActionName": "iam:PassRole","EvalResourceName": "arn:aws:iam::111122223333:role/OrgAdminRole","EvalDecision": "implicitDeny","MatchedStatements": [],"MissingContextValues": []}],"IsTruncated": false}
implicitDeny is the result you want, and the word 'implicit' is the tell: no statement matched the request, so nothing granted the action. ci-bot can no longer pass OrgAdminRole anywhere. Run the same check with the app's real role and PassedToService set to lambda.amazonaws.com and you'll get allowed, so the pipeline keeps deploying exactly as before. The escalation path is gone and nothing legitimate broke.
PassRole is the common lever, not the only one. The same 'two tidy permissions add up to admin' pattern shows up as identity self-edits: iam:CreatePolicyVersion or iam:SetDefaultPolicyVersion to quietly rewrite a policy already attached to you, iam:AttachUserPolicy or iam:PutUserPolicy to grant yourself more, iam:UpdateAssumeRolePolicy to add yourself to a role's guest list, iam:CreateAccessKey to mint a key on somebody else's user. Review each of them the way you review PassRole. When you read a policy, don't only ask what each statement allows. Ask what the holder could turn itself into.
One last thing about ci-bot. The only reason any of this was reachable is that ci-bot is a long-lived IAM user carrying a permanent access key, the kind that ends up in a git history or an old laptop backup and stays valid until a human happens to notice. Scoping PassRole shrinks the damage when a key like that leaks. Not issuing the static key in the first place shrinks the target itself. That's where the next lesson goes.
Try this
Work through “Close it: shrink the keyring, then prove it” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: your audit log has no PassRole event to alert on. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.