Building floci-ctf: A Hardened AWS Emulator for Cloud Security CTFs
kangwijen

An Overview
I started building floci-ctf as a personal project while designing cloud security challenges for clients. I wanted players to work through real AWS-style misconfigs, not emulator shortcuts. If the lab accepts any password and skips auth checks, people learn the emulator, not AWS.
I looked at LocalStack first. They had a Community Edition that required no account and no token. That got replaced by a tiered model where even the free Hobby plan requires a LocalStack account and auth token, and IAM enforcement is behind the paid plans. The cheapest paid tier is yearly, runs hundreds of dollars, and still caps which services you get. Full access is around $1000 a year. Not an option for a personal project running club events.
I found Floci instead: MIT license, open source, free, and easy to run. But for security labs it was still too permissive: test/test credentials work by default, IAM enforcement is off, SigV4 is optional. Players could skip steps that were supposed to be the challenge. So I forked it into floci-ctf: same stack, same port, locked down for CTF work.
The Problem
When I built my first proof-of-concept challenge using Floci, players were supposed to dig through IAM, find a policy gap, and assume a role. I tested it using the docs as written. Typed test/test, hit the endpoint, walked straight past IAM because the emulator let it through. That is the upstream default: unknown keys and test bypass enforcement entirely. Fine for local dev, but for a security challenge it breaks the whole point. If any credential works, you are not teaching AWS auth.
Why Local Still Wins for CTF
Real AWS costs real money. The last time I ran labs in a live account for a few days, the bill was about $300. EC2, stuff left running, and lots of people poking at everything at once adds up. Hard to pay every time you run a CTF or workshop.
Real AWS | Permissive emulator | floci-ctf | |
Credentials | Real IAM keys required |
| Must issue real IAM keys |
IAM policies | Enforced | Skipped by default | Enforced |
SigV4 | Required | Optional | Required |
Cost | ~$300 for a few days | Free | Free |
Cleanup | Scripts + billing alerts | Nothing | Nothing |
My default for CTF is always local. I update floci-ctf often to make it more realistic and add coverage. Upstream Floci adds services and fixes too. The fork only stays useful if both keep moving. Local is not perfect, but you get the same environment every run with no surprise bill. The goal is learning IAM and attack paths, not scrambling to tear down EC2 instances before AWS charges you again every hour it runs.
What changed from upstream Floci
Floci works with the normal AWS CLI and SDKs, no account or token needed. The defaults are set for developer convenience, not security training: IAM enforcement is off, credentials can be baked in, resource policies do not block HTTP traffic, and GetCallerIdentity may not reflect who is actually calling. The floci-ctf README lists every difference next to upstream.
floci-ctf changes those defaults for lab operators:
Area | Upstream Floci | floci-ctf |
IAM enforcement | Off by default | On in |
Strict IAM mode | N/A | Denies missing auth, unregistered keys, unknown action mappings |
SigV4 validation | Off by default | Validates |
Default credentials |
| None in |
Deployer principal | Optional | Not seeded when IAM enforcement is on |
S3 presigned URLs | HMAC secret or 12-digit account id in credential | SigV4 query auth, generator uses operator root AKIA |
| Often returns account | Returns the calling principal ARN |
Resource policies | Not enforced on HTTP | S3, Lambda, SQS, SNS, KMS, Secrets Manager |
Scoped IAM | Mostly | Per-service ARNs via |
Internal routes | Open |
|
Container env | Player |
|
Forensics | Optional | Compose defaults: hybrid storage + CloudTrail audit |
Each row is a gap that breaks a challenge category. Together they cover the main attack surfaces players are supposed to learn.
Architecture: HTTP Request Pipeline
Every inbound request on :4566 passes through a stack of JAX-RS filters before it reaches a service controller. Authentication runs before authorization.
SigV4ValidationFilter runs at Priorities.AUTHENTICATION. It verifies the Authorization header against registered IAM access keys, the operator root pair, or presigned query parameters. Temporary credentials (ASIA*) require a matching x-amz-security-token header. IamEnforcementFilter runs next at AUTHENTICATION + 20. It resolves the caller identity, maps the request to an IAM action via IamActionRegistry, builds scoped resource ARNs with ResourceArnBuilder, and evaluates identity policies through IamPolicyEvaluator. When a resource policy exists, ResourcePolicyResolver merges identity and resource Allow statements. Explicit Deny always wins. Strict mode (FLOCI_SERVICES_IAM_STRICT_ENFORCEMENT_ENABLED=true) closes the remaining fall-through: unregistered keys, unsigned requests, and unmapped actions are rejected instead of silently allowed.
Operator vs Participant Credentials
The operator sets up the environment with a root credential pair. Players get scoped IAM keys and nothing else.
Operators export FLOCI_AUTH_ROOT_ACCESS_KEY_ID and FLOCI_AUTH_ROOT_SECRET_ACCESS_KEY on the host before docker compose up. That pair bypasses IAM enforcement for provisioning but still must sign requests with SigV4 and must never be given to players. Participants receive credentials from iam create-access-key. Under strict mode, test/test, floci/floci, and any unregistered key return HTTP 403. sts:GetCallerIdentity returns the IAM user ARN (for example arn:aws:iam::000000000000:user/player1), not account :root, matching real AWS behavior.
Scoped IAM Resource ARNs
Upstream emulators often evaluate every policy statement against Resource: *. In AWS, most data-plane APIs scope to specific ARNs derived from the request body or query string. floci-ctf maps requests to concrete ARNs in ResourceArnBuilder:
Action | Policy | Derived from |
|
|
|
|
|
|
|
|
|
|
|
|
|
| bucket path + |
| role ARN + trust policy |
|
A Deny on one S3 bucket does not accidentally block unrelated buckets. Players reason about least-privilege the same way they would in AWS.
Resource Policies and Trust Relationships
Identity policies alone are not enough for realistic attack paths. floci-ctf also checks resource policies on HTTP for S3, Lambda, SQS, SNS, KMS, and Secrets Manager. A few things that matter for CTF authors: account :root in a resource policy does not authorize every IAM user (the caller still needs an identity policy Allow). SNS topics get no open default topic policy when IAM enforcement is on. S3 presigned GET URLs go through PreSignedUrlFilter for SigV4 query validation before bucket policy evaluation. AssumeRoleTrustPolicyEvaluator enforces trust policy conditions including sts:ExternalId.
In-Process Authorization
Not every AWS interaction goes through HTTP :4566. Step Functions, API Gateway AWS integrations, EventBridge rule targets, Pipes pollers, SNS-to-SQS delivery, Lambda event source mappings, and CloudTrail S3 delivery all invoke services in-process. floci-ctf covers these with two authorizers: InProcessIamAuthorizer checks execution roles on SFN/APIGW JSON-body SDK calls (missing roleArn is denied when enforcement is on), and InProcessTargetAuthorizer covers delivery paths including Pipes, Scheduler, EventBridge, SNS/S3/SES notifications, Lambda ESM pollers, Logs subscriptions, and service S3 delivery. When a role or service principal is present, identity and resource policies are evaluated the same way as HTTP.
Container Credential Hardening
Lambda, ECS tasks, and CodeBuild projects often receive environment variables at runtime. In permissive emulators, a challenge author can accidentally leak credentials into a container the participant controls. ContainerEnvHardening strips AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, and related bypass URIs from container env. When a task or function has an execution role, Floci injects link-local credential URIs on ports 9170 (ECS), 9171 (Lambda), and 9172 (CodeBuild) at http://169.254.170.2/v2/credentials/{token}. Participants who gain code execution inside a Lambda still need to pivot through the execution role, not read plaintext keys from env.
Forensic Lab Defaults
For forensics labs the Compose defaults also help: FLOCI_STORAGE_MODE=hybrid persists service state across restarts, and FLOCI_SERVICES_CLOUDTRAIL_AUDIT_ENABLED=true records management API calls with realistic requestParameters (including SQS queueUrl and messageBody on both Query and JSON 1.0 protocols). CloudTrail trail lifecycle, Config snapshots, S3 access logging, VPC flow logs, GuardDuty, and Security Hub ASFF import are available for live grading against provision-time answers.json. Players investigating a breach can use cloudtrail lookup-events, S3 access logs, and GuardDuty findings the same way they would against real AWS.
Typical Challenge Workflow
Players use the normal AWS CLI or SDKs. They list IAM permissions, abuse resource policies, assume roles, decrypt KMS-wrapped secrets, and move between services like they would in AWS. No test/test shortcut.
First Real Test
I tested floci-ctf at my university club before the fork felt "done". The challenge chain held up. The frustrating part was the onboarding page I set up alongside it. The challenge ran on port 4566, the onboarding webpage was on port 8080, and most people got them mixed up and spent their time poking at the wrong one. Only a few managed to set up their accounts properly. Not a floci-ctf bug, just a lesson about how much context players need before they even touch the environment.
What Is Not Done Yet
This fork is still a work in progress. Gaps live in the repo. Before you build a challenge around a weird edge case, check README.md and AGENTS.md rather than assuming parity with AWS or upstream Floci. The repo is at github.com/kangwijen/floci-ctf if you want to check it.