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
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ jobs:
pytest \
pytest-dependency \
pytest-order \
pytest-timeout \
pytest-github-actions-annotate-failures \
requests \
selenium
Expand Down Expand Up @@ -141,6 +142,7 @@ jobs:
pytest \
pytest-dependency \
pytest-order \
pytest-timeout \
pytest-github-actions-annotate-failures \
requests \
selenium
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ services:
condition: service_started
cache:
condition: service_started
networks:
default:
workspace_net:
ipv4_address: 10.0.0.2

db:
container_name: db
Expand Down
1 change: 1 addition & 0 deletions dojo/dojo-init
Original file line number Diff line number Diff line change
Expand Up @@ -147,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
Expand Down
2 changes: 2 additions & 0 deletions dojo_plugin/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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")
1 change: 1 addition & 0 deletions dojo_plugin/api/v1/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,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,
Expand Down
79 changes: 79 additions & 0 deletions dojo_plugin/api/v1/integrations.py
Original file line number Diff line number Diff line change
@@ -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_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_token:
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_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_token)
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
2 changes: 1 addition & 1 deletion test/local-tester.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 1 addition & 11 deletions test/test_challenges.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down
55 changes: 55 additions & 0 deletions test/test_dojo_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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_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_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):
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}"


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}"
11 changes: 10 additions & 1 deletion test/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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}'"))
82 changes: 82 additions & 0 deletions workspace/core/dojo-command.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
{ pkgs }:

pkgs.writeScriptBin "dojo" ''
#!${pkgs.python3}/bin/python3

import json
import sys
import os
import argparse
import urllib.request
import urllib.error

CTFD_URL = "http://ctfd:8000"

def submit_flag(flag):
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:
request_data = {
"auth_token": auth_token,
"submission": flag
}

req = urllib.request.Request(
f"{CTFD_URL}/pwncollege_api/v1/integrations/solve",
data=json.dumps(request_data).encode(),
headers={"Content-Type": "application/json"}
)

response = urllib.request.urlopen(req, timeout=10)
result = json.loads(response.read().decode())

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"✗ {result.get('message', result.get('status', 'Flag submission failed'))}")
return 1

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

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())
''
6 changes: 6 additions & 0 deletions workspace/core/init.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions workspace/flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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; };
Expand Down Expand Up @@ -113,6 +114,7 @@
exec-suid
sudo
ssh-entrypoint
dojo-command
service
code-service
desktop-service
Expand Down