[AWS] 취약한 EC2 및 ALB 네트워크 구축[AWS] 취약한 EC2 및 ALB 네트워크 구축

[AWS] 취약한 EC2 및 ALB 네트워크 구축

생성 일시
Sep 6, 2026 05:20 PM
최종 편집 일시
Last updated September 10, 2026
태그
AWS
terraform
Infra
작성자
@김태형
Date

🎯 목표 설정

현재는 이전 글(보러 가기)에서 작성한 것처럼 Wazuh 사용을 위한 기본적인 네트워크 뼈대가 구축되어 있다. 하지만, 현재 구성만으로는 Wazuh Dashboard에 접속하여 서비스를 확인하는 것이 전부이고 특별한 Alert를 찾아볼 수는 없다.
최종 목표인 침해 사례를 적용해 공격부터 방어까지의 침해사고 대응과정을 직접 경험하기 위해 우리는 AWS Native 보안 로깅을 활성화하고, 이를 Wazuh Manager와 연동해주어야 한다.
위 과정을 하기 위해서는 우선 로깅을 할 대상이 필요하다. 현재 CloudTrail의 경우에는 AWS 계정을 대상으로 API 호출을 기록하는 것이기 때문에 이미 활성화(Management/Data Events 추적 중) 되어 있다.
VPC Flow Logs는 VPC, Subnet 또는 ENI를 대상으로 활성화 할 수 있는데, 팀 근두운 프로젝트에서는 ALB 및 Victim EC2에서 발생하는 네트워크 흐름을 분석하기 위해 관련 리소스 구축이 선행되어야 한다. 또한 WAF Logs를 수집하기 위해서는 ALB에 연결할 Web ACL 구성이 필요하다.
이번 단계에서는 실제 AWS 환경에서 공격 시뮬레이션 시 로깅 대상이 될 취약한 설정을 가진 EC2를 생성하고, EC2에 Nginx 기반 웹 서버를 설치한 후 ALB와 팀 근두운이 구매한 외부 도메인을 연결하여 외부에 공개하는 것을 목표로 한다.

🏛️ 목표 설계 아키텍처

flowchart TB
    INTER("Internet</br>(Client)")
    CERT("ACM(HTTPS 인증서)")
    SH("AWS Shield")
    RT("Route 53</br>service.kintoun.work")
    ALB("Internet-facing ALB")
    subgraph V["VPC 10.50.0.0/16"]
        TG("Target Group</br>Forward HTTP/80")
        subgraph AZ1["AZ: ap-northeast-2a"]
            subgraph SUB1["Public Subnet 10.50.110.0/24"]
                ALBA("ALB Node A")
            end
            subgraph SUB2["Public Subnet 10.50.40.0/24"]
                VIC("Victim EC2</br>Nginx: Port 80")
            end
        end
        subgraph AZ2["AZ: ap-northeast-2b"]
            subgraph SUB3["Public Subnet 10.50.120.0/24"]
                ALBB("ALB Node B")
            end
        end
    end

INTER --"DNS Query" --> RT -.->|"Alias A 레코드 대상 지정"| ALB
RT -.->|"ALB의 IP주소 응답"| INTER
INTER -- "HTTPS 443" --> ALB
CERT -.->|"TLS 인증서"| ALB
SH -.->|"DDoS 보호"| ALB
ALB -- "Listener로 수신" --> ALBA
ALB -- "Listener로 수신" --> ALBB
ALBA --> TG
ALBB --> TG
TG -- "사설 IP로 통신" --> VIC

🧑🏻‍🔧 취약한 EC2 설계

취약한 설정을 가진 EC2(이하 “Victim EC2”)는 단순 SSRF를 통해 EC2에 부여된 IAM Role의 임시 자격증명 탈취가 가능하도록 IMDSv1 설정을 채택하였다.
IMDS(Instance Metadata Service)는 EC2 인스턴스 내부에서 자기 자신의 설정 정보를 조회하는 서비스로, IMDSv1의 경우에는 Session-Token 인증 없이 GET 요청만으로 인스턴스의 메타데이터를 조회할 수 있다. 만일 SSRF 취약점이 있는 웹 애플리케이션이라면 공격자가 IMDS를 호출하여 정보를 탈취할 위험이 존재한다.
curl http://169.254.169.254 # IMDS 호출 명령 # IMDS 경로를 지정하면 해당 경로의 메타데이터를 얻을 수 있음 # ex) EC2 인스턴스 ID 정보 조회 curl http://169.254.169.254/latest/meta-data/instance-id
조회할 수 있는 메타데이터는 인스턴스 정보, 네트워크 및 보안 설정 정보, 자격 증명 및 권한 정보 등이 있는데, curl http://169.254.169.254/latest/meta-data/iam/security-credentials/<ROLE_NAME> 명령으로 자격 증명 및 권한 정보가 공격자에 의해 탈취당하면, 공격자는 해당 임시 자격증명을 통해 다른 AWS 리소스에 접근하여 정보 유출 또는 비용을 발생시키는 서비스를 활성화 하는 등의 악성 행위를 수행할 수 있게 된다.
IMDS로 액세스할 수 있는 메타데이터 - 인스턴스 메타데이터 서비스를 사용하여 인스턴스 메타데이터에 액세스 - - Amazon Elastic Compute Cloud
실제 운영 환경이라면, Session-Token 인증 기반의 IMDSv2 설정을 권장하고 있다.
# Terraform에서의 IMDS 설정 (EC2 생성 시 결정) metadata_options { http_tokens = "optional" # IMDSv1과 IMDSv2 모두 허용 } metadata_options { http_tokens = "required" # IMDSv2 }
근두운 인프라에서는 자격증명 탈취 공격 기법을 다루기 때문에, IMDSv1 설정을 적용하되, 의도치 않은 AWS API 악용을 방지하기 위해 Victim EC2의 IAM Role에 가짜 시크릿을 저장하고 있는 S3 버킷에 대한 s3:ListBucket, s3:GetObject만 가능하도록 권한과 리소스를 제한하였다.
[+] 공격자가 침투한 AWS 환경에 어떤 S3 버킷이 있는지 모른다는 상황을 가정하여 s3:ListAllMyBuckets 권한도 부여해 준다. 해당 권한은 AWS 계정 안의 모든 버킷 이름을 조회할 수 있다.(CLI 에서 aws s3 ls 가능)

