Sitemap

Beyond the Default VPC: Creating a Custom CTF Environment with Terraform

25 min read2 days ago

--

I have been asked by a penetration tester friend of mine to help set up the infrastructure for a Capture the Flag (CTF) game.

There will be an intentionally vulnerable server which is hosted in the cloud which he wants his friends to attempt to hack into.

Rather than using the default VPC I thought this would be a good time to learn more about provisioning VPCs, route tables, subnets by using Terraform instead.

Users will use WireGuard to VPN into the public subnet, and from there they will be able to access the target (10.0.0.10).

Below is a diagram of what I ended up creating:

Press enter or click to view image in full size
Press enter or click to view image in full size
terraform graph | dot -Tsvg > graph.svg

TLDR — THE FINISHED CONFIGURATION FILES

If you just want the final configuration files, here they are:

Press enter or click to view image in full size

PROVIDERS.TF:

provider "aws" {
region = var.aws_region
}

VARIABLES.TF:

variable "aws_region" {
default = "ap-southeast-2"
description = "The AWS region were our resources will reside."
}

variable "instance_type" {
default = "t2.micro"
description = "The instance type of the EC2 instance."
}

variable "ami_id" {
default = "ami-0ba8d27d35e9915fb"
description = "The AMI ID of the EC2 insance."
}

variable "az" {
default = "ap-southeast-2a"
}

variable "my_public_key" {
description = "The public key which will be imported into aws_instance.ctf_EC2_vpn"
}

variable "your_public_ip_address" {
description = "Your actual public IP address. Eg) 1.2.3.4/32 "
}

variable "friends_public_key" {
description = "Enter your friend's public key here: "
  • When you run terraform apply it will prompt you for your public key as well as your public IP address. This will be used to restrict SSH access to only your IP address.

VERSIONS.TF:

terraform {
required_version = ">= 0.12"

required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}

MAIN.TF:

## Create the VPC ##

resource "aws_vpc" "ctf_vpc" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "ctf_vpc"
}
}

## Create the private subnet ##

resource "aws_subnet" "ctf_private_subnet" {
vpc_id = aws_vpc.ctf_vpc.id
cidr_block = "10.0.0.0/24"
availability_zone = var.az
tags = {
Name = "ctf_private_subnet"
}
}

## Now to create the security group for the PRIVATE subnet ##

resource "aws_security_group" "ctf_sg_private_subnet" {
name = "sg_private_subnet"
description = "Allow access from the public subnet only."
vpc_id = aws_vpc.ctf_vpc.id
tags = {
Name = "ctf_sg_private_subnet"
}
}

## Now to allow SSH from the private subnet ##

resource "aws_vpc_security_group_ingress_rule" "Allow_access_from_public_subnet" {
security_group_id = aws_security_group.ctf_sg_private_subnet.id
cidr_ipv4 = "10.0.1.0/24" ## The source IPv4 range ##
from_port = 22
to_port = 22
ip_protocol = "tcp"
}

## Allow ICMP traffic to the PRIVATE subnet ##

resource "aws_vpc_security_group_ingress_rule" "Allow_ICMP" {
security_group_id = aws_security_group.ctf_sg_private_subnet.id
cidr_ipv4 = "10.0.1.0/24" ## The source IPv4 range ##
from_port = -1
to_port = -1
ip_protocol = "icmp"
}

## Allow all egress out of the private subnet ##

resource "aws_vpc_security_group_egress_rule" "Allow_all_outbound_from_private_subnet" {
security_group_id = aws_security_group.ctf_sg_private_subnet.id
cidr_ipv4 = "0.0.0.0/0" ## The destination IPv4 range ##
ip_protocol = -1
}

## Create the PUBLIC subnet ##

resource "aws_subnet" "ctf_public_subnet" {
vpc_id = aws_vpc.ctf_vpc.id
cidr_block = "10.0.1.0/24"
availability_zone = var.az
map_public_ip_on_launch = true ## Assigns a public IP address on instance launch
tags = {
Name = "ctf_public_subnet"
}
}

## Now to create the security group for the PUBLIC subnet ##

resource "aws_security_group" "ctf_sg_public_subnet" {
name = "sg_public_subnet"
description = "Allow VPN access from everywhere."
vpc_id = aws_vpc.ctf_vpc.id
tags = {
Name = "ctf_sg_public_subnet"
}
}

## Add rule to allow connections from 0.0.0.0/0 into UDP 51832 ##

resource "aws_vpc_security_group_ingress_rule" "Allow_VPN" {
security_group_id = aws_security_group.ctf_sg_public_subnet.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 51832
to_port = 51832
ip_protocol = "udp"
}

## Add rule to allow connections from 0.0.0.0/0 into TCP 22 ##
## Alternatively you can change cidr_ipv4 to be your public IP address ##

resource "aws_vpc_security_group_ingress_rule" "Allow_SSH" {
security_group_id = aws_security_group.ctf_sg_public_subnet.id
cidr_ipv4 = var.your_public_ip_address
from_port = 22
to_port = 22
ip_protocol = "tcp"
}

resource "aws_vpc_security_group_egress_rule" "Allow_all_outbound" {
ip_protocol = "-1" ## All protocols ##
security_group_id = aws_security_group.ctf_sg_public_subnet.id
cidr_ipv4 = "0.0.0.0/0"
description = "Allow all outbound traffic"
}

