Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7c5b33b
Updated Container Auth Token
TheodorKitzenmaier Oct 20, 2025
b3286f6
Container Token Validation Returns UserID and ChallengeID
TheodorKitzenmaier Oct 20, 2025
5c386bf
Added Container Token Authenticated API Endpoint
TheodorKitzenmaier Oct 20, 2025
a0c70f3
Opened CTFD to challenge containers.
TheodorKitzenmaier Oct 21, 2025
f340e42
Added note about Flask
TheodorKitzenmaier Oct 22, 2025
ba17663
Switched Container Communication to Nginx
TheodorKitzenmaier Oct 22, 2025
ce673ec
Convert tabs to spaces.
TheodorKitzenmaier Oct 22, 2025
ed9ae02
Added Dojo Application to Nix
TheodorKitzenmaier Oct 22, 2025
707c87c
Added Dojo CLI Test
TheodorKitzenmaier Oct 22, 2025
1a5d92c
Container Tokens Timeout After 6 Hours
TheodorKitzenmaier Oct 22, 2025
a9c631f
Fix Dojo-CLI path.
TheodorKitzenmaier Oct 22, 2025
e4f0445
Updated dojo-cli to use Urllib
TheodorKitzenmaier Oct 22, 2025
ed69d79
WIP fix for CLI
TheodorKitzenmaier Oct 24, 2025
bf34e66
Fix Integration WHOAMI Endpoint.
TheodorKitzenmaier Oct 28, 2025
c01db6a
Merge branch 'master' into cli2
TheodorKitzenmaier Oct 28, 2025
7c531ed
Improved CLI App Error Handling
TheodorKitzenmaier Oct 28, 2025
e48dc27
Merge remote-tracking branch 'refs/remotes/origin/cli2' into cli2
TheodorKitzenmaier Oct 28, 2025
becb7f0
Merge branch 'master' into cli2
TheodorKitzenmaier Oct 28, 2025
cec2bfc
Add CLI again
TheodorKitzenmaier Oct 28, 2025
93feeaf
Typo
TheodorKitzenmaier Oct 28, 2025
c876948
Handle URLErrors
TheodorKitzenmaier Oct 28, 2025
ade0da2
Test connection to main node
TheodorKitzenmaier Nov 10, 2025
bd52139
Add node_net docker network
TheodorKitzenmaier Nov 11, 2025
ffd49d4
No More Node_Net
TheodorKitzenmaier Nov 12, 2025
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 dojo/dojo-init
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ dojo-node refresh

iptables -F WORKSPACE-NET 2>/dev/null || iptables -N WORKSPACE-NET
iptables -A WORKSPACE-NET -s 10.0.0.0/24 -m conntrack --ctstate NEW -j RETURN
iptables -A WORKSPACE-NET -s 10.0.0.0/8 -d 10.0.0.3 -m conntrack --ctstate NEW -j ACCEPT
iptables -A WORKSPACE-NET -s 10.0.0.0/8 -d 192.168.42.1 -m conntrack --ctstate NEW -j ACCEPT
iptables -A WORKSPACE-NET -s 192.168.42.0/24 -m conntrack --ctstate NEW -j RETURN
Comment on lines 156 to 160

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict workspace firewall rule to nginx ports only

Challenge containers are now allowed to open NEW connections to 192.168.42.1 with no port restriction. That address is the host-side bridge for the infrastructure stack, so accepting all traffic from every 10.0.0.0/8 container exposes every service bound on the host (docker daemon, databases, etc.), not just nginx. If the intent is to permit the CLI to reach the web frontend, this rule needs to narrow the destination ports or IP to the nginx container specifically; otherwise any compromise inside a challenge gains unrestricted network access to internal control planes.

Useful? React with 👍 / 👎.

iptables -A WORKSPACE-NET -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
for host in $(cat /opt/pwn.college/user_firewall.allowed); do
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 @@ -18,6 +18,7 @@
from .v1.search import search_namespace
from .v1.test_error import test_error_namespace
from .v1.user import user_namespace
from .v1.integration import integration_namespace

api = Blueprint("pwncollege_api", __name__)

Expand Down Expand Up @@ -55,6 +56,7 @@ def handle_api_exception(error):
raise


api_v1.add_namespace(integration_namespace, "/integration")
api_v1.add_namespace(auth_namespace, "/auth")
api_v1.add_namespace(user_namespace, "/users")
api_v1.add_namespace(belts_namespace, "/belts")
Expand Down
4 changes: 3 additions & 1 deletion dojo_plugin/api/v1/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
container_name,
lookup_workspace_token,
resolved_tar,
serialize_user_container,
serialize_user_flag,
user_docker_client,
user_node,
Expand Down Expand Up @@ -97,7 +98,7 @@ def start_container(docker_client, user, as_user, user_mounts, dojo_challenge, p
]
)[:64]

auth_token = os.urandom(32).hex()
auth_token = serialize_user_container(user.id, dojo_challenge.id)

