nexctf-sandbox 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ MIT License
2
+
3
+
4
+
5
+ Copyright (c) 2026 d3vyce
6
+
7
+
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+
11
+ of this software and associated documentation files (the "Software"), to deal
12
+
13
+ in the Software without restriction, including without limitation the rights
14
+
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+
17
+ copies of the Software, and to permit persons to whom the Software is
18
+
19
+ furnished to do so, subject to the following conditions:
20
+
21
+
22
+
23
+ The above copyright notice and this permission notice shall be included in all
24
+
25
+ copies or substantial portions of the Software.
26
+
27
+
28
+
29
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
30
+
31
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
32
+
33
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
34
+
35
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
36
+
37
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
38
+
39
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
40
+
41
+ SOFTWARE.
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: nexctf-sandbox
3
+ Version: 1.0.0
4
+ Summary: Sandboxed code execution for NexCTF, script checker and code runner via microsandbox microVMs.
5
+ Keywords: nexctf,ctf,sandbox,microvm,plugin
6
+ Author: NexCTF
7
+ Author-email: NexCTF <contact@nexctf.dev>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Intended Audience :: Education
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.15
18
+ Classifier: Topic :: Security
19
+ Requires-Dist: microsandbox>=0.7.8
20
+ Requires-Dist: nexctf>=0.12,<0.13
21
+ Requires-Python: >=3.15
22
+ Project-URL: Homepage, https://github.com/NexCTF/Sandbox
23
+ Project-URL: Repository, https://github.com/NexCTF/Sandbox
24
+ Project-URL: Issues, https://github.com/NexCTF/Sandbox/issues
25
+ Description-Content-Type: text/markdown
26
+
27
+ # NexCTF Sandbox
28
+
29
+ A [NexCTF](https://github.com/NexCTF/NexCTF) plugin that checks answers by running Python: a code runner that tests players' programs against test cases, and a script checker that validates answers with a function you write. Every run happens in a throwaway [microsandbox](https://github.com/microsandbox/microsandbox) microVM, booted for the submission and removed right after.
30
+
31
+ [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
32
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
33
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
34
+ [![Python 3.15](https://img.shields.io/badge/python-3.15-blue.svg)](https://www.python.org/downloads/)
35
+
36
+ ## Quick Start
37
+
38
+ Add the plugin to NexCTF's `NEXCTF_PLUGINS` and restart it; NexCTF installs it from PyPI and applies its migrations:
39
+
40
+ ```bash
41
+ NEXCTF_PLUGINS=nexctf-sandbox==1.0.0
42
+ ```
43
+
44
+ Each plugin release supports one NexCTF minor version, so upgrade the two together:
45
+
46
+ | nexctf-sandbox | NexCTF |
47
+ |---|---|
48
+ | 1.0.x | 0.12.x |
49
+
50
+ The NexCTF process that checks submissions boots the microVMs, so it needs read and write access to `/dev/kvm`. Nothing else: the network setting is enforced by microsandbox's own network stack, with no extra capability or root.
51
+
52
+ With NexCTF's `compose.yml`, that goes on the `app` service, e.g. in a `compose.override.yml`. `/dev/kvm` belongs to the host's `kvm` group, which the image's unprivileged user has to join; get its id with `stat -c %g /dev/kvm`:
53
+
54
+ ```yaml
55
+ services:
56
+ app:
57
+ devices:
58
+ - /dev/kvm
59
+ group_add:
60
+ - "993" # the kvm group id on the host
61
+ environment:
62
+ NEXCTF_PLUGINS: nexctf-sandbox==1.0.0
63
+ ```
64
+
65
+ Then, on a question, pick the solution type **runner** or **script**.
66
+
67
+ ## Features
68
+
69
+ ### 🏃 Code runner
70
+ - Runs the player's Python 3 code against up to 20 test cases, feeding each one's input on stdin
71
+ - Accepted only when every case's stdout matches the expected output, whitespace stripped
72
+ - A crash or a non-zero exit fails the case
73
+ - All the cases of a submission share one microVM, run one after the other
74
+ - For questions with a **code** input
75
+
76
+ ### ✅ Script checker
77
+ - Validates the answer with your own Python function, `check(answer, team_id, team_fields) -> bool`
78
+ - Hands the checker the team's custom fields, typed, private ones included: per-team flags and seeds work out of the box
79
+ - The starter checker lists the team custom fields that exist
80
+ - Checkers written without `team_fields` keep working as they are
81
+ - For questions with an **input**, **text** or **code** input
82
+
83
+ ## Writing a checker
84
+
85
+ The checker is a function named `check` that returns `True` to accept the answer. `team_id` is the team's id as a string, or `None`; `team_fields` maps each team custom field name to its value (`int`, `bool` or `str`), and a key is missing when the team left the field empty. Declare `team_fields` only if you need it:
86
+
87
+ ```python
88
+ import hashlib
89
+
90
+
91
+ def check(answer: str, team_id: str | None, team_fields: dict) -> bool:
92
+ seed = team_fields.get("seed", "")
93
+ flag = "FLAG{" + hashlib.sha256(seed.encode()).hexdigest()[:16] + "}"
94
+ return answer.strip() == flag
95
+ ```
96
+
97
+ The checker runs in its own microVM, with the base image's Python and the network access set below.
98
+
99
+ ## Settings
100
+
101
+ Under **Settings → Plugins → Sandbox**:
102
+
103
+ | Setting | Default |
104
+ |---|---|
105
+ | Base image | `python:3.14-slim`; any OCI image with `python3` on `PATH` |
106
+ | vCPUs | 1 |
107
+ | Memory | 256 MiB, plus the root disk |
108
+ | Root disk | 64 MiB, in RAM |
109
+ | Network access | `disabled`; `internet` for public addresses and the host's DNS, `all` for unfiltered egress, the host's own network included |
110
+ | Max concurrent microVMs | 8, across all challenges; takes effect after a restart |
111
+
112
+ ## Development
113
+
114
+ The tests stub out the microVMs and need no database. NexCTF is a dev dependency installed from git (currently its `v0.12.0` tag).
115
+
116
+ ```bash
117
+ uv sync
118
+ uv run pytest
119
+ uv run ruff check . && uv run ruff format --check . && uv run ty check .
120
+ ```
121
+
122
+ The `live` tests boot real microVMs and skip without `/dev/kvm`; the one reaching the internet also skips when the host has none. Run them on the host that will check submissions before an event:
123
+
124
+ ```bash
125
+ uv run pytest -m live
126
+ ```
127
+
128
+ To load a local checkout in a dev NexCTF, add its path to `NEXCTF_PLUGINS` in NexCTF's `.env.dev.override`, then restart `task dev:backend`.
@@ -0,0 +1,102 @@
1
+ # NexCTF Sandbox
2
+
3
+ A [NexCTF](https://github.com/NexCTF/NexCTF) plugin that checks answers by running Python: a code runner that tests players' programs against test cases, and a script checker that validates answers with a function you write. Every run happens in a throwaway [microsandbox](https://github.com/microsandbox/microsandbox) microVM, booted for the submission and removed right after.
4
+
5
+ [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
6
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
7
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
8
+ [![Python 3.15](https://img.shields.io/badge/python-3.15-blue.svg)](https://www.python.org/downloads/)
9
+
10
+ ## Quick Start
11
+
12
+ Add the plugin to NexCTF's `NEXCTF_PLUGINS` and restart it; NexCTF installs it from PyPI and applies its migrations:
13
+
14
+ ```bash
15
+ NEXCTF_PLUGINS=nexctf-sandbox==1.0.0
16
+ ```
17
+
18
+ Each plugin release supports one NexCTF minor version, so upgrade the two together:
19
+
20
+ | nexctf-sandbox | NexCTF |
21
+ |---|---|
22
+ | 1.0.x | 0.12.x |
23
+
24
+ The NexCTF process that checks submissions boots the microVMs, so it needs read and write access to `/dev/kvm`. Nothing else: the network setting is enforced by microsandbox's own network stack, with no extra capability or root.
25
+
26
+ With NexCTF's `compose.yml`, that goes on the `app` service, e.g. in a `compose.override.yml`. `/dev/kvm` belongs to the host's `kvm` group, which the image's unprivileged user has to join; get its id with `stat -c %g /dev/kvm`:
27
+
28
+ ```yaml
29
+ services:
30
+ app:
31
+ devices:
32
+ - /dev/kvm
33
+ group_add:
34
+ - "993" # the kvm group id on the host
35
+ environment:
36
+ NEXCTF_PLUGINS: nexctf-sandbox==1.0.0
37
+ ```
38
+
39
+ Then, on a question, pick the solution type **runner** or **script**.
40
+
41
+ ## Features
42
+
43
+ ### 🏃 Code runner
44
+ - Runs the player's Python 3 code against up to 20 test cases, feeding each one's input on stdin
45
+ - Accepted only when every case's stdout matches the expected output, whitespace stripped
46
+ - A crash or a non-zero exit fails the case
47
+ - All the cases of a submission share one microVM, run one after the other
48
+ - For questions with a **code** input
49
+
50
+ ### ✅ Script checker
51
+ - Validates the answer with your own Python function, `check(answer, team_id, team_fields) -> bool`
52
+ - Hands the checker the team's custom fields, typed, private ones included: per-team flags and seeds work out of the box
53
+ - The starter checker lists the team custom fields that exist
54
+ - Checkers written without `team_fields` keep working as they are
55
+ - For questions with an **input**, **text** or **code** input
56
+
57
+ ## Writing a checker
58
+
59
+ The checker is a function named `check` that returns `True` to accept the answer. `team_id` is the team's id as a string, or `None`; `team_fields` maps each team custom field name to its value (`int`, `bool` or `str`), and a key is missing when the team left the field empty. Declare `team_fields` only if you need it:
60
+
61
+ ```python
62
+ import hashlib
63
+
64
+
65
+ def check(answer: str, team_id: str | None, team_fields: dict) -> bool:
66
+ seed = team_fields.get("seed", "")
67
+ flag = "FLAG{" + hashlib.sha256(seed.encode()).hexdigest()[:16] + "}"
68
+ return answer.strip() == flag
69
+ ```
70
+
71
+ The checker runs in its own microVM, with the base image's Python and the network access set below.
72
+
73
+ ## Settings
74
+
75
+ Under **Settings → Plugins → Sandbox**:
76
+
77
+ | Setting | Default |
78
+ |---|---|
79
+ | Base image | `python:3.14-slim`; any OCI image with `python3` on `PATH` |
80
+ | vCPUs | 1 |
81
+ | Memory | 256 MiB, plus the root disk |
82
+ | Root disk | 64 MiB, in RAM |
83
+ | Network access | `disabled`; `internet` for public addresses and the host's DNS, `all` for unfiltered egress, the host's own network included |
84
+ | Max concurrent microVMs | 8, across all challenges; takes effect after a restart |
85
+
86
+ ## Development
87
+
88
+ The tests stub out the microVMs and need no database. NexCTF is a dev dependency installed from git (currently its `v0.12.0` tag).
89
+
90
+ ```bash
91
+ uv sync
92
+ uv run pytest
93
+ uv run ruff check . && uv run ruff format --check . && uv run ty check .
94
+ ```
95
+
96
+ The `live` tests boot real microVMs and skip without `/dev/kvm`; the one reaching the internet also skips when the host has none. Run them on the host that will check submissions before an event:
97
+
98
+ ```bash
99
+ uv run pytest -m live
100
+ ```
101
+
102
+ To load a local checkout in a dev NexCTF, add its path to `NEXCTF_PLUGINS` in NexCTF's `.env.dev.override`, then restart `task dev:backend`.
@@ -0,0 +1,102 @@
1
+ from nexctf.plugins import (
2
+ ConfigCategory,
3
+ ConfigDef,
4
+ ConfigType,
5
+ InputType,
6
+ Plugin,
7
+ TypeDef,
8
+ )
9
+
10
+ from nexctf_sandbox._sandbox import (
11
+ DEFAULT_BASE_IMAGE,
12
+ DEFAULT_CPUS,
13
+ DEFAULT_MAX_CONCURRENT,
14
+ DEFAULT_MEMORY_MIB,
15
+ DEFAULT_NETWORK_ACCESS,
16
+ DEFAULT_ROOT_DISK_MIB,
17
+ NETWORK_ACCESS_CHOICES,
18
+ )
19
+ from nexctf_sandbox.solutions.runner import (
20
+ RunnerSolution,
21
+ RunnerSolutionCreate,
22
+ RunnerSolutionRead,
23
+ RunnerSolutionUpdate,
24
+ )
25
+ from nexctf_sandbox.solutions.script import (
26
+ ScriptSolution,
27
+ ScriptSolutionCreate,
28
+ ScriptSolutionRead,
29
+ ScriptSolutionUpdate,
30
+ )
31
+
32
+ plugin = Plugin(
33
+ solution_types=[
34
+ TypeDef(
35
+ "runner",
36
+ model=RunnerSolution,
37
+ create_schema=RunnerSolutionCreate,
38
+ update_schema=RunnerSolutionUpdate,
39
+ read_schema=RunnerSolutionRead,
40
+ compatible_input_types=[InputType.CODE],
41
+ description="Code runner — executes the submitted Python 3 code against a set of test cases.",
42
+ ),
43
+ TypeDef(
44
+ "script",
45
+ model=ScriptSolution,
46
+ create_schema=ScriptSolutionCreate,
47
+ update_schema=ScriptSolutionUpdate,
48
+ read_schema=ScriptSolutionRead,
49
+ compatible_input_types=[InputType.INPUT, InputType.TEXT, InputType.CODE],
50
+ description="Script checker — runs a custom Python function check(answer, team_id, team_fields) → bool to validate the answer.",
51
+ ),
52
+ ],
53
+ config=ConfigCategory(
54
+ "Sandbox",
55
+ (
56
+ ConfigDef(
57
+ key="base_image",
58
+ label="Base image",
59
+ default=DEFAULT_BASE_IMAGE,
60
+ type=ConfigType.STRING,
61
+ description="OCI image every microVM boots from. It must provide python3 on PATH.",
62
+ ),
63
+ ConfigDef(
64
+ key="cpus",
65
+ label="vCPUs",
66
+ default=DEFAULT_CPUS,
67
+ description="Virtual CPUs given to each microVM.",
68
+ ),
69
+ ConfigDef(
70
+ key="memory_mib",
71
+ label="Memory (MiB)",
72
+ default=DEFAULT_MEMORY_MIB,
73
+ description="Guest RAM for each microVM. The root disk is added on top of this.",
74
+ ),
75
+ ConfigDef(
76
+ key="root_disk_mib",
77
+ label="Root disk (MiB)",
78
+ default=DEFAULT_ROOT_DISK_MIB,
79
+ description="Size of the tmpfs root disk. It is RAM-backed, so it is charged to guest memory.",
80
+ ),
81
+ ConfigDef(
82
+ key="network_access",
83
+ label="Network access",
84
+ default=DEFAULT_NETWORK_ACCESS,
85
+ type=ConfigType.CHOICE,
86
+ choices=NETWORK_ACCESS_CHOICES,
87
+ description=(
88
+ "'disabled': no egress at all.\n"
89
+ "'internet': public addresses, plus the host's DNS resolver on port 53.\n"
90
+ "'all': unfiltered, including everything on the host's own network."
91
+ ),
92
+ ),
93
+ ConfigDef(
94
+ key="max_concurrent",
95
+ label="Max concurrent microVMs",
96
+ default=DEFAULT_MAX_CONCURRENT,
97
+ description="Ceiling on microVMs running at once, across all challenges. Takes effect after a restart.",
98
+ ),
99
+ ),
100
+ icon="box",
101
+ ),
102
+ )
@@ -0,0 +1,178 @@
1
+ """Shared microsandbox helper for solution plugins that execute untrusted code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from contextlib import asynccontextmanager
8
+ from functools import partial
9
+ from uuid import uuid4
10
+
11
+ from microsandbox import (
12
+ Image,
13
+ Network,
14
+ NetworkProfile,
15
+ RootDisk,
16
+ Sandbox,
17
+ )
18
+ from pydantic import ValidationError
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ PLUGIN_SLUG = "nexctf_sandbox"
23
+
24
+ DEFAULT_BASE_IMAGE = "python:3.14-slim"
25
+ DEFAULT_CPUS = 1
26
+ DEFAULT_MEMORY_MIB = 256
27
+ DEFAULT_ROOT_DISK_MIB = 64
28
+ DEFAULT_MAX_CONCURRENT = 8
29
+
30
+ NETWORK_DISABLED = "disabled" # no egress at all
31
+ NETWORK_INTERNET = "internet" # public addresses and DNS, but nothing host-side
32
+ NETWORK_ALL = "all" # unfiltered, including the host's own network
33
+ NETWORK_ACCESS_CHOICES = [NETWORK_DISABLED, NETWORK_INTERNET, NETWORK_ALL]
34
+ DEFAULT_NETWORK_ACCESS = NETWORK_DISABLED
35
+
36
+ type ConfigValue = str | int | float | bool
37
+
38
+ _DEFAULTS: frozendict[str, ConfigValue] = frozendict(
39
+ base_image=DEFAULT_BASE_IMAGE,
40
+ cpus=DEFAULT_CPUS,
41
+ memory_mib=DEFAULT_MEMORY_MIB,
42
+ root_disk_mib=DEFAULT_ROOT_DISK_MIB,
43
+ max_concurrent=DEFAULT_MAX_CONCURRENT,
44
+ network_access=DEFAULT_NETWORK_ACCESS,
45
+ )
46
+
47
+ _MAX_OUTPUT_BYTES = 64 * 1024
48
+ _RUN = (
49
+ f"python3 /code.py >/out 2>/err; rc=$?; "
50
+ f"head -c {_MAX_OUTPUT_BYTES} /out; head -c {_MAX_OUTPUT_BYTES} /err >&2; exit $rc"
51
+ )
52
+ _SLOTS: asyncio.Semaphore | None = None
53
+
54
+ MAX_PAYLOAD_CHARS = _MAX_OUTPUT_BYTES
55
+ MIN_TIMEOUT = 1
56
+ MAX_TIMEOUT = 30
57
+
58
+
59
+ _no_host = False
60
+
61
+
62
+ def _slots(limit: int) -> asyncio.Semaphore:
63
+ """Return the process-wide VM slot semaphore, building it on first use."""
64
+ global _SLOTS
65
+ if _SLOTS is None:
66
+ _SLOTS = asyncio.Semaphore(limit)
67
+ return _SLOTS
68
+
69
+
70
+ async def _settings() -> dict[str, ConfigValue]:
71
+ """Resolve the admin-set VM settings, falling back to ``_DEFAULTS``."""
72
+ global _no_host
73
+ if _no_host:
74
+ return dict(_DEFAULTS)
75
+ try:
76
+ from nexctf.core import appconfig
77
+ from nexctf.core.cache import get_client
78
+ from nexctf.plugins import get_plugin_config
79
+ except ImportError, ValidationError:
80
+ logger.debug("sandbox.config host unavailable; using defaults", exc_info=True)
81
+ _no_host = True
82
+ return dict(_DEFAULTS)
83
+ except Exception:
84
+ logger.warning(
85
+ "sandbox.config host import failed; using defaults", exc_info=True
86
+ )
87
+ return dict(_DEFAULTS)
88
+
89
+ try:
90
+ overrides = await appconfig.fetch_overrides(get_client())
91
+ except Exception:
92
+ logger.warning("sandbox.config fetch failed; using defaults", exc_info=True)
93
+ return dict(_DEFAULTS)
94
+
95
+ resolved: dict[str, ConfigValue] = {}
96
+ for key, fallback in _DEFAULTS.items():
97
+ try:
98
+ resolved[key] = get_plugin_config(key, overrides, plugin_key=PLUGIN_SLUG)
99
+ except Exception:
100
+ logger.warning(
101
+ "sandbox.config bad key=%s; using default", key, exc_info=True
102
+ )
103
+ resolved[key] = fallback
104
+ return resolved
105
+
106
+
107
+ def _network(access: ConfigValue) -> Network:
108
+ """Build the guest's egress policy from the ``network_access`` setting."""
109
+ if access == NETWORK_INTERNET:
110
+ return Network.from_profiles(NetworkProfile.PUBLIC)
111
+ if access == NETWORK_ALL:
112
+ return Network.allow_all()
113
+ if access != NETWORK_DISABLED:
114
+ logger.warning("sandbox.network unknown access=%r; denying egress", access)
115
+ return Network.none()
116
+
117
+
118
+ @asynccontextmanager
119
+ async def _ephemeral(cfg: dict[str, ConfigValue]):
120
+ network = _network(cfg["network_access"])
121
+ root_disk_mib = int(cfg["root_disk_mib"])
122
+ name = f"nexctf-{uuid4().hex}"
123
+ sb = None
124
+ try:
125
+ sb = await Sandbox.create(
126
+ name,
127
+ image=Image.oci(
128
+ str(cfg["base_image"]), root_disk=RootDisk.tmpfs(root_disk_mib)
129
+ ),
130
+ cpus=int(cfg["cpus"]),
131
+ memory=int(cfg["memory_mib"]) + root_disk_mib,
132
+ network=network,
133
+ )
134
+ yield sb
135
+ finally:
136
+ try:
137
+ if sb is not None:
138
+ await sb.destroy(force=True)
139
+ else:
140
+ await Sandbox.remove(name)
141
+ except Exception:
142
+ logger.warning("sandbox.destroy failed name=%s", name, exc_info=True)
143
+
144
+
145
+ async def _exec(sb, code: str, stdin: str = "", *, timeout: int) -> tuple[int, str]:
146
+ """Run *code* in an already-booted microVM. Raises ``ExecTimeoutError`` on timeout."""
147
+ logger.info("sandbox.start stdin=%s timeout=%ds", bool(stdin), timeout)
148
+ await sb.fs.write("/code.py", code.encode())
149
+ result = await sb.shell(
150
+ _RUN,
151
+ stdin=stdin.encode() if stdin else None,
152
+ timeout=float(timeout),
153
+ )
154
+ out = result.stdout_text
155
+ logger.info(
156
+ "sandbox.run exit_code=%d out=%dB err=%dB",
157
+ result.exit_code,
158
+ len(out),
159
+ len(result.stderr_text),
160
+ )
161
+ return result.exit_code, out
162
+
163
+
164
+ @asynccontextmanager
165
+ async def python_runner():
166
+ """Yield ``run(code, stdin, *, timeout) -> (exit_code, stdout)`` on one microVM."""
167
+ cfg = await _settings()
168
+ async with _slots(int(cfg["max_concurrent"])), _ephemeral(cfg) as sb:
169
+ yield partial(_exec, sb)
170
+
171
+
172
+ async def run_python(code: str, stdin: str = "", *, timeout: int) -> tuple[int, str]:
173
+ """Run Python *code* in its own isolated microVM.
174
+
175
+ Returns ``(exit_code, stdout)``. Raises ``ExecTimeoutError`` on timeout.
176
+ """
177
+ async with python_runner() as run:
178
+ return await run(code, stdin, timeout=timeout)
@@ -0,0 +1,58 @@
1
+ """Create solutions_script and solutions_runner tables.
2
+
3
+ The check constraints bound the cost of one verify(): the schemas enforce the
4
+ same ranges, but fixtures and direct model writes do not go through Pydantic.
5
+
6
+ Revision ID: 0001
7
+ Revises:
8
+ Create Date: 2026-04-18
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sqlalchemy as sa
14
+ from alembic import op
15
+ from sqlalchemy.dialects.postgresql import JSONB
16
+
17
+ revision: str = "0001"
18
+ down_revision: str | None = None
19
+ branch_labels = None
20
+ depends_on = None
21
+
22
+ _TIMEOUT = "timeout BETWEEN 1 AND 30"
23
+ _TEST_CASES = "jsonb_array_length(test_cases) <= 20"
24
+ _CHECKER_CODE = "length(checker_code) <= 65536"
25
+ # 20 cases x (input + expected_output) x 65536: a ceiling on the blob, since a
26
+ # CHECK cannot walk the array to bound each field the way the schema does.
27
+ _TEST_CASES_SIZE = "length(test_cases::text) <= 2621440"
28
+
29
+
30
+ def upgrade() -> None:
31
+ op.create_table(
32
+ "solutions_script",
33
+ sa.Column("id", sa.UUID(), nullable=False),
34
+ sa.Column("checker_code", sa.Text(), nullable=False),
35
+ sa.Column("timeout", sa.Integer(), nullable=False, server_default="5"),
36
+ sa.CheckConstraint(_TIMEOUT, name="ck_solutions_script_timeout"),
37
+ sa.CheckConstraint(_CHECKER_CODE, name="ck_solutions_script_checker_code"),
38
+ sa.ForeignKeyConstraint(["id"], ["solutions.id"], ondelete="CASCADE"),
39
+ sa.PrimaryKeyConstraint("id"),
40
+ )
41
+ op.create_table(
42
+ "solutions_runner",
43
+ sa.Column("id", sa.UUID(), nullable=False),
44
+ sa.Column("test_cases", JSONB(), nullable=False, server_default="[]"),
45
+ sa.Column("timeout", sa.Integer(), nullable=False, server_default="5"),
46
+ sa.CheckConstraint(_TIMEOUT, name="ck_solutions_runner_timeout"),
47
+ sa.CheckConstraint(_TEST_CASES, name="ck_solutions_runner_test_cases"),
48
+ sa.CheckConstraint(
49
+ _TEST_CASES_SIZE, name="ck_solutions_runner_test_cases_size"
50
+ ),
51
+ sa.ForeignKeyConstraint(["id"], ["solutions.id"], ondelete="CASCADE"),
52
+ sa.PrimaryKeyConstraint("id"),
53
+ )
54
+
55
+
56
+ def downgrade() -> None:
57
+ op.drop_table("solutions_runner")
58
+ op.drop_table("solutions_script")
@@ -0,0 +1,134 @@
1
+ """Runner solution: executes player Python3 code against test cases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from uuid import UUID
7
+
8
+ from fastapi_toolsets.schemas import PydanticBase
9
+ from microsandbox import ExecTimeoutError
10
+ from nexctf.exceptions import SolutionTimeoutError
11
+ from nexctf.model.solution import Solution
12
+ from nexctf.schema.solution import AdminSolutionRead
13
+ from pydantic import Field
14
+ from sqlalchemy import CheckConstraint, ForeignKey
15
+ from sqlalchemy.dialects.postgresql import JSONB
16
+ from sqlalchemy.orm import Mapped, mapped_column
17
+
18
+ from nexctf_sandbox._sandbox import (
19
+ MAX_PAYLOAD_CHARS,
20
+ MAX_TIMEOUT,
21
+ MIN_TIMEOUT,
22
+ python_runner,
23
+ )
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ MAX_TEST_CASES = 20 # all of them share one microVM, run one after the other
28
+
29
+
30
+ class TestCase(PydanticBase):
31
+ input: str = Field(
32
+ default="",
33
+ max_length=MAX_PAYLOAD_CHARS,
34
+ title="Input (stdin)",
35
+ description="Text fed to the program on stdin.",
36
+ )
37
+ expected_output: str = Field(
38
+ max_length=MAX_PAYLOAD_CHARS,
39
+ title="Expected output",
40
+ description="Expected stdout, compared after stripping whitespace.",
41
+ )
42
+
43
+
44
+ class RunnerSolutionCreate(PydanticBase):
45
+ question_id: UUID
46
+ test_cases: list[TestCase] = Field(
47
+ default=[],
48
+ max_length=MAX_TEST_CASES,
49
+ title="Test cases",
50
+ description="All test cases must pass for the answer to be accepted.",
51
+ )
52
+ timeout: int = Field(
53
+ default=5,
54
+ ge=MIN_TIMEOUT,
55
+ le=MAX_TIMEOUT,
56
+ title="Timeout (s)",
57
+ description="Maximum seconds each test case may run.",
58
+ )
59
+
60
+
61
+ class RunnerSolutionUpdate(PydanticBase):
62
+ id: UUID
63
+ test_cases: list[TestCase] | None = Field(
64
+ default=None, max_length=MAX_TEST_CASES, title="Test cases"
65
+ )
66
+ timeout: int | None = Field(
67
+ default=None, ge=MIN_TIMEOUT, le=MAX_TIMEOUT, title="Timeout (s)"
68
+ )
69
+
70
+
71
+ class RunnerSolutionRead(AdminSolutionRead):
72
+ test_cases: list[TestCase]
73
+ timeout: int
74
+
75
+
76
+ async def run_code(run, code: str, stdin: str, timeout: int) -> str | None:
77
+ """Execute player *code* on *run*'s microVM and return stdout, or None on error.
78
+
79
+ Raises ``ExecTimeoutError`` on timeout: a timeout says nothing about the answer,
80
+ so it must not be flattened into "wrong".
81
+ """
82
+ try:
83
+ exit_code, stdout = await run(code, stdin, timeout=timeout)
84
+ return stdout if exit_code == 0 else None
85
+ except ExecTimeoutError:
86
+ raise
87
+ except Exception:
88
+ logger.exception("runner failed")
89
+ return None
90
+
91
+
92
+ class RunnerSolution(Solution):
93
+ """Runs submitted Python3 code against a set of test cases (all must pass)."""
94
+
95
+ __tablename__ = "solutions_runner"
96
+ __mapper_args__ = {"polymorphic_identity": "runner"} # noqa: RUF012 — SQLAlchemy idiom
97
+ __table_args__ = (
98
+ CheckConstraint(
99
+ f"timeout BETWEEN {MIN_TIMEOUT} AND {MAX_TIMEOUT}",
100
+ name="ck_solutions_runner_timeout",
101
+ ),
102
+ CheckConstraint(
103
+ f"jsonb_array_length(test_cases) <= {MAX_TEST_CASES}",
104
+ name="ck_solutions_runner_test_cases",
105
+ ),
106
+ # Backstop for the per-field max_length above: one ceiling on the whole blob,
107
+ # because a CHECK cannot walk the array (no subqueries).
108
+ CheckConstraint(
109
+ f"length(test_cases::text) <= {MAX_TEST_CASES * 2 * MAX_PAYLOAD_CHARS}",
110
+ name="ck_solutions_runner_test_cases_size",
111
+ ),
112
+ )
113
+
114
+ id: Mapped[UUID] = mapped_column(ForeignKey("solutions.id"), primary_key=True)
115
+ test_cases: Mapped[list[dict]] = mapped_column(JSONB, default=list)
116
+ timeout: Mapped[int] = mapped_column(default=5)
117
+
118
+ async def verify(self, submission: str, *, team_id=None) -> bool:
119
+ if not self.test_cases:
120
+ return False
121
+ try:
122
+ # One VM for the whole submission: booting one per case cost ~1.1s each.
123
+ # The cases see each other's guest state; they cannot see another player.
124
+ async with python_runner() as run:
125
+ for tc in self.test_cases:
126
+ stdin = tc.get("input", "")
127
+ expected = tc.get("expected_output", "")
128
+ actual = await run_code(run, submission, stdin, self.timeout)
129
+ if actual is None or actual.strip() != expected.strip():
130
+ return False
131
+ except ExecTimeoutError as exc:
132
+ logger.warning("runner timed out solution_id=%s", self.id)
133
+ raise SolutionTimeoutError(self.id) from exc
134
+ return True
@@ -0,0 +1,255 @@
1
+ """Script checker solution: runs an admin-provided Python checker in a microVM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from collections.abc import Callable
8
+ from typing import Annotated, Any
9
+ from uuid import UUID
10
+
11
+ from fastapi_toolsets.schemas import PydanticBase
12
+ from microsandbox import ExecTimeoutError
13
+ from nexctf.exceptions import SolutionTimeoutError
14
+ from nexctf.model.custom_field import (
15
+ CustomFieldDefinition,
16
+ CustomFieldTarget,
17
+ CustomFieldType,
18
+ CustomFieldValue,
19
+ )
20
+ from nexctf.model.solution import Solution
21
+ from nexctf.schema.solution import AdminSolutionRead
22
+ from nexctf.util.pydantic import CodeStr, DynamicDefault
23
+ from pydantic import Field
24
+ from sqlalchemy import CheckConstraint, ForeignKey, Text, select
25
+ from sqlalchemy.ext.asyncio import AsyncSession, async_object_session
26
+ from sqlalchemy.orm import Mapped, mapped_column
27
+ lazy from nexctf.core.db import get_db_context
28
+
29
+ from nexctf_sandbox._sandbox import (
30
+ MAX_PAYLOAD_CHARS,
31
+ MAX_TIMEOUT,
32
+ MIN_TIMEOUT,
33
+ run_python,
34
+ )
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ _CHECK_SIGNATURE = (
39
+ "def check(answer: str, team_id: str | None, team_fields: dict) -> bool:\n"
40
+ )
41
+ _CHECK_BODY = ' return answer.strip() == "expected_answer"\n'
42
+ _DEFAULT_CHECKER = _CHECK_SIGNATURE + _CHECK_BODY
43
+
44
+ # team_fields goes by name, and only to a checker that can take it: a checker
45
+ # written before it existed, even check(answer, team_id, strict=False), is unchanged.
46
+ _WRAPPER = """\
47
+ import sys as _sys
48
+ import json as _json
49
+ import inspect as _inspect
50
+
51
+ {checker_code}
52
+
53
+ _data = _json.load(_sys.stdin)
54
+ _args = (_data["answer"], _data.get("team_id"))
55
+ _kwargs = {{"team_fields": _data.get("team_fields") or {{}}}}
56
+ try:
57
+ _inspect.signature(check).bind(*_args, **_kwargs)
58
+ except (TypeError, ValueError):
59
+ _kwargs = {{}}
60
+ _result = check(*_args, **_kwargs)
61
+ _sys.exit(0 if bool(_result) else 1)
62
+ """
63
+
64
+ _CHECKER_DESCRIPTION = (
65
+ "Python function check(answer, team_id, team_fields) → bool. Return True to accept "
66
+ "the answer. team_fields maps each team custom field name to its value (int, bool "
67
+ "or str); declare it only if the checker needs it."
68
+ )
69
+
70
+ # Label shown in the starter checker, and the parser applied before the checker runs.
71
+ _FIELD_TYPES: frozendict[CustomFieldType, tuple[str, Callable[[str], Any]]] = (
72
+ frozendict(
73
+ {
74
+ CustomFieldType.integer: ("int", int),
75
+ CustomFieldType.boolean: ("bool", lambda value: value.lower() == "true"),
76
+ }
77
+ )
78
+ )
79
+ _STR_FIELD = ("str", str)
80
+
81
+
82
+ def checker_template(fields: list[tuple[str, CustomFieldType]]) -> str:
83
+ """Return the default checker, with a comment listing the team custom fields."""
84
+ if not fields:
85
+ return _DEFAULT_CHECKER
86
+ # repr() keeps a field name from ending the comment line or the string.
87
+ lines = [
88
+ f" # team_fields[{name!r}]: {_FIELD_TYPES.get(ftype, _STR_FIELD)[0]}\n"
89
+ for name, ftype in fields
90
+ ]
91
+ header = (
92
+ " # Team custom fields (a key is missing when the team left it empty):\n"
93
+ )
94
+ return "".join([_CHECK_SIGNATURE, header, *lines, _CHECK_BODY])
95
+
96
+
97
+ def _db_context():
98
+ return get_db_context()
99
+
100
+
101
+ async def _default_checker() -> str:
102
+ """Build the starter checker when the admin form asks for the schema."""
103
+ try:
104
+ async with _db_context() as session:
105
+ rows = await session.execute(
106
+ select(CustomFieldDefinition.name, CustomFieldDefinition.field_type)
107
+ .where(CustomFieldDefinition.target == CustomFieldTarget.team)
108
+ .order_by(CustomFieldDefinition.name)
109
+ )
110
+ return checker_template(list(rows))
111
+ except Exception:
112
+ # A missing hint must not take the whole solution form down with it.
113
+ logger.warning("script.checker template without team fields", exc_info=True)
114
+ return _DEFAULT_CHECKER
115
+
116
+
117
+ class ScriptSolutionCreate(PydanticBase):
118
+ question_id: UUID
119
+ checker_code: Annotated[CodeStr, DynamicDefault(_default_checker)] = Field(
120
+ default=_DEFAULT_CHECKER,
121
+ max_length=MAX_PAYLOAD_CHARS,
122
+ title="Checker function",
123
+ description=_CHECKER_DESCRIPTION,
124
+ )
125
+ timeout: int = Field(
126
+ default=5,
127
+ ge=MIN_TIMEOUT,
128
+ le=MAX_TIMEOUT,
129
+ title="Timeout (s)",
130
+ description="Maximum seconds the checker may run before being killed.",
131
+ )
132
+
133
+
134
+ class ScriptSolutionUpdate(PydanticBase):
135
+ id: UUID
136
+ checker_code: CodeStr | None = Field(
137
+ default=None,
138
+ max_length=MAX_PAYLOAD_CHARS,
139
+ title="Checker function",
140
+ description=_CHECKER_DESCRIPTION,
141
+ )
142
+ timeout: int | None = Field(
143
+ default=None,
144
+ ge=MIN_TIMEOUT,
145
+ le=MAX_TIMEOUT,
146
+ title="Timeout (s)",
147
+ description="Maximum seconds the checker may run before being killed.",
148
+ )
149
+
150
+
151
+ class ScriptSolutionRead(AdminSolutionRead):
152
+ checker_code: CodeStr
153
+ timeout: int
154
+
155
+
156
+ def _field_value(field_type: CustomFieldType, value: str | None) -> Any:
157
+ if value is None:
158
+ return None
159
+ _, parse = _FIELD_TYPES.get(field_type, _STR_FIELD)
160
+ try:
161
+ return parse(value)
162
+ except ValueError:
163
+ return value # written before its type changed; hand over the raw string
164
+
165
+
166
+ async def load_team_fields(session: AsyncSession, team_id: UUID) -> dict[str, Any]:
167
+ """Return every custom field value of *team_id*, keyed by field name.
168
+
169
+ Private fields are included: the checker is admin code, and per-team secrets
170
+ or seeds are the point of passing fields at all.
171
+ """
172
+ rows = await session.execute(
173
+ select(
174
+ CustomFieldDefinition.name,
175
+ CustomFieldDefinition.field_type,
176
+ CustomFieldValue.value,
177
+ )
178
+ .join(
179
+ CustomFieldDefinition,
180
+ CustomFieldValue.definition_id == CustomFieldDefinition.id,
181
+ )
182
+ .where(CustomFieldValue.team_id == team_id)
183
+ )
184
+ return {name: _field_value(ftype, value) for name, ftype, value in rows}
185
+
186
+
187
+ async def run_checker(
188
+ checker_code: str,
189
+ answer: str,
190
+ team_id: UUID | None,
191
+ timeout: int,
192
+ *,
193
+ session: AsyncSession | None = None,
194
+ ) -> bool:
195
+ code = _WRAPPER.format(checker_code=checker_code)
196
+ if session is None and team_id is not None:
197
+ # A detached solution has nothing to read from, and opening a connection
198
+ # of our own would starve the pool: the checker sees no fields at all.
199
+ logger.warning(
200
+ "script.checker no session, team_fields empty team_id=%s", team_id
201
+ )
202
+ try:
203
+ team_fields = (
204
+ await load_team_fields(session, team_id)
205
+ if session is not None and team_id is not None
206
+ else {}
207
+ )
208
+ stdin = json.dumps(
209
+ {
210
+ "answer": answer,
211
+ "team_id": str(team_id) if team_id else None,
212
+ "team_fields": team_fields,
213
+ }
214
+ )
215
+ exit_code, _ = await run_python(code, stdin, timeout=timeout)
216
+ return exit_code == 0
217
+ except ExecTimeoutError:
218
+ raise
219
+ except Exception:
220
+ logger.exception("script.checker failed team_id=%s", team_id)
221
+ return False
222
+
223
+
224
+ class ScriptSolution(Solution):
225
+ """Custom checker: admin provides a Python check(answer, team_id, team_fields) -> bool function."""
226
+
227
+ __tablename__ = "solutions_script"
228
+ __mapper_args__ = {"polymorphic_identity": "script"} # noqa: RUF012 — SQLAlchemy idiom
229
+ __table_args__ = (
230
+ CheckConstraint(
231
+ f"timeout BETWEEN {MIN_TIMEOUT} AND {MAX_TIMEOUT}",
232
+ name="ck_solutions_script_timeout",
233
+ ),
234
+ CheckConstraint(
235
+ f"length(checker_code) <= {MAX_PAYLOAD_CHARS}",
236
+ name="ck_solutions_script_checker_code",
237
+ ),
238
+ )
239
+
240
+ id: Mapped[UUID] = mapped_column(ForeignKey("solutions.id"), primary_key=True)
241
+ checker_code: Mapped[str] = mapped_column(Text)
242
+ timeout: Mapped[int] = mapped_column(default=5)
243
+
244
+ async def verify(self, submission: str, *, team_id: UUID | None = None) -> bool:
245
+ try:
246
+ return await run_checker(
247
+ self.checker_code,
248
+ submission,
249
+ team_id,
250
+ self.timeout,
251
+ session=async_object_session(self),
252
+ )
253
+ except ExecTimeoutError as exc:
254
+ logger.warning("script.checker timed out solution_id=%s", self.id)
255
+ raise SolutionTimeoutError(self.id) from exc
@@ -0,0 +1,64 @@
1
+ [project]
2
+ name = "nexctf-sandbox"
3
+ version = "1.0.0"
4
+ description = "Sandboxed code execution for NexCTF, script checker and code runner via microsandbox microVMs."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ keywords = [
9
+ "nexctf",
10
+ "ctf",
11
+ "sandbox",
12
+ "microvm",
13
+ "plugin",
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 5 - Production/Stable",
17
+ "Framework :: AsyncIO",
18
+ "Intended Audience :: Education",
19
+ "Intended Audience :: System Administrators",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: POSIX :: Linux",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ "Programming Language :: Python :: 3.15",
24
+ "Topic :: Security",
25
+ ]
26
+ requires-python = ">=3.15"
27
+ dependencies = [
28
+ "microsandbox>=0.7.8",
29
+ "nexctf>=0.12,<0.13",
30
+ ]
31
+
32
+ [[project.authors]]
33
+ name = "NexCTF"
34
+ email = "contact@nexctf.dev"
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/NexCTF/Sandbox"
38
+ Repository = "https://github.com/NexCTF/Sandbox"
39
+ Issues = "https://github.com/NexCTF/Sandbox/issues"
40
+
41
+ [project.entry-points."nexctf.plugins"]
42
+ nexctf_sandbox = "nexctf_sandbox"
43
+
44
+ [build-system]
45
+ requires = ["uv_build>=0.13.0,<0.14"]
46
+ build-backend = "uv_build"
47
+
48
+ [tool.uv.build-backend]
49
+ module-root = ""
50
+
51
+ [tool.pytest.ini_options]
52
+ asyncio_mode = "auto"
53
+ testpaths = ["tests"]
54
+ markers = ["live: boots a real microVM, needs /dev/kvm"]
55
+ addopts = "-m 'not live'"
56
+
57
+ [dependency-groups]
58
+ dev = [
59
+ "pytest>=9.1.1",
60
+ "pytest-asyncio>=1.4.0",
61
+ "ruff>=0.17.0",
62
+ "ty>=0.0.86",
63
+ "nexctf @ git+https://github.com/NexCTF/NexCTF.git@v0.12.0#subdirectory=backend",
64
+ ]
@@ -0,0 +1,59 @@
1
+ [project]
2
+ name = "nexctf-sandbox"
3
+ version = "1.0.0"
4
+ description = "Sandboxed code execution for NexCTF, script checker and code runner via microsandbox microVMs."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ { name = "NexCTF", email = "contact@nexctf.dev" }
10
+ ]
11
+ keywords = ["nexctf", "ctf", "sandbox", "microvm", "plugin"]
12
+ classifiers = [
13
+ "Development Status :: 5 - Production/Stable",
14
+ "Framework :: AsyncIO",
15
+ "Intended Audience :: Education",
16
+ "Intended Audience :: System Administrators",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: POSIX :: Linux",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.15",
21
+ "Topic :: Security",
22
+ ]
23
+ requires-python = ">=3.15"
24
+ dependencies = [
25
+ "microsandbox>=0.7.8",
26
+ "nexctf>=0.12,<0.13",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/NexCTF/Sandbox"
31
+ Repository = "https://github.com/NexCTF/Sandbox"
32
+ Issues = "https://github.com/NexCTF/Sandbox/issues"
33
+
34
+ [build-system]
35
+ requires = ["uv_build>=0.13.0,<0.14"]
36
+ build-backend = "uv_build"
37
+
38
+ [tool.uv.build-backend]
39
+ module-root = ""
40
+
41
+ [dependency-groups]
42
+ dev = [
43
+ "pytest>=9.1.1",
44
+ "pytest-asyncio>=1.4.0",
45
+ "ruff>=0.17.0",
46
+ "ty>=0.0.86",
47
+ "nexctf @ git+https://github.com/NexCTF/NexCTF.git@v0.12.0#subdirectory=backend",
48
+ ]
49
+
50
+ [project.entry-points."nexctf.plugins"]
51
+ nexctf_sandbox = "nexctf_sandbox"
52
+
53
+ [tool.pytest.ini_options]
54
+ asyncio_mode = "auto"
55
+ testpaths = ["tests"]
56
+ markers = [
57
+ "live: boots a real microVM, needs /dev/kvm",
58
+ ]
59
+ addopts = "-m 'not live'" # opt in with: pytest -m live