🗺️ ALB 네트워크 구성

ALB는 AWS가 제공하는 Load Balancer 중 애플리케이션 트래픽을 분산하는 역할을 하는 Application Load Balancer이다. 일반적으로 AWS 클라우드 서비스를 활용하여 웹 애플리케이션을 운영하는 기업 환경에서는 웹을 외부에 공개하면서 EC2의 직접 노출을 차단하고, WAF나 Shield와 같은 보안 서비스를 연동하기 위해 ALB를 사용한다.
ALB의 주요 구성요소를 살펴보면 다음과 같다.
구분
의미
ALB
사용자의 HTTP/HTTPS 요청을 받는 진입점
Listener
ALB가 어떤 프로토콜과 포트에서 요청을 받을지 지정
Target Group
요청을 전달할 백엔드 대상 묶음
Target
실제 요청을 처리하는 대상(EC2, Lambda, 컨테이너 등)
Health Check
타겟 그룹 내 타겟이 정상인지 주기적으로 검사(GET /health)
우리 근두운 팀 프로젝트에서도 WAF와 Shield 사용을 위해 ALB를 구축하고, Victim EC2를 타겟 그룹으로 설정하여 Victim EC2가 서비스하는 웹 애플리케이션을 안전하게 외부 인터넷에 공개한다.
단, 보안 강화를 고려한 배치인 Private Subnet(Victim EC2) - Public Subnet(ALB) - IGW - 인터넷 구조가 아닌 Public Subnet(Victim EC2) - Public Subnet(ALB) - IGW - 인터넷 구조를 선택하였다.
Victim EC2에 SSM Session Manager를 통한 관리, EC2 인스턴스 내 패키지 설치 및 업데이트 등의 목적으로 Outbound 0.0.0.0/0 TCP 443 허용과 공인 IP가 필요한데, Private Subnet에 EC2가 위치할 경우 공인 IP가 없기 때문에 요구사항을 만족하지 못하기 때문이다.
NAT를 사용하면 Private Subnet에 위치한 EC2 인스턴스도 외부 통신이 가능하게 되지만, 팀 근두운 프로젝트에서는 NAT의 비용 문제(한 달 유지 시, 약 $42)로 운영하기가 힘들 것으로 판단하였고, 따라서 우리는 Public Subnet에 EC2를 배치하는 것으로 결정하였다.
이렇게 되면 EC2 인스턴스가 공인 IP를 가지게 되어, NAT 사용 없이 보안 그룹 아웃바운드 정책만으로 SSM 및 패키지 업데이트 목적을 달성할 수 있다. 하지만, 외부에서의 접근 시 ALB를 거치지 않고서 EC2에 직접 접근하는 것도 가능하게 된다. 이를 방지하고자 Victim EC2의 보안 그룹 Inbound 정책 설정 시 only ALB 보안 그룹 ID에서 들어오는 트래픽만 허용하도록 제한한다.
Victim EC2 보안 그룹의 Inbound 정책에 referenced_security_group_id 인자의 값을 ALB의 보안 그룹 ID로 지정
# Terraform에서의 SG 설정 resource "aws_security_group" "victim_sg" { # Victim EC2 보안 그룹 ... } resource "aws_security_group" "alb_sg" { # ALB 보안 그룹 ... } # Inbound 정책 설정(서로의 보안 그룹을 참조하도록 설정하여 ALB를 거쳐서만 EC2에 접근하도록 함) resource "aws_vpc_security_group_ingress_rule" "victim_from_alb" { security_group_id = aws_security_group.victim_sg.id referenced_security_group_id = aws_security_group.alb_sg.id }
그리고, ALB를 생성하게 되면, AWS에서 *.elb.amazonaws.com과 같은 고유한 DNS Name을 자동으로 부여 해준다. 외부 사용자는 이 주소를 브라우저에 입력하여 Victim EC2가 제공하는 웹 서비스에 접속할 수 있게 된다.
하지만, 해당 AWS 제공 도메인은 소유자가 AWS이기 때문에 사용자가 해당 이름에 대한 ACM 인증서를 발급받아 HTTPS 리스너에 연결할 수 없다. 따라서 브라우저에서 신뢰할 수 있는 HTTPS 통신을 구현하려면 사용자가 소유한 도메인이 필요하다.
팀 근두운은 별도 외부 도메인(kintoun.work)을 구매한 상황이라 이를 활용할 것이다.
현재 kintoun.work 루트 도메인은 팀 블로그에 적용되어 있기 때문에, service.kintoun.work라는 서브 도메인을 사용하여 ALB와 연결해 EC2의 웹 애플리케이션과 통신할 수 있도록 설계한다.
sequenceDiagram
    participant C as 사용자 브라우저
    participant D as DNS / Route 53
    participant A as Internet-facing ALB
    participant V as Victim EC2

    C->>D: ① service.kintoun.work의 주소 조회
    D-->>C: ② ALB의 현재 IP 주소 응답
    C->>A: ③ 응답받은 IP로 HTTPS 443 연결
    A->>V: ④ Target Group을 통해 HTTP 80 전달
    V-->>A: ⑤ Nginx 응답
    A-->>C: ⑥ HTTPS 응답

