fix(portal): per-challenge SSH user in web terminal + credential API

The web SSH terminal and the credential API reported `ctfuser` for all 16
challenges, but only the 6 native GEMASTIK XVIII images provision ctfuser.
Every imported XVI/XVII image does `RUN echo root:${PASSWORD} | chpasswd`,
so 10 of 16 participant logins were refused with "Permission denied".

Root causes (all the same class of bug - login hardcoded in the wrong layer):
- main.py websocket ssh handler read st["ssh_user"], a single team-wide value
  defaulting to ctfuser, instead of the per-challenge registry field
- /api/credential proxied the global receiver on :18080, which only knows the
  6 native challenges, so the other 10 returned "Invalid challenge"
- team.html hardcoded the challenge picker to those same 6 challenges, making
  the other 10 unreachable from the terminal entirely
- index.html rendered `<b>ctfuser</b>` and a stale hardcoded SSH port table

Fixes:
- orch.challenge_credential()/all_teams() read the TEAM's state.json, which
  holds the same per-challenge password the panel chpasswds
- gen_receiver_services.py injects SSH_USER_<port> from the registry so the
  receiver's /credential endpoint agrees with the panel
- receiver Challenge.credentials() honours SSH_USER_<port> (ctfuser fallback)
- new /api/team/{idx}/own-challenges feeds the picker; targets now carry
  challenge + ssh_user
- UI takes user and port from the server instead of hardcoding them

Verified: 32/32 credential payloads correct across teams 1-2, and 32/32 real
paramiko SSH logins succeed with whoami confirming the expected account.

Also adds bulk team delete: POST /api/teams/bulk-delete runs one background
thread and is polled via GET /api/teams/bulk-delete/{job_id}, plus per-team
checkboxes with select-all/clear in the UI. Deletion must stay sequential
because delete_team() regenerates shared artifacts at the end.
This commit is contained in:
root committed 2026-09-26 16:37:40 +08:00
1 parent 0a56906b18
commit 50cb782ded
15 files changed
+820 -28

No files matched your search

