Skip to main content

Lab 7 — Secrets Baked Into the Snapshot

MicroVMs boot fast because they resume from a memory snapshot taken after initialization. If your init code loads a secret at module scope — secretsmanager:GetSecretValue before CMD — that secret is baked into the snapshot. Every MicroVM launched from that image, on every resume, carries the same secret in memory, indefinitely. No per-resource cloud scanner looks inside a Firecracker snapshot. This lab catches it.

Like the runtime limits in Lab 6, this is an asserted signal: whether init loads a secret isn't an AWS API field — it's a property the image build surfaces (a static check over the Dockerfile / app init). The transform records it; the control evaluates it. Verified against the real stave binary.

Prerequisites

Same shared exports as Lab 1. Then:

mkdir -p lab7-snapshot-secret && cd lab7-snapshot-secret && mkdir -p snapshots

The defect

A non-compliant app.py loads the secret during init — at module scope, before the server starts:

import boto3
app = Flask(__name__)

# BAD: runs at init -> the value is in the snapshot taken after init
client = boto3.client("secretsmanager")
SECRET = client.get_secret_value(SecretId="prod-db-creds")["SecretString"]

The build step surfaces this as microvm_image.init_secret_access = true.

Step 1 — Detect

Assert the init-secret signal via the transform; everything else defaults compliant:

INIT_SECRET=true \
BUCKET=stave-microvm-lab-${AWS_ACCOUNT_ID} bash "$TRANSFORM" snapshot-secret

"$STAVE" apply -i "$CTRL" -o observations/snapshot-secret --eval-time "$TS" --format json \
| jq -r '"\(.findings|length) finding(s):", (.findings[] | " [\(.control_severity)] \(.control_id) — \(.control_name)")'
1 finding(s):
[critical] CTL.LAMBDA.MICROVM.SNAPSHOTSECRET.001 — MicroVM Image Loads Secrets Into the Snapshot at Init

microvm_image.init_secret_access = true fires SNAPSHOTSECRET (critical).

Step 2 — Remediate and re-verify

Defer secret loading to a runtime lifecycle hook (/run) so it executes after the snapshot is taken — the value is fetched fresh on each run, never baked in:

@app.route("/run", methods=["POST"])
def run_hook():
app.config["SECRET"] = boto3.client("secretsmanager") \
.get_secret_value(SecretId="prod-db-creds")["SecretString"]
return jsonify(status="ready"), 200

Rebuild the image from the fixed app; the build now reports init_secret_access = false:

INIT_SECRET=false \
BUCKET=stave-microvm-lab-${AWS_ACCOUNT_ID} bash "$TRANSFORM" snapshot-secret-fixed

"$STAVE" apply -i "$CTRL" -o observations/snapshot-secret-fixed --eval-time "$TS" --format json | jq -r '"\(.findings|length) finding(s):"'
0 finding(s):

What you learned

  • The fast-boot mechanism (memory snapshot) is itself an attack surface: a secret loaded at init persists in every launched MicroVM.
  • The fix is structural — move secret loading into a runtime hook so it runs after the snapshot, not before.
  • Some controls evaluate a build-time signal (init_secret_access), not a cloud-API field — the four-quadrant point that LLM source-scanning and config evaluation are different jobs.

You've finished the series

All seven labs map to the CTL.LAMBDA.MICROVM.* family:

LabControls
1 Baselineall 16 pass
2 Over-privileged roleEXECROLE, TAGSESSION
3 Missing TagSessionTAGSESSION
4 Compound pathgraph reachability (Soufflé + Z3)
5 Public S3S3PUBLIC, S3VERSION
6 Excessive idle/runtimeIDLE, RUNTIME
7 Secrets in snapshotSNAPSHOTSECRET

Browse the full catalog in the invariants reference, or author your own with Writing Controls.