Skip to content

Commit 2099bc0

Browse files
committed
Harden pwnshop challenge validation and runtimes
1 parent 25334e8 commit 2099bc0

6 files changed

Lines changed: 448 additions & 9 deletions

File tree

runtime/workspace/agent/cmd/workspace-entrypoint/main.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ const workspaceProfileBin = "/run/workspace/profile/bin"
2626
const workspaceProfileScript = "/run/workspace/profile/etc/profile.d/99-pwn-workspace.sh"
2727
const workspaceUserRunDir = "/run/workspace/user"
2828
const workspaceServicesDir = "/run/workspace/user/services"
29+
const legacyDojoWorkspaceDir = "/run/dojo/sys/workspace"
30+
const legacyDojoPrivilegedFile = "/run/dojo/sys/workspace/privileged"
2931
const systemProfileScript = "/etc/profile.d/99-pwn-workspace.sh"
3032
const userShell = "/run/workspace/profile/bin/bash"
3133

@@ -141,6 +143,9 @@ func prepareWorkspace(config workspaceConfig) error {
141143
if err := setupRunDirectories(); err != nil {
142144
return err
143145
}
146+
if err := setupLegacyDojoWorkspace(); err != nil {
147+
return err
148+
}
144149
if err := setupSystemEnvironment(); err != nil {
145150
return err
146151
}
@@ -182,6 +187,20 @@ func setupRunDirectories() error {
182187
return linkServiceDefinitions()
183188
}
184189

190+
func setupLegacyDojoWorkspace() error {
191+
privileged := "0\n"
192+
if os.Getenv("PWN_WORKSPACE_PRIVILEGED") == "1" {
193+
privileged = "1\n"
194+
}
195+
if err := os.MkdirAll(legacyDojoWorkspaceDir, 0755); err != nil {
196+
return err
197+
}
198+
if err := os.WriteFile(legacyDojoPrivilegedFile, []byte(privileged), 0644); err != nil {
199+
return err
200+
}
201+
return os.Chmod(legacyDojoPrivilegedFile, 0644)
202+
}
203+
185204
func linkChallengeBin() error {
186205
if _, err := os.Stat(challengeBin); errors.Is(err, os.ErrNotExist) {
187206
return nil

tools/dojo/parse-dojo-yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_valida
2222
ID_REGEX = re.compile(r"^[a-z0-9-]{1,32}$")
2323

2424
INHERIT_KEYS = ["privileged", "interfaces"]
25+
LOCAL_CHALLENGE_CONFIG_KEYS = {"allow_unsupported_tests"}
2526

2627

2728
class Visibility(BaseModel):
@@ -278,6 +279,11 @@ def main() -> int:
278279
if resource.get("type") == "challenge":
279280
challenge_id = resource.get("id")
280281
challenge_data, challenge_description_md = challenge_reads.get(challenge_id, ({}, None))
282+
challenge_data = {
283+
key: value
284+
for key, value in challenge_data.items()
285+
if key not in LOCAL_CHALLENGE_CONFIG_KEYS
286+
}
281287

282288
resource = {**challenge_data, **resource, "type": "challenge", "id": challenge_id}
283289
resource.setdefault(
@@ -300,6 +306,11 @@ def main() -> int:
300306

301307
challenge_id = challenge.get("id")
302308
challenge_data, challenge_description_md = challenge_reads.get(challenge_id, ({}, None))
309+
challenge_data = {
310+
key: value
311+
for key, value in challenge_data.items()
312+
if key not in LOCAL_CHALLENGE_CONFIG_KEYS
313+
}
303314

304315
resource = {**challenge_data, **challenge, "type": "challenge", "id": challenge_id}
305316
resource.setdefault(

tools/pwnshop/src/pwnshop/commands/test.py

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import click
1111
import requests
12+
import yaml
1213
from rich.progress import (
1314
BarColumn,
1415
MofNCompleteColumn,
@@ -23,6 +24,33 @@
2324

2425
logger = logging.getLogger(__name__)
2526

27+
UNSUPPORTED_TEST_EXIT_CODE = 77
28+
29+
30+
def _read_metadata(path: pathlib.Path) -> dict:
31+
if not path.is_file():
32+
return {}
33+
metadata = yaml.safe_load(path.read_text()) or {}
34+
return metadata if isinstance(metadata, dict) else {}
35+
36+
37+
def _allows_unsupported_tests(challenge_path: pathlib.Path) -> bool:
38+
return (
39+
_read_metadata(challenge_path / "challenge.yml").get("allow_unsupported_tests")
40+
is True
41+
)
42+
43+
44+
def _challenge_requires_solve(challenge_path: pathlib.Path) -> bool:
45+
module = _read_metadata(challenge_path.parent / "module.yml")
46+
for resource in module.get("resources", []):
47+
if (
48+
resource.get("type") == "challenge"
49+
and resource.get("id") == challenge_path.name
50+
):
51+
return resource.get("required", True) is not False
52+
return True
53+
2654

2755
def run_workspace_command(
2856
workspace_url: str,
@@ -73,7 +101,7 @@ def run_workspace_command(
73101
default=None,
74102
help="Timeout in seconds for each individual test.",
75103
)
76-
@click.option("--require-solved", is_flag=True, help="Fail if any challenge is unsolved.")
104+
@click.option("--require-solved", is_flag=True, help="Fail if any required challenge is unsolved.")
77105
@click.option(
78106
"--log-failures",
79107
metavar="DIR",
@@ -110,22 +138,25 @@ def test_challenge(challenge_path):
110138
challenge_path = pathlib.Path(challenge_path)
111139
rendered = None
112140
try:
141+
allow_unsupported_tests = _allows_unsupported_tests(challenge_path)
142+
requires_solve = _challenge_requires_solve(challenge_path)
113143
rendered = lib.render_challenge(challenge_path)
114144
image_id = lib.build_challenge(challenge_path)
115145
tests = sorted(rendered.rglob("test*/test_*"))
116146
if not tests:
117147
logger.warning("no tests found for %s", challenge_path)
118-
return {"path": challenge_path, "tests": [], "solved": False}
148+
return {"path": challenge_path, "tests": [], "solved": not requires_solve}
119149
logger.info("running %d test(s) for %s", len(tests), challenge_path)
120150
results = []
121-
solved = False
151+
solved = not requires_solve
122152
for test in tests:
123153
test_name = test.relative_to(rendered)
124154
logger.debug("running test %s in %s", test_name, challenge_path)
125155
passed = False
126156
last_output = ""
127157
failed_attempt_outputs = []
128158
for attempt in range(1, attempts + 1):
159+
test_unsupported = False
129160
logger.debug("running test %s in %s (attempt %d/%d)", test_name, challenge_path, attempt, attempts)
130161
with lib.run_challenge(challenge_path, image_id, volumes=[test]) as (
131162
_container,
@@ -148,17 +179,21 @@ def test_challenge(challenge_path):
148179
last_output += e.stderr
149180
passed = False
150181
else:
151-
passed = run.returncode == 0
182+
test_unsupported = (
183+
allow_unsupported_tests
184+
and run.returncode == UNSUPPORTED_TEST_EXIT_CODE
185+
)
152186
last_output = (run.stdout or "") + (run.stderr or "")
187+
passed = run.returncode == 0 or test_unsupported
153188
logger.debug(
154189
"test %s %s (rc=%d, attempt %d/%d)",
155190
test_name,
156-
"PASSED" if passed else "FAILED",
191+
"UNSUPPORTED" if test_unsupported else "PASSED" if passed else "FAILED",
157192
run.returncode,
158193
attempt,
159194
attempts,
160195
)
161-
solved = solved or flag in last_output
196+
solved = solved or test_unsupported or flag in last_output
162197
if passed:
163198
if attempt > 1:
164199
logger.info(
@@ -217,7 +252,8 @@ def test_challenge(challenge_path):
217252
else:
218253
console.print(f"[red]FAIL[/] {challenge}: {error}")
219254
elif not tests:
220-
unsolved.add(challenge)
255+
if not solved:
256+
unsolved.add(challenge)
221257
passed_count += 1
222258
else:
223259
for test_path, passed, output in tests:

tools/pwnshop/src/pwnshop/lib/__init__.py

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import base64
22
import contextlib
3+
import errno
34
import json
45
import logging
56
import os
@@ -8,8 +9,10 @@
89
import random
910
import re
1011
import shutil
12+
import struct
1113
import subprocess
1214
import tempfile
15+
import threading
1316
import time
1417
from typing import Iterable, Iterator, List, Optional, Sequence
1518

@@ -22,6 +25,20 @@
2225

2326
CHALLENGE_SEED = int(os.environ.get("CHALLENGE_SEED", "0"))
2427

28+
_KATA_TRANSITION_LOCK = threading.Lock()
29+
KATA_KVM_ACL_TIMEOUT = 10
30+
KATA_KVM_ACL_STABLE_TIME = 0.25
31+
KATA_KVM_POLL_INTERVAL = 0.05
32+
KVM_DEVICE_PATH = pathlib.Path("/dev/kvm")
33+
KVM_ACL_XATTR = "system.posix_acl_access"
34+
35+
_POSIX_ACL_XATTR_VERSION = 2
36+
_POSIX_ACL_USER = 2
37+
_POSIX_ACL_MASK = 16
38+
_POSIX_ACL_READ_WRITE = 6
39+
_POSIX_ACL_HEADER = struct.Struct("<I")
40+
_POSIX_ACL_ENTRY = struct.Struct("<HHI")
41+
2542
clang_format = shutil.which("clang-format")
2643
if not clang_format:
2744
logger.warning("clang-format not found; C templates will not be formatted")
@@ -100,6 +117,79 @@ def image_path(challenge_image: str) -> str:
100117
return ""
101118

102119

120+
def _mapped_root_kvm_acl_is_healthy() -> Optional[bool]:
121+
"""Check whether the outer user namespace grants mapped root KVM access."""
122+
try:
123+
acl = os.getxattr(KVM_DEVICE_PATH, KVM_ACL_XATTR)
124+
except OSError as error:
125+
if error.errno in {
126+
errno.EACCES,
127+
errno.ENODATA,
128+
errno.ENOENT,
129+
errno.ENOTSUP,
130+
errno.EOPNOTSUPP,
131+
errno.EPERM,
132+
}:
133+
return None
134+
raise RuntimeError(f"Could not inspect {KVM_DEVICE_PATH} ACL: {error}") from error
135+
136+
if len(acl) < _POSIX_ACL_HEADER.size:
137+
return False
138+
(version,) = _POSIX_ACL_HEADER.unpack_from(acl)
139+
entries = acl[_POSIX_ACL_HEADER.size :]
140+
if version != _POSIX_ACL_XATTR_VERSION or len(entries) % _POSIX_ACL_ENTRY.size:
141+
return False
142+
143+
parsed_entries = list(_POSIX_ACL_ENTRY.iter_unpack(entries))
144+
mapped_root_permissions = next(
145+
(permissions for tag, permissions, user_id in parsed_entries if tag == _POSIX_ACL_USER and user_id == 0),
146+
None,
147+
)
148+
mask_permissions = next(
149+
(permissions for tag, permissions, _ in parsed_entries if tag == _POSIX_ACL_MASK),
150+
None,
151+
)
152+
if mapped_root_permissions is None or mask_permissions is None:
153+
return False
154+
return mapped_root_permissions & mask_permissions & _POSIX_ACL_READ_WRITE == _POSIX_ACL_READ_WRITE
155+
156+
157+
def _wait_for_stable_mapped_root_kvm_acl() -> None:
158+
deadline = time.monotonic() + KATA_KVM_ACL_TIMEOUT
159+
healthy_since = None
160+
161+
while True:
162+
now = time.monotonic()
163+
if _mapped_root_kvm_acl_is_healthy() is True:
164+
if healthy_since is None:
165+
healthy_since = now
166+
elif now - healthy_since >= KATA_KVM_ACL_STABLE_TIME:
167+
return
168+
else:
169+
healthy_since = None
170+
171+
if now >= deadline:
172+
raise RuntimeError(
173+
f"Timed out after {KATA_KVM_ACL_TIMEOUT}s waiting for {KVM_DEVICE_PATH} "
174+
"to retain its mapped-root read/write ACL"
175+
)
176+
time.sleep(min(KATA_KVM_POLL_INTERVAL, max(0, deadline - now)))
177+
178+
179+
def _run_with_transition_lock(runtime: str, function, *args, wait_before: bool = True, **kwargs):
180+
if runtime != "kata":
181+
return function(*args, **kwargs)
182+
with _KATA_TRANSITION_LOCK:
183+
acl_is_observable = _mapped_root_kvm_acl_is_healthy() is not None
184+
if acl_is_observable and wait_before:
185+
_wait_for_stable_mapped_root_kvm_acl()
186+
try:
187+
return function(*args, **kwargs)
188+
finally:
189+
if acl_is_observable:
190+
_wait_for_stable_mapped_root_kvm_acl()
191+
192+
103193
@contextlib.contextmanager
104194
def run_challenge(
105195
challenge_path: pathlib.Path,
@@ -135,7 +225,9 @@ def run_challenge(
135225
)
136226
container = None
137227
try:
138-
container = subprocess.check_output(
228+
container = _run_with_transition_lock(
229+
runtime,
230+
subprocess.check_output,
139231
[
140232
"docker",
141233
"run",
@@ -203,8 +295,11 @@ def run_challenge(
203295
finally:
204296
if container:
205297
logger.debug("removing container %s", container[:12])
206-
subprocess.run(
298+
_run_with_transition_lock(
299+
runtime,
300+
subprocess.run,
207301
["docker", "rm", "--force", container],
302+
wait_before=False,
208303
stdout=subprocess.DEVNULL,
209304
stderr=subprocess.DEVNULL,
210305
)

0 commit comments

Comments
 (0)