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
+12
View File
@@ -14,6 +14,14 @@ from pathlib import Path
TEAMS_DIR = Path("/opt/gemastik18-final/teams")
RECEIVER_VENV = "/opt/gemastik18-final/receiver/.venv/bin/python"
UNIT_DIR = Path("/etc/systemd/system")
REGISTRY_PATH = TEAMS_DIR / "challenge_registry.json"
def load_registry() -> dict:
try:
return json.loads(REGISTRY_PATH.read_text())
except Exception:
return {"sets": {}, "challenges": []}
def write_unit(idx: int, st: dict):
port = st["ports"]["receiver"]
@@ -23,12 +31,16 @@ def write_unit(idx: int, st: dict):
# NOTE: systemd Environment= keys must be [A-Za-z0-9_]+ — a hyphen in the
# challenge name (gift-card) would make systemd silently drop the line, so
# normalize the name to underscores here. main.py looks up the same key.
# Same normalization applies to CHALLENGE_SCHEME_<NAME>.
schemes = {c["name"]: c.get("scheme") for c in load_registry().get("challenges", [])}
for ch in st["ports"]:
if ch in ("receiver", "panel"):
continue
key = ch.upper().replace("-", "_")
env[f"CHALLENGE_PORT_{key}"] = str(st["ports"][ch]["chall"])
env[f"CHALLENGE_CONTAINER_{key}"] = f"{ch}_container_team{idx}"
if schemes.get(ch):
env[f"CHALLENGE_SCHEME_{key}"] = schemes[ch]
# SSH passwords: checker Challenge.credentials() reads PASSWORD_<self.port>
# where self.port is the team challenge port.
pwd = st.get("chall_passwords", {}).get(ch)