fix(ssh): per-challenge SSH login + phew buffering/leak/timeout

Passwords failed on 10/16 challenges while state.json looked correct:
- only the 6 native GEMASTIK XVIII images provision 'ctfuser'; every imported
  XVI/XVII image does 'echo root:${PASSWORD} | chpasswd' and logs in as root.
  set_ssh_passwords() hardcoded ctfuser, so chpasswd set a password on an
  account nobody uses -> 'Permission denied' everywhere.
  Registry gains a per-challenge 'ssh_user'; chpasswd now targets the real
  login (and ctfuser/ctf when present) and reports failures loudly.
- phew checker: chall.py block-buffers stdout through the docker exec pipe
  (PYTHONUNBUFFERED now set) and leaks chall.py inside the container on
  timeout (26 orphans, container saturated) -> reaps the whole exec process
  group. Startup does a fresh Pailier keygen (~12 s) so crypto reads need
  _CRYPTO_TIMEOUT, not the 5 s prompt default.

Adds panel/verify_ssh_creds.py (proves the state->container binding from
inside via a real login), audit_ssh_users.sh, reset_runtime.sh.
This commit is contained in:
Cyrene
2026-09-26 14:40:10 +08:00
parent ef385c3397
commit ae50acfe40
33 changed files with 1398 additions and 289 deletions
+288 -200
View File
@@ -1,200 +1,288 @@
from .Challenge import Challenge
import subprocess
import time
import re
import os
class Phew(Challenge):
flag_location = 'flags/phew.txt'
history_location = 'history/phew.txt'
_CONTAINER = os.environ.get("CHALLENGE_CONTAINER_PHEW", "phew_container")
_SERVICE_CMD = ["docker", "exec", "-i", _CONTAINER, "python3", "/home/ctfuser/chall/src/chall.py"]
_HEX_RE = re.compile(r'^[0-9a-fA-F]+$')
def _read_container_flag(self) -> str:
# NB: a timeout is mandatory here. `docker exec` against a container
# whose process table is saturated (accumulated chall.py zombies) can
# block forever and take the whole SLA check loop down with it.
try:
out = subprocess.run(["docker", "exec", self._CONTAINER, "cat", "/flag.txt"],
capture_output=True, text=True, timeout=30)
except subprocess.TimeoutExpired:
raise TimeoutError("docker exec cat /flag.txt timed out (container overloaded?)")
if out.returncode != 0 or not out.stdout.strip():
raise FileNotFoundError("Flag not found in container (/flag.txt)")
return out.stdout.strip()
def _spawn(self):
return subprocess.Popen(
self._SERVICE_CMD,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=0,
)
def _read_until(self, proc, token, timeout=5.0, max_bytes=1_000_000):
start = time.time()
buf = []
r = proc.stdout.read
while True:
if time.time() - start > timeout:
tail = ''.join(buf)[-500:]
raise TimeoutError(f"Timeout waiting for '{token}'. Got so far:\n{tail}")
ch = r(1)
if ch == "" and proc.poll() is not None:
raise RuntimeError(f"Process ended while waiting for '{token}'. Output:\n{''.join(buf)}")
buf.append(ch)
if len(buf) > max_bytes:
raise RuntimeError("Exceeded max read size")
if token in "".join(buf):
return "".join(buf)
def _send_line(self, proc, s: str):
proc.stdin.write(s + "\n")
proc.stdin.flush()
def _expect_hex_field(self, text: str, label: str) -> str:
m = re.search(rf"{re.escape(label)}\s*:\s*([0-9a-fA-F]+)", text)
assert m, f"Missing '{label}' in output. Tail:\n{text[-400:]}"
hx = m.group(1)
assert self._HEX_RE.match(hx), f"{label} is not hex"
return hx
def distribute(self, flag):
try:
os.makedirs(os.path.dirname(self.flag_location), exist_ok=True)
with open(self.flag_location, 'w') as f:
f.write(flag)
os.makedirs(os.path.dirname(self.history_location), exist_ok=True)
with open(self.history_location, 'a') as f:
f.write(flag + '\n')
self.logger.info(f'Flag {flag} written to {self.flag_location}')
return True
except Exception as e:
self.logger.error(f'Could not write flag to {self.flag_location}: {e}')
return False
def check(self):
try:
# parity check
with open(self.flag_location, 'r') as f:
host_flag = f.read().strip()
container_flag = self._read_container_flag()
assert host_flag == container_flag, 'Flag mismatch between host and container'
self.logger.info('[ok] flag parity (phew)')
def run_encrypt_once(pt_hex: str) -> str:
proc = self._spawn()
try:
self._read_until(proc, "> ", timeout=10.0)
self._send_line(proc, "1")
self._read_until(proc, "pt (hex)", timeout=3.0)
self._read_until(proc, "> ", timeout=3.0)
self._send_line(proc, pt_hex)
out = self._read_until(proc, "> ", timeout=5.0)
ct_hex = self._expect_hex_field(out, "ct")
self._send_line(proc, "9")
try:
proc.wait(timeout=2.0)
except subprocess.TimeoutExpired:
proc.kill()
raise AssertionError("Program did not exit after exit command (encrypt)")
return ct_hex
finally:
if proc.poll() is None:
proc.kill()
def run_decrypt_once(ct_hex: str) -> str:
proc = self._spawn()
try:
self._read_until(proc, "> ", timeout=10.0)
self._send_line(proc, "3")
self._read_until(proc, "ct (hex)", timeout=3.0)
self._read_until(proc, "> ", timeout=3.0)
self._send_line(proc, ct_hex)
out = self._read_until(proc, "> ", timeout=5.0)
pt_hex = self._expect_hex_field(out, "pt")
self._send_line(proc, "9")
try:
proc.wait(timeout=2.0)
except subprocess.TimeoutExpired:
proc.kill()
raise AssertionError("Program did not exit after exit command (decrypt)")
return pt_hex
finally:
if proc.poll() is None:
proc.kill()
def run_keyct_once() -> str:
proc = self._spawn()
try:
self._read_until(proc, "> ", timeout=10.0)
self._send_line(proc, "4")
out = self._read_until(proc, "> ", timeout=5.0)
ct_hex = self._expect_hex_field(out, "ct")
self._send_line(proc, "9")
try:
proc.wait(timeout=2.0)
except subprocess.TimeoutExpired:
proc.kill()
raise AssertionError("Program did not exit after exit command (keyct)")
return ct_hex
finally:
if proc.poll() is None:
proc.kill()
def run_bingo_reject_wrong_key():
wrong_key_hex = "00" * 66
proc = self._spawn()
try:
self._read_until(proc, "> ", timeout=10.0)
self._send_line(proc, "2")
self._read_until(proc, "key (hex)", timeout=3.0)
self._read_until(proc, "> ", timeout=3.0)
self._send_line(proc, wrong_key_hex)
out = self._read_until(proc, "> ", timeout=5.0)
assert "nope" in out.lower(), f"bingo did not reject wrong key; got:\n{out[-300:]}"
self._send_line(proc, "9")
try:
proc.wait(timeout=2.0)
except subprocess.TimeoutExpired:
proc.kill()
raise AssertionError("Program did not exit after exit command (bingo)")
finally:
if proc.poll() is None:
proc.kill()
ct1 = run_encrypt_once("414243444546")
assert ct1 and self._HEX_RE.match(ct1), "encrypt(1) did not return hex"
self.logger.info("[ok] encrypt produced hex")
pt_back = run_decrypt_once(ct1)
assert pt_back.strip() != "", "decrypt returned empty output"
assert self._HEX_RE.match(pt_back), "decrypt(3) did not return hex"
self.logger.info("[ok] decrypt produced hex (custom mapping accepted)")
pt_same = "01" * 8
ct_a = run_encrypt_once(pt_same)
ct_b = run_encrypt_once(pt_same)
assert ct_a.lower() != ct_b.lower(), "Encryption appears deterministic for same plaintext"
self.logger.info("[ok] encrypt randomness")
k1 = run_keyct_once()
k2 = run_keyct_once()
assert k1.lower() != k2.lower(), "key? ciphertexts reused randomness"
self.logger.info("[ok] key? randomness")
# run_bingo_reject_wrong_key()
# self.logger.info("[ok] bingo rejects wrong key")
return True
except Exception as e:
self.logger.error(f'Could not check phew: {e}')
return False
from .Challenge import Challenge
import select
import subprocess
import time
import re
import os
def _has_data(proc) -> bool:
"""True if the child's pipe still holds buffered output."""
import fcntl
try:
fd = proc.stdout.fileno()
fl = fcntl.fcntl(fd, fcntl.F_GETFL)
fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
data = proc.stdout.read()
if data:
return True
return False
except Exception:
return False
class Phew(Challenge):
flag_location = 'flags/phew.txt'
history_location = 'history/phew.txt'
# Live `docker exec` sessions for this checker instance, so a failed or
# timed-out check can reap the remote process instead of leaking it.
_children = []
_CONTAINER = os.environ.get("CHALLENGE_CONTAINER_PHEW", "phew_container")
# PYTHONUNBUFFERED is mandatory: chall.py prints its menu to stdout, and the
# checker reads that pipe interactively. Python block-buffers stdout when it
# is not a tty, so without it the child never flushes the "1. encrypt ... > "
# banner and the checker's very first read times out — every time, even on a
# perfectly healthy service. `python3 -u` would do the same thing.
_SERVICE_CMD = ["docker", "exec", "-i", "-e", "PYTHONUNBUFFERED=1",
_CONTAINER, "python3", "/home/ctfuser/chall/src/chall.py"]
_HEX_RE = re.compile(r'^[0-9a-fA-F]+$')
# chall.py generates a fresh Pailier keypair (os.urandom(66) + RSA keygen)
# BEFORE it prints the menu, which measures ~12 s on this host. The first
# read must outlast that or the check fails on a healthy service. Later
# exchanges reuse the same key, so they can stay short.
_BOOT_TIMEOUT = 45.0
# Budget for a single cipher operation. Encrypting the raw 528-bit key
# (menu option 4) is measurably slower than encrypting a small plaintext,
# and a saturated host makes even the small ones slower — 5 s was too tight
# and produced a false DOWN.
_CRYPTO_TIMEOUT = 30.0
def _read_container_flag(self) -> str:
# NB: a timeout is mandatory here. `docker exec` against a container
# whose process table is saturated (accumulated chall.py zombies) can
# block forever and take the whole SLA check loop down with it.
try:
out = subprocess.run(["docker", "exec", self._CONTAINER, "cat", "/flag.txt"],
capture_output=True, text=True, timeout=30)
except subprocess.TimeoutExpired:
raise TimeoutError("docker exec cat /flag.txt timed out (container overloaded?)")
if out.returncode != 0 or not out.stdout.strip():
raise FileNotFoundError("Flag not found in container (/flag.txt)")
return out.stdout.strip()
def _spawn(self):
proc = subprocess.Popen(
self._SERVICE_CMD,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=0,
)
self._children.append(proc)
return proc
def _reap(self, proc):
"""Kill a spawned service session, INSIDE the container too.
proc.kill() only kills the local `docker exec` CLIENT. The chall.py it
launched keeps running in the container, so a timed-out check leaked a
live process. After a few failures the container had 26 concurrent
chall.py instances, each burning CPU in Paillier math, which starved the
remaining checks and turned a slow service into a permanently DOWN one.
Reaping must therefore also pkill the remote process.
"""
if proc.poll() is None:
try:
proc.kill()
proc.wait(timeout=5)
except Exception:
pass
try:
subprocess.run(["docker", "exec", self._CONTAINER, "sh", "-c",
"pkill -f chall.py 2>/dev/null || true"],
capture_output=True, timeout=20)
except Exception:
pass
if proc in self._children:
self._children.remove(proc)
def _reap_all(self):
for p in list(self._children):
self._reap(p)
def _read_until(self, proc, token, timeout=5.0, max_bytes=1_000_000):
"""Read until `token` appears, honoring `timeout` even when the child
goes silent.
The previous implementation used a blocking `stdout.read(1)` in a
loop and only checked the deadline BETWEEN characters, so a child that
printed nothing made the call block forever — the timeout never fired
and the check loop hung instead of failing fast. select() makes the
deadline authoritative.
"""
start = time.time()
buf = []
deadline = start + timeout
while True:
if time.time() > deadline:
tail = ''.join(buf)[-500:]
raise TimeoutError(f"Timeout waiting for '{token}'. Got so far:\n{tail}")
remaining = deadline - time.time()
ready, _, _ = select.select([proc.stdout], [], [], min(remaining, 1.0))
if not ready:
if proc.poll() is not None and not _has_data(proc):
raise RuntimeError(
f"Process ended while waiting for '{token}'. Output:\n{''.join(buf)}")
continue
ch = proc.stdout.read(1)
if ch == "":
raise RuntimeError(
f"Process ended while waiting for '{token}'. Output:\n{''.join(buf)}")
buf.append(ch)
if len(buf) > max_bytes:
raise RuntimeError("Exceeded max read size")
if token in "".join(buf):
return "".join(buf)
def _send_line(self, proc, s: str):
proc.stdin.write(s + "\n")
proc.stdin.flush()
def _expect_hex_field(self, text: str, label: str) -> str:
m = re.search(rf"{re.escape(label)}\s*:\s*([0-9a-fA-F]+)", text)
assert m, f"Missing '{label}' in output. Tail:\n{text[-400:]}"
hx = m.group(1)
assert self._HEX_RE.match(hx), f"{label} is not hex"
return hx
def distribute(self, flag):
try:
os.makedirs(os.path.dirname(self.flag_location), exist_ok=True)
with open(self.flag_location, 'w') as f:
f.write(flag)
os.makedirs(os.path.dirname(self.history_location), exist_ok=True)
with open(self.history_location, 'a') as f:
f.write(flag + '\n')
self.logger.info(f'Flag {flag} written to {self.flag_location}')
return True
except Exception as e:
self.logger.error(f'Could not write flag to {self.flag_location}: {e}')
return False
def check(self):
try:
# parity check
with open(self.flag_location, 'r') as f:
host_flag = f.read().strip()
container_flag = self._read_container_flag()
assert host_flag == container_flag, 'Flag mismatch between host and container'
self.logger.info('[ok] flag parity (phew)')
def run_encrypt_once(pt_hex: str) -> str:
proc = self._spawn()
try:
self._read_until(proc, "> ", timeout=self._BOOT_TIMEOUT)
self._send_line(proc, "1")
self._read_until(proc, "pt (hex)", timeout=3.0)
self._read_until(proc, "> ", timeout=3.0)
self._send_line(proc, pt_hex)
out = self._read_until(proc, "> ", timeout=self._CRYPTO_TIMEOUT)
ct_hex = self._expect_hex_field(out, "ct")
self._send_line(proc, "9")
try:
proc.wait(timeout=2.0)
except subprocess.TimeoutExpired:
proc.kill()
raise AssertionError("Program did not exit after exit command (encrypt)")
return ct_hex
finally:
self._reap(proc)
def run_decrypt_once(ct_hex: str) -> str:
proc = self._spawn()
try:
self._read_until(proc, "> ", timeout=self._BOOT_TIMEOUT)
self._send_line(proc, "3")
self._read_until(proc, "ct (hex)", timeout=3.0)
self._read_until(proc, "> ", timeout=3.0)
self._send_line(proc, ct_hex)
out = self._read_until(proc, "> ", timeout=self._CRYPTO_TIMEOUT)
pt_hex = self._expect_hex_field(out, "pt")
self._send_line(proc, "9")
try:
proc.wait(timeout=2.0)
except subprocess.TimeoutExpired:
proc.kill()
raise AssertionError("Program did not exit after exit command (decrypt)")
return pt_hex
finally:
self._reap(proc)
def run_keyct_once() -> str:
proc = self._spawn()
try:
self._read_until(proc, "> ", timeout=self._BOOT_TIMEOUT)
self._send_line(proc, "4")
# Option 4 runs `cipher.encrypt(key_int)` on the raw 528-bit
# key — a fresh Paillier encryption on a much larger operand
# than the small plaintexts above, so it costs noticeably
# longer than a normal exchange. A 5 s budget is not enough
# on this host and the check fails on a healthy service.
out = self._read_until(proc, "> ", timeout=self._CRYPTO_TIMEOUT)
ct_hex = self._expect_hex_field(out, "ct")
self._send_line(proc, "9")
try:
proc.wait(timeout=2.0)
except subprocess.TimeoutExpired:
proc.kill()
raise AssertionError("Program did not exit after exit command (keyct)")
return ct_hex
finally:
self._reap(proc)
def run_bingo_reject_wrong_key():
wrong_key_hex = "00" * 66
proc = self._spawn()
try:
self._read_until(proc, "> ", timeout=self._BOOT_TIMEOUT)
self._send_line(proc, "2")
self._read_until(proc, "key (hex)", timeout=3.0)
self._read_until(proc, "> ", timeout=3.0)
self._send_line(proc, wrong_key_hex)
out = self._read_until(proc, "> ", timeout=self._CRYPTO_TIMEOUT)
assert "nope" in out.lower(), f"bingo did not reject wrong key; got:\n{out[-300:]}"
self._send_line(proc, "9")
try:
proc.wait(timeout=2.0)
except subprocess.TimeoutExpired:
proc.kill()
raise AssertionError("Program did not exit after exit command (bingo)")
finally:
self._reap(proc)
ct1 = run_encrypt_once("414243444546")
assert ct1 and self._HEX_RE.match(ct1), "encrypt(1) did not return hex"
self.logger.info("[ok] encrypt produced hex")
pt_back = run_decrypt_once(ct1)
assert pt_back.strip() != "", "decrypt returned empty output"
assert self._HEX_RE.match(pt_back), "decrypt(3) did not return hex"
self.logger.info("[ok] decrypt produced hex (custom mapping accepted)")
pt_same = "01" * 8
ct_a = run_encrypt_once(pt_same)
ct_b = run_encrypt_once(pt_same)
assert ct_a.lower() != ct_b.lower(), "Encryption appears deterministic for same plaintext"
self.logger.info("[ok] encrypt randomness")
k1 = run_keyct_once()
k2 = run_keyct_once()
assert k1.lower() != k2.lower(), "key? ciphertexts reused randomness"
self.logger.info("[ok] key? randomness")
# run_bingo_reject_wrong_key()
# self.logger.info("[ok] bingo rejects wrong key")
return True
except Exception as e:
self.logger.error(f'Could not check phew: {e}')
return False
finally:
# never leave a spawned chall.py behind, whatever happened above
self._reap_all()
+1 -1
View File
@@ -28,7 +28,7 @@ class Art(Challenge):
def check(self):
try:
word = self.random_string(8)
url = f'http://localhost:{self.port}/art/{word}'
url = self.url(f'/art/{word}')
r = requests.get(url, timeout=5)
assert r.text == f'<iframe height="100%" width="100%" frameborder="0" src=https://asciified.thelicato.io/api/v2/ascii?text={word}></iframe>', 'Unexpected response'
self.logger.info('Check passed for art')
+1 -1
View File
@@ -33,7 +33,7 @@ class Burvesigner(Challenge):
def check(self):
try:
url = f'http://localhost:{self.port}'
url = self.url()
flag = open(self.flag_location).read()
# C1: login guest success
+29 -1
View File
@@ -10,11 +10,39 @@ class Challenge(object):
name = __name__
settings = get_settings()
port = 0
# Host the checker connects to. The team receiver runs on the same machine
# as the published team ports, so 127.0.0.1 is correct; override with
# RECEIVER_HOST if a receiver ever runs off-host.
host = os.environ.get("RECEIVER_HOST", "127.0.0.1")
# URL scheme for this challenge. TLS services (gleam-drive/bandit) serve
# HTTPS only, so a plain http:// request fails against a healthy service.
# Read from CHALLENGE_SCHEME_<NAME> by the concrete checker via _scheme();
# this default is overridden per class where needed.
scheme = os.environ.get("RECEIVER_SCHEME", "http")
def __init__(self, port):
def __init__(self, port, host=None):
self.port = port
if host:
self.host = host
self.add_logger()
def url(self, path=""):
"""Absolute URL for the challenge service.
Every XVI checker (Art, XL, S3, ...) calls self.url(...) — without this
helper they all fail with "'<Class>' object has no attribute 'url'" and
the receiver reports the service DOWN while it is actually healthy.
The scheme is per-challenge: a TLS service (CHALLENGE_SCHEME_<NAME>=https)
must be reached over https or requests raises an SSLError. The env key is
derived from the class module name, which the generator renders as the
challenge name, with hyphens normalized to underscores.
"""
p = "" if path.startswith("/") else "/"
key = self.name.upper().replace("-", "_")
scheme = os.environ.get(f"CHALLENGE_SCHEME_{key}") or self.scheme
return f"{scheme}://{self.host}:{self.port}{p}{path}"
def add_logger(self):
self.logger = logging.getLogger()
+1 -1
View File
@@ -27,7 +27,7 @@ class Crawlback(Challenge):
def check(self):
try:
r = requests.post(f"http://localhost:{self.port}/crawlback.php", data={'url': MOCK_URL})
r = requests.post(self.url(f'/crawlback.php'), data={'url': MOCK_URL})
assert r.text.split('\n').pop(0) == MOCK_DATA
+5 -5
View File
@@ -35,29 +35,29 @@ class GemasFetcher(Challenge):
## register
username = self.random_string(5)
password = self.random_string(5)
r = sess.post(f"http://localhost:{self.port}/auth/register", data={"username":username,"password": password}, allow_redirects=False)
r = sess.post(self.url(f'/auth/register'), data={"username":username,"password": password}, allow_redirects=False)
assert r.headers.get("location") == "/auth/login", "Register Failed"
## login
r = sess.post(f"http://localhost:{self.port}/auth/login", data={"username":username,"password": password}, allow_redirects=False)
r = sess.post(self.url(f'/auth/login'), data={"username":username,"password": password}, allow_redirects=False)
assert r.headers.get("location") == "/dashboard", "Login Failed"
## wget
content = {"provider": "wget","url":MOCK_URL}
files = {"file": ("visit", b"\x00\x00"+gzip.compress(zlib.compress(json.dumps(content).encode())))}
r = sess.post(f"http://localhost:{self.port}/dashboard/fetch_by_file", files=files)
r = sess.post(self.url(f'/dashboard/fetch_by_file'), files=files)
assert MOCK_DATA_WGET in r.text, "wget Failed"
## curl
content = {"provider": "curl","url":MOCK_URL}
files = {"file": ("visit", b"\x00\x01"+gzip.compress(zlib.compress(json.dumps(content).encode())))}
r = sess.post(f"http://localhost:{self.port}/dashboard/fetch_by_file", files=files)
r = sess.post(self.url(f'/dashboard/fetch_by_file'), files=files)
assert r.text.split('\n').pop(0) == MOCK_DATA_CURL, "curl Failed"
## python
content = {"provider": "python","url":MOCK_URL}
files = {"file": ("visit", b"\x00\x02"+gzip.compress(zlib.compress(json.dumps(content).encode())))}
r = sess.post(f"http://localhost:{self.port}/dashboard/fetch_by_file", files=files)
r = sess.post(self.url(f'/dashboard/fetch_by_file'), files=files)
assert r.text.startswith('"PCFkb2N0eXBlIGh0bWw'), "python Failed"
return True
+2 -4
View File
@@ -4,13 +4,11 @@ import requests
class GemasNotes(Challenge):
history_location = 'history/gemas-notes.txt'
host = "http://localhost:12000"
def distribute(self, flag):
try:
username = "gemasflagreceiver"
password = "AuTeEbn%.Q5$pC_ge6"
result = requests.post(f"{self.host}/flag_receiver", json={"flag": flag}, auth=(username,password)).json()
result = requests.post(self.url("flag_receiver"), json={"flag": flag}, auth=(username,password)).json()
if not result.get("success"):
return False
@@ -26,7 +24,7 @@ class GemasNotes(Challenge):
def check(self):
try:
url = f'http://localhost:{self.port}'
url = self.url()
# login
token = requests.post(f"{url}/api/login",json={"email":"checker@gemasnotes.id", "password":"uRIqCvJ<IGb;VDT14"}).json()["token"]
+1 -1
View File
@@ -27,7 +27,7 @@ class Hirnfick(Challenge):
def check(self):
try:
res = requests.post(
f"http://localhost:{self.port}/api/run",
self.url(f'/api/run'),
timeout=5,
json={
"code":
+1 -3
View File
@@ -6,8 +6,6 @@ import requests
class Pasta(Challenge):
flag_location = 'flags/pasta.txt'
history_location = 'history/pasta.txt'
host = "http://localhost:13000"
def distribute(self, flag):
try:
with open(self.flag_location, 'w') as f:
@@ -25,7 +23,7 @@ class Pasta(Challenge):
def check(self):
try:
url = f'http://localhost:{self.port}'
url = self.url()
username = f"checker-{self.random_string(8)}"
pwd = self.random_string(12)
flag = open(self.flag_location).read()
+2 -4
View File
@@ -7,8 +7,6 @@ import os
class S3(Challenge):
flag_location = 'flags/s3.txt'
history_location = 'history/s3.txt'
host = 'http://localhost:20000'
def distribute(self, flag):
try:
with open(self.flag_location, 'w') as f:
@@ -29,11 +27,11 @@ class S3(Challenge):
filename = self.random_string(8) + ".txt"
content = self.random_string(64)
r = requests.post(f"http://localhost:{self.port}/upload", files={'file': (filename, content)})
r = requests.post(self.url(f'/upload'), files={'file': (filename, content)})
assert r.status_code == 200
assert r.text == f'Download <a href="/download?filename={filename}">here</a>'
r = requests.get(f"http://localhost:{self.port}/download?filename={filename}")
r = requests.get(self.url(f'/download?filename={filename}'))
assert r.status_code == 200
assert r.text == content
+1 -1
View File
@@ -44,7 +44,7 @@ class XL(Challenge):
def check(self):
try:
url = f'http://localhost:{self.port}'
url = self.url()
files = {'file': self.generate_mock_file()}
r = requests.post(url, files=files, timeout=5)
assert r.json() == MOCK_RESULT, 'Unexpected response'
+11 -1
View File
@@ -10,11 +10,21 @@ class Challenge(object):
name = __name__
settings = get_settings()
port = 0
# Host the checker connects to. Same machine as the published team ports;
# override with RECEIVER_HOST if a receiver ever runs off-host.
host = os.environ.get("RECEIVER_HOST", "127.0.0.1")
def __init__(self, port):
def __init__(self, port, host=None):
self.port = port
if host:
self.host = host
self.add_logger()
def url(self, path=""):
"""Absolute URL for the challenge service (see xvi/Challenge.py)."""
p = "" if path.startswith("/") else "/"
return f"http://{self.host}:{self.port}{p}{path}"
def add_logger(self):
self.logger = logging.getLogger()
+38 -15
View File
@@ -45,15 +45,38 @@ def _flag_in_container(container: str, path: str = "/flag.txt") -> bool:
return bool(r and "FLAG_OK" in (r.stdout or ""))
def _web_alive(port: int, timeout: float = 5.0) -> bool:
"""Any HTTP response (even 4xx/5xx) proves the listener is up."""
try:
r = requests.get(f"http://127.0.0.1:{port}/", timeout=timeout, allow_redirects=False)
return r.status_code < 600
except requests.exceptions.RequestException:
return False
except Exception:
return False
def _web_alive(port: int, timeout: float = 5.0, scheme: str = None) -> bool:
"""Any HTTP response (even 4xx/5xx) proves the listener is up.
Some challenges serve TLS (gleam-drive's bandit runs
`Listening on https://localhost:8000`). A plain http:// GET against a TLS
port returns an SSLError/wrong-version-number, which reads as "service
down" even though it is perfectly healthy. The scheme is per-challenge and
comes from CHALLENGE_SCHEME_<NAME>; when unset, try http first then fall
back to https so a mis-tagged challenge still checks correctly.
"""
schemes = [scheme] if scheme else ["http", "https"]
for sch in schemes:
if not sch:
continue
try:
# verify=False is required, not lazy: the challenge serves a
# self-signed cert from inside the contest network, so there is no
# CA to validate against. This probe only proves the listener
# answers — it never carries a secret, and a MITM here would gain
# nothing beyond the liveness bit we already discard.
r = requests.get(f"{sch}://127.0.0.1:{port}/", timeout=timeout,
allow_redirects=False, verify=False)
if r.status_code < 600:
return True
except Exception:
continue
return False
def _scheme(challenge: str) -> str | None:
"""Per-challenge URL scheme from CHALLENGE_SCHEME_<NAME> (underscored)."""
return os.environ.get(f"CHALLENGE_SCHEME_{_key(challenge)}", "") or None
def _tcp_alive(port: int, timeout: float = 5.0, send: bytes = None) -> bool:
@@ -88,7 +111,7 @@ class AntiAlchemy(Challenge):
history_location = "history/anti-alchemy.txt"
def check(self):
return _web_alive(self.port) and _flag_in_container(_container("anti-alchemy"))
return _web_alive(self.port, scheme=_scheme("anti-alchemy")) and _flag_in_container(_container("anti-alchemy"))
class Asmr(Challenge):
@@ -112,7 +135,7 @@ class Fjb(Challenge):
history_location = "history/fjb.txt"
def check(self):
return _web_alive(self.port) and _flag_in_container(_container("fjb"))
return _web_alive(self.port, scheme=_scheme("fjb")) and _flag_in_container(_container("fjb"))
class GiftCard(Challenge):
@@ -140,7 +163,7 @@ class GleamDrive(Challenge):
history_location = "history/gleam-drive.txt"
def check(self):
return _web_alive(self.port) and _flag_in_container(_container("gleam-drive"))
return _web_alive(self.port, scheme=_scheme("gleam-drive")) and _flag_in_container(_container("gleam-drive"))
class GoGreen(Challenge):
@@ -156,7 +179,7 @@ class KodeViewer(Challenge):
history_location = "history/kode-viewer.txt"
def check(self):
return _web_alive(self.port) and _flag_in_container(_container("kode-viewer"))
return _web_alive(self.port, scheme=_scheme("kode-viewer")) and _flag_in_container(_container("kode-viewer"))
class MoreLess(Challenge):
@@ -164,7 +187,7 @@ class MoreLess(Challenge):
history_location = "history/more-less.txt"
def check(self):
return _web_alive(self.port) and _flag_in_container(_container("more-less"))
return _web_alive(self.port, scheme=_scheme("more-less")) and _flag_in_container(_container("more-less"))
class TempestPoc(Challenge):
@@ -172,7 +195,7 @@ class TempestPoc(Challenge):
history_location = "history/tempest-poc.txt"
def check(self):
return _web_alive(self.port) and _flag_in_container(_container("tempest-poc"))
return _web_alive(self.port, scheme=_scheme("tempest-poc")) and _flag_in_container(_container("tempest-poc"))
class Ticketer(Challenge):