Serverless cold-start side channels
TL;DR: Serverless platforms (Lambda, Cloud Functions, Vercel Functions, Cloudflare Workers) reuse warm instances for performance. State that persists between invocations — module globals, in-memory caches, file descriptors, /tmp — can leak data across requests when one user’s invocation warms the runtime and another user’s invocation hits the same instance. Audit: any non-request-scoped state, any /tmp write, any module-level data structure, and any “lazy init” pattern.
What it is
A serverless function lifecycle:
- Cold start: container/isolate spawned, module loaded, top-level code runs.
- Warm: same container reused for subsequent requests until the platform evicts.
- Eviction: container killed; next request cold-starts again.
The trust model implicit in serverless is “one invocation per request”. But warm reuse means the same process state services multiple user requests — and platforms don’t guarantee user isolation between warm invocations on the same instance.
Bug patterns
1. Module-globals as cache
1
2
3
4
5
let cachedUser = null;
export async function handler(event) {
if (!cachedUser) cachedUser = await db.user.findById(event.userId);
return cachedUser;
}
First user’s data cached; second user’s request hits warm instance → gets first user’s data.
- Fix: never module-level state for per-request data.
2. Logger / metrics with request context bleed
1
2
3
4
5
6
7
let currentRequest = null;
log.setContext = (r) => currentRequest = r;
export async function handler(event) {
log.setContext(event);
await doWork();
log.info('done'); // attaches event to log
}
If doWork is async and returns to event loop, a concurrent invocation in the same warm runtime overwrites currentRequest. Logs cross-contaminate. Cloudflare Workers, Vercel Edge, Lambda Node all run multiple requests in one isolate when concurrency allowed.
- Fix: use
AsyncLocalStorage(Node) or per-request context object passed explicitly.
3. /tmp write persisted across invocations
Lambda’s /tmp is per-container (warm-reuse). File written during one invocation visible in next.
- Vulnerable: user uploads file to
/tmp/in.jpg, handler processes it. Next user’s invocation reads/tmp/in.jpg(or shells out and globs/tmp/*). - Cross-user data leak.
- Fix: namespace
/tmpwrites by request ID; clean up before return.
4. Lazy-init secret in module global
1
2
3
4
5
let stripeClient;
async function getStripe() {
if (!stripeClient) stripeClient = new Stripe(await getSecret('STRIPE_KEY'));
return stripeClient;
}
Stripe client is fine to cache — but if the secret fetch is per-tenant, you’ve cached the wrong tenant’s client.
- Fix: keys for caches must include tenant/user discriminator; or no caching of per-tenant resources.
5. Database connection pool leak
- ORM connection pool initialised at module load → connects on cold start → reused.
- One request’s transaction may bleed isolation level / search_path into the next (Postgres
SET LOCALvsSET). - Fix: explicit per-request
BEGIN/COMMIT; reset session state at end.
6. Tenant cache poisoning
- Cache layer (memcached, Redis) shared across tenants.
- Key without tenant ID → cross-tenant leak.
- Fix: tenant prefix on every cache key; assertion in cache wrapper.
7. Cold-start timing oracle
- Cold start takes 100ms-2s; warm is 1-50ms.
- Differential timing leaks “have you seen this user before?” info.
- Theoretical for most apps; real for high-precision auth bypass tests.
8. Race window in lazy init
- Two requests arrive on a freshly-warm instance simultaneously.
- Both hit
if (!cache) cache = await load()— both load → second’s load may overwrite first’s partial state. - Pre-init in
process.env-driven startup avoids this.
9. Cross-invocation timer / setInterval
setInterval(..., 60_000)started in one invocation continues firing in the warm container.- Reads stale request context; may call APIs outside any user’s session.
- Fix: never long-lived timers in handler bodies; use platform’s scheduled-event primitives.
10. Cold-start error masking
- First invocation after cold start can fail differently than warm (e.g., DB not yet connected).
- Error-handling paths often less audited.
- Attacker can force cold starts via rate-throttle + retry to exercise edge cases.
11. Provisioned concurrency edge cases
- AWS Lambda provisioned concurrency = pre-warmed instances. Init code runs N times in parallel during scale-up.
- Race conditions in initialisation (writing to S3 or DB during init) replicate.
12. Tenant context in observability
- DataDog / OpenTelemetry trace context propagated via module globals → cross-trace leak.
- Use
AsyncLocalStorageproperly or a request-scoped context object.
Audit checklist
1
2
3
4
5
6
7
8
9
10
11
# Module-globals likely leaking state
rg -n 'let \w+\s*=\s*(null|undefined|\[\]|\{\})\s*$' src/handlers
rg -n 'const \w+ = new \w+\(' src/handlers # client/pool created at module scope
# /tmp usage
rg -n '/tmp/' src/
# AsyncLocalStorage usage (if absent, suspicion increases)
rg -n 'AsyncLocalStorage' src/
# Lazy init patterns
rg -n 'if\s*\(\s*!\w+\s*\)\s*\w+\s*=' src/
# Timers / intervals
rg -n 'setInterval|setTimeout' src/
Hardening
- Per-request context via
AsyncLocalStorage(Node) /requestContext(Edge runtimes). - No module-level mutable state outside framework / SDK clients.
/tmpwrites namespaced by request ID; cleanup infinally.- Cache keys include tenant/user.
- DB sessions reset to defaults at handler end.
- Avoid
setInterval/ long-running timers; use platform scheduled events.