SOC Tales: Secrets in Lambda
20 Feb 2026
6 min read
Intro
I recently worked a case where an attacker turned a low-privilege leaked key into full AWS account access. The technique was straightforward: dump Lambda functions and grab the secrets sitting in them.
There was no zero-day or clever bypass here. The attacker just called an API that AWS designed to return everything about a Lambda function, and in this case that included another key with way more privileges.
I know it’s 2026 and this isn’t a new topic, but it still happens. So here’s what the attack actually looks like, why it works, and how to catch it.
The Misconfig
How you manage secrets in your workloads is pretty fundamental, and Lambda gives you a few ways to get it wrong.
Environment Variables: The common assumption is “it’s encrypted with KMS, so it’s secure.” And sure, Lambda environment variables are encrypted at rest. But they come back in plaintext when someone calls GetFunction. That database password, API key, or AWS access key, it’s right there in the JSON response.
Hardcoded Secrets: Sometimes developers embed credentials directly in the Lambda code. The reasoning is usually something like “it’s only in Lambda, not in our git repo.” But Lambda deployment packages live in AWS-managed S3 buckets, and GetFunction hands back a presigned URL to download the whole thing. Unzip it, read the code, find the secrets.
The “I’ll fix it later” Credentials: Probably the most dangerous pattern. Someone puts a secret in an environment variable intending to move it to Secrets Manager eventually. That “eventually” turns into months. The Lambda runs fine in production, nobody touches it, and the credentials just sit there. All an attacker needs is lambda:GetFunction permission.
In this particular case, a developer had stored access keys for a high-privilege role in environment variables across several Lambda functions. Not just one, several.
The Exploit
The attack chain is about as simple as it gets:
Step 1: Enumerate Functions
aws lambda list-functions --region us-east-1
List all Lambda functions in the account. This returns function names, runtimes, and basic metadata. You only need lambda:ListFunctions, which is a pretty common permission to have.
Step 2: Dump Each Function
aws lambda get-function --function-name <function> --region us-east-1
For each function that looks interesting, anything with “prod,” “admin,” “api,” “database” in the name, the attacker calls GetFunction. The response has two sections worth caring about:
Configuration: Environment variables in plaintext
"Environment": {
"Variables": {
"DB_PASSWORD": "SuperSecretP@ssw0rd",
"AWS_ACCESS_KEY_ID": "AKIA...",
"AWS_SECRET_ACCESS_KEY": "..."
}
}
Code: A presigned S3 URL to download the deployment package
"Code": {
"Location": "https://awslambda-us-east-1-tasks.s3.us-east-1.amazonaws.com/snapshots/..."
}
Step 3: Escalate
At this point the attacker has two paths:
- Use any exposed credentials from the environment variables
- Download the code package via the presigned URL and grep through it for hardcoded secrets
Why this works so well:
lambda:GetFunctionis commonly granted for legitimate operational reasons- There’s no additional encryption protecting secrets in the API response
- CloudTrail logs the call, but unless you have detection logic looking for this pattern, nobody notices
- The presigned URL expires in about 10 minutes, but that’s plenty of time to download a zip file
Detection
This all runs through CloudTrail, so detection comes down to watching GetFunction for patterns that don’t look like normal operations. The fields worth pivoting on are the event name, the calling principal’s ARN, the source IP, and the user agent.
The strongest single signal is the enumerate-then-dump pattern: a principal that calls ListFunctions and then rips through a bunch of GetFunction calls in the same short window. The function name sits inside the request parameters as JSON, so you parse it out to count how many distinct functions got touched.
Here it is for Sentinel, where the connector lands CloudTrail in the AWSCloudTrail table:
AWSCloudTrail
| where EventSource == "lambda.amazonaws.com"
| where EventName in ("ListFunctions", "GetFunction")
| extend FunctionName = tostring(parse_json(RequestParameters).functionName)
| summarize
ListCalls = countif(EventName == "ListFunctions"),
DumpedFunctions = make_set_if(FunctionName, EventName == "GetFunction")
by UserIdentityArn, SourceIpAddress, bin(TimeGenerated, 10m)
| extend FunctionsDumped = array_length(DumpedFunctions)
| where ListCalls > 0 and FunctionsDumped > 5
And the same logic in Athena, against the standard CloudTrail table:
SELECT *
FROM (
SELECT
useridentity.arn AS principal,
sourceipaddress,
from_unixtime(floor(to_unixtime(from_iso8601_timestamp(eventtime)) / 600) * 600) AS window_start,
count_if(eventname = 'ListFunctions') AS list_calls,
count(DISTINCT CASE
WHEN eventname = 'GetFunction'
THEN json_extract_scalar(requestparameters, '$.functionName')
END) AS functions_dumped
FROM cloudtrail_logs
WHERE eventsource = 'lambda.amazonaws.com'
AND eventname IN ('ListFunctions', 'GetFunction')
AND "timestamp" >= '2026/06/01' -- partition column from the AWS DDL; adjust to your range
GROUP BY 1, 2, 3
) t
WHERE list_calls > 0 AND functions_dumped > 5
Both bucket activity into 10 minute windows per principal and source IP, then flag anyone who listed functions and then pulled more than five distinct ones. Tune that threshold to whatever counts as normal in your environment. Beyond this pattern, the usual anomaly angles apply: a principal or identity type that never normally touches Lambda, a source IP or user agent you haven’t seen from them before, or a plain volume spike above their baseline.
Mitigation
The fix is to get secrets out of Lambda code and environment variables and into Secrets Manager.
1. Store Secrets in Secrets Manager
aws secretsmanager create-secret \
--name prod/database/credentials \
--secret-string '{"username":"admin","password":"SuperSecretP@ssw0rd"}'
2. Grant Lambda GetSecretValue
{
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue"
],
"Resource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/database/credentials"
}
3. Retrieve Secrets at Runtime
import boto3
import json
def lambda_handler(event, context):
client = boto3.client('secretsmanager')
secret = client.get_secret_value(SecretId='prod/database/credentials')
creds = json.loads(secret['SecretString'])
# Use creds['username'] and creds['password']
With this setup, secrets don’t show up in GetFunction responses. Pulling one now takes secretsmanager:GetSecretValue on that specific secret ARN, which is a more restricted permission than the broadly granted lambda:GetFunction, and one you can scope tightly, alert on, and rotate behind. It doesn’t make a stolen key any less powerful, but it does make the secret harder to get at in the first place.
If you want to clean this up in your environment:
- Audit your Lambda functions, check each one for secrets in environment variables or embedded in code
- Move everything to Secrets Manager
- Update Lambda code to pull secrets at runtime
- Tighten IAM policies so
lambda:GetFunctionis only granted to principals that actually need it
Not a new problem, but an easy one to miss. Thanks for stopping by.