🧑🏻‍💻 Terraform 코드 작성

victim이라는 별도의 폴더에서 리소스별로 분리하여 테라폼 코드를 작성할 것이다.
먼저, 이전의 platform 설계 시 구축했던 VPC와 IGW를 그대로 사용하기 때문에 terraform_remote_state를 활용해 platform/network의 state를 보관하는 S3 백엔드 버킷에서 필요한 data를 가져와야 한다.
다른 모듈에서 생성한 state data를 가져오기 위해서는 대상 모듈에서 output을 통해 .tfstate 파일의 outputs 블록에 가져올 값들을 출력 해야한다.
# platform/network 모듈 output "main_vpc_id" { value = aws_vpc.main_vpc.id } # 이렇게 platform/network 모듈의 데이터 사용이 필요하면, 위와 같이 output을 통해 정의하고, # terraform apply를 하면, terraform.tfstate의 outputs에 data를 관리하게 된다.
그리고, 사용하려는 모듈에서 terraform_remote_state를 통해 다른 모듈의 state를 가져온다.
# victim 모듈 data "terraform_remote_state" "network" { backend = "s3" config = { bucket = "kintoun-tfstate" key = "platform/network/terraform.tfstate" # 가져올 객체(키) 지정 region = "ap-northeast-2" } }
그러면, 이제 victim 모듈에서 data.terraform_remote_state.network.outputs.main_vpc_id로 platform/network 모듈에서 생성했던 main_vpc의 id를 사용할 수 있게 된다.
platform/network/outputs.tf
output "main_vpc_id" { description = "Victim 모듈에서 활용할 VPC ID(wazuh와 동일한 VPC 공유)" value = aws_vpc.main_vpc.id } output "main_igw_id" { description = "Victim 모듈에서 활용할 IGW ID(Wazuh와 동일한 IGW 사용)" value = aws_internet_gateway.main_igw.id }
platform/wazuh/outputs.tf
output "ssm_policy_arn" { description = "Victim EC2 SSM 서비스 사용을 위한 권한 정책 ARN" value = aws_iam_policy.ssm_role.arn } output "wazuh_sg_agent_id" { description = "Victim 모듈에서 Wazuh Agent용 ingress/egress 정책 적용" value = aws_security_group.wazuh_sg_agent.id }
victim 모듈 Terraform 코드 구성
  • versions.tf - terraform과 aws provider 버전을 명시하는 코드
  • providers.tf - provider를 정의하는 코드
  • variables.tf - victim 모듈에서 사용할 변수를 정의하는 코드
  • backend.tf - terraform apply 시 테라폼이 관리할 state를 보관하는 곳을 정의하는 코드
  • remote_state.tf - 다른 모듈의 state data를 사용하기 위해 원격 저장소를 불러오는 코드
  • main.tf - ALB와 Victim EC2가 위치할 서브넷, VIctim EC2 생성, 가짜 시크릿 파일을 보관할 S3 Bucket 생성을 정의하는 코드
  • iam.tf - Victim EC2가 가짜 시크릿 파일을 보관하는 S3 Bucket에 대한 Access 권한을 정의하는 코드
  • web.tf - ALB를 생성하고, ALB가 HTTPS 데이터를 전달할 타겟 그룹을 정의, ACM을 통해 도메인에 대한 SSL/TLS 인증서를 발급・검증하고, ALB에 도메인을 연결하는 코드
  • sg.tf - ALB와 Victim EC2의 보안 그룹을 생성하고, 인/아웃바운드 정책을 정의하는 코드
