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
+331 -9
View File
@@ -443,8 +443,15 @@ def create_team(idx: int, label: str = None, domain: str = None):
for j, line in enumerate(recv_env):
if line.startswith(f"PASSWORD_{10000+coff*1000}="):
recv_env[j] = f"PASSWORD_{10000+coff*1000}={state['chall_passwords'][name]}"
recv_env.append(f"CHALLENGE_PORT_{name.upper()}={ports[name]['chall']}")
recv_env.append(f"CHALLENGE_CONTAINER_{name.upper()}={name}_container_team{idx}")
# systemd EnvironmentFile keys must match [A-Za-z_][A-Za-z0-9_]* — a
# hyphen (gift-card, bit-canvas, ...) makes systemd log
# "Ignoring invalid environment assignment" and DROP the line, so the
# checker falls back to a default port/container and reports the
# service down. Normalize to underscores on the writer; the reader
# (gen_receiver_main._envkey) uses the same normalization.
key = name.upper().replace("-", "_")
recv_env.append(f"CHALLENGE_PORT_{key}={ports[name]['chall']}")
recv_env.append(f"CHALLENGE_CONTAINER_{key}={name}_container_team{idx}")
(recv_dir / ".env").write_text("\n".join(recv_env) + "\n")
# --- flags (randomized per team so each team has unique flags) ---
@@ -484,6 +491,47 @@ def update_team(idx: int, label: str = None, domain: str = None) -> dict:
ensure_team_domains()
return st
def missing_sidecars(idx: int) -> list[str]:
"""Sidecar services in the team's compose that are NOT running.
A multi-container challenge (anti-alchemy + its postgres, gemas-notes +
database/validation, kode-viewer + redis, ...) needs its sidecars to boot:
the main service's `create_db_conn()` retries in an infinite loop until the
DB hostname resolves, so a missing sidecar leaves the main container
"Up" with NO app listening and the checker reports it DOWN. Symptom class:
container is running, port is open at the docker level, but the app never
starts. Recover with `docker compose -p teamN up -d` (no service name).
"""
svc_dir = TEAMS_DIR / f"team{idx}" / "services"
compose = svc_dir / "docker-compose.yml"
if not compose.exists():
return []
declared = _compose_service_names(compose)
if not declared:
return []
want = {f"team{idx}-{n}-1" for n in declared}
running = set(subprocess.run(["docker", "ps", "--format", "{{.Names}}"],
capture_output=True, text=True).stdout.split())
return sorted(want - running)
def _compose_service_names(compose: Path) -> list[str]:
"""Top-level service names from a compose file, without needing PyYAML."""
names: list[str] = []
in_services = False
for line in compose.read_text().splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
if not line.startswith((" ", "\t")):
in_services = line.rstrip() == "services:"
continue
if in_services and line.startswith(" ") and not line.startswith(" "):
name = line.strip().rstrip(":")
if name and not name.startswith("-"):
names.append(name)
return names
def start_team(idx: int):
team_dir = TEAMS_DIR / f"team{idx}"
if not (team_dir / "state.json").exists():
@@ -491,6 +539,13 @@ def start_team(idx: int):
svc_dir = team_dir / "services"
subprocess.run(["docker", "compose", "-p", f"team{idx}", "-f", svc_dir / "docker-compose.yml", "up", "-d", "--build"],
cwd=str(svc_dir), check=False, capture_output=True)
# Self-heal: a per-service `up` (challenge toggle) or a raced start can
# leave a sidecar behind while the main container looks fine. One plain
# `up -d` reconciles the whole project (already-running ones are no-ops).
gone = missing_sidecars(idx)
if gone:
subprocess.run(["docker", "compose", "-p", f"team{idx}", "-f", svc_dir / "docker-compose.yml", "up", "-d"],
cwd=str(svc_dir), check=False, capture_output=True)
_start_receiver(idx)
st = json.loads((team_dir / "state.json").read_text())
st["status"] = "running"
@@ -498,19 +553,53 @@ def start_team(idx: int):
# Set per-team SSH passwords at runtime (images are shared across teams,
# so chpasswd ensures each team's containers have the team's own password).
set_ssh_passwords(idx)
if gone:
print(f"[start_team] team{idx}: started missing sidecar(s): {', '.join(gone)}")
return st
def challenge_ssh_users() -> dict:
"""{challenge_name: ssh login} from the registry.
Only the 6 native GEMASTIK XVIII images provision `ctfuser`. Every imported
XVI/XVII image does `RUN echo root:${PASSWORD} | chpasswd` and logs in as
`root` (some also create an unprivileged `ctf` for the app). Hardcoding
`ctfuser` made chpasswd either fail or set a password on an account nobody
uses, so SSH returned "Permission denied" for every team on 10 of 16
challenges while state.json still looked correct.
"""
out = {}
for c in registry_challenges():
out[c["name"]] = c.get("ssh_user") or "root"
return out
def set_ssh_passwords(idx: int):
"""Set SSH password for ctfuser in each challenge container of a team."""
"""Set the SSH password for the CORRECT login of each challenge container.
The login is per-challenge (registry `ssh_user`), not a global `ctfuser`.
Retries because right after `compose up` the container may still be booting.
Reports failures loudly instead of writing to a log nobody reads.
"""
team_dir = TEAMS_DIR / f"team{idx}"
st = json.loads((team_dir / "state.json").read_text())
users = challenge_ssh_users()
failures = []
for name, coff, soff in CHALLENGES:
cont = f"{name}_container_team{idx}"
pw = st["chall_passwords"][name]
cmd = f"echo 'ctfuser:{pw}' | chpasswd"
# retry a few times — right after `compose up`, container may still be booting
user = users.get(name, "root")
pw = st["chall_passwords"].get(name)
if not pw:
failures.append(f"{cont}: no password in state")
continue
# Set the password for the real login AND for ctfuser when that account
# exists, so either convention works during a transition.
cmd = (f"id {user} >/dev/null 2>&1 && echo '{user}:{pw}' | chpasswd; "
f"id ctfuser >/dev/null 2>&1 && echo 'ctfuser:{pw}' | chpasswd; "
f"id ctf >/dev/null 2>&1 && echo 'ctf:{pw}' | chpasswd; "
f"id {user} >/dev/null 2>&1")
ok = False
r = None
for attempt in range(5):
r = subprocess.run(["docker", "exec", cont, "sh", "-c", cmd],
capture_output=True, text=True, timeout=30)
@@ -519,9 +608,13 @@ def set_ssh_passwords(idx: int):
break
time.sleep(3)
if not ok:
print(f"[set_ssh_passwords] {cont}: FAILED after retries ({r.stderr.strip()[:100]})")
failures.append(f"{cont}: {(r.stderr or '').strip()[:100]}")
else:
print(f"[set_ssh_passwords] {cont}: OK")
print(f"[set_ssh_passwords] {cont} (login={user}): OK")
if failures:
print(f"[set_ssh_passwords] team{idx} FAILED {len(failures)}/{len(CHALLENGES)}: "
+ "; ".join(failures))
return failures
def stop_team(idx: int):
team_dir = TEAMS_DIR / f"team{idx}"
@@ -622,12 +715,241 @@ def ensure_team_domains() -> str:
- main: {host}
""")
if not out:
return "no teams to route"
# No teams left. The file MUST still be rewritten (to an empty router
# set) — returning early leaves the previous content on disk, so a
# DELETED team keeps its Traefik router and stays routable to the panel
# portal forever. That was the stale-domain bug.
(traefik_dir / "attackdefense-teams.yaml").write_text("http:\n routers: {}\n")
return "no teams to route (emptied attackdefense-teams.yaml)"
# Keep the team domain block in its own file so the main attackdefense.yaml stays untouched
(traefik_dir / "attackdefense-teams.yaml").write_text("http:\n routers:\n" + "".join(out))
return f"wrote {len(out)} team domain(s) in attackdefense-teams.yaml"
def team_port_block(idx: int) -> list[int]:
"""Every host port a team occupies: each challenge's chall+ssh port, the
receiver, and the reserved panel slot."""
st_path = TEAMS_DIR / f"team{idx}" / "state.json"
ports: set[int] = set()
if st_path.exists():
st = json.loads(st_path.read_text())
for name, pv in (st.get("ports") or {}).items():
if isinstance(pv, dict):
for k in ("chall", "ssh"):
if pv.get(k):
ports.add(int(pv[k]))
elif isinstance(pv, int):
ports.add(pv)
else:
# team dir already gone (mid-delete): derive from the port scheme
base = PORT_BASE + idx * STEP
for name, coff, soff in CHALLENGES:
ports.add(base + coff)
ports.add(base + soff)
ports.add(base + 80)
ports.add(base + 81)
return sorted(ports)
def sync_team_ufw(idx: int, remove: bool = False) -> dict:
"""Reconcile UFW rules for one team's port block.
UFW here defaults to deny(incoming), so a port that is not explicitly
allowed is blackholed — the team is unreachable from the internet AND from
its own containers. Team creation never opened these ports (they were
opened by hand earlier), so a new team silently gets no connectivity.
remove=True DELETES the rules instead of adding them (called from
delete_team). The two modes are mutually exclusive: never add-then-delete,
that would flap the rules and briefly re-open a dead team's ports.
"""
ports = team_port_block(idx)
if remove:
for p in ports:
subprocess.run(["ufw", "--force", "delete", "allow", f"{p}/tcp"],
check=False, capture_output=True)
return {"team": idx, "ports": len(ports), "closed": ports, "failed": []}
failed = []
for p in ports:
r = subprocess.run(["ufw", "allow", f"{p}/tcp"], check=False, capture_output=True, text=True)
# `ufw allow` on an existing rule prints "Skipping adding existing rule"
# and still exits 0; a non-zero rc with a skip message is not a failure.
if r.returncode != 0 and "Skipping" not in (r.stdout + r.stderr):
failed.append(p)
return {"team": idx, "ports": len(ports), "opened": [p for p in ports if p not in failed],
"failed": failed}
def _purge_team_records(idx: int) -> dict:
"""Drop every ledger row that references a team, so a deleted team leaves
no ghost entries on the leaderboard / points / attack topology."""
removed = {"leaderboard": 0, "points": 0, "attacks": 0}
lb_path = TEAMS_DIR / "leaderboard.json"
if lb_path.exists():
try:
lb = json.loads(lb_path.read_text())
before = len(lb.get("solves", []))
lb["solves"] = [e for e in lb.get("solves", [])
if e.get("team") != idx and e.get("target") != idx]
removed["leaderboard"] = before - len(lb["solves"])
lb_path.write_text(json.dumps(lb, indent=2))
except Exception:
pass
p_path = TEAMS_DIR / "points.json"
if p_path.exists():
try:
data = json.loads(p_path.read_text())
if str(idx) in data.get("teams", {}):
data["teams"].pop(str(idx))
removed["points"] = 1
p_path.write_text(json.dumps(data, indent=2))
except Exception:
pass
a_path = TEAMS_DIR / "attacks.json"
if a_path.exists():
try:
log = json.loads(a_path.read_text())
before = len(log.get("events", []))
log["events"] = [e for e in log.get("events", [])
if e.get("attacker") != idx and e.get("target") != idx]
removed["attacks"] = before - len(log["events"])
a_path.write_text(json.dumps(log, indent=2))
except Exception:
pass
return removed
def repair_team_receiver_env(idx: int) -> dict:
"""Rewrite a team receiver .env with systemd-legal keys.
Teams created before the hyphen fix have `CHALLENGE_PORT_GIFT-CARD=` in
their .env. systemd logs "Ignoring invalid environment assignment" and drops
the line, so every hyphenated challenge (gift-card, bit-canvas, gleam-drive,
more-less, anti-alchemy, gift-voucher) reports DOWN even though its
container is up. This rewrites the file in place, fixing existing teams
without forcing a re-create.
"""
env_path = TEAMS_DIR / f"team{idx}" / "receiver" / ".env"
if not env_path.exists():
raise FileNotFoundError(f"team{idx} receiver .env not found")
st = json.loads((TEAMS_DIR / f"team{idx}" / "state.json").read_text())
lines = env_path.read_text().splitlines()
kept, fixed, dropped = [], [], []
for line in lines:
if line.startswith("CHALLENGE_PORT_") or line.startswith("CHALLENGE_CONTAINER_"):
k, _, v = line.partition("=")
nk = k.replace("-", "_")
if nk != k:
fixed.append(f"{k}->{nk}")
else:
kept.append(line)
continue
kept.append(line)
# re-append authoritative values for every challenge in state
for name in (st.get("ports") or {}):
if name in ("receiver", "panel"):
continue
key = name.upper().replace("-", "_")
kept.append(f"CHALLENGE_PORT_{key}={st['ports'][name]['chall']}")
kept.append(f"CHALLENGE_CONTAINER_{key}={name}_container_team{idx}")
env_path.write_text("\n".join(kept) + "\n")
# also refresh the shared checker packages (team1 was missing xvi.Art)
try:
sys.path.insert(0, str(BASE / "panel"))
from gen_receiver_main import _sync_checker_packages
_sync_checker_packages(TEAMS_DIR / f"team{idx}" / "receiver")
except Exception as e:
dropped.append(f"checker sync failed: {e}")
return {"team": idx, "fixed_keys": fixed, "notes": dropped}
def delete_team(idx: int, purge_scores: bool = True) -> dict:
"""Permanently remove ONE team and free everything it owns.
Teardown order matters — do the teardown against the OLD compose before
removing the directory, or `docker compose` has no file to read and the
containers survive as orphans:
1. stop the per-team receiver systemd unit and remove the unit file
2. `docker compose -p teamN down -v` against the team compose
(also drops the teamN_default network)
3. force-remove any surviving <chall>_container_teamN container
4. delete the team's UFW rules
5. rmtree teams/teamN (compose, receiver, flags, state.json)
6. purge leaderboard / points / attacks rows for that team
7. rewrite the Traefik team-domain file so the domain stops resolving
Images (services-*) are SHARED across teams and are never removed here.
purge_scores=False keeps the team's leaderboard/points history.
"""
team_dir = TEAMS_DIR / f"team{idx}"
if not (team_dir / "state.json").exists():
raise FileNotFoundError(f"Team {idx} not created")
st = json.loads((team_dir / "state.json").read_text())
label = st.get("label", f"Team {idx}")
steps: list[str] = []
# 1. receiver unit
unit = f"gemastik-receiver-team{idx}.service"
subprocess.run(["systemctl", "disable", unit], check=False, capture_output=True)
subprocess.run(["systemctl", "stop", unit], check=False, capture_output=True)
unit_path = Path("/etc/systemd/system") / f"{unit}"
if unit_path.exists():
unit_path.unlink()
steps.append("removed receiver unit")
subprocess.run(["systemctl", "daemon-reload"], check=False, capture_output=True)
# 2. compose down (containers + network) while the compose file still exists
svc_dir = team_dir / "services"
compose = svc_dir / "docker-compose.yml"
if compose.exists():
r = subprocess.run(["docker", "compose", "-p", f"team{idx}", "-f", str(compose),
"down", "-v", "--remove-orphans"],
cwd=str(svc_dir), check=False, capture_output=True, text=True,
timeout=600)
steps.append("compose down" if r.returncode == 0 else f"compose down rc={r.returncode}")
# 3. force-remove leftovers (a half-written compose can leave orphans)
left = subprocess.run(["docker", "ps", "-aq", "--filter", f"name=_container_team{idx}"],
capture_output=True, text=True).stdout.split()
if left:
subprocess.run(["docker", "rm", "-f", *left], check=False, capture_output=True)
steps.append(f"force-removed {len(left)} container(s)")
net_rm = subprocess.run(["docker", "network", "rm", f"team{idx}_default"],
check=False, capture_output=True, text=True)
if net_rm.returncode == 0:
steps.append("removed docker network")
# 4. UFW
ufw = sync_team_ufw(idx, remove=True)
steps.append(f"closed {len(ufw.get('closed', []))} ufw port(s)")
# 5. directory
shutil.rmtree(team_dir, ignore_errors=True)
if team_dir.exists():
raise RuntimeError(f"team{idx} directory could not be removed")
steps.append("deleted team directory")
# 6. ledgers
purged = _purge_team_records(idx) if purge_scores else {}
if purge_scores:
steps.append("purged score records")
# 7. Traefik: drop the dead domain
try:
ensure_team_domains()
steps.append("rewrote team domains")
except Exception as e:
steps.append(f"traefik rewrite failed: {e}")
return {"ok": True, "team": idx, "label": label, "domain": st.get("domain", ""),
"steps": steps, "purged": purged}
def list_teams() -> list:
out = []
if not TEAMS_DIR.exists():