challenge_bin_path = "/run/challenge/bin"
dojo_bin_path = "/run/dojo/bin"
Expand Down Expand Up @@ -170,6 +171,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),
"pwn.college": "192.168.42.1",
**USER_FIREWALL_ALLOWED,
},
init=True,
Expand Down
91 changes: 91 additions & 0 deletions dojo_plugin/api/v1/integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
from typing import Any
from flask import request, session
from flask_restx import Namespace, Resource
from CTFd.plugins import bypass_csrf_protection
from CTFd.models import Users
from ...utils import validate_user_container, get_current_container

integration_namespace = Namespace(
"integration",
description="Endpoints for internal container integration",
decorators=[bypass_csrf_protection]
)

def authenticate_container(token : str) -> tuple[Any, str | None, int | None]:
"""
Takes in a container token and returns the user if authentication succeeds.
Otherwise it will return `None`, an error message, and an error code.
"""

try:
userID, challengeID = validate_user_container(token)
except:
# validate user container (probably) raised BadSignature.
return None, "Failed to authenticate container token.", 401

# Validate user.
user = Users.query.filter_by(id=userID).one()
if user is None:
return None, "Failed to authenticate container token", 401

# Validate challenge matches.
container = get_current_container(user)
if container is None:
return None, "No active challenge container.", 403
if container.labels["dojo.challenge_id"] != challengeID:
return None, "Token failed to authenticate active challenge container.", 403

return user, None, None

# Idealy we would want to use before_request and teardown_request,
# however this is only supported at the application level. It is
# currently not possible to define this at the route or namespace
# level.
def authenticated(func):
"""
Function decorator.

Performs authentication of the request. Excepts
authentication information to be provided as part
of the request Headers. Temporarily creates an
authenticated session, then destroys it before
returning.
"""
def wrapper(*args, **kwargs):
# Authenticate.
token = request.headers.get("AuthToken", None)
if token is None:
return ({"success": False, "error": "Authentication token not provided."}, 400)
user, error, code = authenticate_container(token)
if user is None:
return ({"success": False, "error": error}, code)

try:
# Configure session and perform operation.
session["id"] = user.id
session["name"] = user.name
session["type"] = user.type
session["verified"] = user.verified
return func(*args, **kwargs)

except:
# FUBAR
return ({"success": False, "error": "An internal exception occured."}, 500)

finally:
# Make sure we destroy the session, no matter what.
session["id"] = None
session["name"] = None
session["type"] = None
session["verified"] = None
return wrapper

@integration_namespace.route("/whoami")
class whoami(Resource):
@authenticated
def get(self):
return ({
"success": True,
"message": f"You are the epic hacker {session["name"]} ({session["id"]}).",
"user_id": session["id"]
}, 200)
26 changes: 25 additions & 1 deletion dojo_plugin/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import docker
import docker.errors
from flask import current_app, Response, Markup, abort, g
from itsdangerous.url_safe import URLSafeSerializer
from itsdangerous.url_safe import URLSafeSerializer, URLSafeTimedSerializer
from CTFd.exceptions import UserNotFoundException, UserTokenExpiredException
from CTFd.models import db, Solves, Challenges, Users
from CTFd.utils.encoding import hexencode
Expand Down Expand Up @@ -73,6 +73,30 @@ def get_all_containers(dojo=None):
]


def validate_user_container(token: str, secret=None) -> tuple[int, str]:
"""
Returns the userID and challenge id of the signed container token.
Raises an exception if validation of signature fails.
"""
if secret is None:
secret = current_app.config["SECRET_KEY"]
serializer = URLSafeTimedSerializer(secret)
data = serializer.loads(token, max_age=(21600))
return (data[0], data[1])


def serialize_user_container(account_id: int, challenge_id: str, secret=None) -> str:
"""
Gives a unique token for a container based on the user and current challenge.
"""
if secret is None:
secret = current_app.config["SECRET_KEY"]
serializer = URLSafeTimedSerializer(secret)
data = [account_id, challenge_id, "cli-container-token"]
token = serializer.dumps(data)
return token


def serialize_user_flag(account_id, challenge_id, *, secret=None):
if secret is None:
secret = current_app.config["SECRET_KEY"]
Expand Down
22 changes: 22 additions & 0 deletions test/test_integrations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import subprocess

from utils import workspace_run, start_challenge

def test_whoami(random_user, welcome_dojo):
"""
Tests the dojo application with the "whoami" command.

Likely reasons for failure are:
- Issue with the dojo application.
- Issue with the integrations api.
- Issue with container (challenge -> nginx) networking.
"""
# Make sure we have a running challenge container.
name, session = random_user
start_challenge(welcome_dojo, "welcome", "challenge", session=session)

try:
result = workspace_run("dojo whoami", user=name)
assert name in result.stdout, f"Expected hacker to be {name}, got: {(result.stdout, result.stderr)}"
except subprocess.CalledProcessError as error:
assert False, f"Exception in when running command \"dojo whoami\": {(error.stdout, error.stderr)}"
1 change: 1 addition & 0 deletions test/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def _get_container_ip(container_name):