versions.tf
terraform { required_version = ">= 1.11" required_providers { aws = { source = "hashicorp/aws" version = ">= 5.31" } random = { source = "hashicorp/random" version = "~> 3.6" } } }
providers.tf
provider "aws" { region = "ap-northeast-2" }
variables.tf
# ======================================================= # Project Name 변수 (tags 에 활용) # ======================================================= variable "project_name" { description = "tags 에 명시할 프로젝트 이름" type = string default = "kintoun-secops-infra" } # ======================================================= # IAM Role 경로 접두사 # ======================================================= variable "iam_role_path_prefix" { description = "IAM Role 경로 접두사" type = string default = "/project/" validation { condition = startswith(var.iam_role_path_prefix, "/project/") && endswith(var.iam_role_path_prefix, "/") error_message = "iam_role_path_prefix는 /project/ 로 시작하고 / 로 끝나야 한다." } } # ======================================================= # 팀 근두운 권한경계 # ======================================================= variable "permissions_boundary_arn" { description = "IAM Role에 붙일 권한경계" type = string default = "arn:aws:iam::446413909569:policy/KintounGuardrailBoundary" } # ======================================================= # Victim S3 Bucket 이름 # ======================================================= variable "victim_bucket_name" { description = "공격당할 S3 버킷 이름" type = string default = "whs4-kintoun-victim-secrets" } # ======================================================= # Public Subnet 주소 범위 변수 (for Victim EC2) # ======================================================= variable "victim_public_subnet_cidr" { description = "Victim EC2가 위치할 Public Subnet IPv4 주소 범위" type = string default = "10.50.40.0/24" validation { condition = can(cidrnetmask(var.victim_public_subnet_cidr)) error_message = "public_subnet_cidr는 올바른 IPv4 CIDR 형식 이어야 한다." } } # ======================================================= # Public Subnet 주소 범위 변수 (for ALB) # ======================================================= variable "alb_public_subnets" { description = "ALB가 사용할 서로 다른 AZ의 Public Subnets" type = map(object({ cidr_block = string availability_zone = string })) default = { a = { cidr_block = "10.50.110.0/24" availability_zone = "ap-northeast-2a" } b = { cidr_block = "10.50.120.0/24" availability_zone = "ap-northeast-2b" } } } # ======================================================= # ALB <-> Victim EC2 통신용 애플리케이션 포트 변수 # ======================================================= variable "victim_app_port" { description = "Victim EC2에서 사용할 웹 애플리케이션 포트" type = number default = 80 # Nginx/Apache 서버 } # ======================================================= # ALB에 연결할 전체 서비스 도메인(FQDN) 변수 # ======================================================= variable "service_domain" { description = "ALB에 연결할 서비스 FQDN" type = string default = "service.kintoun.work" } # ======================================================= # 구입한 루트 도메인 변수 # ======================================================= variable "hosted_zone_name" { description = "Route 53에 등록된 Domain 이름" type = string default = "kintoun.work" }
backend.tf
# ======================================================= # platform/ 모듈이 apply된 이후에 apply # ======================================================= terraform { backend "s3" { bucket = "kintoun-tfstate" key = "platform/victim/terraform.tfstate" region = "ap-northeast-2" use_lockfile = true encrypt = true } }
remote_state.tf
# ======================================================= # platform/wazuh 모듈의 tfstate에서 가져오기 # ======================================================= data "terraform_remote_state" "wazuh" { backend = "s3" config = { bucket = "kintoun-tfstate" key = "platform/wazuh/terraform.tfstate" region = "ap-northeast-2" } } # ======================================================= # platform/network 모듈의 tfstate에서 가져오기 # ======================================================= data "terraform_remote_state" "network" { backend = "s3" config = { bucket = "kintoun-tfstate" key = "platform/network/terraform.tfstate" region = "ap-northeast-2" } } # ======================================================= # platform 모듈의 main_vpc_id, ssm_service 권한 정책 # ======================================================= locals { main_vpc_id = data.terraform_remote_state.network.outputs.main_vpc_id main_igw_id = data.terraform_remote_state.network.outputs.main_igw_id ssm_policy_arn = data.terraform_remote_state.wazuh.outputs.ssm_policy_arn wazuh_sg_agent_id = data.terraform_remote_state.wazuh.outputs.wazuh_sg_agent_id }
main.tf
# ================================================================== # =====================Victim EC2를 위한 Network 설계================== # ================================================================== # ======================================================= # Public Subnet 생성 for ALB(서로 다른 AZ에 배치) # ======================================================= resource "aws_subnet" "alb_public_subnets" { for_each = var.alb_public_subnets vpc_id = local.main_vpc_id cidr_block = each.value.cidr_block availability_zone = each.value.availability_zone map_public_ip_on_launch = false tags = { Name = "${var.project_name}-alb-public-subnet-${each.key}" Role = "ALB" ManagedBy = "Terraform" } } # ======================================================= # Public Subnet 생성 for Victim EC2 # ======================================================= resource "aws_subnet" "victim_public_subnet" { vpc_id = local.main_vpc_id cidr_block = var.victim_public_subnet_cidr availability_zone = "ap-northeast-2a" map_public_ip_on_launch = false tags = { Name = "${var.project_name}-victim-public-subnet" Role = "Victim" ManagedBy = "Terraform" } } # ======================================================= # Victim Subnet and ALB Subnets 용 라우트 테이블 생성 및 라우팅 # ======================================================= resource "aws_route_table" "victim_route_table" { vpc_id = local.main_vpc_id route { cidr_block = "0.0.0.0/0" gateway_id = local.main_igw_id } tags = { Name = "${var.project_name}-victim-public-route-table" Role = "Victim" ManagedBy = "Terraform" } } resource "aws_route_table_association" "victim_public" { subnet_id = aws_subnet.victim_public_subnet.id route_table_id = aws_route_table.victim_route_table.id } resource "aws_route_table_association" "alb_public_subnets" { for_each = aws_subnet.alb_public_subnets subnet_id = each.value.id route_table_id = aws_route_table.victim_route_table.id } # ================================================================== # ====================Victim EC2 생성================================ # ================================================================== # ======================================================= # Victim EC2 생성 # ======================================================= data "aws_ssm_parameter" "amazon_linux_2023" { name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64" } resource "aws_instance" "victim_ec2" { ami = data.aws_ssm_parameter.amazon_linux_2023.value instance_type = "t3.micro" subnet_id = aws_subnet.victim_public_subnet.id vpc_security_group_ids = [ aws_security_group.victim_sg.id, aws_security_group.victim_sg_agent.id ] iam_instance_profile = aws_iam_instance_profile.victim_ec2_profile.name associate_public_ip_address = true user_data = templatefile("${path.module}/files/victim-install.sh", { victim_app_port = var.victim_app_port }) user_data_replace_on_change = false root_block_device { volume_type = "gp3" iops = 3000 throughput = 125 encrypted = true delete_on_termination = true } metadata_options { http_endpoint = "enabled" http_tokens = "optional" # IMDSv1과 IMDSv2 모두 허용 http_put_response_hop_limit = 2 # docker 환경에서도 IMDS 사용 instance_metadata_tags = "disabled" # 태그정보는 노출되지 않도록하여 공격 표면 최소화" } lifecycle { ignore_changes = [ ami, associate_public_ip_address ] } tags = { Name = "${var.project_name}-victim-ec2" ManagedBy = "Terraform" } } # ================================================================== # =================== 공격 대상 S3 Bucket 생성 ======================== # ================================================================== # ====================================================== # 공격용 S3 Bucket 생성 # ====================================================== resource "random_string" "suffix" { length = 6 special = false upper = false } resource "aws_s3_bucket" "victim" { bucket = "${var.victim_bucket_name}-${random_string.suffix.result}" force_destroy = true tags = { Name = "${var.project_name}-victim-bucket" ManagedBy = "Terraform" } } # ====================================================== # 공격용 S3 Bucket BPA # ====================================================== resource "aws_s3_bucket_public_access_block" "victim" { bucket = aws_s3_bucket.victim.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true } # ====================================================== # 공격용 S3 Bucket SSE # ====================================================== resource "aws_s3_bucket_server_side_encryption_configuration" "victim" { bucket = aws_s3_bucket.victim.id rule { apply_server_side_encryption_by_default { # S3 관리형 키이기 때문에, # s3:GetObject에 대한 권한만 있으면 S3가 객체를 자동으로 복호화하여 반환 sse_algorithm = "AES256" } } } # ====================================================== # 공격용 S3 Bucket에 가짜 Secrets 업로드 # ====================================================== /*locals { upload_dir = "${path.module}/files/secrets-file" upload_files = fileset(local.upload_dir, "**") # 하위 폴더 포함 모든 파일 찾기 } resource "aws_s3_object" "secrets_upload" { for_each = local.upload_files bucket = aws_s3_bucket.victim.id key = each.value source = "${local.upload_dir}/${each.value}" # 암호화 방식이나 S3 ETag 형식에 영향받지 않고 파일변경 감지 - 파일 내용 변경 시 객체 다시 업로드 source_hash = filemd5("${local.upload_dir}/${each.value}") }*/ # ====================================================== # 기존 실습 파일을 S3에 보존하고 Terraform 관리만 해제 # ====================================================== removed { from = aws_s3_object.secrets_upload lifecycle { destroy = false } }
files/victim-install.sh
#!/bin/bash set -Eeuo pipefail umask 022 BOOTSTRAP_STATE_DIR="/var/lib/victim-bootstrap" BOOTSTRAP_LOG="/var/log/victim-bootstrap.log" install -d -m 755 "$${BOOTSTRAP_STATE_DIR}" if [[ -f "$${BOOTSTRAP_STATE_DIR}/complete" ]]; then exit 0 fi exec > >(tee -a "$${BOOTSTRAP_LOG}") 2>&1 trap 'echo "[ERROR] Victim bootstrap failed at line $${LINENO}"' ERR echo "[INFO] Victim bootstrap started at $(date --iso-8601=seconds)" # amazon linux 패키지 업데이트 및 최신 버전 설치 dnf upgrade --refresh -y # ======================================================= # Nginx 웹 서버 설치 및 설정 # ======================================================= dnf install -y nginx # Terraform이 포트를 채운 뒤, 아래 내용을 Nginx 설정 파일로 저장 cat > /etc/nginx/nginx.conf << 'NGINX' user nginx; worker_processes auto; # 오류 로그와 실행 중인 PID 저장 error_log /var/log/nginx/error.log; pid /run/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; server { # Terraform에서 전달 받은 포트로 IPv4 IPv6 요청을 받아 헬스체크 처리 listen ${victim_app_port} default_server; listen [::]:${victim_app_port} default_server; server_name _; # 기존 스크립트가 생성하는 index.html과 health 파일의 위치 root /usr/share/nginx/html; index index.html; location / { # 요청 경로에 해당하는 파일을 제공하고 없으면 404 반환 try_files $uri $uri/ =404; } } } NGINX # Nginx가 웹 파일을 제공할 기본 디렉터리를 확인 및 생성 install -d -m 755 /usr/share/nginx/html # 기본 페이지 생성 cat > /usr/share/nginx/html/index.html <<'EOF' <!DOCTYPE html> <html lang="ko"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Kintoun Project</title> </head> <body> <h1>Kintoun Project</h1> </body> </html> EOF # root만 수정할 수 있도록 권한 설정(read는 all) chmod 644 /usr/share/nginx/html/index.html # ======================================================= # ALB Health Check 경로 생성 # ======================================================= # ALB가 Get /health 요청을 보냈을 때 HTTP 200 응답을 받을 수 있도록 정적 파일 생성 printf '%s\n' 'healthy' > /usr/share/nginx/html/health chmod 644 /usr/share/nginx/html/health # Nginx 설정 문법 오류 확인 nginx -t systemctl enable --now nginx systemctl is-active --quiet nginx # 로컬에서 /health 요청을 보내 HTTP 오류가 있는지 확인 curl \ --fail \ --silent \ --show-error \ --max-time 10 \ http://127.0.0.1:${victim_app_port}/health # SSM Agent 실행 systemctl enable --now amazon-ssm-agent systemctl is-active --quiet amazon-ssm-agent # 모든 설치와 서비스 검증이 끝났음을 기록 touch "$${BOOTSTRAP_STATE_DIR}/complete" echo "[INFO] Victim bootstrap completed at $(date --iso-8601=seconds)"
iam.tf
# ======================================================= # IAM Role 생성 for Victim EC2 (신뢰 정책 + IAM Role) # ======================================================= data "aws_iam_policy_document" "victim_role" { statement { sid = "AssumeRoleForVictimEC2" effect = "Allow" actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = ["ec2.amazonaws.com"] } } } resource "aws_iam_role" "victim_role" { name = "${var.project_name}-victim-role" path = "${var.iam_role_path_prefix}victim/" assume_role_policy = data.aws_iam_policy_document.victim_role.json permissions_boundary = var.permissions_boundary_arn tags = { Name = "${var.project_name}-victim-role" ManagedBy = "Terraform" } } # ======================================================= # S3 Bucket Access 정책 for Victim EC2 (권한 정책) # ======================================================= data "aws_iam_policy_document" "s3_access" { statement { sid = "S3ListVictimBucket" effect = "Allow" actions = ["s3:ListBucket"] # 지정한 버킷 내부의 객체 키 목록 조회 resources = [ aws_s3_bucket.victim.arn ] condition { test = "ArnEquals" variable = "ec2:SourceInstanceARN" values = [ aws_instance.victim_ec2.arn ] } } statement { sid = "S3ListAccountBuckets" effect = "Allow" actions = ["s3:ListAllMyBuckets"] # aws s3 ls 가능(계정 내 모든 버킷 이름 목록 조회) resources = ["*"] condition { test = "ArnEquals" variable = "ec2:SourceInstanceARN" values = [ aws_instance.victim_ec2.arn ] } } statement { sid = "S3GetVictimObjects" effect = "Allow" actions = ["s3:GetObject"] resources = [ "${aws_s3_bucket.victim.arn}/*" ] condition { test = "ArnEquals" variable = "ec2:SourceInstanceARN" values = [ aws_instance.victim_ec2.arn ] } } } resource "aws_iam_policy" "s3_access" { name = "${var.project_name}-s3-access-policy-for-victim-ec2" description = "Allow S3 Read for Victim EC2" policy = data.aws_iam_policy_document.s3_access.json tags = { Name = "${var.project_name}-s3-access-policy-for-victim-ec2" ManagedBy = "Terraform" } } # ======================================================= # S3 Bucket Access 권한 정책 Victim EC2 Role에 연결 # ======================================================= resource "aws_iam_role_policy_attachment" "s3_access" { role = aws_iam_role.victim_role.name policy_arn = aws_iam_policy.s3_access.arn } # ======================================================= # Victim EC2에 IAM Role 연결을 위한 Profile 생성 # ======================================================= resource "aws_iam_instance_profile" "victim_ec2_profile" { name = "${var.project_name}-victim-ec2-profile" role = aws_iam_role.victim_role.name } # ======================================================= # SSM 서비스 정책 for Victim EC2(서버 관리용) # ======================================================= resource "aws_iam_role_policy_attachment" "ssm_service" { role = aws_iam_role.victim_role.name policy_arn = local.ssm_policy_arn }
web.tf
# 웹 접속을 위한 ALB 및 DNS 설정 # ======================================================= # ALB 생성 # ======================================================= resource "aws_lb" "alb" { name = "${var.project_name}-alb" internal = false # 인터넷과 연결(Public) / true(VPC 내부, Private) load_balancer_type = "application" drop_invalid_header_fields = true # 로드 밸런서로 들어오는 유효하지 않은 헤더 삭제 security_groups = [ aws_security_group.alb_sg.id ] subnets = [ for subnet in aws_subnet.alb_public_subnets : subnet.id ] tags = { Name = "${var.project_name}-alb" ManagedBy = "Terraform" } } # ======================================================= # ALB가 요청을 전달할 서버 타겟 그룹 생성 # ======================================================= resource "aws_lb_target_group" "alb_tg" { name = "${var.project_name}-alb-tg" port = var.victim_app_port protocol = "HTTP" target_type = "instance" vpc_id = local.main_vpc_id health_check { enabled = true path = "/health" protocol = "HTTP" port = "traffic-port" # 대상 서버가 트래픽을 받고 있는 실제 서비스 포트를 그대로 사용 matcher = "200-399" # 헬스체크가 정상이라고 판단할 HTTP 응답코드 범위 healthy_threshold = 2 # 비정상이었던 서버가 정상으로 판정받기 위해 연속 성공해야하는 횟수 unhealthy_threshold = 3 # 정상이었던 서버가 비정상으로 판정받기 위해 연속 실패해야하는 횟수 interval = 30 # 초 단위 timeout = 5 # 초 단위 } tags = { Name = "${var.project_name}-alb-tg" ManagedBy = "Terraform" } } # ======================================================= # ALB 타겟 그룹 등록 (Victim EC2) # ======================================================= resource "aws_lb_target_group_attachment" "alb_tg_victim" { target_group_arn = aws_lb_target_group.alb_tg.arn target_id = aws_instance.victim_ec2.id port = var.victim_app_port } # ======================================================= # HTTP -> HTTPS Redirect 설정 (ACM 인증서 먼저 셋팅) # ======================================================= resource "aws_lb_listener" "http" { load_balancer_arn = aws_lb.alb.arn port = 80 protocol = "HTTP" default_action { type = "redirect" redirect { protocol = "HTTPS" port = "443" status_code = "HTTP_301" } } } # ================================================================== # ====================== DNS 설정 및 연결 ============================= # ================================================================== # ======================================================= # ACM을 통해 HTTPS 인증서 요청 # ======================================================= resource "aws_acm_certificate" "victim" { domain_name = var.service_domain validation_method = "DNS" lifecycle { create_before_destroy = true # 인증서 갱신 시 기존 인증서 유지하면서 갱신, 갱신 후 기존 인증서 삭제 } tags = { Name = "${var.project_name}-victim-certificate" ManagedBy = "Terraform" } } # ======================================================= # 기존에 생성되어 있는 Route 53 호스팅 영역 조회 후 불러오기 # ======================================================= data "aws_route53_zone" "kintoun" { name = var.hosted_zone_name private_zone = false } # ======================================================= # ACM 검증을 위한 DNS CNAME 레코드 생성 # ======================================================= resource "aws_route53_record" "acm_validation" { for_each = { for option in aws_acm_certificate.victim.domain_validation_options : option.domain_name => { name = option.resource_record_name type = option.resource_record_type record = option.resource_record_value } } allow_overwrite = true # 동일한 인증서 검증 레코드가 있으면 갱신(인증서 갱신 및 테라폼 replace 될 때 최신 정보로 덮어씀) zone_id = data.aws_route53_zone.kintoun.zone_id name = each.value.name type = each.value.type records = [each.value.record] ttl = 300 } # ======================================================= # ACM 인증서 검증 프로세스 완료 및 대기 # ======================================================= resource "aws_acm_certificate_validation" "victim" { certificate_arn = aws_acm_certificate.victim.arn validation_record_fqdns = [ for record in aws_route53_record.acm_validation : record.fqdn ] } # ======================================================= # ALB에 도메인 연결 # ======================================================= resource "aws_route53_record" "alb" { zone_id = data.aws_route53_zone.kintoun.zone_id name = var.service_domain type = "A" alias { name = aws_lb.alb.dns_name zone_id = aws_lb.alb.zone_id evaluate_target_health = true } } # ======================================================= # 인증서 발급 후, ALB의 HTTPS 리스너에 인증서 연결 # ======================================================= resource "aws_lb_listener" "https" { load_balancer_arn = aws_lb.alb.arn port = 443 protocol = "HTTPS" certificate_arn = aws_acm_certificate_validation.victim.certificate_arn # HTTPS 요청을 Target Group으로 전달 default_action { type = "forward" target_group_arn = aws_lb_target_group.alb_tg.arn } }
sg.tf
# ======================================================= # Security Group 생성 for ALB # ======================================================= resource "aws_security_group" "alb_sg" { name = "${var.project_name}-alb-sg" description = "Security Group for ALB" vpc_id = local.main_vpc_id tags = { Name = "${var.project_name}-alb-sg" ManagedBy = "Terraform" } } resource "aws_vpc_security_group_ingress_rule" "alb_https" { security_group_id = aws_security_group.alb_sg.id cidr_ipv4 = "0.0.0.0/0" from_port = 443 to_port = 443 ip_protocol = "tcp" description = "Allow HTTPS from Internet" } resource "aws_vpc_security_group_ingress_rule" "alb_http" { security_group_id = aws_security_group.alb_sg.id cidr_ipv4 = "0.0.0.0/0" from_port = 80 to_port = 80 ip_protocol = "tcp" description = "Allow HTTP for HTTPS Redirect" } resource "aws_vpc_security_group_egress_rule" "alb_to_victim" { security_group_id = aws_security_group.alb_sg.id referenced_security_group_id = aws_security_group.victim_sg.id from_port = var.victim_app_port to_port = var.victim_app_port ip_protocol = "tcp" description = "Forward ALB traffic to Victim" } # ======================================================= # Security Group 생성 for Victim EC2 (ALB 통신 및 외부 통신) # ======================================================= resource "aws_security_group" "victim_sg" { name = "${var.project_name}-victim-sg" description = "Security Group for Victim EC2" vpc_id = local.main_vpc_id tags = { Name = "${var.project_name}-victim-sg" ManagedBy = "Terraform" } } resource "aws_vpc_security_group_ingress_rule" "victim_from_alb" { security_group_id = aws_security_group.victim_sg.id referenced_security_group_id = aws_security_group.alb_sg.id from_port = var.victim_app_port to_port = var.victim_app_port ip_protocol = "tcp" description = "Allow Application traffic only from ALB" } resource "aws_vpc_security_group_egress_rule" "victim_sg_ssm" { security_group_id = aws_security_group.victim_sg.id cidr_ipv4 = "0.0.0.0/0" from_port = 443 to_port = 443 ip_protocol = "tcp" description = "Allow SSM Agent and Package Install" } # ======================================================= # Security Group 생성 for Victim EC2 (Wazuh Agent용) # ======================================================= resource "aws_security_group" "victim_sg_agent" { name = "${var.project_name}-victim-sg-agent" description = "Security Group for Victim EC2 with Wazuh Agent" vpc_id = local.main_vpc_id tags = { Name = "${var.project_name}-victim-sg-agent" ManagedBy = "Terraform" } } resource "aws_vpc_security_group_egress_rule" "victim_to_wazuh_1514" { security_group_id = aws_security_group.victim_sg_agent.id referenced_security_group_id = local.wazuh_sg_agent_id from_port = 1514 to_port = 1514 ip_protocol = "tcp" description = "Allow Wazuh Agent Logging" } resource "aws_vpc_security_group_egress_rule" "victim_to_wazuh_1515" { security_group_id = aws_security_group.victim_sg_agent.id referenced_security_group_id = local.wazuh_sg_agent_id from_port = 1515 to_port = 1515 ip_protocol = "tcp" description = "Allow Wazuh Agent Enrollment" } # ======================================================= # Security Group 정책 연결 for Wazuh EC2 (Wazuh Agent용) # ======================================================= resource "aws_vpc_security_group_ingress_rule" "wazuh_from_victim_1514" { security_group_id = local.wazuh_sg_agent_id referenced_security_group_id = aws_security_group.victim_sg_agent.id from_port = 1514 to_port = 1514 ip_protocol = "tcp" description = "Allow Wazuh Agent Logging from Victim" } resource "aws_vpc_security_group_ingress_rule" "wazuh_from_victim_1515" { security_group_id = local.wazuh_sg_agent_id referenced_security_group_id = aws_security_group.victim_sg_agent.id from_port = 1515 to_port = 1515 ip_protocol = "tcp" description = "Allow Wazuh Agent Enrollment from Victim" }

