A self-contained AWS IAM misconfiguration lab running entirely in Docker via Floci — no real AWS account required. Demonstrates real attack chains, then proves remediation using GRC tooling.
This lab models a common real-world scenario: an S3 bucket containing sensitive credentials and PII, protected by account namespace isolation, but undermined by two misconfigurations that completely bypass that protection:
- A Lambda execution role with
s3:*onResource: *— any invoker gets full read access to every bucket regardless of which account owns it - A
devops-rolewithiam:PassRoleonResource: *and noiam:PassedToServicecondition — anyone with this role can create a new Lambda, attach the overpermissive exec role to it, and invoke freely
An attacker who cannot touch the bucket directly can still exfiltrate every secret in it by pivoting through the Lambda. These are not theoretical findings — the lab proves the full chain executes end to end.
| Phase | Test | What It Proves |
|---|---|---|
| Direct access | Attacker account hits S3 and SSM directly | Namespace isolation denies the attacker |
| Lambda pivot | Attacker invokes data-processor Lambda |
Overpermissive exec role returns vault contents and SSM SecureStrings to the attacker |
| PassRole privesc | Attacker creates evil-exfil Lambda via devops-role |
Unscoped iam:PassRole allows deploying attacker-controlled compute with elevated permissions |
| GRC detection | tfsec and conftest/OPA scan Terraform plan |
All misconfigurations are flagged before deployment |
| Remediation | Same tests run against terraform-fix/ |
Attacks fail, GRC scan returns clean |
These misconfigurations are among the most common findings in AWS environments and are consistently missed in manual reviews:
- Wildcard IAM resources (
Resource: *) on S3 and SSM are frequently left in place because the service "works" — the blast radius is invisible until exploited iam:PassRolewithout aPassedToServicecondition is a well-documented privilege escalation path that bypasses every other control in the environment- Lambda execution roles are often copy-pasted from documentation with overly broad permissions and never reviewed again
The GRC layer — OPA Rego policies and tfsec custom checks — demonstrates that
these issues are detectable at the IaC review stage, before any infrastructure
is deployed. The before/after delta between --vuln and --fixed is the
evidence artifact for a finding report.
All scripts accept --vuln or --fixed to target the correct environment.
They use named AWS CLI profiles (--profile allowed, --profile attacker,
--profile root) internally so the shell identity does not affect results.
| Script | What It Does |
|---|---|
quicktest.sh |
Validates Floci health, profile configuration, bucket accessibility, and Lambda existence before any test runs |
01_access_denied_demo.sh |
Proves the baseline — attacker is denied on all sensitive paths, allowed account can read freely |
02_lambda_pivot.sh |
Runs the full three-phase attack: direct denial confirmation → Lambda pivot exfiltrating vault contents and SSM SecureStrings → PassRole privesc deploying an attacker-controlled Lambda that reads PII |
04_compare_roles.sh |
Runs identical S3 and SSM actions as both accounts simultaneously, outputs a color-coded ALLOW/DENY matrix and saves a CSV report to /tmp/ |
run_grc_checks.sh |
Runs OPA unit tests against the Rego policies, conftest against the Terraform plan JSON for IAM and S3 namespaces, and tfsec with custom checks — expects findings on --vuln and a clean scan on --fixed |
| # | Attack | Misconfiguration | GRC Finding |
|---|---|---|---|
| 1 | Namespace isolation baseline | Bucket owned by allowed account | S3-003/004 |
| 2 | Lambda pivot — attacker reads vault via exec role | s3:* on Resource: * |
IAM-001 |
| 3 | SSM SecureString dump via Lambda | ssm:GetParameter* on Resource: * |
IAM-002 |
| 4 | iam:PassRole privilege escalation |
iam:PassRole on Resource: * no condition |
IAM-003/004 |
| 5 | Any account can invoke Lambda | Missing aws_lambda_permission |
LAMBDA-001 |
iam-lab/
├── docker-compose.yml Floci container config
├── README.md This file
├── lambda_src/
│ ├── index.js Lambda source — list/read/ssm_dump actions
│ └── data_processor.zip Built deployment package (build before apply)
├── scripts/
│ ├── env.sh Shared env config sourced by all scripts
│ ├── quicktest.sh Sanity check — run this first every time
│ ├── 01_access_denied_demo.sh Proves namespace isolation
│ ├── 02_lambda_pivot.sh Lambda pivot + PassRole privesc
│ ├── 04_compare_roles.sh Side-by-side matrix + CSV report
│ └── destroy.sh Safe destroy — pre-empties versioned bucket
├── terraform/
│ └── main.tf VULNERABLE infrastructure
├── terraform-fix/
│ └── main.tf FIXED infrastructure
└── policies/
├── run_grc_checks.sh Runs all GRC tools with pass/fail per mode
├── opa/
│ ├── iam_no_wildcard_resources.rego
│ ├── s3_security_baseline.rego
│ └── iam_test.rego
└── tfsec/
└── iam_wildcard_resources.yaml
# Docker Engine + Compose v2
docker --version && docker compose version
# Terraform >= 1.3
terraform --version
# AWS CLI v2
aws --version
# zip + python3
zip --version && python3 --version
# OPA
curl -L -o /usr/local/bin/opa \
https://openpolicyagent.org/downloads/latest/opa_linux_amd64_static
chmod +x /usr/local/bin/opa
opa version
# conftest
wget https://github.com/open-policy-agent/conftest/releases/download/v0.50.0/conftest_0.50.0_Linux_x86_64.tar.gz
tar xzf conftest_0.50.0_Linux_x86_64.tar.gz
sudo mv conftest /usr/local/bin/
conftest --version
# tfsec
sudo apt install tfsec -y
tfsec --version
# GitHub CLI (optional — for pushing to GitHub)
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | \
sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] \
https://cli.github.com/packages stable main" | \
sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
sudo apt update && sudo apt install gh -yFloci uses 12-digit numeric Access Key IDs as account IDs. Resources are
stored in completely separate namespaces — there is no shared state between
accounts. Account 222222222222 looking up a bucket owned by 111111111111
gets a 404 because it does not exist in that account's namespace.
AWS_ACCESS_KEY_ID=111111111111 → account 111111111111 (allowed — owns S3/SSM)
AWS_ACCESS_KEY_ID=222222222222 → account 222222222222 (attacker — denied)
AWS_ACCESS_KEY_ID=test → account 000000000000 (root — IAM/Lambda)
Floci has a known limitation where 12-digit AKIDs break CreateFunction and
IAM API calls returning HTTP 500. The Terraform uses a dual-provider setup:
- S3 buckets, objects, SSM parameters →
access_key = "111111111111"so theallowedprofile owns them - IAM roles, Lambda functions →
access_key = "test"(root) to avoid the bug
The Lambda env vars include AWS_ACCESS_KEY_ID=111111111111 so the runtime
reads S3/SSM from the allowed account namespace when executing.
curl -s http://localhost:4566/_localstack/health | jqgit clone https://github.com/leeclay95/iam_lab.git
cd iam_labFloci runs as non-root inside the container. Without this, hybrid storage writes fail and all S3/SSM operations return errors.
sudo chmod -R 777 ./floci-dataIf a container named floci already exists from a previous session, remove
it first:
docker stop floci 2>/dev/null; docker rm floci 2>/dev/null; true
docker network rm iam_lab_net 2>/dev/null; trueStart fresh:
docker compose up -d
sleep 5
curl -s http://localhost:4566/_localstack/health | jq .versionExpected output: "1.5.14"
Three named profiles are required. The scripts use --profile flags
internally so these must exist regardless of what is set in the shell.
aws configure --profile allowed
# Access Key ID: 111111111111
# Secret Access Key: test
# Region: us-east-1
# Output: json
aws configure --profile attacker
# Access Key ID: 222222222222
# Secret Access Key: test
# Region: us-east-1
# Output: json
aws configure --profile root
# Access Key ID: test
# Secret Access Key: test
# Region: us-east-1
# Output: jsonVerify all three are configured correctly:
aws configure list --profile allowed # should show 111111111111
aws configure list --profile attacker # should show 222222222222
aws configure list --profile root # should show testOpen three separate terminals. Export the following in each one at the start
of every session. Adding these to ~/.zshrc or ~/.bashrc makes them
persistent across restarts.
Important: If
~/.aws/confighas an[default]section containingsso_start_url, remove it. SSO profiles intercept all unauthenticated calls and causesession expirederrors that override these exports.
Shell 1 — Allowed (owns S3 and SSM, runs all test scripts):
export AWS_ACCESS_KEY_ID=111111111111
export AWS_SECRET_ACCESS_KEY=test
export AWS_DEFAULT_REGION=us-east-1
export AWS_ENDPOINT_URL=http://localhost:4566
unset AWS_PROFILE
unset AWS_SESSION_TOKENShell 2 — Attacker (denied on everything, used for manual verification):
export AWS_ACCESS_KEY_ID=222222222222
export AWS_SECRET_ACCESS_KEY=test
export AWS_DEFAULT_REGION=us-east-1
export AWS_ENDPOINT_URL=http://localhost:4566
unset AWS_PROFILE
unset AWS_SESSION_TOKENShell 3 — Root / Terraform (all Terraform operations, IAM, Lambda):
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
export AWS_DEFAULT_REGION=us-east-1
export AWS_ENDPOINT_URL=http://localhost:4566
unset AWS_PROFILE
unset AWS_SESSION_TOKENFrom Shell 3:
cd terraform
terraform init
terraform apply -auto-approveLambda pulls public.ecr.aws/lambda/nodejs18.x on first deploy — allow
1-2 minutes. Watch progress with docker logs floci --tail 20 -f.
Verify from Shell 1 — should return data:
aws s3api list-buckets --output text
aws s3 cp s3://company-secrets-vault/credentials/db-creds.txt -
# Expected: DB_HOST=prod-db.internal ... DB_PASSWORD=SuperSecret123!Verify from Shell 2 — should be denied:
aws s3 cp s3://company-secrets-vault/credentials/db-creds.txt - 2>&1
# Expected: 404 Not FoundRun all tests and GRC checks from Shell 1:
cd ..
./scripts/quicktest.sh --vuln
./scripts/01_access_denied_demo.sh --vuln
./scripts/02_lambda_pivot.sh --vuln
./scripts/04_compare_roles.sh --vuln
./policies/run_grc_checks.sh --vulnFrom Shell 3:
cd ..
./scripts/destroy.sh --vulnVerify everything is gone from Shell 3:
aws s3api list-buckets --output text
aws lambda list-functions --query 'Functions[].FunctionName' --output text
aws iam list-roles --query 'Roles[].RoleName' --output textAll three should return empty output.
From Shell 3:
cd terraform-fix
terraform init
terraform apply -auto-approveVerify from Shell 1 — should return data:
aws s3api list-buckets --output text
aws s3 cp s3://company-secrets-vault-fixed/credentials/db-creds.txt -
aws ssm get-parameter --name /prod/db/password --with-decryption \
--output text --query 'Parameter.Value'Verify from Shell 2 — should be denied:
aws s3 cp s3://company-secrets-vault-fixed/credentials/db-creds.txt - 2>&1
# Expected: 404 Not FoundRun all tests and GRC checks from Shell 1:
cd ..
./scripts/quicktest.sh --fixed
./scripts/01_access_denied_demo.sh --fixed
./scripts/02_lambda_pivot.sh --fixed
./scripts/04_compare_roles.sh --fixed
./policies/run_grc_checks.sh --fixedquicktest all PASS
01_access_denied attacker DENIED on all sensitive paths ✓
allowed ALLOWED on all paths ✓
02_lambda_pivot Phase 1: direct access DENIED ✓
Phase 2: vault enumerated, db-creds exfiltrated,
SSM SecureStrings dumped via Lambda pivot
Phase 3: evil Lambda created via PassRole abuse,
PII exfiltrated
04_compare_roles credentials/* — Allowed=ALLOW Attacker=DENY ✓
run_grc_checks OPA 8/8 PASS
conftest IAM: 8 failures detected ✓
tfsec: 9 HIGH findings detected ✓
quicktest all PASS
01_access_denied attacker DENIED ✓ allowed ALLOWED ✓
02_lambda_pivot Phase 2: scoped role cannot read sensitive prefixes ✓
Phase 3: overpermissive role does not exist ✓
04_compare_roles credentials/* — Allowed=ALLOW Attacker=DENY ✓
run_grc_checks OPA 8/8 PASS
conftest IAM: 0 failures ✓
tfsec: No problems detected ✓
Expected ALLOW results that are not findings:
s3:PutObjectas attacker — writes into the attacker's own empty namespace, not the vaultssm:GetParametersByPath /prod/as attacker — returns an empty list from the attacker's namespace, not the vault contents
From Shell 3:
# If running the fixed lab
./scripts/destroy.sh --fixed
# If running the vulnerable lab
./scripts/destroy.sh --vulnConfirm all resources are gone:
aws s3api list-buckets --output text
aws lambda list-functions --query 'Functions[].FunctionName' --output text
aws iam list-roles --query 'Roles[].RoleName' --output textAll three should return empty.
docker compose downConfirm the container and network are gone:
docker ps -a | grep floci # should return nothing
docker network ls | grep iam_lab # should return nothingIf you want a completely clean slate including all Floci state:
docker compose down
sudo rm -rf floci-data/*To bring the lab back up from scratch after a full reset:
sudo chmod -R 777 ./floci-data
docker compose up -d
sleep 5
curl -s http://localhost:4566/_localstack/health | jq .version// Before
{"Effect": "Allow", "Action": ["s3:*"], "Resource": "*"}
// After
{"Effect": "Allow", "Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::company-secrets-vault/app-output/*"]}// Before
{"Effect": "Allow", "Action": ["iam:PassRole"], "Resource": "*"}
// After
{
"Effect": "Allow",
"Action": ["iam:PassRole"],
"Resource": ["arn:aws:iam::ACCOUNT:role/lambda-correctly-scoped-role"],
"Condition": {
"StringEquals": {"iam:PassedToService": "lambda.amazonaws.com"}
}
}resource "aws_lambda_permission" "invoke_restriction" {
statement_id = "AllowOnlyAuthorizedAccount"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.data_processor_fixed.function_name
principal = "arn:aws:iam::111111111111:root"
}