DOJO_IP = _get_container_ip(DOJO_CONTAINER) or os.getenv("DOJO_IP", "localhost")
DOJO_URL = os.getenv("DOJO_URL", f"http://{DOJO_IP}:80/")
DOJO_API = f"{DOJO_URL}pwncollege_api/v1"
DOJO_SSH_HOST = os.getenv("DOJO_SSH_HOST", DOJO_IP)
TEST_DOJOS_LOCATION = pathlib.Path(__file__).parent / "dojos"

Expand Down
142 changes: 142 additions & 0 deletions workspace/core/dojo-cli.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
{ pkgs }:

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

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

DOJO_URL = "http://pwn.college:80"
DOJO_API = f"{DOJO_URL}/pwncollege_api/v1"

INCORRECT_USAGE = 1
TOKEN_NOT_FOUND = 2
API_ERROR = 3

def get_token() -> str | None:
return os.environ.get("DOJO_AUTH_TOKEN")

def apiRequest(endpoint: str, method: str = "GET", args: dict[str, str] = {}) -> tuple[int, str | None, dict[str, str]]:
"""
Make a request to the given integration endpoint.
Will call `sys.exit` if the auth token is not specified in the environment.

Returns the http response code, an error message (or `None`), and a dictionary with the json response data.

Supports `GET` and `POST` methods.
"""
# Container authentication token required.
token = get_token()
if not token:
print("Failed to find authentication token (DOJO_AUTH_TOKEN). Did you change environment variables?")
sys.exit(TOKEN_NOT_FOUND)

# Append args to URL.
url = f"{DOJO_API}{endpoint}"
if (len(args) > 0 and method == "GET"):
url += f"?{urllib.parse.urlencode(args)}"

# Construct HTTP request.
match method:
case "GET":
request = urllib.request.Request(
url,
method="GET",
headers={
"AuthToken":token
}
)
case "POST":
data = json.dumps(args).encode()
request = urllib.request.Request(
url,
method="POST",
data=data,
headers={
"AuthToken":token,
"Content-Type": "application/json; charset=utf-8",
}
)
case _:
return 0, f"Unsupported method \"{method}\".", {}

# Make request, handle errors.
try:
response = urllib.request.urlopen(request, timeout=5.0)
except urllib.error.HTTPError as exception:
try:
return exception.code, json.loads(exception.read().decode())["error"], {}
except:
return exception.code, exception.reason, {}
except urllib.error.URLError as exception:
return 0, exception.reason, {}

# Parse response.
try:
response_json = json.loads(response.read().decode())
except json.JSONDecodeError as exception:
return response.status, exception.msg, {}
except UnicodeDecodeError as exception:
return response.status, exception.reason, {}
except Exception as exception:
return response.status, "Exception while parsing reponse.", {}

if not response_json.get("success", False):
error = response_json.get("error", "No message provided.")
return response.status, error, response_json

return response.status, None, response_json

def whoami() -> int:
"""
Calls the WHOAMI integration api, printing information
about the current user such as userID and username.
"""

# Make request.
status, error, jsonData = apiRequest("/integration/whoami")
if error is not None:
print(f"WHOAMI request failed ({status}): {error}")
sys.exit(API_ERROR)

# Print who's hacking.
print(jsonData["message"])
return 0

def main():
parser = argparse.ArgumentParser(
prog="dojo",
description="Command-line application for interacting with the dojo from inside of the challenge environment."
)

subparsers = parser.add_subparsers(
dest="command",
help="Dojo command to execute, not case sensitive."
)

whoami_parser = subparsers.add_parser(
name="whoami",
help="Prints information about the current user (you!)."
)

args = parser.parse_args()

if args.command is None:
parser.print_help()
return INCORRECT_USAGE

if args.command.lower() == "whoami":
return whoami()

parser.print_help()
return INCORRECT_USAGE

if __name__ == "__main__":
sys.exit(main())

''
4 changes: 4 additions & 0 deletions workspace/core/init.nix
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ let
mkdir -p /bin && ln -sfT /run/dojo/bin/sh /bin/sh
fi

if [ -x /nix/store/*/bin/dojo ]; then
mkdir -p /run/dojo/bin && ln -sf /nix/store/*/bin/dojo /run/dojo/bin/dojo
fi

home_directory="/home/hacker"
home_mount_options="$(findmnt -nro OPTIONS -- "$home_directory")"
if [ -n "$home_mount_options" ] && ! printf '%s' "$home_mount_options" | grep -Fqw 'nosuid'; then
Expand Down
2 changes: 2 additions & 0 deletions workspace/flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
init = import ./core/init.nix { inherit pkgs; };
ssh-entrypoint = import ./core/ssh-entrypoint.nix { inherit pkgs; };
sudo = import ./core/sudo.nix { inherit pkgs; };
dojo-cli = import ./core/dojo-cli.nix { inherit pkgs; };

service = import ./services/service.nix { inherit pkgs; };
code-service = import ./services/code.nix { inherit pkgs; };
Expand Down Expand Up @@ -127,6 +128,7 @@
code-service
desktop-service
terminal-service
dojo-cli
];

fullPackages = corePackages ++ additional.packages;
Expand Down