🚥 Trivy로 Misconfiguration 점검

이번 Terraform 코드도 설정오류가 있는지 확인을 위해 Trivy도구를 사용해 코드를 스캔하였다.
notion imagenotion image
 
main.tf 4건, sg.tf 1건, web.tf 2건으로 Misconfigurations이 총 7건 탐지된 것을 확인할 수 있다.
먼저 main.tf 코드에서의 Misconfigurations을 확인해 보면,
  1. [HIGH] IMDS 접근 시 토큰 요구 필요
notion imagenotion image
IMDS 액세스 시 세션 토큰을 요구하지 않는 IMDSv1 버전을 사용하고 있어 보안 경고를 일으켰다. 해당 설정은 취약한 EC2 설계 목차에서 설명한 것처럼 의도된 취약 설정이기 때문에, 수용하는 것으로 한다.
  1. [Low] S3 버킷에 대한 로깅 비활성화
notion imagenotion image
해당 S3 버킷은 공격 시나리오에서 권한 탈취 및 객체 조회를 실습하기 위한 전용 버킷이며, 실제 인증정보나 민감정보가 아닌 가짜 시크릿 파일만 저장하고 있다. Victim EC2의 IAM Role에 해당 버킷에 대한 최소 접근 권한만 부여한 상태로 운영한다. 그리고, 해당 버킷에 대한 조회 및 데이터 탈취의 경우에는 CloudTrail Data Events를 통해 기록하고 있기 때문에 해당 보안 위험은 수용하는 것으로 한다.
  1. [MEDIUM] S3 버킷에 대한 버전 관리 비활성화
