Lab 4 — Compound Path: Graph Reachability
This is the lab that shows why graph reasoning matters. The MicroVM's execution
role (LooksSafe) only has sts:AssumeRole on one specific role — it passes
every per-resource check. The intermediate role only has
secretsmanager:GetSecretValue on one secret — it passes every per-resource
check too. Individually, both look fine.
The lethal property is the composition:
MicroVM (LooksSafe) ─sts:AssumeRole→ IntermediateRole ─GetSecretValue→ production secret
Per-resource controls (Labs 2–3) are blind to this by design. Reachability is what graph engines see. Stave answers it with two independent engines — Soufflé (Datalog) and Z3 (SMT) — over edges modeled from the captured policies. Verified against the real engines.
Prerequisites
Same shared exports as Lab 1, plus
souffle and z3 on your PATH:
export PATH="$HOME/.local/bin:$PATH"
souffle --version && z3 --version # confirm both are installed
mkdir -p lab4-compound && cd lab4-compound && mkdir -p snapshots
The graph programs ship in the repo at ctf/labs/microvm/reasoning/:
export REASONING=~/work/bizacademy/ctf/labs/microvm/reasoning
Step 1 — Build the chain
Create the production secret, the intermediate role (reads only that secret), and the LooksSafe execution role (assumes only the intermediate role):
aws secretsmanager create-secret --name stave-lab-production-db-creds \
--secret-string '{"username":"admin","password":"lab-test-only"}'
aws iam create-role --role-name MicroVMLab-IntermediateRole \
--assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::'${AWS_ACCOUNT_ID}':role/MicroVMLab-ExecRole-LooksSafe"},"Action":"sts:AssumeRole"}]}'
aws iam put-role-policy --role-name MicroVMLab-IntermediateRole --policy-name secrets-access \
--policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"secretsmanager:GetSecretValue","Resource":"arn:aws:secretsmanager:'${AWS_REGION}':'${AWS_ACCOUNT_ID}':secret:stave-lab-production-db-creds*"}]}'
aws iam create-role --role-name MicroVMLab-ExecRole-LooksSafe \
--assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":["sts:AssumeRole","sts:TagSession"]}]}'
aws iam put-role-policy --role-name MicroVMLab-ExecRole-LooksSafe --policy-name assume-intermediate \
--policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"sts:AssumeRole","Resource":"arn:aws:iam::'${AWS_ACCOUNT_ID}':role/MicroVMLab-IntermediateRole"}]}'
Step 2 — Per-resource check is blind (the point)
Capture the LooksSafe role and run the per-resource controls — it passes:
aws iam get-role --role-name MicroVMLab-ExecRole-LooksSafe > snapshots/exec-role-lookssafe.json
aws iam get-role-policy --role-name MicroVMLab-ExecRole-LooksSafe --policy-name assume-intermediate > snapshots/exec-role-lookssafe-policy.json
aws iam get-role --role-name MicroVMLab-IntermediateRole > snapshots/intermediate-role.json
aws iam get-role-policy --role-name MicroVMLab-IntermediateRole --policy-name secrets-access > snapshots/intermediate-role-policy.json
EXEC_ROLE=snapshots/exec-role-lookssafe.json EXEC_POL=snapshots/exec-role-lookssafe-policy.json \
BUCKET=stave-microvm-lab-${AWS_ACCOUNT_ID} bash "$TRANSFORM" compound
"$STAVE" apply -i "$CTRL" -o observations/compound --eval-time "$TS" --format json | jq -r '"\(.findings|length) finding(s)"'
0 finding(s)
Zero. AssumeRole on one role isn't over-privileged; the trust policy has
TagSession. Per-resource checks are satisfied — and the compound path is still
lethal.
Step 3 — Graph reachability
Model the edges from the captured policies, then run both engines:
# 1. Edge modeling: assumes(role,role) + reads_secret(role,secret) + microvm_role(role)
MICROVM_ROLE=MicroVMLab-ExecRole-LooksSafe bash "$REASONING/build-facts.sh" facts
# 2. Soufflé (Datalog) transitive reachability
mkdir -p out
souffle "$REASONING/microvm-compound-path.dl" -F facts -D out
echo "compound path:"; cat out/compound_path.csv
# 3. Z3 (SMT) — independent cross-check (grep drops a cosmetic line on unsat)
cat facts/facts.smt2 "$REASONING/microvm-017.smt2" | z3 -in | grep -vF 'model is not available'
compound path:
MicroVMLab-ExecRole-LooksSafe stave-lab-production-db-creds
sat
((start "MicroVMLab-ExecRole-LooksSafe")
(mid "MicroVMLab-IntermediateRole")
(secret "stave-lab-production-db-creds"))
Both agree: Soufflé lists the reachable (role, secret) pair; Z3 returns sat
with the full witness — LooksSafe → IntermediateRole → production secret. The
closed-world facts (each relation true only for captured edges) make the Z3
verdict sound: it cannot invent an edge.
Step 4 — Remediate and re-verify
Break the chain — scope LooksSafe so it can no longer assume the intermediate role — then re-run:
aws iam put-role-policy --role-name MicroVMLab-ExecRole-LooksSafe --policy-name assume-intermediate \
--policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["logs:CreateLogGroup","logs:PutLogEvents"],"Resource":"arn:aws:logs:*:*:*"}]}'
aws iam get-role-policy --role-name MicroVMLab-ExecRole-LooksSafe --policy-name assume-intermediate > snapshots/exec-role-lookssafe-policy.json
MICROVM_ROLE=MicroVMLab-ExecRole-LooksSafe bash "$REASONING/build-facts.sh" facts
mkdir -p out; souffle "$REASONING/microvm-compound-path.dl" -F facts -D out
echo "compound path:"; cat out/compound_path.csv # empty
cat facts/facts.smt2 "$REASONING/microvm-017.smt2" | z3 -in | grep -vF 'model is not available' # unsat
Soufflé's compound_path.csv is now empty and Z3 returns unsat — the
assume-role hop is gone, so the role no longer reaches the secret.
Step 5 — Cleanup
for R in MicroVMLab-ExecRole-LooksSafe MicroVMLab-IntermediateRole; do
aws iam list-role-policies --role-name $R --query 'PolicyNames[]' --output text \
| xargs -n1 aws iam delete-role-policy --role-name $R --policy-name
aws iam delete-role --role-name $R
done
aws secretsmanager delete-secret --secret-id stave-lab-production-db-creds --force-delete-without-recovery
What you learned
- Per-resource checks can pass on every node while the path is lethal. That blindness is the finding, not a bug.
- Stave models the assume-role / secret-read edges from the captured policies and
computes reachability with two independent engines (Soufflé + Z3) that
agree —
sat= vulnerable,unsat= safe. - Remediation is structural: remove one edge and the path is gone.