Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ challenges/*/terraform/versions.tf
*.egg-info

.vscode/

.idea
50 changes: 48 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,48 @@
# CTF Script

## Usage
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
- Ansible to configure the challenges

Setup a `CTF_ROOT_DIR` environment variable to make the script execute in the right folder or execute the script from that folder.
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,
where 700+ participants compete in a 48-hour long CTF competition.

## Features and Usage

- `ctf init` to initialize a new ctf repository
- `ctf new` to create a new challenge. Supports templates for common challenge types.
- `ctf deploy` deploys the challenges to a local (or remote) Incus server
- `ctf validate` runs lots of static checks (including JSON Schemas) on the challenges to ensure quality
- `ctf stats` provide lots of helpful statistics about the CTF
- and many more. See `ctf --help` for the full list of commands.

To run `ctf` from any directory, set up the `CTF_ROOT_DIR` environment variable to make the script
execute in the right directory or execute the script from that directory. If not set, `ctf` will go up the directory
tree until it finds `challenges/` and `.deploy` directories, which is the root of the CTF repository.

## Structure of a CTF repository

```
my-ctf/
├── challenges/ # Directory containing all the tracks
│ ├── track1/ # Directory for a specific track that contains N flags.
│ │ ├── track.yaml # Main file that describes the track
│ │ ├── files/ # Directory that contains all the files available for download in the track
│ │ │ ├── somefile.zip
│ │ ├── ansible/ # Directory containing Ansible playbooks to configure the track
│ │ │ ├── deploy.yaml # Main playbook to deploy the track
│ │ │ └── inventory # Inventory file for Ansible
│ │ ├── terraform/ # Directory containing OpenTofu (terraform fork) files to describe the infrastructure
│ │ │ └── main.tf # Main OpenTofu file to deploy the track
│ │ ├── posts/ # Directory containing forum posts related to the track
│ │ │ ├── track1.yaml # Initial post for the track
│ │ │ └── track1_flag1.yaml # Inventory file for Ansible

```

## Installation

Expand All @@ -18,6 +58,12 @@ Install with pipx:
pipx install git+https://github.com/nsec/ctf-script.git
```

Install with pip:

```bash
pip install git+https://github.com/nsec/ctf-script.git
```

### Add Bash/Zsh autocompletion to .bashrc

```bash
Expand Down
21 changes: 16 additions & 5 deletions ctf/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,14 @@ def terraform_binary() -> str:


def init(args: argparse.Namespace) -> None:
if os.path.isdir(os.path.join(args.path, "challenges")) or os.path.isdir(
os.path.join(args.path, ".deploy")
):
LOG.error(f"Directory {args.path} is already initialized.")
if (
os.path.isdir(os.path.join(args.path, "challenges"))
or os.path.isdir(os.path.join(args.path, ".deploy"))
) and not args.force:
LOG.error(
f"Directory {args.path} is already initialized. Use --force to overwrite."
)
LOG.error(args.force)
exit(code=1)

created_assets: list[str] = []
Expand All @@ -105,7 +109,7 @@ def init(args: argparse.Namespace) -> None:
for asset in os.listdir(p := os.path.join(TEMPLATES_ROOT_DIRECTORY, "init")):
dst_asset = os.path.join(args.path, asset)
if os.path.isdir(src_asset := os.path.join(p, asset)):
shutil.copytree(src_asset, dst_asset)
shutil.copytree(src_asset, dst_asset, dirs_exist_ok=True)
LOG.info(f"Created {dst_asset} folder")
else:
shutil.copy(src_asset, dst_asset)
Expand Down Expand Up @@ -1356,6 +1360,13 @@ def main():
default=CTF_ROOT_DIRECTORY,
help="Initialize the folder at the given path.",
)
parser_init.add_argument(
"--force",
"-f",
action="store_true",
default=False,
help="Overwrite the directory if it's already initialized.",
)

parser_flags = subparsers.add_parser(
"flags",
Expand Down
18 changes: 18 additions & 0 deletions ctf/templates/init/.devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": {
"ghcr.io/robbert229/devcontainer-features/opentofu:1": {
"version": "1.9.0"
},
"ghcr.io/devcontainers-extra/features/ansible:2": {}
},
"runArgs": [
"--privileged",
"--cap-add=SYS_PTRACE",
"--security-opt", "seccomp=unconfined",
"--cgroupns=host",
"--pid=host",
"--volume", "/dev:/dev",
"--volume", "/lib/modules:/lib/modules:ro"
]
}
2 changes: 2 additions & 0 deletions ctf/templates/init/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@ challenges/*/terraform/versions.tf
*.egg-info

.vscode/
!.vscode/settings.json
!.vscode/extensions.json

8 changes: 8 additions & 0 deletions ctf/templates/init/.vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"recommendations": [
"redhat.vscode-yaml",
"hashicorp.terraform",
"github.codespaces",
"redhat.ansible"
]
}
9 changes: 9 additions & 0 deletions ctf/templates/init/.vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"[yaml]": {
"editor.tabSize": 2
},
"yaml.schemas": {
"scripts/schemas/track.yaml.json": "challenges/**/track.yaml",
"scripts/schemas/post.json": "challenges/*/posts/*.yaml"
}
}
Loading