notion imagenotion image
앞서 설명한 것처럼, 해당 S3 버킷은 공격 시나리오에서 데이터 유출을 위한 일회성 리소스이며, 이 버킷에 접근하는 Victim EC2의 IAM Role에는 s3:ListBucket과 s3:GetObject 권한만 허용해 두었다. 따라서, 이전 버전을 보존해야 하는 요구사항이 없어 보안 위험을 수용하는 것으로 한다.
  1. [HIGH] S3 버킷 암호화에 고객 관리형 키(KMS) 사용 권장
notion imagenotion image
마찬가지로, Victim EC2의 IAM Role 자격증명이 탈취되어 S3 버킷 내 데이터 유출이 발생하는 공격 시나리오 적용이 목표이다. KMS 암호화 방식은 kms:Decrypt 권한도 있어야 객체를 읽을 수 있어, S3 권한과 암호화 키 권한을 별도로 분리할 수가 있다. 하지만, S3 버킷의 객체 액세스 권한을 가진 IAM Role 자격증명 탈취를 실습으로 하기 때문에, KMS 암호화 방식을 적용하여도 실습을 위해 Victim EC2의 IAM Role에 kms:Decrypt 권한을 부여해줘야 하는 상황이다. 따라서, 우리는 실습 목적으로 SSE-S3 암호화 방식을 수용한다.
다음은 sg.tf 코드의 Misconfigurations을 확인해 본다.
notion imagenotion image
[CRITICAL] 보안 그룹의 아웃바운드 정책이 “0.0.0.0/0”를 허용하고 있어 보안 경고가 발생하였다. 해당 설정은 이전 과정 Wazuh 인프라 구축 때와 같은 목적(Victim EC2 패키지 설치 및 SSM Session Manager 관리)으로 위험을 수용한다.
마지막으로 web.tf 코드의 Misconfigurations을 확인해 보면,
  1. [HIGH] 유효하지 않은 헤더 삭제 필요
