diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..f11cf63 --- /dev/null +++ b/.ruff.toml @@ -0,0 +1 @@ +line-length = 120 diff --git a/mkchal/mkchal.py b/mkchal/mkchal.py index 157c0f3..1623e70 100644 --- a/mkchal/mkchal.py +++ b/mkchal/mkchal.py @@ -1,5 +1,6 @@ from __future__ import annotations +import argparse import os import stat from enum import Enum @@ -9,7 +10,7 @@ from secrets import token_hex # Infra constants -ROOT_DOMAIN = os.getenv("ROOT_DOMAIN", "b01le.rs") # TODO: make it compliant with the testing workflow and VPS +ROOT_DOMAIN = os.getenv("ROOT_DOMAIN", "b01le.rs") # TODO: make it compliant with the testing workflow and VPS HTTP_ENTRY = 443 TCP_SEC_ENTRY = 1337 DOCKER_REGISTRY = "localhost:5000" @@ -50,9 +51,6 @@ # location of the pwn template directory PWN_TEMPLATE_DIR = TEMPLATES_DIR / "pwn" - -import argparse - """ Should be in the structure of type1: [chal.json1, chal.json2...], @@ -62,6 +60,7 @@ loaded_challs = {} DEBUG = False + class ChallengeType(str, Enum): """Describes a CTF challenge type.""" @@ -73,6 +72,7 @@ class ChallengeType(str, Enum): BLOCKCHAIN = "blockchain" OSINT = "osint" + class ChallengeDifficulty(str, Enum): """Describes a CTF challenge difficulty""" @@ -89,14 +89,16 @@ class DeployType(str, Enum): KLODD = "klodd" NO_DEPLOY = "none" + SPECIAL_CHAL_TYPES = (ChallengeType.WEB, ChallengeType.PWN) + def make_file_executable(path: Path): st = os.stat(path) os.chmod(path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) -class ChallengeUtils: +class ChallengeUtils: @staticmethod def validate_name(challenge: Challenge) -> tuple[bool, str]: """Validates a challenge name""" @@ -105,15 +107,18 @@ def validate_name(challenge: Challenge) -> tuple[bool, str]: return (False, "Unloaded challs") for chall_name in loaded_challs[challenge.type.value].keys(): if ChallengeUtils.safe_name(challenge.name) == ChallengeUtils.safe_name(chall_name): - return (False, f"Name {challenge.name} conficts with challenge {chall_name} in category {challenge.type.value}") - return (True, "success") - + return ( + False, + f"Name {challenge.name} conficts with challenge {chall_name} in category {challenge.type.value}", + ) + return True, "success" + @staticmethod def validate_flag(flag: str) -> bool: """Validates whether a flag fits the required format""" - return match(r"^bctf\{.*\}$", flag) != None - + return match(r"^bctf\{.*\}$", flag) is not None + @staticmethod def generate(challenge_obj: Challenge) -> bool: """Generates a challenge. Assumes valid fields""" @@ -126,10 +131,10 @@ def generate(challenge_obj: Challenge) -> bool: @staticmethod def retrieve_valid_port(type: ChallengeType) -> tuple[bool, int]: """returns: (success, port)""" - #TODO create server on b01lers server that generates a valid port + # TODO create server on b01lers server that generates a valid port # We let the user choose a port for now, with traefik this shouldn't be needed - return (False, 0) - + return False, 0 + @staticmethod def load_challenges() -> dict: """ @@ -144,85 +149,132 @@ def load_challenges() -> dict: "blockchain": {}, "web": {}, "misc": {}, - "osint": {} + "osint": {}, } for dir in SRC_DIR.iterdir(): if dir.is_dir() and dir.name in d.keys(): for challenge in dir.iterdir(): d[dir.name][challenge.name] = loads((challenge / CHAL_JSON).read_text()) return d - + @staticmethod def generate_service_name(name: str) -> str: """Ensures uniqueness between challenge service names""" return f"{name}" - + @staticmethod def generate_file_content(filename: Path, kwargs: dict) -> str: """generates the sample file content for a template file""" return filename.read_text().format(**kwargs) - + @staticmethod def safe_name(name: str) -> str: """Creates a safe name for docker services""" - + return sub(r"^-+|-+$", "", sub(r"[^a-z0-9-]", "", sub(" ", "-", name.lower()))) - + @staticmethod def __generate_defaults(challenge_obj: Challenge, challenge: Path) -> None: (challenge / SRC).mkdir(parents=True, exist_ok=DEBUG) (challenge / DIST).mkdir(parents=True, exist_ok=DEBUG) (challenge / SOLVE).mkdir(parents=True, exist_ok=DEBUG) - (challenge / README).write_text(challenge_obj.gen_readme()) - (challenge / CHAL_JSON).write_text(str(challenge_obj)) - (challenge / FLAG).write_text(challenge_obj.flag) + (challenge / README).write_text(challenge_obj.gen_readme(), encoding="utf-8") + (challenge / CHAL_JSON).write_text(str(challenge_obj), encoding="utf-8") + (challenge / FLAG).write_text(challenge_obj.flag, encoding="utf-8") @staticmethod def __generate_deployments(challenge_obj: Challenge, challenge: Path) -> None: if challenge_obj.deploy == DeployType.NO_DEPLOY: return - + (challenge / DEPLOY).mkdir(parents=True, exist_ok=DEBUG) - (challenge / DEPLOY / DOCKERFILE).write_text(challenge_obj.gen_dockerfile()) - (challenge / DEPLOY / COMPOSE).write_text(challenge_obj.gen_docker_compose()) + (challenge / DEPLOY / DOCKERFILE).write_text(challenge_obj.gen_dockerfile(), encoding="utf-8") + (challenge / DEPLOY / COMPOSE).write_text(challenge_obj.gen_docker_compose(), encoding="utf-8") (challenge / DEPLOY / COMPOSE_PROD).write_text( - ChallengeUtils.generate_file_content(TEMPLATES_DIR / COMPOSE_PROD, {}) + ChallengeUtils.generate_file_content(TEMPLATES_DIR / COMPOSE_PROD, {}), + encoding="utf-8", ) - (challenge / DEPLOY / WRAPPER).write_text(challenge_obj.gen_wrapper()) + (challenge / DEPLOY / WRAPPER).write_text(challenge_obj.gen_wrapper(), encoding="utf-8") - (challenge / RUN_SH).write_text(challenge_obj.gen_run_sh()) - (challenge / DEV_SH).write_text(challenge_obj.gen_dev_sh()) + (challenge / RUN_SH).write_text(challenge_obj.gen_run_sh(), encoding="utf-8") + (challenge / DEV_SH).write_text(challenge_obj.gen_dev_sh(), encoding="utf-8") make_file_executable(challenge / RUN_SH) make_file_executable(challenge / DEV_SH) - + if challenge_obj.type == ChallengeType.PWN: # special build Dockerfile and redpwn jail for pwn - (challenge / SRC / SAMPLE_C).write_text(challenge_obj.gen_sample()) - (challenge / SRC / BUILD_SH).write_text(challenge_obj.gen_pwn_build_script()) + (challenge / SRC / SAMPLE_C).write_text(challenge_obj.gen_sample(), encoding="utf-8") + (challenge / SRC / BUILD_SH).write_text(challenge_obj.gen_pwn_build_script(), encoding="utf-8") make_file_executable(challenge / SRC / BUILD_SH) - (challenge / DEPLOY / DOCKERFILE_BUILD).write_text(challenge_obj.gen_pwn_dockerfile_build()) + (challenge / DEPLOY / DOCKERFILE_BUILD).write_text( + challenge_obj.gen_pwn_dockerfile_build(), + encoding="utf-8", + ) # for now pwn only support docker-compose assert challenge_obj.deploy == DeployType.DOCKER_COMPOSE - (challenge / BUILD_DIST).write_text(challenge_obj.gen_pwn_build_dist()) + (challenge / BUILD_DIST).write_text(challenge_obj.gen_pwn_build_dist(), encoding="utf-8") make_file_executable(challenge / BUILD_DIST) - + return - (challenge / SRC / SAMPLE_PY).write_text(challenge_obj.gen_sample()) - + (challenge / SRC / SAMPLE_PY).write_text( + challenge_obj.gen_sample(), + encoding="utf-8", + ) + if challenge_obj.deploy == DeployType.KLODD: - #TODO: b01lers kube interface would be different, wait for vinh's decision - (challenge / DEPLOY / KLODD_YAML).write_text(challenge_obj.gen_klodd_challenge()) + # TODO: b01lers kube interface would be different, wait for vinh's decision + (challenge / DEPLOY / KLODD_YAML).write_text( + challenge_obj.gen_klodd_challenge(), + encoding="utf-8", + ) + class Challenge: """Represents a challenge object""" - __slots__ = ["name", "author", "description", "flag", "type", "deploy", "ports", "hidden", "minPoints", "maxPoints", "tiebreakEligible", "prereqs", "tags", "difficulty", "auto", "registry", "root_domain"] - optional_fields = ["ports", "hidden", "minPoints", "maxPoints", "tiebreakEligible", "prereqs", "tags"] - - def __init__(self, name: str, author: str, description: str, flag: str, type: ChallengeType, deploy: DeployType, difficulty: ChallengeDifficulty, auto:bool=False) -> None: + __slots__ = [ + "name", + "author", + "description", + "flag", + "type", + "deploy", + "ports", + "hidden", + "minPoints", + "maxPoints", + "tiebreakEligible", + "prereqs", + "tags", + "difficulty", + "auto", + "registry", + "root_domain", + ] + optional_fields = [ + "ports", + "hidden", + "minPoints", + "maxPoints", + "tiebreakEligible", + "prereqs", + "tags", + ] + + def __init__( + self, + name: str, + author: str, + description: str, + flag: str, + type: ChallengeType, + deploy: DeployType, + difficulty: ChallengeDifficulty, + auto: bool = False, + ) -> None: self.name = name self.author = author self.description = description @@ -240,7 +292,7 @@ def __init__(self, name: str, author: str, description: str, flag: str, type: Ch self.difficulty = difficulty self.registry = DOCKER_REGISTRY self.root_domain = ROOT_DOMAIN - + def to_json(self) -> dict: """converts a challenge to its valid chal.json output""" d: dict = { @@ -249,14 +301,14 @@ def to_json(self) -> dict: "description": self.description, "flag": self.flag, "difficulty": self.difficulty.value, - "can_be_auto_deployed": self.auto + "can_be_auto_deployed": self.auto, } for field in self.optional_fields: val = getattr(self, field) if isinstance(val, list) and len(val) > 0 or val is not None and not isinstance(val, list): d[field] = val return d - + def gen_readme(self) -> str: """Generates a README.md with instructions on how to setup the directory""" @@ -286,7 +338,7 @@ def gen_readme(self) -> str: ret += f"""\n## Quickstart to challenge development Make sure you develop your challenge on a new branch. You can create one with ```bash -git checkout -b {self.name}_{self.author} +git switch -c {self.name}_{self.author} ```""" if self.deploy == DeployType.DOCKER_COMPOSE: ret += f"""\n### {self.name}/deploy @@ -303,9 +355,9 @@ def gen_readme(self) -> str: - [nsjail](https://github.com/google/nsjail) - [redpwn jail](https://github.com/redpwn/jail). """ - + if self.type == ChallengeType.PWN and self.deploy != DeployType.NO_DEPLOY: - ret += f"""\n### Build system (for pwn challenges) + ret += """\n### Build system (for pwn challenges) The sample files generated for a pwn challenge include a build system which will build your executable and place it in the dist directory. The sample `Dockerfile` uses this executable in dist to run the challenge. You should keep this structure the same when you add your challenge as it is important for the Docker container to run the same binary as you give the competitors. @@ -323,7 +375,7 @@ def gen_readme(self) -> str: - `challenge.yml`: Configuration file defining Klodd deployment settings. If you're new to Klodd, avoid modifying these files without checking with the CTF developers. """ - + ret += f"""\n### {self.name}/dist Contains files distributed to competitors. If multiple files are included, bundle them into a ZIP archive. ### {self.name}/solve @@ -344,19 +396,15 @@ def gen_readme(self) -> str: This README was autogenerated by `mkchal.py`, but written by Neil (CygnusX). Suggestions are welcome. """ return ret - + def gen_dockerfile(self) -> str: """Generates a sample Dockerfile""" - kwargs = { - "name": ChallengeUtils.safe_name(self.name), - "port": self.ports[0] - } + kwargs = {"name": ChallengeUtils.safe_name(self.name), "port": self.ports[0]} if self.type in SPECIAL_CHAL_TYPES: return ChallengeUtils.generate_file_content(TEMPLATES_DIR / self.type.value / DOCKERFILE, kwargs) return ChallengeUtils.generate_file_content(TEMPLATES_DIR / DOCKERFILE, kwargs) - - + def gen_docker_compose(self) -> str: """Generates a sample docker-compose.yml""" safe_name = ChallengeUtils.safe_name(self.name) @@ -364,34 +412,32 @@ def gen_docker_compose(self) -> str: "name": safe_name, "hash": ChallengeUtils.generate_service_name(safe_name), "port": self.ports[0], - "root_domain": self.root_domain + "root_domain": self.root_domain, } if self.type in SPECIAL_CHAL_TYPES: return ChallengeUtils.generate_file_content(TEMPLATES_DIR / self.type.value / COMPOSE, kwargs) return ChallengeUtils.generate_file_content(TEMPLATES_DIR / COMPOSE, kwargs) - + def gen_wrapper(self) -> str: """Generates a sample wrapper.sh""" - + safe_name = ChallengeUtils.safe_name(self.name) - kwargs = { - "name": safe_name - } + kwargs = {"name": safe_name} if self.type in SPECIAL_CHAL_TYPES: return ChallengeUtils.generate_file_content(TEMPLATES_DIR / self.type.value / WRAPPER, kwargs) return ChallengeUtils.generate_file_content(TEMPLATES_DIR / WRAPPER, kwargs) - + def gen_sample(self) -> str: """Generates the sample challenge file""" - kwargs = { - "name": self.name, - "port": self.ports[0] - } + kwargs = {"name": self.name, "port": self.ports[0]} if self.type in SPECIAL_CHAL_TYPES: - return ChallengeUtils.generate_file_content(TEMPLATES_DIR / self.type.value / (SAMPLE_PY if self.type == ChallengeType.WEB else SAMPLE_C), kwargs) + return ChallengeUtils.generate_file_content( + TEMPLATES_DIR / self.type.value / (SAMPLE_PY if self.type == ChallengeType.WEB else SAMPLE_C), + kwargs, + ) return ChallengeUtils.generate_file_content(TEMPLATES_DIR / SAMPLE_PY, kwargs) - + def gen_klodd_challenge(self) -> str: """Generates a sample challenge.yml""" safe_name = ChallengeUtils.safe_name(self.name) @@ -399,7 +445,7 @@ def gen_klodd_challenge(self) -> str: "unsafe_name": self.name, "name": safe_name, "port": self.ports[0], - "image": f"{self.registry}/{safe_name}" + "image": f"{self.registry}/{safe_name}", } if self.type == ChallengeType.WEB: return ChallengeUtils.generate_file_content(TEMPLATES_DIR / self.type.value / KLODD_YAML, kwargs) @@ -408,10 +454,7 @@ def gen_klodd_challenge(self) -> str: def gen_pwn_build_script(self) -> str: """Generates build.sh build script for pwn challenges""" safe_name = ChallengeUtils.safe_name(self.name) - kwargs = { - "name": safe_name, - "port": self.ports[0] - } + kwargs = {"name": safe_name, "port": self.ports[0]} assert self.type == ChallengeType.PWN return ChallengeUtils.generate_file_content(PWN_TEMPLATE_DIR / BUILD_SH, kwargs) @@ -422,7 +465,7 @@ def gen_pwn_dockerfile_build(self) -> str: kwargs = { "name": safe_name, "hash": ChallengeUtils.generate_service_name(safe_name), - "port": self.ports[0] + "port": self.ports[0], } assert self.type == ChallengeType.PWN @@ -434,12 +477,12 @@ def gen_pwn_build_dist(self) -> str: kwargs = { "name": safe_name, "port": self.ports[0], - "hash": ChallengeUtils.generate_service_name(safe_name) + "hash": ChallengeUtils.generate_service_name(safe_name), } assert self.type == ChallengeType.PWN return ChallengeUtils.generate_file_content(PWN_TEMPLATE_DIR / BUILD_DIST, kwargs) - + def gen_run_sh(self): safe_name = ChallengeUtils.safe_name(self.name) subdomain = ChallengeUtils.generate_service_name(safe_name) @@ -447,34 +490,32 @@ def gen_run_sh(self): "name": safe_name, "remote_command": ( f"curl https://{subdomain}.{ROOT_DOMAIN}" - if self.type == ChallengeType.WEB + if self.type == ChallengeType.WEB else f"ncat --ssl {subdomain}.{ROOT_DOMAIN} {TCP_SEC_ENTRY}" ), - "registry": self.registry + "registry": self.registry, } if self.type == ChallengeType.WEB and self.deploy == DeployType.KLODD: return ChallengeUtils.generate_file_content(TEMPLATES_DIR / self.type.value / "klodd" / RUN_SH, kwargs) return ChallengeUtils.generate_file_content(TEMPLATES_DIR / RUN_SH, kwargs) - + def gen_dev_sh(self): safe_name = ChallengeUtils.safe_name(self.name) kwargs = { "name": safe_name, "local_command": ( - "curl http://localhost:1337" - if self.type == ChallengeType.WEB - else "ncat localhost 1337" - ) + "curl http://localhost:1337" if self.type == ChallengeType.WEB else "ncat localhost 1337" + ), } return ChallengeUtils.generate_file_content(TEMPLATES_DIR / DEV_SH, kwargs) - + def create(self) -> bool: """Creates the challenge structure for a challenge""" return ChallengeUtils.generate(self) - + def __repr__(self) -> str: return dumps(self.to_json(), indent=4) - + if __name__ == "__main__": print() @@ -484,42 +525,22 @@ def __repr__(self) -> str: print(e) print("Error: " + "Challenge repo is malformed") exit() - parser = argparse.ArgumentParser(prog='mkchal', description='Creates a sample challenge for a ctf') + parser = argparse.ArgumentParser(prog="mkchal", description="Creates a sample challenge for a ctf") - parser.add_argument( - "--name", - type=str, - required=True, - help="The name of the challenge." - ) + parser.add_argument("--name", type=str, required=True, help="The name of the challenge.") - parser.add_argument( - "--desc", - type=str, - required=True, - help="The description of the challenge." - ) + parser.add_argument("--desc", type=str, required=True, help="The description of the challenge.") - parser.add_argument( - "--author", - type=str, - required=True, - help="The author of the challenge." - ) + parser.add_argument("--author", type=str, required=True, help="The author of the challenge.") - parser.add_argument( - "--flag", - type=str, - required=True, - help="The challenge flag." - ) + parser.add_argument("--flag", type=str, required=True, help="The challenge flag.") parser.add_argument( "--type", type=ChallengeType, required=True, choices=[c.value for c in ChallengeType], - help="The type of the challenge." + help="The type of the challenge.", ) parser.add_argument( @@ -527,7 +548,7 @@ def __repr__(self) -> str: type=DeployType, required=True, choices=[c.value for c in DeployType], - help="How the challenge will be deployed" + help="How the challenge will be deployed", ) parser.add_argument( @@ -542,7 +563,7 @@ def __repr__(self) -> str: "--autodeploy", type=bool, required=True, - choices=[b for b in [False, True]], + choices=[False, True].copy(), help="Whether or not the challenge can be automatically deployed.", ) @@ -564,8 +585,9 @@ def __repr__(self) -> str: args.type, args.deploy, args.difficulty, - args.autodeploy) - + args.autodeploy, + ) + if args.ports: c.ports = args.ports elif args.deploy != DeployType.NO_DEPLOY: @@ -581,9 +603,9 @@ def __repr__(self) -> str: if not conflict: print("Error: " + r"Flag does not match ^bctf\{.*\}$") exit() - + conflict = ChallengeUtils.generate(c) if conflict: - print(f"Done. Run `git checkout -b {c.name}_{c.author}` to switch to a branch and start working.") + print(f"Done. Run `git switch -c {c.name}_{c.author}` to switch to a branch and start working.") else: print("Error: Failed to create challenge.") diff --git a/mkchal/templates/dev.sh b/mkchal/templates/dev.sh index 88ddd78..87bfeb4 100644 --- a/mkchal/templates/dev.sh +++ b/mkchal/templates/dev.sh @@ -1,7 +1,17 @@ #!/bin/sh set -e + +if command -v docker >/dev/null 2>&1; then + runner="sudo docker" +elif command -v podman >/dev/null 2>&1; then + runner="podman" +else + echo "Docker/Podman not found" + exit 1 +fi + cd -- "$(dirname -- "$0")/deploy" -sudo docker compose up -d --build chall +$runner compose up -d --build chall echo ' diff --git a/mkchal/templates/run.sh b/mkchal/templates/run.sh index df4f6a6..3776fe1 100644 --- a/mkchal/templates/run.sh +++ b/mkchal/templates/run.sh @@ -1,10 +1,20 @@ #!/bin/sh set -e cd -- "$(dirname -- "$0")/deploy" + +if command -v docker >/dev/null 2>&1; then + runner="docker" +elif command -v podman >/dev/null 2>&1; then + runner="podman" +else + echo "Docker/Podman not found" + exit 1 +fi + if [ -f docker-compose.prod.yml ]; then - docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build chall + $runner compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build chall else - docker compose up -d --build chall + $runner compose up -d --build chall fi echo '