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:
1 parent
0a56906b18
commit
50cb782ded
15 files changed
+820
-28
No files matched your search
+151
-2
@@ -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", "")
|
||||
|
||||
Reference in new issue
Block a user