notion imagenotion image
https://avd.aquasec.com/misconfig/aws-0052 에서 해당 Misconfiguration을 보면, 로드 밸런서를 생성하는 과정에서 unknown or invalid 헤더가 대상에 전달되지 않도록 삭제해주는 옵션을 권장하고 있다. 이를 악용한 공격 실습을 계획하고 있지 않기 때문에 조치사항을 적용하였다.
# web.tf resource "aws_lb" "alb" { + drop_invalid_header_fields = true }
  1. [HIGH] 로드 밸런서가 인터넷에 공개 되어 있음
notion imagenotion image
https://avd.aquasec.com/misconfig/aws-0053 에서 확인해 보면, 로드 밸런서가 인터넷에 공개되어 있어 위험하다는 것이다. Trivy 검사 도구는 해당 ALB가 공개 웹 서비스인지, 실수로 노출된 내부 서비스인지 판단할 수 없기 때문에 이러한 보안 경고를 일으킨 것으로 보인다. 우리는 인프라 설계 목적상 service.kintoun.work 도메인을 통해 외부 사용자가 Victim EC2의 웹 애플리케이션 서비스에 접속해야 하기 때문에, 보안 위험을 수용하는 것으로 한다. 단, 보안 그룹에서 80, 443포트의 접근만 허용하는 것으로 강제한다.

💁 마무리

이번 과정에서 설계한 Terraform 코드로 인프라의 구색을 어느 정도는 갖추게 되었다. 이전 단계에서 설계했던 Wazuh SIEM에 더해 Victim EC2 + ALB + DNS 연결을 통해 웹 애플리케이션을 실제로 운영하는 것처럼 구축하였다.
다음 단계에서는 AWS 인프라 내에서 발생하는 각종 보안 이벤트와 로그를 Wazuh SIEM에서 중앙 수집할 수 있도록 연동하는 과정을 진행할 것이다. 그렇게 되면, 침해 사례를 적용해 실제 공격 시뮬레이션을 통해 침해사고 대응 과정을 직접 경험하고, 그 과정에서 발생하는 계층별 로그를 분석할 수 있을 것으로 기대한다.