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

kangwijen

kangwijen

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

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

test/test works

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 docker-compose.yml

Strict IAM mode

N/A

Denies missing auth, unregistered keys, unknown action mappings

SigV4 validation

Off by default

Validates Authorization on every HTTP API call

Default credentials

test/test baked into image

None in docker/Dockerfile

Deployer principal

Optional floci/floci user

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

GetCallerIdentity

Often returns account :root

Returns the calling principal ARN

Resource policies

Not enforced on HTTP

S3, Lambda, SQS, SNS, KMS, Secrets Manager

Scoped IAM Resource

Mostly *

Per-service ARNs via ResourceArnBuilder

Internal routes

Open

FLOCI_CTF_HIDE_INTERNAL_ENDPOINTS returns 404

Container env

Player AWS_* can leak into Lambda/ECS/CodeBuild

ContainerEnvHardening strips credential keys

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.

mermaid

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

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 Resource

Derived from

dynamodb:GetItem

arn:aws:dynamodb:REGION:ACCOUNT:table/name

TableName in JSON body

sqs:ReceiveMessage

arn:aws:sqs:REGION:ACCOUNT:queue

QueueUrl

secretsmanager:GetSecretValue

arn:aws:secretsmanager:REGION:ACCOUNT:secret:name-*

SecretId

kms:Decrypt

arn:aws:kms:REGION:ACCOUNT:key/KEY-ID

KeyId or kms:v2: blob

s3:GetObjectVersion

arn:aws:s3:::bucket/key

bucket path + versionId

sts:AssumeRole

role ARN + trust policy

RoleArn + 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

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

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

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.