From 3fd3668d15495fa058bcd4d209e4315bba452fb5 Mon Sep 17 00:00:00 2001 From: Yan Date: Sun, 3 Aug 2025 19:20:24 -0700 Subject: [PATCH 1/4] set a 60-second timeout for individual testcases --- .github/workflows/test.yml | 2 ++ test/local-tester.sh | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 55ccad064..2f4c69728 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,6 +42,7 @@ jobs: pytest \ pytest-dependency \ pytest-order \ + pytest-timeout \ pytest-github-actions-annotate-failures \ requests \ selenium @@ -141,6 +142,7 @@ jobs: pytest \ pytest-dependency \ pytest-order \ + pytest-timeout \ pytest-github-actions-annotate-failures \ requests \ selenium diff --git a/test/local-tester.sh b/test/local-tester.sh index d8ef6dca4..f8281294a 100755 --- a/test/local-tester.sh +++ b/test/local-tester.sh @@ -245,6 +245,6 @@ log_endgroup if [ "$TEST" == "yes" ]; then log_newgroup "Running tests" export MOZ_HEADLESS=1 - pytest --order-dependencies -v test "$@" + pytest --order-dependencies --timeout=60 -v test "$@" log_endgroup fi From 023034f9d731dd7d379b64756d7c92fb353bb182 Mon Sep 17 00:00:00 2001 From: Yan Date: Sun, 3 Aug 2025 19:21:24 -0700 Subject: [PATCH 2/4] move db_sql and get_user_id to test utils --- test/test_challenges.py | 12 +----------- test/utils.py | 9 +++++++++ 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/test/test_challenges.py b/test/test_challenges.py index ec48a06ea..dbf2e847f 100644 --- a/test/test_challenges.py +++ b/test/test_challenges.py @@ -3,7 +3,7 @@ import json import re -from utils import DOJO_URL, dojo_run, workspace_run, start_challenge, solve_challenge +from utils import DOJO_URL, dojo_run, workspace_run, start_challenge, solve_challenge, db_sql, get_user_id def check_mount(path, *, user, fstype=None, check_nosuid=True): try: @@ -23,16 +23,6 @@ def check_mount(path, *, user, fstype=None, check_nosuid=True): assert "nosuid" in filesystem["options"], f"Expected '{path}' to be mounted nosuid, but got: {filesystem}" -def db_sql(sql): - db_result = dojo_run("db", "-qAt", input=sql) - return db_result.stdout - - -def get_user_id(user_name): - return int(db_sql(f"SELECT id FROM users WHERE name = '{user_name}'")) - - - @pytest.mark.dependency(depends=["test/test_dojos.py::test_create_dojo"], scope="session") def test_start_challenge(admin_session): start_challenge("example", "hello", "apple", session=admin_session) diff --git a/test/utils.py b/test/utils.py index dd644ae19..84c9b657c 100644 --- a/test/utils.py +++ b/test/utils.py @@ -95,3 +95,12 @@ def solve_challenge(dojo, module, challenge, *, session, flag=None, user=None): ) assert response.status_code == 200, f"Expected status code 200, but got {response.status_code}" assert response.json()["success"], "Expected to successfully submit flag" + + +def db_sql(sql): + db_result = dojo_run("db", "-qAt", input=sql) + return db_result.stdout + + +def get_user_id(user_name): + return int(db_sql(f"SELECT id FROM users WHERE name = '{user_name}'")) From 1ca3c8e4ff0c9ac83842e0fb47f7cab5b6605576 Mon Sep 17 00:00:00 2001 From: Yan Date: Sun, 3 Aug 2025 20:33:03 -0700 Subject: [PATCH 3/4] initial implementation of dojo command --- docker-compose.yml | 18 ++++ dojo-socket/Dockerfile | 16 ++++ dojo-socket/dojo-socket-service.py | 127 +++++++++++++++++++++++++++++ dojo/dojo-init | 1 + dojo_plugin/api/__init__.py | 2 + dojo_plugin/api/v1/docker.py | 6 ++ dojo_plugin/api/v1/integrations.py | 79 ++++++++++++++++++ test/test_dojo_command.py | 60 ++++++++++++++ test/utils.py | 2 +- workspace/core/dojo-command.nix | 104 +++++++++++++++++++++++ workspace/core/init.nix | 6 ++ workspace/flake.nix | 2 + 12 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 dojo-socket/Dockerfile create mode 100644 dojo-socket/dojo-socket-service.py create mode 100644 dojo_plugin/api/v1/integrations.py create mode 100644 test/test_dojo_command.py create mode 100644 workspace/core/dojo-command.nix diff --git a/docker-compose.yml b/docker-compose.yml index b0f809d6f..a925054d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -159,6 +159,24 @@ services: condition: service_started cache: condition: service_started + dojo-socket: + condition: service_started + + dojo-socket: + container_name: dojo-socket + profiles: + - main + hostname: dojo-socket + build: ./dojo-socket + restart: always + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - /data/dojo-command:/var/run/dojo-command + healthcheck: + test: ["CMD", "test", "-S", "/var/run/dojo-command/socket"] + interval: 10s + timeout: 5s + retries: 3 db: container_name: db diff --git a/dojo-socket/Dockerfile b/dojo-socket/Dockerfile new file mode 100644 index 000000000..1bff189a0 --- /dev/null +++ b/dojo-socket/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +# Install required packages +RUN pip install --no-cache-dir docker + +# Create directory for socket +RUN mkdir -p /var/run/dojo + +# Copy socket service script +COPY dojo-socket-service.py /app/dojo-socket-service.py + +# Set working directory +WORKDIR /app + +# Run the socket service +CMD ["python3", "/app/dojo-socket-service.py"] \ No newline at end of file diff --git a/dojo-socket/dojo-socket-service.py b/dojo-socket/dojo-socket-service.py new file mode 100644 index 000000000..309754ba8 --- /dev/null +++ b/dojo-socket/dojo-socket-service.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 + +import socket +import os +import json +import logging +import threading +import struct +import traceback +from pathlib import Path + +import docker + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + +SOCKET_PATH = "/var/run/dojo-command/socket" +CTFD_URL = "http://ctfd:8000" + + +class DojoSocketService: + def __init__(self): + self.docker_client = docker.from_env() + + def start(self): + os.makedirs(os.path.dirname(SOCKET_PATH), exist_ok=True) + + if os.path.exists(SOCKET_PATH): + os.unlink(SOCKET_PATH) + + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server.bind(SOCKET_PATH) + os.chmod(SOCKET_PATH, 0o666) + server.listen(5) + + logger.info(f"Dojo socket service listening on {SOCKET_PATH}") + + while True: + try: + conn, _ = server.accept() + thread = threading.Thread(target=self.handle_client, args=(conn,)) + thread.daemon = True + thread.start() + except Exception as e: + logger.error(f"Error accepting connection: {e}") + + def handle_client(self, conn): + try: + data = self.recv_message(conn) + if not data: + return + + request = json.loads(data.decode()) + command = request.get("command") + + if command == "submit_flag": + response = self.handle_submit_flag(request) + else: + response = {"success": False, "error": "Unknown command"} + + self.send_message(conn, json.dumps(response).encode()) + + except Exception as e: + logger.error(f"Error handling client: {e}") + logger.error(traceback.format_exc()) + error_response = {"success": False, "error": "Internal server error"} + try: + self.send_message(conn, json.dumps(error_response).encode()) + except: + pass + finally: + conn.close() + + def recv_message(self, conn): + length_data = conn.recv(4) + if len(length_data) != 4: + return None + length = struct.unpack("!I", length_data)[0] + + data = b"" + while len(data) < length: + chunk = conn.recv(min(length - len(data), 4096)) + if not chunk: + return None + data += chunk + return data + + def send_message(self, conn, data): + conn.sendall(struct.pack("!I", len(data)) + data) + + def handle_submit_flag(self, request): + auth_token = request.get("auth_token") + flag = request.get("flag") + + if not all([auth_token, flag]): + return {"success": False, "error": "Missing required parameters"} + + import requests + + try: + response = requests.post( + f"{CTFD_URL}/pwncollege_api/v1/integrations/solve", + json={"auth_code": auth_token, "submission": flag}, + timeout=10 + ) + + result = response.json() + + if response.status_code == 200: + if result.get("status") == "already_solved": + return {"success": True, "message": "You already solved this challenge!"} + else: + return {"success": True, "message": "Congratulations! Flag accepted!"} + else: + return {"success": False, "message": result.get("message", result.get("status", "Flag submission failed"))} + + except requests.exceptions.RequestException as e: + logger.error(f"Error calling solve API: {e}") + return {"success": False, "error": "Failed to submit flag to server"} + except Exception as e: + logger.error(f"Unexpected error: {e}") + return {"success": False, "error": "Internal server error"} + + +if __name__ == "__main__": + service = DojoSocketService() + service.start() diff --git a/dojo/dojo-init b/dojo/dojo-init index 5b81a91c3..2582fe28b 100755 --- a/dojo/dojo-init +++ b/dojo/dojo-init @@ -90,6 +90,7 @@ fi mkdir -p /data/workspace/nix mkdir -p /data/workspacefs/bin mkdir -p /data/ctfd-ipython +mkdir -p /data/dojo-command mkdir -p /data/homes if [ "$(findmnt -n -o FSTYPE /data/homes)" != "btrfs" ] && [ "$(findmnt -n -o FSTYPE /data)" != "btrfs" ]; then diff --git a/dojo_plugin/api/__init__.py b/dojo_plugin/api/__init__.py index 0e9ba21e9..68edaf1c7 100644 --- a/dojo_plugin/api/__init__.py +++ b/dojo_plugin/api/__init__.py @@ -11,6 +11,7 @@ from .v1.workspace_tokens import workspace_tokens_namespace from .v1.workspace import workspace_namespace from .v1.search import search_namespace +from .v1.integrations import integrations_namespace api = Blueprint("pwncollege_api", __name__) @@ -26,3 +27,4 @@ api_v1.add_namespace(workspace_tokens_namespace, "/workspace_tokens") api_v1.add_namespace(workspace_namespace, "/workspace") api_v1.add_namespace(search_namespace, "/search") +api_v1.add_namespace(integrations_namespace, "/integrations") diff --git a/dojo_plugin/api/v1/docker.py b/dojo_plugin/api/v1/docker.py index 21eda17cc..c9171d353 100644 --- a/dojo_plugin/api/v1/docker.py +++ b/dojo_plugin/api/v1/docker.py @@ -122,6 +122,12 @@ def start_container(docker_client, user, as_user, user_mounts, dojo_challenge, p read_only=True, propagation="slave", ), + docker.types.Mount( + "/run/dojo/socket", + f"{HOST_DATA_PATH}/dojo-command/socket", + "bind", + read_only=True, + ), *user_mounts, ] diff --git a/dojo_plugin/api/v1/integrations.py b/dojo_plugin/api/v1/integrations.py new file mode 100644 index 000000000..62c007fe0 --- /dev/null +++ b/dojo_plugin/api/v1/integrations.py @@ -0,0 +1,79 @@ +import logging +import docker +from flask import request, session +from flask_restx import Namespace, Resource +from CTFd.plugins.challenges import get_chal_class +from CTFd.plugins import bypass_csrf_protection +from CTFd.models import Users, Solves + +from ...models import DojoChallenges + +logger = logging.getLogger(__name__) + +integrations_namespace = Namespace( + "integrations", description="Endpoints for external integrations", + decorators=[bypass_csrf_protection] +) + + +def authenticate_container(auth_code): + if not auth_code: + return None, ({"success": False, "error": "Missing auth_code"}, 400) + + docker_client = docker.DockerClient(base_url="unix://var/run/docker.sock") + + container = None + try: + for c in docker_client.containers.list(): + if c.labels.get("dojo.auth_token") == auth_code: + container = c + break + except Exception as e: + logger.error(f"Error listing containers: {e}") + return None, ({"success": False, "error": "Failed to verify authentication"}, 500) + + if not container: + return None, ({"success": False, "error": "Invalid authentication code"}, 401) + + user_id = int(container.labels.get("dojo.as_user_id")) + dojo_id = container.labels.get("dojo.dojo_id") + module_id = container.labels.get("dojo.module_id") + challenge_id = container.labels.get("dojo.challenge_id") + + user = Users.query.filter_by(id=user_id).one() + dojo_challenge = (DojoChallenges.from_id(dojo_id, module_id, challenge_id) + .filter(DojoChallenges.visible()).one()) + + session["id"] = user_id + return (user, dojo_challenge), None + + +@integrations_namespace.route("/solve") +class IntegrationSolve(Resource): + def post(self): + data = request.get_json() + auth_code = data.get("auth_code") + submission = data.get("submission") + + if not submission: + return {"success": False, "error": "Missing submission"}, 400 + + auth_result, error_response = authenticate_container(auth_code) + if error_response: + return error_response + + user, dojo_challenge = auth_result + + solve = Solves.query.filter_by(user=user, challenge=dojo_challenge.challenge).first() + if solve: + return {"success": True, "status": "already_solved"} + + chal_class = get_chal_class(dojo_challenge.challenge.type) + request.form = {"submission": submission} + status, _ = chal_class.attempt(dojo_challenge.challenge, request) + if status: + chal_class.solve(user, None, dojo_challenge.challenge, request) + return {"success": True, "status": "solved"} + else: + chal_class.fail(user, None, dojo_challenge.challenge, request) + return {"success": False, "status": "incorrect"}, 400 diff --git a/test/test_dojo_command.py b/test/test_dojo_command.py new file mode 100644 index 000000000..93ef403e3 --- /dev/null +++ b/test/test_dojo_command.py @@ -0,0 +1,60 @@ +import subprocess +import requests +import pytest +import time + +from utils import DOJO_URL, workspace_run, start_challenge, solve_challenge, get_user_id + + +def test_integrations_solve_endpoint(example_dojo, random_user): + uid, session = random_user + start_challenge(example_dojo, "hello", "apple", session=session) + + auth_token = workspace_run("cat /run/dojo/var/auth_token", user=uid, root=True).stdout.strip() + flag = workspace_run("cat /flag", user=uid, root=True).stdout.strip() + + response = requests.post( + f"{DOJO_URL}/pwncollege_api/v1/integrations/solve", + json={"auth_code": auth_token, "submission": flag} + ) + assert response.status_code == 200 + assert response.json()["status"] == "solved" + + response = requests.post( + f"{DOJO_URL}/pwncollege_api/v1/integrations/solve", + json={"auth_code": auth_token, "submission": flag} + ) + assert response.status_code == 200 + assert response.json()["status"] == "already_solved" + + +def test_dojo_command_files(example_dojo, random_user): + """Test that socket has correct permissions""" + uid, session = random_user + start_challenge(example_dojo, "hello", "apple", session=session) + + result = workspace_run("ls -la /run/dojo/bin/dojo", user=uid) + assert result.returncode == 0, f"dojo command should exist, got {result.stderr}" + + result = workspace_run("ls -la /run/dojo/socket", user=uid) + assert result.returncode == 0, f"Socket should exist, got {result.stderr}" + assert "srw" in result.stdout, f"Expected socket file, got {result.stdout}" + + +def test_dojo_command_submit(random_user, example_dojo): + uid, session = random_user + start_challenge(example_dojo, "hello", "apple", session=session) + + result = workspace_run("dojo submit 'pwn.college{wrong_flag}'", user=uid, check=False) + assert "incorrect" in result.stdout, f"Expected error message, got {result.stdout}" + assert result.returncode == 1, f"Expected failure, got return code {result.returncode}" + + flag = workspace_run("cat /flag", user=uid, root=True).stdout.strip() + result = workspace_run(f"dojo submit '{flag}'", user=uid, check=False) + assert "Congratulations! Flag accepted!" in result.stdout, f"Expected success message, got {result.stdout}" + assert result.returncode == 0, f"Expected success, got return code {result.returncode}" + + flag = workspace_run("cat /flag", user=uid, root=True).stdout.strip() + result = workspace_run(f"dojo submit '{flag}'", user=uid, check=False) + assert "You already solved this challenge!" in result.stdout, f"Expected already solved message, got {result.stdout}" + assert result.returncode == 0, f"Expected success, got return code {result.returncode}" diff --git a/test/utils.py b/test/utils.py index 84c9b657c..f038f0f0f 100644 --- a/test/utils.py +++ b/test/utils.py @@ -72,7 +72,7 @@ def workspace_run(cmd, *, user, root=False, **kwargs): if root: args += [ "-s" ] args += [ user ] - return dojo_run(*args, input=cmd, check=True, **kwargs) + return dojo_run(*args, input=cmd, check=kwargs.pop("check", True), **kwargs) def start_challenge(dojo, module, challenge, practice=False, *, session, as_user=None, wait=0): diff --git a/workspace/core/dojo-command.nix b/workspace/core/dojo-command.nix new file mode 100644 index 000000000..345b5c255 --- /dev/null +++ b/workspace/core/dojo-command.nix @@ -0,0 +1,104 @@ +{ pkgs }: + +# This creates the /run/dojo/bin/dojo command available in user containers +# The command communicates with the socket service running in the CTFd container +pkgs.writeScriptBin "dojo" '' + #!${pkgs.python3}/bin/python3 + + import socket + import json + import sys + import os + import struct + import argparse + + SOCKET_PATH = "/run/dojo/socket" + + def send_message(sock, data): + sock.sendall(struct.pack("!I", len(data)) + data) + + def recv_message(sock): + length_data = sock.recv(4) + if len(length_data) != 4: + return None + length = struct.unpack("!I", length_data)[0] + + data = b"" + while len(data) < length: + chunk = sock.recv(min(length - len(data), 4096)) + if not chunk: + return None + data += chunk + return data + + def submit_flag(flag): + if not os.path.exists(SOCKET_PATH): + print("Error: Dojo socket not available. Are you running this inside a challenge container?") + return 1 + + if int(open("/run/dojo/sys/workspace/privileged").read()): + print("Error: workspace is in practice mode. Flag submission disabled.") + return 1 + + auth_token = os.environ.get("DOJO_AUTH_TOKEN") + if not auth_token: + print("Error: Authentication token not found (DOJO_AUTH_TOKEN not set)") + return 1 + + try: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.connect(SOCKET_PATH) + + request = { + "command": "submit_flag", + "auth_token": auth_token, + "flag": flag + } + + send_message(sock, json.dumps(request).encode()) + response_data = recv_message(sock) + + if not response_data: + print("Error: No response from server") + return 1 + + response = json.loads(response_data.decode()) + + if response.get("success"): + print(f"✓ {response.get('message', 'Flag submitted successfully!')}") + return 0 + else: + print(f"✗ {response.get('message', 'Unknown error')}") + return 1 + + except socket.error as e: + print(f"Error: Unable to connect to dojo service: {e}") + return 1 + except Exception as e: + print(f"Error: {e}") + return 1 + finally: + sock.close() + + def main(): + parser = argparse.ArgumentParser( + description="Dojo command-line tool for pwn.college", + prog="dojo" + ) + + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + submit_parser = subparsers.add_parser("submit", help="Submit a flag") + submit_parser.add_argument("flag", help="The flag to submit") + + args = parser.parse_args() + + if args.command == "submit": + return submit_flag(args.flag) + else: + parser.print_help() + return 1 + + if __name__ == "__main__": + sys.exit(main()) +'' diff --git a/workspace/core/init.nix b/workspace/core/init.nix index 962c68f68..ae6880101 100644 --- a/workspace/core/init.nix +++ b/workspace/core/init.nix @@ -16,6 +16,12 @@ let for path in /run/current-system/sw/*; do ln -sfT $path /run/dojo/$(basename $path) done + + # Ensure dojo command is available if it exists + if [ -x /nix/store/*/bin/dojo ]; then + mkdir -p /run/dojo/bin + ln -sf /nix/store/*/bin/dojo /run/dojo/bin/dojo + fi mkdir -pm 1777 /run/dojo/var /tmp mkdir /run/dojo/var/root diff --git a/workspace/flake.nix b/workspace/flake.nix index 85d0aa012..cdf153441 100644 --- a/workspace/flake.nix +++ b/workspace/flake.nix @@ -62,6 +62,7 @@ exec-suid = import ./core/exec-suid.nix { inherit pkgs; }; sudo = import ./core/sudo.nix { inherit pkgs; }; ssh-entrypoint = import ./core/ssh-entrypoint.nix { inherit pkgs; }; + dojo-command = import ./core/dojo-command.nix { inherit pkgs; }; service = import ./services/service.nix { inherit pkgs; }; code-service = import ./services/code.nix { inherit pkgs; }; desktop-service = import ./services/desktop.nix { inherit pkgs; }; @@ -113,6 +114,7 @@ exec-suid sudo ssh-entrypoint + dojo-command service code-service desktop-service From 75f85f5e90d822d0fa582e3bbcfc0e2b6995670a Mon Sep 17 00:00:00 2001 From: Yan Date: Mon, 4 Aug 2025 12:49:36 -0700 Subject: [PATCH 4/4] switch to direct ctfd communication for the dojo command --- docker-compose.yml | 22 +---- dojo-socket/Dockerfile | 16 ---- dojo-socket/dojo-socket-service.py | 127 ----------------------------- dojo/dojo-init | 2 +- dojo_plugin/api/v1/docker.py | 7 +- dojo_plugin/api/v1/integrations.py | 12 +-- test/test_dojo_command.py | 9 +- workspace/core/dojo-command.nix | 70 ++++++---------- 8 files changed, 38 insertions(+), 227 deletions(-) delete mode 100644 dojo-socket/Dockerfile delete mode 100644 dojo-socket/dojo-socket-service.py diff --git a/docker-compose.yml b/docker-compose.yml index a925054d1..46e33eb47 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -159,24 +159,10 @@ services: condition: service_started cache: condition: service_started - dojo-socket: - condition: service_started - - dojo-socket: - container_name: dojo-socket - profiles: - - main - hostname: dojo-socket - build: ./dojo-socket - restart: always - volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro - - /data/dojo-command:/var/run/dojo-command - healthcheck: - test: ["CMD", "test", "-S", "/var/run/dojo-command/socket"] - interval: 10s - timeout: 5s - retries: 3 + networks: + default: + workspace_net: + ipv4_address: 10.0.0.2 db: container_name: db diff --git a/dojo-socket/Dockerfile b/dojo-socket/Dockerfile deleted file mode 100644 index 1bff189a0..000000000 --- a/dojo-socket/Dockerfile +++ /dev/null @@ -1,16 +0,0 @@ -FROM python:3.12-slim - -# Install required packages -RUN pip install --no-cache-dir docker - -# Create directory for socket -RUN mkdir -p /var/run/dojo - -# Copy socket service script -COPY dojo-socket-service.py /app/dojo-socket-service.py - -# Set working directory -WORKDIR /app - -# Run the socket service -CMD ["python3", "/app/dojo-socket-service.py"] \ No newline at end of file diff --git a/dojo-socket/dojo-socket-service.py b/dojo-socket/dojo-socket-service.py deleted file mode 100644 index 309754ba8..000000000 --- a/dojo-socket/dojo-socket-service.py +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env python3 - -import socket -import os -import json -import logging -import threading -import struct -import traceback -from pathlib import Path - -import docker - -logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) - -SOCKET_PATH = "/var/run/dojo-command/socket" -CTFD_URL = "http://ctfd:8000" - - -class DojoSocketService: - def __init__(self): - self.docker_client = docker.from_env() - - def start(self): - os.makedirs(os.path.dirname(SOCKET_PATH), exist_ok=True) - - if os.path.exists(SOCKET_PATH): - os.unlink(SOCKET_PATH) - - server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - server.bind(SOCKET_PATH) - os.chmod(SOCKET_PATH, 0o666) - server.listen(5) - - logger.info(f"Dojo socket service listening on {SOCKET_PATH}") - - while True: - try: - conn, _ = server.accept() - thread = threading.Thread(target=self.handle_client, args=(conn,)) - thread.daemon = True - thread.start() - except Exception as e: - logger.error(f"Error accepting connection: {e}") - - def handle_client(self, conn): - try: - data = self.recv_message(conn) - if not data: - return - - request = json.loads(data.decode()) - command = request.get("command") - - if command == "submit_flag": - response = self.handle_submit_flag(request) - else: - response = {"success": False, "error": "Unknown command"} - - self.send_message(conn, json.dumps(response).encode()) - - except Exception as e: - logger.error(f"Error handling client: {e}") - logger.error(traceback.format_exc()) - error_response = {"success": False, "error": "Internal server error"} - try: - self.send_message(conn, json.dumps(error_response).encode()) - except: - pass - finally: - conn.close() - - def recv_message(self, conn): - length_data = conn.recv(4) - if len(length_data) != 4: - return None - length = struct.unpack("!I", length_data)[0] - - data = b"" - while len(data) < length: - chunk = conn.recv(min(length - len(data), 4096)) - if not chunk: - return None - data += chunk - return data - - def send_message(self, conn, data): - conn.sendall(struct.pack("!I", len(data)) + data) - - def handle_submit_flag(self, request): - auth_token = request.get("auth_token") - flag = request.get("flag") - - if not all([auth_token, flag]): - return {"success": False, "error": "Missing required parameters"} - - import requests - - try: - response = requests.post( - f"{CTFD_URL}/pwncollege_api/v1/integrations/solve", - json={"auth_code": auth_token, "submission": flag}, - timeout=10 - ) - - result = response.json() - - if response.status_code == 200: - if result.get("status") == "already_solved": - return {"success": True, "message": "You already solved this challenge!"} - else: - return {"success": True, "message": "Congratulations! Flag accepted!"} - else: - return {"success": False, "message": result.get("message", result.get("status", "Flag submission failed"))} - - except requests.exceptions.RequestException as e: - logger.error(f"Error calling solve API: {e}") - return {"success": False, "error": "Failed to submit flag to server"} - except Exception as e: - logger.error(f"Unexpected error: {e}") - return {"success": False, "error": "Internal server error"} - - -if __name__ == "__main__": - service = DojoSocketService() - service.start() diff --git a/dojo/dojo-init b/dojo/dojo-init index 2582fe28b..fd4b8214e 100755 --- a/dojo/dojo-init +++ b/dojo/dojo-init @@ -90,7 +90,6 @@ fi mkdir -p /data/workspace/nix mkdir -p /data/workspacefs/bin mkdir -p /data/ctfd-ipython -mkdir -p /data/dojo-command mkdir -p /data/homes if [ "$(findmnt -n -o FSTYPE /data/homes)" != "btrfs" ] && [ "$(findmnt -n -o FSTYPE /data)" != "btrfs" ]; then @@ -148,6 +147,7 @@ iptables -I DOCKER-USER -i workspace_net -j DROP for host in $(cat /opt/pwn.college/user_firewall.allowed); do iptables -I DOCKER-USER -i workspace_net -d $(host $host | awk '{print $NF; exit}') -j ACCEPT done +iptables -I DOCKER-USER -i workspace_net -s 10.0.0.0/8 -d 10.0.0.2 -m conntrack --ctstate NEW -j ACCEPT iptables -I DOCKER-USER -i workspace_net -s 10.0.0.0/24 -m conntrack --ctstate NEW -j ACCEPT iptables -I DOCKER-USER -i workspace_net -d 10.0.0.0/8 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT iptables -I DOCKER-USER -i workspace_net -s 192.168.42.0/24 -m conntrack --ctstate NEW -j ACCEPT diff --git a/dojo_plugin/api/v1/docker.py b/dojo_plugin/api/v1/docker.py index c9171d353..849b16a9d 100644 --- a/dojo_plugin/api/v1/docker.py +++ b/dojo_plugin/api/v1/docker.py @@ -122,12 +122,6 @@ def start_container(docker_client, user, as_user, user_mounts, dojo_challenge, p read_only=True, propagation="slave", ), - docker.types.Mount( - "/run/dojo/socket", - f"{HOST_DATA_PATH}/dojo-command/socket", - "bind", - read_only=True, - ), *user_mounts, ] @@ -172,6 +166,7 @@ def start_container(docker_client, user, as_user, user_mounts, dojo_challenge, p "challenge.localhost": "127.0.0.1", "hacker.localhost": "127.0.0.1", "dojo-user": user_ipv4(user), + "ctfd": "10.0.0.2", **USER_FIREWALL_ALLOWED, }, init=True, diff --git a/dojo_plugin/api/v1/integrations.py b/dojo_plugin/api/v1/integrations.py index 62c007fe0..76213c3dd 100644 --- a/dojo_plugin/api/v1/integrations.py +++ b/dojo_plugin/api/v1/integrations.py @@ -16,16 +16,16 @@ ) -def authenticate_container(auth_code): - if not auth_code: - return None, ({"success": False, "error": "Missing auth_code"}, 400) +def authenticate_container(auth_token): + if not auth_token: + return None, ({"success": False, "error": "Missing auth_token"}, 400) docker_client = docker.DockerClient(base_url="unix://var/run/docker.sock") container = None try: for c in docker_client.containers.list(): - if c.labels.get("dojo.auth_token") == auth_code: + if c.labels.get("dojo.auth_token") == auth_token: container = c break except Exception as e: @@ -52,13 +52,13 @@ def authenticate_container(auth_code): class IntegrationSolve(Resource): def post(self): data = request.get_json() - auth_code = data.get("auth_code") + auth_token = data.get("auth_token") or data.get("auth_code") # Support both for backward compatibility submission = data.get("submission") if not submission: return {"success": False, "error": "Missing submission"}, 400 - auth_result, error_response = authenticate_container(auth_code) + auth_result, error_response = authenticate_container(auth_token) if error_response: return error_response diff --git a/test/test_dojo_command.py b/test/test_dojo_command.py index 93ef403e3..3f67c24c8 100644 --- a/test/test_dojo_command.py +++ b/test/test_dojo_command.py @@ -15,31 +15,26 @@ def test_integrations_solve_endpoint(example_dojo, random_user): response = requests.post( f"{DOJO_URL}/pwncollege_api/v1/integrations/solve", - json={"auth_code": auth_token, "submission": flag} + json={"auth_token": auth_token, "submission": flag} ) assert response.status_code == 200 assert response.json()["status"] == "solved" response = requests.post( f"{DOJO_URL}/pwncollege_api/v1/integrations/solve", - json={"auth_code": auth_token, "submission": flag} + json={"auth_token": auth_token, "submission": flag} ) assert response.status_code == 200 assert response.json()["status"] == "already_solved" def test_dojo_command_files(example_dojo, random_user): - """Test that socket has correct permissions""" uid, session = random_user start_challenge(example_dojo, "hello", "apple", session=session) result = workspace_run("ls -la /run/dojo/bin/dojo", user=uid) assert result.returncode == 0, f"dojo command should exist, got {result.stderr}" - result = workspace_run("ls -la /run/dojo/socket", user=uid) - assert result.returncode == 0, f"Socket should exist, got {result.stderr}" - assert "srw" in result.stdout, f"Expected socket file, got {result.stdout}" - def test_dojo_command_submit(random_user, example_dojo): uid, session = random_user diff --git a/workspace/core/dojo-command.nix b/workspace/core/dojo-command.nix index 345b5c255..cc5b11c03 100644 --- a/workspace/core/dojo-command.nix +++ b/workspace/core/dojo-command.nix @@ -1,41 +1,18 @@ { pkgs }: -# This creates the /run/dojo/bin/dojo command available in user containers -# The command communicates with the socket service running in the CTFd container pkgs.writeScriptBin "dojo" '' #!${pkgs.python3}/bin/python3 - import socket import json import sys import os - import struct import argparse + import urllib.request + import urllib.error - SOCKET_PATH = "/run/dojo/socket" - - def send_message(sock, data): - sock.sendall(struct.pack("!I", len(data)) + data) - - def recv_message(sock): - length_data = sock.recv(4) - if len(length_data) != 4: - return None - length = struct.unpack("!I", length_data)[0] - - data = b"" - while len(data) < length: - chunk = sock.recv(min(length - len(data), 4096)) - if not chunk: - return None - data += chunk - return data + CTFD_URL = "http://ctfd:8000" def submit_flag(flag): - if not os.path.exists(SOCKET_PATH): - print("Error: Dojo socket not available. Are you running this inside a challenge container?") - return 1 - if int(open("/run/dojo/sys/workspace/privileged").read()): print("Error: workspace is in practice mode. Flag submission disabled.") return 1 @@ -46,39 +23,40 @@ pkgs.writeScriptBin "dojo" '' return 1 try: - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.connect(SOCKET_PATH) - - request = { - "command": "submit_flag", + request_data = { "auth_token": auth_token, - "flag": flag + "submission": flag } - send_message(sock, json.dumps(request).encode()) - response_data = recv_message(sock) + req = urllib.request.Request( + f"{CTFD_URL}/pwncollege_api/v1/integrations/solve", + data=json.dumps(request_data).encode(), + headers={"Content-Type": "application/json"} + ) - if not response_data: - print("Error: No response from server") - return 1 - - response = json.loads(response_data.decode()) + response = urllib.request.urlopen(req, timeout=10) + result = json.loads(response.read().decode()) - if response.get("success"): - print(f"✓ {response.get('message', 'Flag submitted successfully!')}") + if response.status == 200: + if result.get("status") == "already_solved": + print("✓ You already solved this challenge!") + else: + print("✓ Congratulations! Flag accepted!") return 0 else: - print(f"✗ {response.get('message', 'Unknown error')}") + print(f"✗ {result.get('message', result.get('status', 'Flag submission failed'))}") return 1 - except socket.error as e: - print(f"Error: Unable to connect to dojo service: {e}") + except urllib.error.HTTPError as e: + result = json.loads(e.read().decode()) + print(f"✗ {result.get('message', result.get('status', 'Flag submission failed'))}") + return 1 + except urllib.error.URLError as e: + print(f"Error: Unable to connect to CTFd service: {e}") return 1 except Exception as e: print(f"Error: {e}") return 1 - finally: - sock.close() def main(): parser = argparse.ArgumentParser(