Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

Opinionated command line interface (CLI) tool to manage Capture The Flag (CTF) challenges.
It uses:

- YAML files to describe a challenge and forum posts
- OpenTofu (terraform fork) to describe the infrastructure
- Incus (LXD fork) to run the challenges in containers
Expand All @@ -10,7 +11,7 @@ It uses:
![Demo GIF](./doc/images/demo.gif)

This tool is used by the NorthSec CTF team to manage their challenges since 2025.
[NorthSec](https://nsec.io/) is one of the largest on-site cybersecurity CTF in the world, held annually in Montreal, Canada,
[NorthSec](https://nsec.io/) is one of the largest on-site cybersecurity CTF in the world, held annually in Montreal, Canada,
where 700+ participants compete in a 48-hour long CTF competition.

## Features and Usage
Expand All @@ -28,7 +29,7 @@ tree until it finds `challenges/` and `.deploy` directories, which is the root o

## Structure of a CTF repository

```
```raw
my-ctf/
├── challenges/ # Directory containing all the tracks
│ ├── track1/ # Directory for a specific track that contains N flags.
Expand Down Expand Up @@ -79,6 +80,10 @@ echo 'eval "$(register-python-argcomplete ctf)"' >> ~/.bashrc && source ~/.bashr
echo 'eval "$(register-python-argcomplete ctf)"' >> ~/.zshrc && source ~/.zshrc # If using zsh
```

## Create a Windows Image in Incus

To create a Windows Image, you can use [incus-windows](https://github.com/antifob/incus-windows) that will build a Windows image with the Incus agent already installed as a service. And then, use the imported image for the `main.tf` OpenTOFU/Terraform in your track. Simply put the name of the image (alias) or the fingerprint of the image in the appriopriate field in `main.tf`.

## Development

Install with [uv](https://docs.astral.sh/uv/guides/tools/) virtual environment:
Expand Down
96 changes: 89 additions & 7 deletions ctf/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import shutil
import subprocess
import textwrap
import time

import typer
from typing_extensions import Annotated
Expand Down Expand Up @@ -107,7 +108,16 @@ def deploy(
destroy(tracks=tracks, production=production, remote=remote, force=True)
exit(code=0)

for track in distinct_tracks:
# Starting a timer for tracks with a virtual machine in them.
start_timer: float = time.time()

for track in sorted(
distinct_tracks,
key=lambda t: (
t.has_virtual_machine,
t.name,
), # Running ansible on containers first then virtual machines
):
if track.require_build_container:
run_ansible_playbook(
remote=remote,
Expand Down Expand Up @@ -177,6 +187,70 @@ def deploy(
):
continue

if track.has_virtual_machine:
incus_list = json.loads(
s=subprocess.run(
args=["incus", "list", f"--project={track}", "--format", "json"],
check=True,
capture_output=True,
env=ENV,
).stdout.decode()
)

# Waiting for virtual machine to be up and running
# Starting with a minute
if start_timer > time.time() - (seconds := 60.0):
LOG.info(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ça serait mieux d'utiliser https://rich.readthedocs.io/en/latest/progress.html à la place

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pas certain de comprendre comment utiliser progress, surtout dans ce contexte. Penses-tu pouvoir t'en occuper?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

je sais pas comment utiliser ça. Quel nom d'image windows je peux utiliser?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tu dois créer ta propre image avec incus-windows par antifob, voir le README.

f"Waiting for the virtual machine to be ready. Remaining {(seconds - (time.time() - start_timer)):.1f} seconds..."
)

for machine in incus_list:
if machine["type"] != "virtual-machine":
continue

rebooting: bool = False
cmd: str = "whoami" # Should works on most OS
while start_timer > time.time() - seconds:
# Avoid spamming too much, sleeping for a second between each request.
time.sleep(1)

s = subprocess.run(
args=[
"incus",
"exec",
f"--project={track}",
"-T",
machine["name"],
"--",
cmd,
],
capture_output=True,
env=ENV,
)

match s.returncode:
case 127:
# If "whoami" is not found by the OS, change the command to sleep as it is most likely Linux.
LOG.debug(
f'Command not found, changing it to "{(cmd := "sleep 0")}".'
)
start_timer = time.time()
case 0:
if not rebooting:
LOG.debug(
f"Remaining {(seconds - (time.time() - start_timer)):.1f} seconds..."
)
else:
LOG.info("Agent is up and running!")
break
case _:
# Once the virtual machine rebooted once, set the timer to 30 minutes.
if not rebooting:
LOG.info(
"Virtual machine is most likely rebooting. Once the agent is back up, let's move on."
)
rebooting = True

run_ansible_playbook(
remote=remote, production=production, track=track.name, path=path
)
Expand All @@ -192,6 +266,8 @@ def deploy(
)
ipv6_to_container_name = {}
for machine in incus_list:
if machine["type"] == "virtual-machine":
continue
addresses = machine["state"]["network"]["eth0"]["addresses"]
ipv6_address = list(
filter(lambda address: address["family"] == "inet6", addresses)
Expand All @@ -205,7 +281,17 @@ def deploy(
track_yaml = parse_track_yaml(track_name=track.name)

for service in track_yaml["services"]:
if service.get("dev_port_mapping"):
if (
service.get("dev_port_mapping")
and (
service["address"]
.replace(":0", ":")
.replace(":0", ":")
.replace(":0", ":")
.replace(":0", ":")
)
in ipv6_to_container_name
):
LOG.debug(
f"Adding incus proxy for service {track}-{service['name']}-port-{service['port']}"
)
Expand Down Expand Up @@ -308,11 +394,7 @@ def run_ansible_playbook(
"-i",
"inventory",
] + extra_args
subprocess.run(
args=ansible_args,
cwd=path,
check=True,
)
subprocess.run(args=ansible_args, cwd=path, check=True)

artifacts_path = os.path.join(path, "artifacts")
if os.path.exists(path=artifacts_path):
Expand Down
2 changes: 2 additions & 0 deletions ctf/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
get_all_available_tracks,
get_terraform_tracks_from_modules,
terraform_binary,
track_has_virtual_machine,
validate_track_can_be_deployed,
)

Expand Down Expand Up @@ -70,6 +71,7 @@ def generate(
remote=remote,
production=production,
require_build_container=does_track_require_build_container(track),
has_virtual_machine=track_has_virtual_machine(track),
)
)
distinct_tracks = tmp_tracks
Expand Down
3 changes: 2 additions & 1 deletion ctf/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class Track(BaseModel):
remote: str = "local"
production: bool = False
require_build_container: bool = False
has_virtual_machine: bool = False

def __eq__(self, other: Any) -> bool:
match other:
Expand All @@ -31,7 +32,7 @@ def __hash__(self) -> int:
return self.name.__hash__()

def __repr__(self) -> str:
return f'{self.__class__.__name__}(name="{self.name}", remote="{self.remote}", production={self.production}, require_build_container={self.require_build_container})'
return f'{self.__class__.__name__}(name="{self.name}", remote="{self.remote}", production={self.production}, require_build_container={self.require_build_container}, has_virtual_machine={self.has_virtual_machine})'

def __str__(self) -> str:
return self.name
Expand Down
8 changes: 7 additions & 1 deletion ctf/new.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class Template(StrEnum):
FILES_ONLY = "files-only"
TRACK_YAML_ONLY = "track-yaml-only"
RUST_WEBSERVICE = "rust-webservice"
WINDOWS_VM = "windows-vm"


@app.command(help="Create a new CTF track with a given name")
Expand Down Expand Up @@ -205,6 +206,7 @@ def new(
"ipv6_subnet": ipv6_subnet,
"full_ipv6_address": full_ipv6_address,
"with_build": with_build_container,
"is_windows": template == Template.WINDOWS_VM,
}
)
with open(
Expand Down Expand Up @@ -278,7 +280,11 @@ def new(

track_template = env.get_template(name=os.path.join("common", "inventory.j2"))
render = track_template.render(
data={"name": name, "with_build": with_build_container}
data={
"name": name,
"with_build": with_build_container,
"is_windows": template == Template.WINDOWS_VM,
}
)
with open(
file=(p := os.path.join(ansible_directory, "inventory")),
Expand Down
2 changes: 1 addition & 1 deletion ctf/templates/init/.deploy/cleanup.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
- name: Pre-deployment system cleanup
hosts: all,!build
hosts: all,!build,!windows
order: shuffle
gather_facts: false
any_errors_fatal: true
Expand Down
2 changes: 1 addition & 1 deletion ctf/templates/init/.deploy/common.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
- name: Pre-deployment Common
hosts: all,!build
hosts: all,!build,!windows
order: shuffle
gather_facts: false
any_errors_fatal: true
Expand Down
19 changes: 18 additions & 1 deletion ctf/templates/new/common/inventory.j2
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
# This YAML file defines all machines that Ansible needs to know about to run playbooks and configure machines.
all:
hosts:
hosts:{% if not data.is_windows %}
# The following line defines how this machine will be referred to in Ansible scripts.
{{ data.name }}:
# This one tells Ansible that this host is reached using incus, and the name of the machine in incus is `{{ data.name }}`.
ansible_incus_host: {{ data.name }}
{% else %}
# If you also need Linux containers, add them here.
# linux-incus-container:
# ansible_incus_host: linux-incus-container
{% endif %}
# You can set variables here to use in your Ansible playbooks. For example, you can set the flags here to set them dynamically when setting up the challenge.
vars:
# Do not change these.
Expand All @@ -24,4 +29,16 @@ build:
build-container:
# The name must be the same as the previous line.
ansible_incus_host: build-container
{% endif %}{% if data.is_windows %}
# This section is needed if you need Windows virtual machines. It's a group of hosts regrouped under the name "windows" which MUST remain the same.
# The group "windows" is removed from the "cleanup.yaml" and "common.yaml", which is why you should not change it.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pas sûr de comprendre cette ligne. le groupe windows est retiré de cleanup.yaml et common.yaml, mais ta PR ne modifie pas ces fichiers?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Je les ai enlevé sur mon instance local de ctf. Mais j'ai oublié de l'enlever des fichiers templates, ouch. Good catch.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

C'est fait.

windows:
hosts:
# The following line defines how this machine will be referred to in Ansible scripts.
{{ data.name }}:
# This one tells Ansible that this host is reached using incus, and the name of the machine in incus is `{{ data.name }}`.
ansible_incus_host: {{ data.name }}
vars:
# This variable is used to tell Ansible that the hosts are Windows hosts and require a PowerShell shell.
ansible_shell_type: powershell
{% endif %}
35 changes: 26 additions & 9 deletions ctf/templates/new/common/main.tf.j2
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,18 @@ resource "incus_profile" "this" {
remote = var.incus_remote
project = incus_project.this.name

name = "containers"
description = "Default profile for containers in the ${local.track.name} track"
name = {% if data.is_windows %}"windows-vm"{% else %}"containers"{% endif %}
description = "Default profile for {% if data.is_windows %}Windows virtual machine{% else %}containers{% endif %} in the ${local.track.name} track"

config = {
# These limits should only be adjusted if you NEED more resources.
"limits.cpu" = "2"
"limits.memory" = "256MiB"
"limits.memory" = {% if data.is_windows %}"3GiB"{% else %}"256MiB"
"limits.processes" = "2000"
"environment.http_proxy" = var.deploy == "production" ? "http://proxy.ctf-int.internal.nsec.io:3128" : null
"environment.https_proxy" = var.deploy == "production" ? "http://proxy.ctf-int.internal.nsec.io:3128" : null
"environment.https_proxy" = var.deploy == "production" ? "http://proxy.ctf-int.internal.nsec.io:3128" : null{% endif %}
}


device {
name = "root"
type = "disk"
Expand All @@ -54,7 +53,7 @@ resource "incus_profile" "this" {
"pool" = "default"
"path" = "/"
# This limit should only be adjusted if you NEED more resources.
"size" = "1GiB"
"size" = {% if data.is_windows %}"32GiB"{% else %}"1GiB"{% endif %}
}
}
}
Expand Down Expand Up @@ -87,8 +86,14 @@ resource "incus_instance" "this" {

name = each.key

image = "images:ubuntu/24.04"
profiles = ["default", incus_profile.this.name]
type = {% if data.is_windows %}"virtual-machine"{% else %}"container"{% endif %}

image = {% if data.is_windows %}"CHANGE_ME" # Change to the Windows image location. Refer to the ctf-script README to know how to create an image in Incus.{% else %}"images:ubuntu/24.04"{% endif %}
profiles = [{% if not data.is_windows %}"default", {% endif %}incus_profile.this.name]

{% if data.is_windows %}config = {
"security.secureboot" = "false"
}{% endif %}

device {
name = "eth0"
Expand All @@ -101,9 +106,22 @@ resource "incus_instance" "this" {
}
}

{% if data.is_windows %}device {
name = "incusagent"
type = "disk"

properties = {
"source" = "agent:config"
}
}{% endif %}

lifecycle {
ignore_changes = [running]
}

{% if data.is_windows %}wait_for {
type = "agent"
}{% endif %}
}
{% if data.with_build %}
# AUTOGENERATED - No need to change this section #
Expand Down Expand Up @@ -170,7 +188,6 @@ resource "incus_network_zone_record" "this" {
}
}


# If you need to manually add DNS records, here is an example.
#resource "incus_network_zone_record" "sub" {
# remote = var.incus_remote
Expand Down
Loading