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
+57 -1
View File
@@ -9,6 +9,8 @@ import json
import time
import asyncio
import threading
import subprocess
import sys
import httpx
from pathlib import Path
from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect
@@ -530,6 +532,7 @@ async def api_teams_set(req: Request):
labels = data.get("labels") or {} # { "1": "Tim Satu", ... }
domains = data.get("domains") or {} # { "1": "mycustom", ... }
created = []
ufw = []
for i in range(1, n + 1):
td = orch.TEAMS_DIR / f"team{i}"
if not td.exists():
@@ -537,6 +540,9 @@ async def api_teams_set(req: Request):
dom = domains.get(str(i)) or domains.get(i) or None
st = orch.create_team(i, label, domain=dom)
created.append(st["index"])
# UFW defaults to deny(incoming) on this host: without these rules
# the team's challenge/SSH/receiver ports are silently blackholed.
ufw.append(orch.sync_team_ufw(i))
else:
# team exists: apply any label/domain overrides
label = labels.get(str(i)) or labels.get(i)
@@ -544,7 +550,7 @@ async def api_teams_set(req: Request):
if label or dom:
orch.update_team(i, label=label, domain=dom)
orch.ensure_team_domains()
return {"created": created, "total": len(orch.list_teams())}
return {"created": created, "total": len(orch.list_teams()), "ufw": ufw}
@app.put("/api/teams/{idx}")
async def api_team_update(idx: int, req: Request):
@@ -559,6 +565,56 @@ async def api_team_update(idx: int, req: Request):
except FileNotFoundError as e:
raise HTTPException(404, str(e))
@app.delete("/api/teams/{idx}")
async def api_team_delete(idx: int, req: Request):
"""Permanently delete ONE team: containers, network, receiver unit, ports,
directory, score records and its Traefik domain.
Body: {"purge_scores": true} (default) — set false to keep the team's
leaderboard/points history. Destructive and irreversible, so the UI gates
it behind a confirm dialog.
"""
require_login(req)
raw = {}
try:
raw = await req.json()
except Exception:
pass # DELETE with no body is fine
if not (orch.TEAMS_DIR / f"team{idx}" / "state.json").exists():
raise HTTPException(404, f"Team {idx} not found")
try:
# compose down for 16 services takes a while — keep the event loop free
result = await asyncio.to_thread(orch.delete_team, idx,
bool(raw.get("purge_scores", True)))
# refresh the remaining teams' generated artifacts (receiver main.py,
# systemd units) so nothing points at the deleted team
subprocess.run([sys.executable, str(orch.BASE / "panel" / "gen_receiver_services.py"), "start"],
check=False, capture_output=True)
return result
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/api/teams/{idx}/ufw")
async def api_team_ufw(idx: int, req: Request):
"""Reconcile UFW rules for a team's port block.
UFW defaults to deny(incoming) on this host, so a team whose ports were
never opened is blackholed. Use this after creating a team out-of-band, or
to close the ports of a team you just deleted by hand.
Body: {"remove": true} deletes the rules instead.
"""
require_login(req)
raw = {}
try:
raw = await req.json()
except Exception:
pass
return await asyncio.to_thread(orch.sync_team_ufw, idx, bool(raw.get("remove", False)))
@app.post("/api/teams/start")
async def api_teams_start(req: Request):
require_login(req)