diff --git a/README.md b/README.md index 2a897ca..e3717f4 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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. @@ -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: diff --git a/ctf/deploy.py b/ctf/deploy.py index 409bf4e..da89e94 100644 --- a/ctf/deploy.py +++ b/ctf/deploy.py @@ -3,6 +3,7 @@ import shutil import subprocess import textwrap +import time import typer from typing_extensions import Annotated @@ -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, @@ -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( + 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 ) @@ -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) @@ -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']}" ) @@ -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): diff --git a/ctf/generate.py b/ctf/generate.py index f6b146e..50f29ff 100644 --- a/ctf/generate.py +++ b/ctf/generate.py @@ -15,6 +15,7 @@ get_all_available_tracks, get_terraform_tracks_from_modules, terraform_binary, + track_has_virtual_machine, validate_track_can_be_deployed, ) @@ -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 diff --git a/ctf/models.py b/ctf/models.py index 8ae8c21..fb40697 100644 --- a/ctf/models.py +++ b/ctf/models.py @@ -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: @@ -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 diff --git a/ctf/new.py b/ctf/new.py index c9617b7..525366e 100644 --- a/ctf/new.py +++ b/ctf/new.py @@ -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") @@ -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( @@ -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")), diff --git a/ctf/templates/init/.deploy/cleanup.yaml b/ctf/templates/init/.deploy/cleanup.yaml index 918d4f5..66fe58b 100644 --- a/ctf/templates/init/.deploy/cleanup.yaml +++ b/ctf/templates/init/.deploy/cleanup.yaml @@ -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 diff --git a/ctf/templates/init/.deploy/common.yaml b/ctf/templates/init/.deploy/common.yaml index 1556a7c..26f231a 100644 --- a/ctf/templates/init/.deploy/common.yaml +++ b/ctf/templates/init/.deploy/common.yaml @@ -1,5 +1,5 @@ - name: Pre-deployment Common - hosts: all,!build + hosts: all,!build,!windows order: shuffle gather_facts: false any_errors_fatal: true diff --git a/ctf/templates/new/common/inventory.j2 b/ctf/templates/new/common/inventory.j2 index 1f5cb10..8c4eda7 100644 --- a/ctf/templates/new/common/inventory.j2 +++ b/ctf/templates/new/common/inventory.j2 @@ -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. @@ -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. +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 %} \ No newline at end of file diff --git a/ctf/templates/new/common/main.tf.j2 b/ctf/templates/new/common/main.tf.j2 index 642131d..dc4947d 100644 --- a/ctf/templates/new/common/main.tf.j2 +++ b/ctf/templates/new/common/main.tf.j2 @@ -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" @@ -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 %} } } } @@ -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" @@ -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 # @@ -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 diff --git a/ctf/templates/new/windows-vm/deploy.yaml.j2 b/ctf/templates/new/windows-vm/deploy.yaml.j2 new file mode 100644 index 0000000..e7d12f7 --- /dev/null +++ b/ctf/templates/new/windows-vm/deploy.yaml.j2 @@ -0,0 +1,55 @@ +# This is the main ansible script to deploy the challenge. + +# Example on how to run stuff on all hosts of the track +- name: "Windows deployment" + hosts: all{% if data.with_build %},!build{% endif %} + vars_files: + - ../track.yaml + tasks: + # This is a helper task that loads the tracks' `track.yaml` file and loads the flags as + # ansible facts (like variables) to use in subsequent steps. The key is the `discourse` tag + # of the flag. See the index.php file for an example on how to use/print the flags. + - name: "Load flags" + loop: "{{ '{{ flags }}' }}" + vars: + key: "{{ '{{ (item.tags).discourse }}' }}" + value: "{{ '{{ item.flag }}' }}" + ansible.builtin.set_fact: + track_flags: "{{ '{{ track_flags | default({}) | combine({key: value}) }}' }}" + + - name: "Running free form: directly with the executable (PowerShell)" + ansible.builtin.raw: | + powershell -c $systeminfo = Get-ComputerInfo + Write-Host "$systeminfo" + + - name: "Running free form: path to cmd.exe" + ansible.builtin.raw: C:\windows\system32\cmd.exe /c whoami && systeminfo + + - name: "Running win_shell: PowerShell" + ansible.windows.win_shell: | + $systeminfo = Get-ComputerInfo + Write-Host "$systeminfo" + + - name: Create temp directory + ansible.windows.win_file: + dest: "C:\\temp" + state: directory + + # Example on how to use flags that were loaded in the "Load Flags" task earlier in the file. + - name: Create flag file + ansible.windows.win_copy: + dest: "C:\\temp\\flag-rce.txt" + content: | + {{ '{{' }} track_flags.{{ data.name | replace("-","_") }}_flag_1 {{ '}}' }} (2/2) + {{ '{{ "" }}' }} + +{% if data.with_build %} + # When using a build container, the unarchive module can be used to install the content on the remote. + - name: Unarchive the content of the build + ansible.builtin.unarchive: + src: /tmp/build.tar + dest: /tmp/ + owner: root + group: root + mode: '0755' +{% endif %} diff --git a/ctf/utils.py b/ctf/utils.py index 9ccfb34..211580d 100644 --- a/ctf/utils.py +++ b/ctf/utils.py @@ -61,6 +61,20 @@ def does_track_require_build_container(track: Track) -> bool: ) and bool(load_yaml_file(build_yaml_file_path)) +def track_has_virtual_machine(track: str | Track) -> bool: + with open( + os.path.join( + find_ctf_root_directory(), + "challenges", + track.name if isinstance(track, Track) else track, + "terraform", + "main.tf", + ), + "r", + ) as f: + return re.search(r'type\s*=\s*"virtual-machine"', f.read()) is not None + + def validate_track_can_be_deployed(track: Track) -> bool: return ( os.path.exists( @@ -249,6 +263,7 @@ def get_terraform_tracks_from_modules() -> set[Track]: remote=remote, production=production, require_build_container=require_build_container, + has_virtual_machine=track_has_virtual_machine(track=name), ) ) name = "" diff --git a/pyproject.toml b/pyproject.toml index 276c4fd..260537e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "typer==0.16.0", "pydantic" ] -version = "4.1.1" +version = "4.2.0" classifiers = [ "Programming Language :: Python :: 3", "Operating System :: OS Independent",