+151 -2
View File
@@ -39,6 +39,7 @@ async def _start_background():
# Toggle jobs: Docker builds take minutes, so PATCH /api/challenges runs the
# work on a background thread and the client polls /api/challenges/jobs/<id>.
_DELETE_JOBS = {}
_TOGGLE_JOBS: dict[str, dict] = {}
CHALLENGES = [
@@ -216,6 +217,32 @@ async def api_team_session(idx: int, req: Request):
"""True when this browser has a valid team session for idx."""
return {"authed": _team_authorized(req, idx)}
@app.get("/api/team/{idx}/own-challenges")
async def api_team_own_challenges(idx: int, req: Request):
"""The challenges THIS team runs, each with the SSH login it provisions.
The terminal's challenge picker used to be a hardcoded list of only the 6
native GEMASTIK XVIII entries, so the 10 imported XVI/XVII challenges could
not be selected at all. Names + per-challenge ssh_user only -- no passwords.
"""
td = orch.TEAMS_DIR / f"team{idx}"
if not (td / "state.json").exists():
raise HTTPException(404, "Team not found")
st = json.loads((td / "state.json").read_text())
if not _check_team_host(req, st):
raise HTTPException(403, "Akses team lain tidak diizinkan")
if not _team_authorized(req, idx):
raise HTTPException(401, "Login portal team dulu")
users = orch.challenge_ssh_users()
out = []
for name, _coff, _soff in orch.CHALLENGES:
p = st.get("ports", {}).get(name)
if not p:
continue
out.append({"name": name, "ssh_user": users.get(name, "ctfuser"),
"port": p.get("chall"), "ssh_port": p.get("ssh")})
return {"challenges": out}
@app.get("/api/team/{idx}/targets")
async def api_team_targets(idx: int, req: Request):
"""Team targets — requires team login + own host. Only domain + port."""
@@ -228,6 +255,7 @@ async def api_team_targets(idx: int, req: Request):
if not _team_authorized(req, idx):
raise HTTPException(401, "Login portal team dulu")
out = []
ssh_users = orch.challenge_ssh_users()
for d in sorted(orch.TEAMS_DIR.glob("team*")):
if not (d / "state.json").exists():
continue
@@ -241,8 +269,12 @@ async def api_team_targets(idx: int, req: Request):
out.append({
"team_idx": st.get("index"),
"team_label": st.get("label", f"Team {st.get('index')}"),
"challenge": name,
"domain": st.get("domain") or (st.get("slug", f"team{st.get('index')}") + ".attackdefense.imrnes.team"),
"port": p["chall"],
# attackers need the same login the victim container provisions;
# it is per-challenge, so surface it instead of assuming ctfuser
"ssh_user": ssh_users.get(name, "ctfuser"),
})
return {"targets": out}
@@ -496,8 +528,26 @@ async def api_deactivate(challenge: str, req: Request):
return {"receiver_status": resp.status_code, "receiver_body": resp.text}
@app.get("/api/credential/{challenge}")
async def api_credential(challenge: str, req: Request):
async def api_credential(challenge: str, req: Request, team: int = None):
require_login(req)
# The global receiver on :18080 only knows the 6 native GEMASTIK XVIII
# challenges, so proxying everything there returns "Invalid challenge" for the
# 10 imported XVI/XVII ones -- participants saw no SSH creds at all. A team's
# state.json is the authority for BOTH the password and the SSH login user
# (the same `ssh_user` the panel chpasswds), plus the team-specific port.
# `?team=N` selects the team; team portal hosts imply their own index.
idx = team
if idx is None:
host = (req.headers.get("host") or "").split(":")[0]
for t in orch.all_teams():
dom = (t.get("domain") or "").split(":")[0]
if dom and dom == host:
idx = t.get("index")
break
if idx is not None:
cred = orch.challenge_credential(idx, challenge)
if cred:
return cred
resp = await _proxy("GET", f"/credential/{challenge}")
if resp.status_code == 200:
return resp.json()
@@ -598,6 +648,98 @@ async def api_team_delete(idx: int, req: Request):
raise HTTPException(500, str(e))
@app.post("/api/teams/bulk-delete")
async def api_teams_bulk_delete(req: Request):
"""Delete SEVERAL teams in one background job.
Body: {"indices": [5, 6], "purge_scores": true}
Why a job and not a loop of DELETE /api/teams/{idx}: a single team takes
~100 s (compose down of 16 services), so deleting four teams inline would
hold the request open for ~7 minutes and trip every proxy/browser timeout
in front of the panel. Each team is therefore deleted sequentially inside
ONE background thread, and the client polls a single job id.
Teams are processed one at a time on purpose. delete_team() runs
`docker compose -p teamN down` and regenerates shared artifacts
(receiver main.py, systemd units) afterwards, so running several in
parallel would race on those shared files.
A team that fails does NOT abort the rest: the job records the error and
moves on, because the point of a bulk delete is to clear stale teams and
one broken compose shouldn't strand the others.
"""
require_login(req)
data = await req.json()
raw = data.get("indices") or data.get("indices[]") or []
try:
indices = sorted({int(i) for i in raw})
except (TypeError, ValueError):
raise HTTPException(400, "indices must be a list of team numbers")
if not indices:
raise HTTPException(400, "No team selected")
if len(indices) > 20:
raise HTTPException(400, "Refusing to delete more than 20 teams at once")
purge = bool(data.get("purge_scores", True))
existing = [i for i in indices
if (orch.TEAMS_DIR / f"team{i}" / "state.json").exists()]
skipped = [i for i in indices if i not in existing]
if not existing:
raise HTTPException(404, "None of the selected teams exist")
job_id = f"bulkdel-{int(time.time())}-{len(existing)}"
_DELETE_JOBS[job_id] = {
"job": job_id, "indices": existing, "skipped": skipped,
"state": "running", "done": [], "errors": {},
"detail": "queued", "started": int(time.time()),
}
def _worker():
job = _DELETE_JOBS[job_id]
try:
for n, i in enumerate(existing, 1):
job["detail"] = f"menghapus team {i} ({n}/{len(existing)})"
try:
# delete_team is blocking (subprocess + shutil), and this
# runs in a plain thread, so call it directly.
res = orch.delete_team(i, purge)
job["done"].append({
"team": i,
"label": res.get("label", f"Team {i}"),
"steps": res.get("steps", []),
"purged": res.get("purged", {}),
})
except Exception as e:
job["errors"][str(i)] = str(e)
# regenerate shared artifacts once at the end, so the remaining
# teams' receiver main.py / systemd units stop referencing deleted ones
subprocess.run(
[sys.executable, str(orch.BASE / "panel" / "gen_receiver_services.py"), "start"],
check=False, capture_output=True)
job["state"] = "done" if not job["errors"] else "partial"
job["detail"] = "complete" if not job["errors"] else "completed with errors"
except Exception as e:
job["state"] = "error"
job["detail"] = str(e)
finally:
job["finished"] = int(time.time())
threading.Thread(target=_worker, name=f"bulkdel-{job_id}", daemon=True).start()
return {"ok": True, "job": job_id, "state": "running",
"indices": existing, "skipped": skipped}
@app.get("/api/teams/bulk-delete/{job_id}")
async def api_teams_bulk_delete_job(job_id: str, req: Request):
"""Poll a bulk team delete started by POST /api/teams/bulk-delete."""
require_login(req)
job = _DELETE_JOBS.get(job_id)
if not job:
raise HTTPException(404, "Unknown job")
return job
@app.post("/api/teams/{idx}/ufw")
async def api_team_ufw(idx: int, req: Request):
"""Reconcile UFW rules for a team's port block.
@@ -858,7 +1000,14 @@ async def team_ssh_ws(ws: WebSocket, idx: int):
await ws.close(code=4002, reason="unknown challenge")
return
recv_port = st["ports"][chall]["ssh"]
user = st.get("ssh_user", "ctfuser")
# The SSH login is PER-CHALLENGE, not per-team. Only the 6 native GEMASTIK
# XVIII images provision `ctfuser`; every imported XVI/XVII image does
# `RUN echo root:${PASSWORD} | chpasswd`. Reading st["ssh_user"] (a single
# team-wide value, default ctfuser) made the web terminal log in as ctfuser
# for all 16 challenges, so 10 of them always failed with "Permission denied".
# challenge_ssh_users() reads the registry -- the same field set_ssh_passwords()
# chpasswds -- so the login and the password can never drift apart.
user = orch.challenge_ssh_users().get(chall) or st.get("ssh_user", "ctfuser")
# each challenge container has its own password (chall_passwords);
# ssh_pass is the portal login password (may differ).
pw = st.get("chall_passwords", {}).get(chall) or st.get("ssh_pass", "")