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.
- nexctf_sandbox-1.0.0/LICENSE +41 -0
- nexctf_sandbox-1.0.0/PKG-INFO +128 -0
- nexctf_sandbox-1.0.0/README.md +102 -0
- nexctf_sandbox-1.0.0/nexctf_sandbox/__init__.py +102 -0
- nexctf_sandbox-1.0.0/nexctf_sandbox/_sandbox.py +178 -0
- nexctf_sandbox-1.0.0/nexctf_sandbox/alembic/versions/0001_init_sandbox.py +58 -0
- nexctf_sandbox-1.0.0/nexctf_sandbox/solutions/__init__.py +0 -0
- nexctf_sandbox-1.0.0/nexctf_sandbox/solutions/runner.py +134 -0
- nexctf_sandbox-1.0.0/nexctf_sandbox/solutions/script.py +255 -0
- nexctf_sandbox-1.0.0/pyproject.toml +64 -0
- nexctf_sandbox-1.0.0/pyproject.toml.orig +59 -0
|
@@ -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
|
+
[](https://github.com/astral-sh/ty)
|
|
32
|
+
[](https://github.com/astral-sh/uv)
|
|
33
|
+
[](https://github.com/astral-sh/ruff)
|
|
34
|
+
[](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
|
+
[](https://github.com/astral-sh/ty)
|
|
6
|
+
[](https://github.com/astral-sh/uv)
|
|
7
|
+
[](https://github.com/astral-sh/ruff)
|
|
8
|
+
[](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")
|
|
File without changes
|
|
@@ -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
|