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
+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):