## Now to create the route table for the PRIVATE subnet ##

resource "aws_route_table" "ctf_rtb_private_subnet" {
vpc_id = aws_vpc.ctf_vpc.id
route {
# cidr_block = "10.0.0.0/16"
# gateway_id = "local" ## this as a local route is already created by default
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.ctf_nat_gw.id
}
tags = {
Name = "ctf_rtb_private_subnet"
}
}

## Now we have to associate the routing table wuth the private subnet ##

resource "aws_route_table_association" "ctf_rtb_association_private_subnet" {
route_table_id = aws_route_table.ctf_rtb_private_subnet.id
subnet_id = aws_subnet.ctf_private_subnet.id
}

## Create an Internet Gateway ##

resource "aws_internet_gateway" "ctf_igw" {
vpc_id = aws_vpc.ctf_vpc.id
tags = {
Name = "ctf_igw"
}
}

## Now to create the route table for the PUBLIC subnet ##

resource "aws_route_table" "ctf_rtb_public_subnet" {
vpc_id = aws_vpc.ctf_vpc.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.ctf_igw.id

}
tags = {
Name = "ctf_rtb_public_subnet"
}
}

## Now we have to associate the routing table with the public subnet ##

resource "aws_route_table_association" "ctf_rtb_association_public_subnet" {
route_table_id = aws_route_table.ctf_rtb_public_subnet.id
subnet_id = aws_subnet.ctf_public_subnet.id
}

## Now to create the NACL for the private subnet ##

## Commented this out as this is too restrictive for what the CTF needs ##

