From One Bad Parameter to Full Cloud Takeover: Serverless Attack Paths
A publicly exposed serverless function with no authentication is a normal, often deliberate architecture choice. Plenty of workloads have to be reachable by anonymous users. The trouble is what sits behind it: custom code, third-party packages, and an identity attached to the runtime. Get code execution in that container and the interesting target isn't the function, it's the token the function can ask for.
Mandiant's Corné de Jong published a walkthrough of this pattern based on customer engagements, focused on Google Cloud Run and Cloud Functions. The examples are GCP, but the attack chain is identical on AWS and Azure, and the hardening reasoning generalizes cleanly. Worth understanding whether you run serverless or not, because the same shape applies to any container with cloud credentials attached.
The Chain, in Order
It starts with an ordinary application vulnerability. LFI, RFI, or command injection in code that trusts user input. From there:
- Read source code and config to find hardcoded secrets and internal endpoints.
- Enumerate dependencies to find known CVEs in the runtime.
- On RCE, hit the cloud metadata server and pull the service account's bearer token.
- Use that token from your own machine to drive the cloud CLI as the compromised identity.
Step 3 is the pivot that turns an application bug into a cloud incident, and it's the one people underestimate.
Local File Inclusion: Reading the Box
The vulnerable pattern is depressingly simple. A function takes a filename from the request and opens it:
import functions_framework
@functions_framework.http
def hello_http(request):
request_json = request.get_json(silent=True)
request_args = request.args
if request_json and 'file' in request_json:
file = request_json['file']
elif request_args and 'file' in request_args:
file = request_args['file']
# VULNERABILITY: 'file' goes straight into open() with no validation
with open(file, 'r') as resp:
filedata = resp.read()
return 'local file data {}!'.format(filedata)
An attacker asks for the source:
curl -X POST https://cloudrun01-abc.europe-west3.run.app/ -H "Content-Type: application/json" -d '{"file": "main.py"}'
That returns the complete source, which hands over hardcoded secrets, business logic flaws, internal endpoints, and the import list that reveals your stack and its CVE exposure. Then standard traversal walks the filesystem:
curl -X POST https://cloudrun01-abc.europe-west3.run.app/ -H "Content-Type: application/json" -d '{"file": "../../../etc/passwd"}'
The file list worth fuzzing is predictable: requirements.txt, package.json, go.mod to enumerate packages and versions against known CVEs. .env files for environment variables and secrets. Application config for database credentials and API keys. /etc/passwd and /proc/self/environ for users and env. Application logs for auth tokens and PII.
⚠️ The dependency-manifest read is worth pausing on. An attacker pulling your requirements.txt is building an SBOM of your function, then matching it against public CVE data. They're doing the inventory work you should already have done. If you don't know which vulnerable packages are in your runtime and an attacker can read the manifest in one request, they know your attack surface better than you do. That's the practical argument for generating and querying your own SBOMs rather than finding out this way.
The rule this enforces: never store secrets or credentials in source code or local container files. Use a secrets manager and inject at runtime. This is exactly the lesson from the CISA GitHub leak, where plaintext credentials in a repo became a national-scale incident. Same failure, different blast radius.
Command Injection: Getting the Token
The second scenario is worse, and shorter:
import functions_framework
import subprocess
@functions_framework.http
def hello_http(request):
request_json = request.get_json(silent=True)
request_args = request.args
if request_json and 'input' in request_json:
input = request_json['input']
elif request_args and 'input' in request_args:
input = request_args['input']
result = subprocess.run(input, shell=True, capture_output=True, text=True)
return format(result)
shell=True with unsanitized input is RCE by construction. The attacker's first move isn't a reverse shell, it's the metadata server:
curl -X POST https://cloudrun02-abc.europe-west3.run.app/ -H "Content-Type: application/json" -d "{\"input\": \"curl 'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token' -H 'Metadata-Flavor: Google'\"}"
That returns an OAuth 2.0 bearer token, valid for an hour. The attacker exports it on their own machine:
export CLOUDSDK_AUTH_ACCESS_TOKEN="<stolen bearer token>"
And now they're running gcloud as your service account, from their infrastructure, with no further access to yours needed.
⚠️ If that service account is the default compute service account with Editor, which is the default many deployments never change, this is full project takeover: read/write/delete most resources, deploy services, access secrets and encryption keys, exfiltrate data across all accessible storage, and establish persistence via new service accounts or SSH keys. One unvalidated parameter to owning the project.
This Is Not a GCP Problem
The metadata server is the transferable part, and every cloud has one at a link-local address:
| Cloud | Metadata endpoint | Notes |
|---|---|---|
| GCP | http://metadata.google.internal (169.254.169.254) |
Requires Metadata-Flavor: Google header |
| AWS | http://169.254.169.254/latest/meta-data/ |
IMDSv1 is a plain GET; IMDSv2 requires a PUT to get a session token first |
| Azure | http://169.254.169.254/metadata/identity/oauth2/token |
Requires Metadata: true header |
⚠️ On AWS, this is the entire argument for enforcing IMDSv2. IMDSv1 answers a simple GET, which means any SSRF or command injection reaches it trivially, and it's how a long list of real breaches escalated. IMDSv2 requires a PUT with a token and enforces a hop limit, which breaks naive SSRF and most container-escape-by-curl paths. If you run anything on EC2 or ECS, enforce IMDSv2 and set the hop limit to 1. It's a metadata options change, not an application change, and it closes step 3 of this chain by itself.
The header requirements on GCP and Azure serve a similar purpose (they defeat trivial SSRF that can't set headers), but they don't stop full command injection, which can set any header it likes.
Hardening: Layers, Not a Fix
Application code should be correct. It won't always be. The point of the rest is that a bug doesn't become a takeover.
Least-privilege identity
Do not run functions as the default compute service account. Create a custom one and grant only what the function needs:
- Read objects from one bucket:
roles/storage.objectViewerscoped to that bucket, not project-wide. - Read one secret:
roles/secretmanager.secretAccessoron that secret, not all of them.
⚠️ This is the single highest-value control in the whole list. The token theft still succeeds, but a stolen token for an identity that can read one bucket is an incident you contain in an afternoon. A stolen token for Editor is a rebuild. Everything else here reduces the odds of compromise; least privilege reduces what compromise means.
Segregate public services
Host public-facing services consumed by untrusted users in a dedicated, isolated project. A compromise then has no immediate path to critical internal resources. Same reasoning as network segmentation, applied at the project boundary, and the same blast-radius logic behind restricting east-west traffic between workloads.
Put a load balancer and WAF in front
Restrict the function's ingress to internal only, and expose it through an external Layer 7 load balancer instead. That buys centralized header and TLS policy, rate limiting, real logging, and a place to attach a WAF.
On GCP, Cloud Armor has preconfigured rules for exactly these attack classes:
evaluatePreconfiguredWaf('lfi-v33-stable', {'sensitivity': 3})
evaluatePreconfiguredWaf('rce-v33-stable', {'sensitivity': 3})
With those active, both attacks above return 403 Forbidden instead of your source code or your token.
⚠️ A WAF is a compensating control, not a fix. Preconfigured LFI and RCE rules block the obvious payloads and get bypassed by creative encoding often enough that you should never treat a green WAF as "the vulnerability is handled." It buys you time to fix the code. It does not replace fixing the code.
Block egress to the metadata server
Here's a control the source doesn't mention and it's worth adding. If your function has no legitimate need to call the metadata server at runtime, and many don't once secrets come from a secrets manager, then egress filtering that blocks 169.254.169.254 from the application context removes the token-theft step entirely. It's not always straightforward on managed serverless (the platform itself uses the metadata service), but wherever you control the network path (self-managed containers, VMs, Kubernetes with a CNI that supports egress policy), denying workload access to link-local metadata is a cheap, high-value rule. On Kubernetes specifically it's a natural fit for a NetworkPolicy egress rule.
VPC Service Controls and lateral movement
When using direct VPC egress or VPC Access connectors, VPC Service Controls restrict lateral movement and exfiltration through granular access policies. The same principle applies on any cloud: the identity boundary and the network boundary should both have to fail before data leaves.
The Vibe Coding Angle
The source frames this around AI-generated code, and the framing is fair. Generative AI has made deploying serverless code faster than ever, and AI workflows (chatbots, image generation, multi-step agents) run on exactly these functions. Faster deployment with the same review depth means more of these bugs shipping.
Mandiant's recommendations for AI-generated code are sensible and mostly boil down to not letting speed skip the pipeline: isolate AI experimentation in dedicated sandboxes, enforce egress controls so experiments can't touch production data, restrict development to approved IDEs with human-in-the-loop review and least-privilege plugins, and hold AI-generated software to the same secure-SDLC controls as anything else.
⚠️ The honest read: subprocess.run(user_input, shell=True) is not a novel AI failure, it's the oldest injection bug there is, and humans have been shipping it for decades. What's changed is throughput. If your review process was already the thing catching these, it's now catching a smaller fraction of a larger volume. That's a process problem, not an AI problem, and the fix is the same S-SDLC and least-privilege discipline that should have been there anyway. This ties into the pipeline controls in 8 container security best practices and the secrets-injection pattern from secure GitOps delivery.
Bottom Line
The chain is short: unvalidated input, code execution, metadata server, bearer token, cloud CLI as your identity. Application bugs will happen, so build so they stay application bugs. Never put secrets in code or container files. Never run on the default service account with Editor. Scope every permission to the specific bucket or secret it needs. Front public functions with a load balancer and WAF, segregate them into their own project, and enforce IMDSv2 if you're on AWS. Least privilege is the control that matters most, because it's the only one that changes what a successful compromise actually costs you.