Skip to content

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

kangwijen

9 min read

Why I built floci-ctf

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 (opens in a new tab) 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.

Permissive defaults break CTFs

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 CTFs

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 AWSPermissive emulatorfloci-ctf
CredentialsReal IAM keys requiredtest/test worksMust issue real IAM keys
IAM policiesEnforcedSkipped by defaultEnforced
SigV4RequiredOptionalRequired
Cost~$300 for a few daysFreeFree
CleanupScripts + billing alertsNothingNothing

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 (opens in a new tab) lists every difference next to upstream.

floci-ctf changes those defaults for lab operators:

AreaUpstream Flocifloci-ctf
IAM enforcementOff by defaultOn in docker-compose.yml
Strict IAM modeN/ADenies missing auth, unregistered keys, unknown action mappings
SigV4 validationOff by defaultValidates Authorization on every HTTP API call
Default credentialstest/test baked into imageNone in docker/Dockerfile
Deployer principalOptional floci/floci userNot seeded when IAM enforcement is on
S3 presigned URLsHMAC secret or 12-digit account id in credentialSigV4 query auth, generator uses operator root AKIA
GetCallerIdentityOften returns account :rootReturns the calling principal ARN
Resource policiesNot enforced on HTTPS3, Lambda, SQS, SNS, KMS, Secrets Manager
Scoped IAM ResourceMostly *Per-service ARNs via ResourceArnBuilder
Internal routesOpenFLOCI_CTF_HIDE_INTERNAL_ENDPOINTS returns 404
Container envPlayerAWS_*can leak into Lambda/ECS/CodeBuildContainerEnvHardening strips credential keys
ForensicsOptionalCompose 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.

mermaid

Rendering diagram...

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.

mermaid

Rendering diagram...

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:

ActionPolicy ResourceDerived from
dynamodb:GetItemarn:aws:dynamodb:REGION:ACCOUNT:table/nameTableName in JSON body
sqs:ReceiveMessagearn:aws:sqs:REGION:ACCOUNT:queueQueueUrl
secretsmanager:GetSecretValuearn:aws:secretsmanager:REGION:ACCOUNT:secret:name-*SecretId
kms:Decryptarn:aws:kms:REGION:ACCOUNT:key/KEY-IDKeyId or kms:v2: blob
s3:GetObjectVersionarn:aws:s3:::bucket/keybucket path + versionId
sts:AssumeRolerole ARN + trust policyRoleArn + ExternalId conditions

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.

mermaid

Rendering diagram...

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.

mermaid

Rendering diagram...

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

mermaid

Rendering diagram...

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.

Known gaps

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 (opens in a new tab) and AGENTS.md (opens in a new tab) rather than assuming parity with AWS or upstream Floci. The repo is at github.com/kangwijen/floci-ctf (opens in a new tab) if you want to check it.