Lambda UpdateFunctionCode Bypasses the PassRole Check
A CI/CD deploy role in AWS Lambda holds lambda:UpdateFunctionCode and lambda:InvokeFunction against a function whose execution role already carries broad S3, DynamoDB and KMS access. IAM's PassRole check never fires because the role isn't being reassigned, so anyone who can push new code inherits that role's full permission set the moment it runs.
Operational summary
At a glance
- Symptom
- A principal without any PassRole permission overwrites an existing function's deployment package and invokes it, inheriting the already-attached execution role's permissions.
- Likely cause
- iam:PassRole is correctly scoped with a PassedToService condition, but lambda:UpdateFunctionCode and lambda:InvokeFunction remain unscoped on functions holding privileged execution roles.
- Impact
- Deploy identities with only code-update rights silently inherit the full permission set of any execution role attached to functions they can update, bypassing PassRole entirely.
- Verification signal
- Re-run the detection test and a controlled negative-path test.
- Safe correction
- Attach permissions boundaries to every Lambda execution role and scope UpdateFunctionCode and InvokeFunction by resource tag, independent of PassRole conditions.
- Rollback or recovery
- Restore the exported configuration if the new control blocks required production traffic, then narrow the policy before redeployment.
The Trap
Lambda execution roles created via the AWSLambdaBasicExecutionRole managed policy look tightly scoped — CloudWatch Logs write access and nothing else — while the IAM policy governing the deploy pipeline carries iam:PassRole on Resource “*” restricted with a Condition StringEquals iam:PassedToService lambda.amazonaws.com. Reviewers sign this off because PassRole is conditioned and the execution role itself is minimal on paper. The trap is that the same deploy pipeline role also holds lambda:UpdateFunctionCode20150331v2 and lambda:InvokeFunction against existing functions, and neither of those API calls triggers a PassRole evaluation at all.
The Default State
Terraform modules and Serverless Framework pipelines routinely grant one deployment role broad lambda:Update* and lambda:Invoke* permissions across an entire account, because scoping by function name or tag is treated as pipeline friction. Meanwhile, individual functions accumulate execution roles far wider than AWSLambdaBasicExecutionRole over their lifetime — a data-ingestion function ends up with s3:GetObject, dynamodb:PutItem and kms:Decrypt bolted on ad hoc, none of which get re-reviewed once the function is live and passing tests.
The Blast Radius
Any principal with lambda:UpdateFunctionCode on that function can overwrite its deployment package with arbitrary code, then call lambda:InvokeFunction, or simply wait for its EventBridge or S3 trigger, and execute that code under the existing execution role. AWS never re-checks iam:PassRole here because the ExecutionRole parameter isn’t part of either API call — the role stays attached, only the code changes underneath it. A developer with narrow, apparently harmless deploy permissions on one low-risk function silently inherits KMS decrypt and DynamoDB write access on data the security review never associated with their access level. CloudTrail shows only UpdateFunctionCode20150331v2 and Invoke events; there is no AssumeRole or PassRole entry to flag the escalation.
The Lead Mechanic Fix
Attach a permissions boundary to every Lambda execution role so its effective privilege is capped regardless of what code runs inside it: aws iam put-role-permissions-boundary –role-name –permissions-boundary arn:aws:iam:::policy/LambdaExecBoundary. Separate the “who can pass a role” question from “who can change code” — scope lambda:UpdateFunctionCode* and lambda:InvokeFunction with a Condition on aws:ResourceTag/Sensitivity, and deny both actions account-wide via SCP unless the caller’s own tag matches the function’s tag. Route UpdateFunctionCode20150331v2 calls through an EventBridge rule keyed on the API name, alerting whenever a function tagged Sensitivity=high receives a code update from outside the release pipeline’s assumed-role session.
Apply the safer control
Before you change production
Confirm the affected scope, export the current configuration, and test the replacement control in a non-production environment first.
Attach permissions boundaries to every Lambda execution role and scope UpdateFunctionCode and InvokeFunction by resource tag, independent of PassRole conditions.
Validate the vendor-specific syntax in official documentation before applying it.
Verify, roll back or escalate
Verify
Re-run the detection test and a controlled negative-path test. Confirm the unsafe behaviour is blocked while approved traffic still succeeds.
Rollback
Restore the exported configuration if the new control blocks required production traffic, then narrow the policy before redeployment.
Escalate
Escalate when the blast radius is uncertain, the control cannot be tested safely, or remediation requires an outage or security exception.