resource "aws_network_acl" "ctf_nacl_private_subnet" {
vpc_id = aws_vpc.ctf_vpc.id
subnet_ids = [aws_subnet.ctf_private_subnet.id] # subnet_ids expects a list, therefore must be in square brackets
# egress {
# protocol = "-1"
# rule_no = 100
# action = "allow"
# cidr_block = "10.0.1.0/24"
# from_port = 0
# to_port = 0
# }
# ingress {
# protocol = "tcp"
# rule_no = 100
# action = "allow"
# cidr_block = "10.0.1.0/24"
# from_port = 22
# to_port = 22
# }
# ingress { ## Allow ICMP Echo Reply ##
# protocol = "icmp"
# rule_no = 101
# action = "allow"
# cidr_block = "10.0.1.0/24"
# from_port = 0
# to_port = 0
# }
# ingress { ## Allow ICMP Echo Response ##
# protocol = "icmp"
# rule_no = 102
# action = "allow"
# cidr_block = "10.0.1.0/24"
# from_port = 0
# to_port = 0
# icmp_type = 8
# }

ingress {
protocol = -1
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
egress {
protocol = -1
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
tags = {
Name = "ctf_nacl_private_subnet"
}
}

## Create the NACL for the public subnet ##

resource "aws_network_acl" "ctf_nacl_public_subnet" {
vpc_id = aws_vpc.ctf_vpc.id
subnet_ids = [aws_subnet.ctf_public_subnet.id]


ingress {
protocol = "udp"
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 51832
to_port = 51832
}
ingress {
protocol = "tcp"
rule_no = 101
action = "allow"
cidr_block = var.your_public_ip_address
from_port = 22
to_port = 22
}
ingress {
protocol = "tcp"
rule_no = 102
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 1024
to_port = 65535
}
ingress {
protocol = "udp"
rule_no = 103
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 1024
to_port = 65535
}
ingress {
protocol = "icmp"
rule_no = 104
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
ingress {
protocol = -1
rule_no = 105
action = "allow"
cidr_block = "10.0.0.0/16"
from_port = 0
to_port = 0
}
egress {
protocol = "-1"
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
tags = {
Name = "ctf_nacl_public_subnet"
}
}

## Now to create the EC2 Instance within the PRIVATE subnet ##

resource "aws_instance" "ctf_EC2_vuln" {
ami = var.ami_id
availability_zone = var.az
instance_type = var.instance_type
subnet_id = aws_subnet.ctf_private_subnet.id
vpc_security_group_ids = [aws_security_group.ctf_sg_private_subnet.id]
private_ip = "10.0.0.10"
key_name = aws_key_pair.lumenyx_public_key.id
tags = {
Name = "ctf_EC2_vuln"
}
root_block_device {
volume_size = 50
volume_type = "gp3"
}
user_data = <<-EOF
#!/bin/bash
sudo apt update
sudo apt upgrade -y
sudo apt install docker.io -y
echo "${var.friends_public_key}" >> /home/ubuntu/.ssh/authorized_keys
sudo hostnamectl set-hostname vulnerable_instance
sed -i 's/preserve_hostname: false/preserve_hostname: true/' /etc/cloud/cloud.cfg
EOF

depends_on = [aws_nat_gateway.ctf_nat_gw] ## The script was running before the NAT gateway was provisioned. Therefore we need this ##
}

## Adding the key pair ##

resource "aws_key_pair" "lumenyx_public_key" {
key_name = "lumenyx_public_key"
public_key = var.my_public_key
tags = {
Name = "lumenyx_public_key"
}
}

## Now to create the EC2 Instance within the PUBLIC subnet ##

resource "aws_instance" "ctf_EC2_vpn" {
ami = var.ami_id
availability_zone = var.az
instance_type = var.instance_type
subnet_id = aws_subnet.ctf_public_subnet.id
vpc_security_group_ids = [aws_security_group.ctf_sg_public_subnet.id]
key_name = aws_key_pair.lumenyx_public_key.id
tags = {
Name = "ctf_EC2_vpn"
}

user_data = <<-EOF
#!/bin/bash
sudo apt update
sudo apt upgrade -y
sudo hostnamectl set-hostname vpn-server
sed -i 's/preserve_hostname: false/preserve_hostname: true/' /etc/cloud/cloud.cfg
EOF
}

## Adding the NAT Gateway ##

resource "aws_nat_gateway" "ctf_nat_gw" {
allocation_id = aws_eip.ctf_nat_gw_eip.id
subnet_id = aws_subnet.ctf_public_subnet.id
}

## Allocating an Elastic IP address for the NAT gw ##

resource "aws_eip" "ctf_nat_gw_eip" {
tags = {
Name = "ctf_nat_gw_eip"
}
depends_on = [aws_internet_gateway.ctf_igw]
}

OUTPUTS.TF:

output "vpc_id" {
description = "The ID of the VPC."
value = aws_vpc.ctf_vpc.id
}

output "instance_public_ip" {
description = "The public IP of the EC2 instance."
value = aws_instance.ctf_EC2_vpn.public_ip
}

AWS MANAGEMENT CONSOLE — OPTIONAL

I’m going to briefly go over the basics in the AWS Management Console first before I translate it to Terraform.

You can skip this section by going to TERRAFORM CONFIGURATIONS below. However if you’re here to learn how to build things in Terraform (which is text-based), it might help to do it visually in the GUI first.

Now we’ll create the basic infrastructure.

Let it be known that there is a better and faster way of creating a VPC than the way I’m going to show. There’s actually a way to automate the creation of all of this in less than 5 minutes. However for learning purposes we’ll do everything the long way so we can internalise what is needed for when we create it in Terraform.

I chose the region physically closest to me. You can change it to whatever you want as long as it remains consistent:

Press enter or click to view image in full size

Here are the basic steps we will be doing in this section:

  1. Create the VPC.
  2. Create public / private subnets.
  3. Enable a public IPv4 address for the public subnet.
  4. Create the Internet Gateway.
  5. Attach the IGW to the VPC.
  6. Add a default gateway route for the public route table.
  7. Explicitly associate the public route table with the public subnet.
  8. Create a route table for the private subnet.
  9. Configure the rtb’s inbound rules to come from one port (22) from the public subnet
  10. Create a security group for the public subnet
  11. Edit the security groups inbound rules (allow UDP 51832 from anywhere)

VPC

Go to the VPC Console and Create VPC:

Press enter or click to view image in full size

Create the VPC. Below are the settings that I chose:

Press enter or click to view image in full size

Create the VPC.

Now we can review:

Press enter or click to view image in full size

SUBNETS

We’ll create the Public and Private Subnets. Take note of the information needed for each subnet, like the IPv4 range and the VPC we’re linking to.

Still within the VPC Console, go to Subnets and ‘Create subnet’:

Press enter or click to view image in full size

Within the screenshots are the steps to create the Public subnet:

Press enter or click to view image in full size
Press enter or click to view image in full size

Take note of the IPv4 subnet block and the Availability Zone. We will be using adding this in Terraform manually soon.

Now that that’s done, let’s create the Private subnet:

Press enter or click to view image in full size
Press enter or click to view image in full size

Create the subnet.

Now we have two newly created subnets:

Press enter or click to view image in full size

Now we want to assign ctf-public-subnet with a public IP address:

Press enter or click to view image in full size
Press enter or click to view image in full size

NOTE: The subnet is still not public. The difference between public and private subnets is their internet connectivity. For a subnet to be considered public, it has to have a route to an Internet Gateway and resources must have a public IPv4 address.

INTERNET GATEWAY

Now we will create an Internet gateway and link it to the route table (which was automatically created):

Press enter or click to view image in full size
Press enter or click to view image in full size
Press enter or click to view image in full size
Press enter or click to view image in full size

Now the Internet gateway is attached to the ‘ctf’ VPC. It is still NOT connected to the public subnet yet.

The route table still needs to be updated.

ROUTE TABLES

Press enter or click to view image in full size
Press enter or click to view image in full size
Press enter or click to view image in full size

Now we have to explicitly associate this route table with the public subnet:

Press enter or click to view image in full size
Press enter or click to view image in full size

CREATE SECURITY GROUPS

Create a security group for the public subnet.

Press enter or click to view image in full size

Name the group: ctf-sg-public-subnet

Description: Security group for the public subnet.

We’ll also add a rule:

Press enter or click to view image in full size
CORRECTION: We’re going to use WireGuard not OpenVPN.

Create the security Group.

I’ll stop it here with the AWS Management Console. There is more we’ll do with the configurations in Terraform.

TERRAFORM CONFIGURATIONS

Install AWS CLI by following the official AWS documentation here.

Install Terraform for your specific OS by following the Terraform documentation here.

Create the Terraform folders:

mkdir terraform-projects && cd terraform-projects
mkdir ctf-project2026-03-09 && cd ctf-project

CREATE THE TERRAFORM FILES:

touch providers.tf variables.tf main.tf outputs.tf versions.tf
  • main.tf — The main configuration file for Terraform.
  • outputs.tf — “Terraform output values let you export structured data about your resources. You can use this data to configure other parts of your infrastructure with automation tools, or as a data source for another Terraform workspace.”
  • providers.tf — Is used to define what cloud platforms (like AWS, Azure, GCP) will be used.
  • variables.tf — Used to define variables.
  • versions.tf — Is used to define what version of Terraform will be used to ensure that the configurations remain consistent over time.

Edit the files:

VARIABLES.TF:

There are 3 parameters for the variables.tf file which are default, type, and description.

variable "aws_region" {
default = "ap-southeast-2"
description = "The AWS region were our resources will reside."
}

variable "instance_type" {
default = "t2.micro"
description = "The instance type of the EC2 instance."
}

variable "ami_id" {
default = "ami-0ba8d27d35e9915fb"
description = "The AMI ID of the EC2 insance."
}

variable "az" {
default = "ap-southeast-2a"
}

I got my AMI ID from EC2 > Instances > Launch an Instance > Chose my AMI and copied the ID:

Press enter or click to view image in full size

PROVIDERS.TF:

provider "aws" {
region = var.aws_region
}

VERSIONS.TF:

terraform {
required_version = ">= 0.12"

required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}

OUTPUTS.TF:

output "vpc_id" {
description = "The ID of the VPC."
value = aws_vpc.ctf_vpc.id
}

output "instance_public_ip" {
description = "The public IP of the EC2 instance."
value = aws_instance.ctf_EC2_vpn.public_ip
}

BUILDING MAIN.TF

Here are my initial configurations for main.tf. This is what I started off with and fixed it up heavily down the line.

MAIN.TF:

Get Lumenyx’s stories in your inbox

Join Medium for free to get updates from this writer.

Virtual Private Cloud — VPC:

## Create the VPC ##

resource "aws_vpc" "ctf_vpc" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "ctf_vpc"
}
}

Private Subnet:

## Create the private subnet ##

resource "aws_subnet" "ctf_private_subnet" {
vpc_id = aws_vpc.ctf_vpc.id
cidr_block = "10.0.0.0/24"
availability_zone = var.az
tags = {
Name = "ctf_private_subnet"
}
}

Security Group with Rules — Private Subnet:

## Now to create the security group for the PRIVATE subnet ##

resource "aws_security_group" "ctf_sg_private_subnet" {
name = "sg_private_subnet"
description = "Allow access from the public subnet only."
vpc_id = aws_vpc.ctf_vpc.id
}

## Now to create the ingress rules for the PRIVATE subnet ##

resource "aws_vpc_security_group_ingress_rule" "Allow_access_from_public_subnet" {
security_group_id = aws_security_group.ctf_sg_private_subnet.id
cidr_ipv4 = "10.0.1.0/24" ## The source IPv4 range ##
from_port = 22
to_port = 22
ip_protocol = "tcp"
}

Route Table — Private Subnet:

## Now to create the route table for the PRIVATE subnet ##

resource "aws_route_table" "ctf_rtb_private_subnet" {
vpc_id = aws_vpc.ctf_vpc.id
# cidr_block = "10.0.0.0/16"
# gateway_id = "local" ## this as a local route is already created by default
tags = {
Name = "ctf_rtb_private_subnet"
}
}

## Now we have to associate the routing table wuth the private subnet ##

resource "aws_route_table_association" "ctf_rtb_association_private_subnet" {
route_table_id = aws_route_table.ctf_rtb_private_subnet.id
subnet_id = aws_subnet.ctf_private_subnet.id
}

Public Subnet:

## Create the PUBLIC subnet ##

resource "aws_subnet" "ctf_public_subnet" {
vpc_id = aws_vpc.ctf_vpc.id
cidr_block = "10.0.1.0/24"
availability_zone = var.az
map_public_ip_on_launch = true ## Assigns a public IP address on instance launch
tags = {
Name = "ctf_public_subnet"
}
}

Security Group with Rules — Public Subnet:

## Now to create the security group for the PUBLIC subnet ##

resource "aws_security_group" "ctf_sg_public_subnet" {
name = "sg_public_subnet"
description = "Allow VPN access from everywhere."
vpc_id = aws_vpc.ctf_vpc.id
tags = {
Name = "ctf_sg_public_subnet"
}
}

## Add rule to allow connections from 0.0.0.0/0 into UDP 51832 ##

resource "aws_vpc_security_group_ingress_rule" "Allow_VPN" {
security_group_id = aws_security_group.ctf_sg_public_subnet.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 51832
to_port = 51832
ip_protocol = "udp"
}

Route Table — Public Subnet:

## Now to create the route table for the PUBLIC subnet ##

resource "aws_route_table" "ctf_rtb_public_subnet" {
vpc_id = aws_vpc.ctf_vpc.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.ctf_igw.id

}
tags = {
Name = "ctf_rtb_public_subnet"
}
}

## Now we have to associate the routing table with the public subnet ##

resource "aws_route_table_association" "ctf_rtb_association_public_subnet" {
route_table_id = aws_route_table.ctf_rtb_public_subnet.id
subnet_id = aws_subnet.ctf_public_subnet.id
}

Internet Gateway:

## Create an Internet Gateway ##

resource "aws_internet_gateway" "ctf_igw" {
vpc_id = aws_vpc.ctf_vpc.id
tags = {
Name = "ctf_igw"
}
}

TESTING

So far we have added everything in the main.tf file except for the Network Access Control Lists (NACLs) and the EC2 instances themselves.

Message from the future: There was so much more I had to add and fix.

Let’s test out how it’s going:

Run these commands to initialise, plan, and apply the configurations:

terraform init
Press enter or click to view image in full size
terraform plan
Press enter or click to view image in full size
terraform apply
Press enter or click to view image in full size

Now let’s check the AWS management console.

VPC:

Press enter or click to view image in full size

Subnets:

Press enter or click to view image in full size

Route tables:

Press enter or click to view image in full size

Internet Gateway:

Press enter or click to view image in full size

Security groups:

Press enter or click to view image in full size
Press enter or click to view image in full size
Press enter or click to view image in full size

Resource map shows that items within the public subnet can reach the Internet Gateway:

Press enter or click to view image in full size

Look good to me so far. If there’s any upcoming issues I’ll troubleshoot it.

EC2 & NETWORK ACCESS CONTROL LISTS (NACLs)

Now let’s add the NACLs and the EC2 Instances to the main.tf file.

A Network Access Control list is basically a stateless subnet-level firewall.

Since it’s stateless you must explicitly state inbound rules as well as outbound.

NOTE: We don’t necessarily need to add the NACLs, however it is good to learn how to use them.

NACL — Private Subnet:

## Now to create the NACL for the private subnet ##

resource "aws_network_acl" "ctf_nacl_private_subnet" {
vpc_id = aws_vpc.ctf_vpc.id
subnet_ids = aws_subnet.ctf_private_subnet.id
ingress {
protocol = "tcp"
rule_no = 100
action = "allow"
cidr_block = "10.0.0.0/24"
from_port = 22
to_port = 22
}
tags = {
Name = "ctf_nacl_private_subnet"
}
}

NACL — Public Subnet:

## Create the NACL for the public subnet ## 

resource "aws_network_acl" "ctf_nacl_public_subnet" {
vpc_id = aws_vpc.ctf_vpc.id
subnet_ids = aws_subnet.ctf_public_subnet.id
ingress {
protocol = "udp"
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 51832
to_port = 51832
}
tags = {
Name = "ctf_nacl_public_subnet"
}
}

EC2 Instance — Vulnerable Instance:

## Now to create the EC2 Instance within the PRIVATE subnet ##

resource "aws_instance" "ctf_EC2_vuln" {
ami = var.ami_id
availability_zone = var.az
instance_type = var.instance_type
subnet_id = aws_subnet.ctf_private_subnet.id
vpc_security_group_ids = [aws_security_group.ctf_sg_private_subnet.id]
private_ip = "10.0.0.10"
key_name = aws_key_pair.lumenyx_public_key.id
tags = {
Name = "ctf_EC2_vuln"
}
root_block_device {
volume_size = 50
volume_type = "gp3"
}
user_data = <<-EOF
#!/bin/bash
sudo apt update
sudo apt upgrade -y
sudo apt install docker.io -y
echo "${var.friend_public_key}" >> /home/ubuntu/.ssh/authorized_keys
sudo hostnamectl set-hostname vulnerable_instance
sed -i 's/preserve_hostname: false/preserve_hostname: true/' /etc/cloud/cloud.cfg
EOF
}
  • Within `user_data`, I want to ensure that my friend’s public key is added so he can SSH into the vulnerable box too.

EC2 Instance — VPN Server:

## Now to create the EC2 Instance within the PUBLIC subnet ##

resource "aws_instance" "ctf_EC2_vpn" {
ami = var.ami_id
availability_zone = var.az
instance_type = var.instance_type
subnet_id = aws_subnet.ctf_public_subnet.id
vpc_security_group_ids = [aws_security_group.ctf_sg_public_subnet.id]
key_name = aws_key_pair.lumenyx_public_key.id
tags = {
Name = "ctf_EC2_vpn"
}

user_data = <<-EOF
#!/bin/bash
sudo apt update
sudo apt upgrade -y
sudo hostnamectl set-hostname vpn-server
sed -i 's/preserve_hostname: false/preserve_hostname: true/' /etc/cloud/cloud.cfg
EOF
}

NOTE: Regarding the private IP address of aws_instance.ctf_EC2_vuln, the first 4 ip addresses of the range is reserved (0 to 3), therefore I couldn’t make it 10.0.0.1.

Press enter or click to view image in full size

Now that the EC2 Instances are created, let’s also allow keys for the VPN instance so we can SSH from our host machine.

Added these three variables to variables.tf:

variable "my_public_key" {
description = "The public key which will be imported into aws_instance.ctf_EC2_vpn"
}

variable "your_public_ip_address" {
description = "Your actual public IP address. Eg) 1.2.3.4/32 "
}

variable "friend_public_key" {
description = "Friend's public key: "
}

Added this to main.tf:

## Adding the key pair ##

resource "aws_key_pair" "lumenyx_public_key" {
key_name = "lumenyx_public_key"
public_key = var.my_public_key
tags = {
Name = "lumenyx_public_key"
}
}

When you run the terraform plan or terraform apply commands, it will prompt you for your public key as well as your public IP address.

The public key will be used to authenticate SSH access, and the public IP will be used to whitelist only your public IP address.

Press enter or click to view image in full size

Now let’s test it out on our host machine if we can SSH into it without a password:

ssh ubuntu@[IP address listed]

It timed out.

I believe the issue is because we didn’t update the NACL configuration to allow SSH, nor did I allow outbound traffic (there’s an implicit deny that blocks all outbound traffic).

After lots of troubleshooting I realised that the rules that I ended up using were too restrictive to allow the CTF to properly take place, which is why I opened it up completely.

Here is what I ended up with:

## Now to create the NACL for the private subnet ##

## Commented this out as this is too complicated for what the CTF needs ##

resource "aws_network_acl" "ctf_nacl_private_subnet" {
vpc_id = aws_vpc.ctf_vpc.id
subnet_ids = [aws_subnet.ctf_private_subnet.id] # subnet_ids expects a list, therefore must be in square brackets
# egress {
# protocol = "-1"
# rule_no = 100
# action = "allow"
# cidr_block = "10.0.1.0/24"
# from_port = 0
# to_port = 0
# }
# ingress {
# protocol = "tcp"
# rule_no = 100
# action = "allow"
# cidr_block = "10.0.1.0/24"
# from_port = 22
# to_port = 22
# }
# ingress { ## Allow ICMP Echo Reply ##
# protocol = "icmp"
# rule_no = 101
# action = "allow"
# cidr_block = "10.0.1.0/24"
# from_port = 0
# to_port = 0
# }
# ingress { ## Allow ICMP Echo Response ##
# protocol = "icmp"
# rule_no = 102
# action = "allow"
# cidr_block = "10.0.1.0/24"
# from_port = 0
# to_port = 0
# icmp_type = 8
# }

ingress {
protocol = -1
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
egress {
protocol = -1
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
tags = {
Name = "ctf_nacl_private_subnet"
}
}
## Create the NACL for the public subnet ##

resource "aws_network_acl" "ctf_nacl_public_subnet" {
vpc_id = aws_vpc.ctf_vpc.id
subnet_ids = [aws_subnet.ctf_public_subnet.id]

ingress { ## Allow inbound VPN traffic ##
protocol = "udp"
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 51832
to_port = 51832
}
ingress { ## Allow SSH from only my public IP address ##
protocol = "tcp"
rule_no = 101
action = "allow"
cidr_block = var.your_public_ip_address
from_port = 22
to_port = 22
}
ingress { ## Allow TCP traffic on ephemeral ports ##
protocol = "tcp"
rule_no = 102
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 1024
to_port = 65535
}
ingress { ## Allow UDP traffic on ephemeral ports ##
protocol = "udp"
rule_no = 103
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 1024
to_port = 65535
}
ingress { ## Allow ICMP Echo Replies ##
protocol = "icmp"
rule_no = 104
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
ingress { ## Allow all traffic from 10.0.0.0/16 ##
protocol = -1
rule_no = 105
action = "allow"
cidr_block = "10.0.0.0/16"
from_port = 0
to_port = 0
}
egress { ## Allow all outbound traffic ##
protocol = "-1"
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
tags = {
Name = "ctf_nacl_public_subnet"
}
}

Now lets try to SSH into it now:

This calls for a celebration.

FIXING SSH — THE VULNERABLE INSTANCE

Press enter or click to view image in full size

I’m not able to SSH into the vulnerable instance yet. It keeps timing out.

I believe the issue is that there is a missing outbound rule for the public subnet’s security group. Therefore I added this resource:

resource "aws_vpc_security_group_egress_rule" "Allow_all_outbound_from_private_subnet" {
security_group_id = aws_security_group.ctf_sg_private_subnet.id
cidr_ipv4 = "0.0.0.0/0" ## The destination IPv4 range ##
ip_protocol = -1
}

As long as the VPN is on, I can now SSH into the vulnerable server from my host machine.

Press enter or click to view image in full size

However there’s another issue. It cannot connect to the internet as it’s in a private subnet.

Press enter or click to view image in full size

It needs an internet connection to pull docker images and dependencies for the CTF.

We can resolve this by adding a NAT Gateway.

NAT GATEWAY AND ELASTIC IP

A NAT Gateway is used to allow resources in a private subnet to initiate outbound connections to the internet without the internet being able to talk to these resources directly.

The NAT gateway must be attached to a public subnet and also have an elastic IP address attached.

Press enter or click to view image in full size

NAT Gateway:

## Adding the NAT Gateway ##

resource "aws_nat_gateway" "ctf_nat_gw" {
allocation_id = aws_eip.ctf_nat_gw_eip.id
subnet_id = aws_subnet.ctf_public_subnet.id
}

Elastic IP:

## Allocating an Elastic IP address for the NAT gw ##

resource "aws_eip" "ctf_nat_gw_eip" {
tags = {
Name = "ctf_nat_gw_eip"
}
depends_on = [aws_internet_gateway.ctf_igw]
}

That’s all the configurations for main.tf. Here is the finished version:


## Create the VPC ##

resource "aws_vpc" "ctf_vpc" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "ctf_vpc"
}
}

## Create the private subnet ##

resource "aws_subnet" "ctf_private_subnet" {
vpc_id = aws_vpc.ctf_vpc.id
cidr_block = "10.0.0.0/24"
availability_zone = var.az
tags = {
Name = "ctf_private_subnet"
}
}

## Now to create the security group for the PRIVATE subnet ##

resource "aws_security_group" "ctf_sg_private_subnet" {
name = "sg_private_subnet"
description = "Allow access from the public subnet only."
vpc_id = aws_vpc.ctf_vpc.id
tags = {
Name = "ctf_sg_private_subnet"
}
}

## Now to allow SSH from the private subnet ##

resource "aws_vpc_security_group_ingress_rule" "Allow_access_from_public_subnet" {
security_group_id = aws_security_group.ctf_sg_private_subnet.id
cidr_ipv4 = "10.0.1.0/24" ## The source IPv4 range ##
from_port = 22
to_port = 22
ip_protocol = "tcp"
}

## Allow ICMP traffic to the PRIVATE subnet ##

resource "aws_vpc_security_group_ingress_rule" "Allow_ICMP" {
security_group_id = aws_security_group.ctf_sg_private_subnet.id
cidr_ipv4 = "10.0.1.0/24" ## The source IPv4 range ##
from_port = -1
to_port = -1
ip_protocol = "icmp"
}

## Allow all egress out of the private subnet ##

resource "aws_vpc_security_group_egress_rule" "Allow_all_outbound_from_private_subnet" {
security_group_id = aws_security_group.ctf_sg_private_subnet.id
cidr_ipv4 = "0.0.0.0/0" ## The destination IPv4 range ##
ip_protocol = -1
}

## Create the PUBLIC subnet ##

resource "aws_subnet" "ctf_public_subnet" {
vpc_id = aws_vpc.ctf_vpc.id
cidr_block = "10.0.1.0/24"
availability_zone = var.az
map_public_ip_on_launch = true ## Assigns a public IP address on instance launch
tags = {
Name = "ctf_public_subnet"
}
}

## Now to create the security group for the PUBLIC subnet ##

resource "aws_security_group" "ctf_sg_public_subnet" {
name = "sg_public_subnet"
description = "Allow VPN access from everywhere."
vpc_id = aws_vpc.ctf_vpc.id
tags = {
Name = "ctf_sg_public_subnet"
}
}

## Add rule to allow connections from 0.0.0.0/0 into UDP 51832 ##

resource "aws_vpc_security_group_ingress_rule" "Allow_VPN" {
security_group_id = aws_security_group.ctf_sg_public_subnet.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 51832
to_port = 51832
ip_protocol = "udp"
}

## Add rule to allow connections from 0.0.0.0/0 into TCP 22 ##
## Alternatively you can change cidr_ipv4 to be your public IP address ##

resource "aws_vpc_security_group_ingress_rule" "Allow_SSH" {
security_group_id = aws_security_group.ctf_sg_public_subnet.id
cidr_ipv4 = var.your_public_ip_address
from_port = 22
to_port = 22
ip_protocol = "tcp"
}

resource "aws_vpc_security_group_egress_rule" "Allow_all_outbound" {
ip_protocol = "-1" ## All protocols ##
security_group_id = aws_security_group.ctf_sg_public_subnet.id
cidr_ipv4 = "0.0.0.0/0"
description = "Allow all outbound traffic"
}

## Now to create the route table for the PRIVATE subnet ##

resource "aws_route_table" "ctf_rtb_private_subnet" {
vpc_id = aws_vpc.ctf_vpc.id
route {
# cidr_block = "10.0.0.0/16"
# gateway_id = "local" ## this as a local route is already created by default
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.ctf_nat_gw.id
}
tags = {
Name = "ctf_rtb_private_subnet"
}
}

## Now we have to associate the routing table wuth the private subnet ##

resource "aws_route_table_association" "ctf_rtb_association_private_subnet" {
route_table_id = aws_route_table.ctf_rtb_private_subnet.id
subnet_id = aws_subnet.ctf_private_subnet.id
}

## Create an Internet Gateway ##

resource "aws_internet_gateway" "ctf_igw" {
vpc_id = aws_vpc.ctf_vpc.id
tags = {
Name = "ctf_igw"
}
}

## Now to create the route table for the PUBLIC subnet ##

resource "aws_route_table" "ctf_rtb_public_subnet" {
vpc_id = aws_vpc.ctf_vpc.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.ctf_igw.id

}
tags = {
Name = "ctf_rtb_public_subnet"
}
}

## Now we have to associate the routing table with the public subnet ##

resource "aws_route_table_association" "ctf_rtb_association_public_subnet" {
route_table_id = aws_route_table.ctf_rtb_public_subnet.id
subnet_id = aws_subnet.ctf_public_subnet.id
}

## Now to create the NACL for the private subnet ##

## Commented this out as this is too restrictive for what the CTF needs ##

resource "aws_network_acl" "ctf_nacl_private_subnet" {
vpc_id = aws_vpc.ctf_vpc.id
subnet_ids = [aws_subnet.ctf_private_subnet.id] # subnet_ids expects a list, therefore must be in square brackets
# egress {
# protocol = "-1"
# rule_no = 100
# action = "allow"
# cidr_block = "10.0.1.0/24"
# from_port = 0
# to_port = 0
# }
# ingress {
# protocol = "tcp"
# rule_no = 100
# action = "allow"
# cidr_block = "10.0.1.0/24"
# from_port = 22
# to_port = 22
# }
# ingress { ## Allow ICMP Echo Reply ##
# protocol = "icmp"
# rule_no = 101
# action = "allow"
# cidr_block = "10.0.1.0/24"
# from_port = 0
# to_port = 0
# }
# ingress { ## Allow ICMP Echo Response ##
# protocol = "icmp"
# rule_no = 102
# action = "allow"
# cidr_block = "10.0.1.0/24"
# from_port = 0
# to_port = 0
# icmp_type = 8
# }

ingress {
protocol = -1
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
egress {
protocol = -1
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
tags = {
Name = "ctf_nacl_private_subnet"
}
}

## Create the NACL for the public subnet ##

resource "aws_network_acl" "ctf_nacl_public_subnet" {
vpc_id = aws_vpc.ctf_vpc.id
subnet_ids = [aws_subnet.ctf_public_subnet.id]


ingress {
protocol = "udp"
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 51832
to_port = 51832
}
ingress {
protocol = "tcp"
rule_no = 101
action = "allow"
cidr_block = var.your_public_ip_address
from_port = 22
to_port = 22
}
ingress {
protocol = "tcp"
rule_no = 102
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 1024
to_port = 65535
}
ingress {
protocol = "udp"
rule_no = 103
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 1024
to_port = 65535
}
ingress {
protocol = "icmp"
rule_no = 104
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
ingress {
protocol = -1
rule_no = 105
action = "allow"
cidr_block = "10.0.0.0/16"
from_port = 0
to_port = 0
}
egress {
protocol = "-1"
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
tags = {
Name = "ctf_nacl_public_subnet"
}
}

## Now to create the EC2 Instance within the PRIVATE subnet ##

resource "aws_instance" "ctf_EC2_vuln" {
ami = var.ami_id
availability_zone = var.az
instance_type = var.instance_type
subnet_id = aws_subnet.ctf_private_subnet.id
vpc_security_group_ids = [aws_security_group.ctf_sg_private_subnet.id]
private_ip = "10.0.0.10"
key_name = aws_key_pair.lumenyx_public_key.id
tags = {
Name = "ctf_EC2_vuln"
}
root_block_device {
volume_size = 50
volume_type = "gp3"
}
user_data = <<-EOF
#!/bin/bash
sudo apt update
sudo apt upgrade -y
sudo apt install docker.io -y
echo "${var.friend_public_key}" >> /home/ubuntu/.ssh/authorized_keys
EOF
}

## Adding the key pair ##

resource "aws_key_pair" "lumenyx_public_key" {
key_name = "lumenyx_public_key"
public_key = var.my_public_key
tags = {
Name = "lumenyx_public_key"
}
}

## Now to create the EC2 Instance within the PUBLIC subnet ##

resource "aws_instance" "ctf_EC2_vpn" {
ami = var.ami_id
availability_zone = var.az
instance_type = var.instance_type
subnet_id = aws_subnet.ctf_public_subnet.id
vpc_security_group_ids = [aws_security_group.ctf_sg_public_subnet.id]
key_name = aws_key_pair.lumenyx_public_key.id
tags = {
Name = "ctf_EC2_vpn"
}

user_data = <<-EOF
#!/bin/bash
sudo apt update
sudo apt upgrade -y
EOF
}

## Adding the NAT Gateway ##

resource "aws_nat_gateway" "ctf_nat_gw" {
allocation_id = aws_eip.ctf_nat_gw_eip.id
subnet_id = aws_subnet.ctf_public_subnet.id
}

## Allocating an Elastic IP address for the NAT gw ##

resource "aws_eip" "ctf_nat_gw_eip" {
tags = {
Name = "ctf_nat_gw_eip"
}
depends_on = [aws_internet_gateway.ctf_igw]
}

INSTALLING WIREGUARD WITH PIVPN

Now I’ll SSH into the VPN server and install WireGuard:

ssh ubuntu@[public-IP]

Now let’s update everything:

sudo apt update && sudo apt upgrade -y

Install PiVPN:

curl -L https://install.pivpn.io | bash

Go through the installer:

*game

Ensure the port is UDP 51832:

Press enter or click to view image in full size

Now we can add everyone’s profile and generate keys for them to log into the VPN:

pivpn -a

I’ll supply the configurations with the players:

cat ~/configs/[configuration file]

CONCLUSION

Now every client who has a configuration can now access the public subnet, and from there they can attack the vulnerable server in the other subnet.

Press enter or click to view image in full size

How to connect to the VPN server.

We’re all set! I’ll see you on game day.

--

--