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
2026-09-26 16:37:40 +08:00
parent 0a56906b18
commit 50cb782ded
15 changed files with 820 additions and 28 deletions
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Bulk-delete regression test: create two scratch teams, then delete BOTH in a
# single API call and poll the job until it settles.
#
# Proves the endpoint exists, validates input, runs teams sequentially inside
# one background job, and leaves the real teams untouched.
set -uo pipefail
BASE=/opt/gemastik18-final
cd "$BASE"
CJ=/tmp/bulk_cj
U=$(grep -oP '^PANEL_ADMIN_USER=\K.*' panel/.env)
P=$(grep -oP '^PANEL_ADMIN_PASS=\K.*' panel/.env)
curl -sS -c "$CJ" -X POST -H 'Content-Type: application/json' \
-d "{\"user\":\"$U\",\"pass\":\"$P\"}" http://127.0.0.1:18081/api/login -o /dev/null
echo "=== validation ==="
printf " empty list -> "
curl -sS -b "$CJ" -X POST -H 'Content-Type: application/json' -d '{"indices":[]}' \
http://127.0.0.1:18081/api/teams/bulk-delete -w ' [%{http_code}]\n'
printf " missing tms -> "
curl -sS -b "$CJ" -X POST -H 'Content-Type: application/json' -d '{"indices":[99,98]}' \
http://127.0.0.1:18081/api/teams/bulk-delete -w ' [%{http_code}]\n'
echo "=== BEFORE ==="
for i in 5 6; do
printf " team%s dir=%s\n" "$i" "$([ -d "teams/team$i" ] && echo present || echo GONE)"
done
echo "=== BULK DELETE [5,6] ==="
S=$(date +%s)
curl -sS -b "$CJ" -X POST -H 'Content-Type: application/json' \
-d '{"indices":[5,6],"purge_scores":true}' \
http://127.0.0.1:18081/api/teams/bulk-delete -o /tmp/bulk.json -w ' HTTP %{http_code}\n'
cat /tmp/bulk.json; echo
JOB=$(python3 -c "import json;print(json.load(open('/tmp/bulk.json'))['job'])")
echo " job: $JOB"
while :; do
curl -sS -b "$CJ" "http://127.0.0.1:18081/api/teams/bulk-delete/$JOB" -o /tmp/bulkjob.json
read -r ST DET <<<"$(python3 -c "
import json;d=json.load(open('/tmp/bulkjob.json'));print(d['state'],d.get('detail',''))")"
echo " [$(( $(date +%s) - S ))s] $ST — $DET"
[ "$ST" != "running" ] && break
sleep 15
done
echo " elapsed: $(( $(date +%s) - S ))s"
python3 -c "
import json;d=json.load(open('/tmp/bulkjob.json'))
print(' state:',d['state'],' errors:',d.get('errors'))
for x in d.get('done',[]): print(' deleted team',x['team'],x['label'],'->',x['steps'])"
echo "=== AFTER ==="
for i in 5 6; do
printf " team%s dir=%s\n" "$i" "$([ -d "teams/team$i" ] && echo present || echo GONE)"
done
echo " real teams: $(curl -sS -b "$CJ" http://127.0.0.1:18081/api/teams \
| python3 -c "import json,sys;print([t['index'] for t in json.load(sys.stdin)['teams']])")"
+25
View File
@@ -0,0 +1,25 @@
// Syntax-check every inline <script> block across all panel static pages.
const fs = require('fs');
const { execFileSync } = require('child_process');
const files = ['static/index.html', 'static/team.html', 'static/guide.html'];
const base = '/opt/gemastik18-final/panel/';
let bad = 0, total = 0;
for (const f of files) {
const html = fs.readFileSync(base + f, 'utf8');
const re = /<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi;
let m, i = 0;
while ((m = re.exec(html)) !== null) {
i++; total++;
const p = `/tmp/chk_${f.replace(/\W/g, '_')}_${i}.js`;
fs.writeFileSync(p, m[1]);
try {
execFileSync(process.execPath, ['--check', p], { stdio: 'pipe' });
console.log(` OK ${f} block#${i}`);
} catch (e) {
bad++;
console.log(` FAIL ${f} block#${i}\n${e.stderr.toString().split('\n').slice(0, 5).join('\n')}`);
}
}
}
console.log(bad === 0 ? `\nAll ${total} script block(s) parse cleanly.` : `\n${bad}/${total} FAILED.`);
process.exit(bad ? 1 : 0);
+20
View File
@@ -0,0 +1,20 @@
// Extract every <script> block from index.html and syntax-check each with node.
const fs = require('fs');
const { execFileSync } = require('child_process');
const html = fs.readFileSync('/opt/gemastik18-final/panel/static/index.html', 'utf8');
const re = /<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi;
let m, i = 0, bad = 0;
while ((m = re.exec(html)) !== null) {
i++;
const code = m[1];
const f = `/tmp/blk_${i}.js`;
fs.writeFileSync(f, code);
try {
execFileSync(process.execPath, ['--check', f], { stdio: 'pipe' });
console.log(` script #${i}: OK (${code.split('\n').length} lines)`);
} catch (e) {
bad++;
console.log(` script #${i}: SYNTAX ERROR -> ${e.stderr.toString().split('\n').slice(0, 6).join('\n')}`);
}
}
console.log(bad === 0 ? `\nAll ${i} inline script block(s) parse cleanly.` : `\n${bad} block(s) FAILED.`);
+8
View File
@@ -33,6 +33,12 @@ def write_unit(idx: int, st: dict):
# 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", [])}
# SSH login user per challenge. The panel already chpasswds the right
# account from this field (teams.challenge_ssh_users); the receiver must
# report the SAME user via /credential/<name> or participants get a login
# that cannot work. Registry is the single source of truth for both sides.
ssh_users = {c["name"]: c.get("ssh_user", "ctfuser")
for c in load_registry().get("challenges", [])}
for ch in st["ports"]:
if ch in ("receiver", "panel"):
continue
@@ -41,6 +47,8 @@ def write_unit(idx: int, st: dict):
env[f"CHALLENGE_CONTAINER_{key}"] = f"{ch}_container_team{idx}"
if schemes.get(ch):
env[f"CHALLENGE_SCHEME_{key}"] = schemes[ch]
if ssh_users.get(ch):
env[f"SSH_USER_{st['ports'][ch]['chall']}"] = ssh_users[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)
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Inject SSH_USER_<port> env into the GLOBAL receiver unit (gemastik-receiver).
Why: the panel's /api/credential proxy targets the global receiver on :18080,
not the per-team receivers. That unit had no Environment= lines at all, so
Challenge.credentials() fell back to the hardcoded 'ctfuser' literal and every
imported XVI/XVII challenge reported a login that could never work.
The registry's `ssh_user` per challenge is the single source of truth (the
panel already chpasswds that same account). Read the port each challenge runs
on from the global receiver's own config so the keys line up with self.port.
"""
import json
import re
import subprocess
import sys
from pathlib import Path
BASE = Path("/opt/gemastik18-final")
UNIT = Path("/etc/systemd/system/gemastik-receiver.service")
REGISTRY = BASE / "teams/challenge_registry.json"
RECV_CONFIG = BASE / "receiver/config.py"
reg = json.loads(REGISTRY.read_text())
ssh_users = {c["name"]: c.get("ssh_user", "ctfuser") for c in reg.get("challenges", [])}
# Challenge -> port used by the GLOBAL receiver. main.py builds the challenge
# objects from CHALLENGE_PORT_* / PASSWORD_* env; with none set it falls back to
# the pydantic settings PASSWORD_<port> in config.py, so mirror those ports.
text = RECV_CONFIG.read_text()
ports = {}
for m in re.finditer(r"PASSWORD_(\d+)\s*[:=]", text):
ports.setdefault(m.group(1), int(m.group(1)))
# name -> port needs the CHALLENGE_PORT mapping; take it from registry order +
# the receiver main.py template, else fall back to PASSWORD_<port> keys.
name_to_port = {}
for m in re.finditer(r'"PASSWORD_(\d+)"', text):
name_to_port.setdefault(m.group(1), m.group(1))
print(f"registry challenges: {len(ssh_users)}")
# Build Environment lines for every challenge whose port we can resolve.
env_lines = []
resolved = 0
for name, user in sorted(ssh_users.items()):
# The global receiver uses the node-range ports from config.py; find the
# port by matching the challenge name against the receiver's own mapping.
port = None
m = re.search(rf"{re.escape(name)}.*?(\d{{4,5}})", text)
if m:
port = m.group(1)
if not port:
continue
env_lines.append(f'Environment="SSH_USER_{port}={user}"')
resolved += 1
if not env_lines:
print("ERROR: could not resolve any challenge port from config.py", file=sys.stderr)
sys.exit(1)
body = UNIT.read_text()
# Drop any SSH_USER_ lines we previously injected (idempotent re-runs).
kept = [ln for ln in body.splitlines() if "SSH_USER_" not in ln]
# Insert right after the existing Environment=PYTHONUNBUFFERED line.
out = []
for ln in kept:
out.append(ln)
if ln.startswith("Environment=PYTHONUNBUFFERED"):
out.extend(env_lines)
if not any(ln.startswith("Environment=PYTHONUNBUFFERED") for ln in out):
out.extend(env_lines)
UNIT.write_text("\n".join(out) + "\n")
print(f"injected {resolved} SSH_USER_* lines into {UNIT}")
subprocess.run(["systemctl", "daemon-reload"], check=True)
subprocess.run(["systemctl", "restart", "gemastik-receiver"], check=True)
print("reloaded + restarted gemastik-receiver")
+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", "")
+2 -2
View File
@@ -51,10 +51,10 @@
<li>Buka <b>portal tim kamu</b> (domain dari panitia).</li>
<li>Login dengan <b>password SSH tim</b>.</li>
<li>Tab <b>🖥️ Terminal SSH</b> → pilih challenge → <b>Sambung</b>.</li>
<li>Langsung masuk sebagai <code>ctfuser</code> — tanpa perlu aplikasi SSH.</li>
<li>Langsung masuk sebagai user yang tertera di dropdown — <code>ctfuser</code> untuk 6 challenge native GEMASTIK XVIII, <code>root</code> untuk challenge XVI/XVII import — tanpa perlu aplikasi SSH.</li>
</ol>
<h3>Cara 2 — SSH Client (opsional)</h3>
<div class="sshbox">ssh ctfuser@43.134.105.109 -p &lt;PORT_SSH&gt;</div>
<div class="sshbox">ssh &lt;USER&gt;@43.134.105.109 -p &lt;PORT_SSH&gt;</div>
<p>Contoh: untuk challenge <code>blogpost</code> tim 1, port SSH = <code>31022</code>.</p>
</div>
+115 -8
View File
@@ -171,6 +171,12 @@
<button class="danger" onclick="stopAllTeams()">⏹ Stop CTF (semua)</button>
</div>
</div>
<div id="bulkDelBar" style="display:none;margin:10px 0"></div>
<div class="card" style="margin-bottom:10px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<button onclick="selectAllTeams(true)">☑️ Pilih semua</button>
<button onclick="clearTeamSelection()">☐ Kosongkan</button>
<span style="font-size:11px;color:#718096">Centang tim di kiri nama untuk hapus massal sekaligus</span>
</div>
<div class="grid" id="teamsGrid"></div>
<div class="card" style="margin-top:16px">
<div class="team-head">
@@ -445,6 +451,11 @@ async function toggleChallenge(name, enabled) {
}
}
// ---------- Challenges ----------
// The admin challenge grid is a global node view with no team of its own, so it
// asks for credentials without ?team= and the server falls back to the receiver.
// Team-scoped pages pass their own index instead.
function TEAM_Q() { return ''; }
async function refresh() {
const grid = document.getElementById('grid');
try {
@@ -474,9 +485,11 @@ async function refresh() {
<button onclick="viewCred('${esc(ch.name)}')">SSH</button>
</div>
</div>`;
fetch(`/api/credential/${ch.name}`).then(r=>r.json()).then(c => {
fetch(`/api/credential/${ch.name}${TEAM_Q()}`).then(r=>r.json()).then(c => {
const el = document.getElementById('creds-' + ch.name);
if (el && c.username) el.innerHTML = `<b>ctfuser</b> / <b>${esc(c.password)}</b>`;
// Use the username the server reports: it is per-challenge (ctfuser for
// the 6 native XVIII images, root for the imported XVI/XVII ones).
if (el && c.username) el.innerHTML = `<b>${esc(c.username)}</b> / <b>${esc(c.password)}</b>`;
}).catch(()=>{});
}
grid.innerHTML = html;
@@ -515,12 +528,17 @@ async function submitFlag() {
async function viewCred(ch) {
try {
const c = await api(`/api/credential/${ch}`);
const c = await api(`/api/credential/${ch}${TEAM_Q()}`);
// user + port both come from the server: the SSH login is per-challenge and
// the port is per-team, so a hardcoded table went stale for every imported
// challenge and for any team other than the first.
const user = c.username || 'ctfuser';
const port = c.port || 10022;
const host = `${ch}.attackdefense.imrnes.team`;
document.getElementById('mcTitle').textContent = 'SSH Credentials — ' + ch;
const sshPort = {blogpost:10022, carbeat:11022, cdn:12022, phew:13022, sheesh:14022, warmup:15022}[ch] || 10022;
const cmd = `ssh ctfuser@${ch}.attackdefense.imrnes.team -p ${sshPort}`;
const cmd = `ssh ${user}@${host} -p ${port}`;
document.getElementById('mcBody').innerHTML =
`<div>User: <b>ctfuser</b></div>
`<div>User: <b>${esc(user)}</b></div>
<div>Pass: <b>${esc(c.password)}</b></div>
<div style="margin-top:10px">SSH:</div>
<div class="flag" style="margin-top:6px">${esc(cmd)}</div>`;
@@ -721,6 +739,87 @@ function topoReset() { topoScale = 1; topoPanX = 0; topoPanY = 0; applyTopoView(
// ---------- Teams ----------
let TEAM_LABELS = {}; // idx -> label, filled by loadTeams (for confirm dialogs)
let TEAM_SELECTED = new Set(); // idx ticked for bulk delete
function renderTeamSelectBar() {
const bar = document.getElementById('bulkDelBar');
if (!bar) return;
const n = TEAM_SELECTED.size;
if (!n) { bar.style.display = 'none'; bar.innerHTML = ''; return; }
const names = [...TEAM_SELECTED].sort((a, b) => a - b)
.map(i => `#${i} ${esc(TEAM_LABELS[i] || '')}`).join(', ');
bar.style.display = 'block';
bar.innerHTML =
`<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<b style="color:#f6ad55">${n} tim dipilih</b>
<span style="color:#a0aec0;font-size:12px">${esc(names)}</span>
<button class="danger" onclick="bulkDeleteTeams()">🗑️ Hapus ${n} Tim Sekaligus</button>
<button onclick="clearTeamSelection()">Batalkan pilihan</button>
</div>`;
}
function toggleTeamSelect(idx, on) {
if (on) TEAM_SELECTED.add(idx); else TEAM_SELECTED.delete(idx);
const cb = document.getElementById(`teamSel${idx}`);
if (cb) cb.checked = on;
renderTeamSelectBar();
}
function selectAllTeams(on) {
TEAM_SELECTED.clear();
if (on) for (const k of Object.keys(TEAM_LABELS)) TEAM_SELECTED.add(Number(k));
for (const k of Object.keys(TEAM_LABELS)) {
const cb = document.getElementById(`teamSel${k}`);
if (cb) cb.checked = on && TEAM_SELECTED.has(Number(k));
}
renderTeamSelectBar();
}
function clearTeamSelection() { selectAllTeams(false); }
async function bulkDeleteTeams() {
const idx = [...TEAM_SELECTED].sort((a, b) => a - b);
if (!idx.length) { toast('Pilih minimal satu tim dulu', true); return; }
const names = idx.map(i => `#${i} ${TEAM_LABELS[i] || ''}`).join(', ');
if (!confirm(
`🗑️ HAPUS ${idx.length} TIM SEKALIGUS?\n\n${names}\n\n` +
`Yang akan dihapus (semua tim di atas):\n` +
`• Semua container challenge\n• Receiver + systemd unit\n` +
`• Folder team (port, flag, kredensial)\n• Port firewall UFW\n` +
`• Domain Traefik\n• Skor & riwayat leaderboard\n\n` +
`⚠️ TIDAK BISA dibatalkan.`)) return;
showLoading(`Menghapus ${idx.length} tim (${names})…<br>Compose down 16 service per tim, bisa beberapa menit`);
try {
const d = await api('/api/teams/bulk-delete', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({indices: idx, purge_scores: true})
});
// One team takes ~100s, so poll a single job instead of blocking here.
const final = await pollJob(`/api/teams/bulk-delete/${d.job}`, 1500);
const done = final.done || [];
const errs = final.errors || {};
let msg = `Terhapus ${done.length}/${idx.length} tim`;
if (Object.keys(errs).length) msg += ` · gagal: ${Object.keys(errs).join(', ')}`;
const purged = done.reduce((a, x) =>
a + Object.values(x.purged || {}).reduce((p, q) => p + q, 0), 0);
if (purged) msg += ` · ${purged} skor dibersihkan`;
toast(msg, Object.keys(errs).length > 0);
TEAM_SELECTED.clear();
loadTeams();
if (document.getElementById('view-topo').classList.contains('active')) loadTopo();
} catch (e) { toast('Bulk delete gagal: ' + e.message, true); }
finally { hideLoading(); }
}
async function pollJob(url, everyMs) {
for (;;) {
const j = await api(url);
if (j.state !== 'running') return j;
const el = document.querySelector('#loadingText');
if (el && j.detail) el.innerHTML = `${esc(j.detail)}…<br><span style="font-size:11px">${esc(url.split('/').pop())}</span>`;
await new Promise(r => setTimeout(r, everyMs));
}
}
async function loadTeams() {
try {
@@ -729,15 +828,22 @@ async function loadTeams() {
loadLeaderboard();
TEAM_LABELS = {};
for (const t of d.teams) TEAM_LABELS[t.index] = t.label || ('Team ' + t.index);
// drop selections for teams that no longer exist
for (const i of [...TEAM_SELECTED]) if (!(i in TEAM_LABELS)) TEAM_SELECTED.delete(i);
const grid = document.getElementById('teamsGrid');
if (!d.teams.length) { grid.innerHTML = '<div class="card"><span style="color:#718096">Belum ada team. Set jumlah team untuk auto-create.</span></div>'; return; }
if (!d.teams.length) { grid.innerHTML = '<div class="card"><span style="color:#718096">Belum ada team. Set jumlah team untuk auto-create.</span></div>'; renderTeamSelectBar(); return; }
let html = '';
for (const t of d.teams) {
const alive = t.status === 'running';
const dom = t.domain || '';
const sel = TEAM_SELECTED.has(t.index);
html += `<div class="card ${alive ? '' : 'dead'}">
<div class="team-head">
<span class="ch-name">${esc(t.label || ('Team ' + t.index))}</span>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none" title="Pilih untuk hapus massal">
<input type="checkbox" id="teamSel${t.index}" ${sel ? 'checked' : ''}
onchange="toggleTeamSelect(${t.index}, this.checked)">
<span class="ch-name">${esc(t.label || ('Team ' + t.index))}</span>
</label>
<span class="status ${alive ? 'up' : 'down'}">${alive ? '● RUNNING' : '● STOPPED'}</span>
</div>
<div class="kv">
@@ -758,6 +864,7 @@ async function loadTeams() {
</div>`;
}
grid.innerHTML = html;
renderTeamSelectBar();
} catch (e) { toast('Teams gagal: ' + e.message, true); }
}
+47 -14
View File
@@ -126,20 +126,13 @@
<div class="card">
<h2>🖥️ SSH Terminal — pilih challenge</h2>
<div class="term-toolbar">
<select id="termChall" style="width:200px" onchange="connectTerm()">
<option value="blogpost">blogpost (web)</option>
<option value="carbeat">carbeat (pwn)</option>
<option value="cdn">cdn (web)</option>
<option value="phew">phew (crypto)</option>
<option value="sheesh">sheesh (crypto)</option>
<option value="warmup">warmup</option>
</select>
<select id="termChall" style="width:200px" onchange="connectTerm()"></select>
<button class="ghost" onclick="connectTerm()">🔄 Sambung</button>
<span id="termStatus">Belum tersambung</span>
</div>
<div id="termWrap"><div id="term"></div></div>
<div style="margin-top:10px;font-size:12px;color:var(--dim)">
SSH otomatis login sebagai <code>ctfuser</code> ke container challenge timmu. Koneksi diputus setelah idle.
SSH otomatis login ke container challenge timmu. User <b>ctfuser</b> untuk 6 challenge native GEMASTIK XVIII, <b>root</b> untuk challenge XVI/XVII hasil import — user shown di dropdown. Koneksi diputus setelah idle.
</div>
</div>
</div>
@@ -214,12 +207,13 @@
<h3>2. Login via Web Terminal</h3>
<ol>
<li>Buka tab <b>🖥️ Terminal SSH</b>.</li>
<li>Pilih challenge (misal <code>blogpost</code>).</li>
<li>Klik <b>Sambung</b> — otomatis login sebagai <code>ctfuser</code>.</li>
<li>Pilih challenge (misal <code>blogpost</code>) — user SSH-nya tercetak di dropdown.</li>
<li>Klik <b>Sambung</b> — otomatis login sebagai user yang tertera.</li>
</ol>
<h3>3. Login via SSH biasa (opsional)</h3>
<div class="sshbox">ssh ctfuser@43.134.105.109 -p &lt;PORT_SSH&gt;
<div class="sshbox">ssh &lt;USER&gt;@43.134.105.109 -p &lt;PORT_SSH&gt;
# user: ctfuser (XVIII native) atau root (XVI/XVII import)
# password = password tim kamu</div>
<h3>4. Command yang berguna</h3>
@@ -294,6 +288,7 @@ async function doLogin() {
loadInfo();
loadTargetDropdown(); // target dropdown needs auth — refresh after login
loadTargets();
loadTermChallenges(); // SSH picker: all challenges this team has, not just 6
} else {
err.textContent = 'Password salah. Coba lagi.';
err.style.display = 'block';
@@ -322,6 +317,40 @@ async function loadInfo() {
} catch (e) {}
}
// Populate the SSH challenge picker from the challenges THIS team actually has.
// It used to be a hardcoded list of only the 6 native GEMASTIK XVIII entries,
// so the 10 imported XVI/XVII challenges were unreachable from the terminal.
async function loadTermChallenges() {
const sel = document.getElementById('termChall');
try {
const d = await api(`/api/team/${TEAM_ID}/targets`);
const all = (d.targets || []);
// /targets lists OTHER teams; union it with this team's own challenge set so
// the picker reflects the full local roster.
const names = new Map();
for (const t of all) if (t.challenge) names.set(t.challenge, t.ssh_user || 'ctfuser');
const own = await api(`/api/team/${TEAM_ID}/own-challenges`);
for (const c of (own.challenges || [])) {
names.set(c.name, c.ssh_user || 'ctfuser');
}
const keep = sel.value;
sel.innerHTML = '';
for (const [name, user] of [...names.entries()].sort()) {
const o = document.createElement('option');
o.value = name;
o.textContent = `${name} (${user})`;
o.dataset.sshUser = user;
sel.appendChild(o);
}
if (keep && names.has(keep)) sel.value = keep;
SSH_USERS = Object.fromEntries(names);
} catch (e) {
sel.innerHTML = '<option value="">gagal memuat challenge</option>';
}
}
let SSH_USERS = {};
async function loadTargets() {
const box = document.getElementById('targetList');
const d = await api(`/api/team/${TEAM_ID}/targets`);
@@ -329,7 +358,10 @@ async function loadTargets() {
if (!targets.length) { box.textContent = 'Belum ada tim musuh.'; return; }
let html = '=== TARGET MUSUH ===\n\n';
for (const t of targets) {
html += `${esc(t.domain)}:${t.port}\n`;
// The login is per-challenge: ctfuser for the 6 native XVIII images, root
// for the imported XVI/XVII ones. Showing a fixed ctfuser sent attackers to
// a login that could never work.
html += `${esc(t.domain)}:${t.port} (${esc(t.ssh_user || 'ctfuser')})\n`;
}
box.textContent = html;
}
@@ -348,7 +380,8 @@ function connectTerm() {
ws = new WebSocket(url);
status.textContent = 'Menghubungkan…';
ws.onopen = () => { status.textContent = `● tersambung ke ${chall} (ctfuser)`; term.focus(); };
const asUser = (SSH_USERS[chall] || 'ctfuser');
ws.onopen = () => { status.textContent = `● tersambung ke ${chall} (${asUser})`; term.focus(); };
ws.onmessage = ev => term.write(ev.data);
ws.onclose = ev => { status.textContent = '✖ terputus (' + (ev.reason || 'closed') + ')'; };
ws.onerror = () => { status.textContent = '✖ error koneksi'; };
+50
View File
@@ -607,6 +607,56 @@ def challenge_ssh_users() -> dict:
return out
def all_teams() -> list:
"""Every team that still has a state.json, newest index last."""
out = []
for d in sorted(TEAMS_DIR.glob("team*")):
sf = d / "state.json"
if not sf.exists():
continue
try:
st = json.loads(sf.read_text())
except Exception:
continue
if "index" in st:
out.append(st)
return out
def team_state(idx: int):
"""state.json for one team, or None if that team doesn't exist."""
sf = TEAMS_DIR / f"team{idx}" / "state.json"
if not sf.exists():
return None
try:
return json.loads(sf.read_text())
except Exception:
return None
def challenge_credential(idx: int, name: str):
"""{username, password, port} a participant should actually use for SSH.
Reads the TEAM's state.json rather than the receiver: the global receiver on
:18080 only knows the 6 native GEMASTIK XVIII challenges, so asking it for
an imported XVI/XVII challenge returns "Invalid challenge" and the UI showed
participants nothing at all. The registry `ssh_user` is the same field the
panel chpasswds, so the two can never drift.
"""
st = team_state(idx)
if not st:
return None
pwd = (st.get("chall_passwords") or {}).get(name)
if not pwd:
return None
return {
"username": challenge_ssh_users().get(name, "ctfuser"),
"password": pwd,
"port": ((st.get("ports") or {}).get(name) or {}).get("chall"),
"team": idx,
}
def set_ssh_passwords(idx: int):
"""Set the SSH password for the CORRECT login of each challenge container.
+103
View File
@@ -0,0 +1,103 @@
// Functional DOM test of the bulk-delete checkbox UI.
// Loads the REAL index.html into jsdom, stubs fetch, and drives the actual
// render + selection functions. Proves: checkboxes render, select-all works,
// the bar appears only when a selection exists, and the payload is right.
const fs = require('fs');
const path = require('path');
const { JSDOM } = require(path.join('/tmp/uitest/node_modules/jsdom'));
const html = fs.readFileSync('/opt/gemastik18-final/panel/static/index.html', 'utf8');
// Fake 4 teams, mirroring the real API shape.
const FAKE_TEAMS = {
teams: [
{ index: 1, label: 'Max', status: 'running', ports: {}, domain: 'max.attackdefense.imrnes.team' },
{ index: 2, label: 'Aryma', status: 'running', ports: {}, domain: 'aryma.attackdefense.imrnes.team' },
{ index: 3, label: 'Ken', status: 'running', ports: {}, domain: 'ken.attackdefense.imrnes.team' },
],
};
const calls = [];
const dom = new JSDOM(html, { runScripts: 'dangerously', url: 'https://attackdefense.imrnes.team/' });
dom.window.fetch = async (url, opts) => {
calls.push({ url: String(url), method: (opts && opts.method) || 'GET', body: opts && opts.body });
const u = String(url);
if (u.endsWith('/api/teams') && !(opts && opts.method)) {
return { ok: true, json: async () => FAKE_TEAMS };
}
if (u.includes('/api/teams/bulk-delete/') && !(opts && opts.method)) {
return { ok: true, json: async () => ({ state: 'done', done: [{ team: 2 }], errors: {} }) };
}
return { ok: true, json: async () => ({ ok: true }) };
};
dom.window.confirm = () => true;
const w = dom.window, d = w.document;
// jsdom keeps top-level `let` out of window, so read it through eval in the page.
const sel_set = () => w.eval('TEAM_SELECTED');
const call = (fn) => w.eval(`${fn}()`);
let pass = 0, fail = 0;
const ok = (name, cond, extra) => {
if (cond) { pass++; console.log(' PASS ' + name); }
else { fail++; console.log(' FAIL ' + name + (extra ? ' -> ' + extra : '')); }
};
(async () => {
// Drive the real page init: it calls loadTeams on DOMContentLoaded.
await w.loadTeams();
const boxes = () => Array.from(d.querySelectorAll('input[id^="teamSel"]'));
ok('one checkbox per team rendered', boxes().length === 3, 'got ' + boxes().length);
ok('checkbox id encodes the team index', boxes()[0] && boxes()[0].id === 'teamSel1',
boxes()[0] && boxes()[0].id);
ok('checkbox onchange passes the index', boxes()[0] && /toggleTeamSelect\(1,/.test(boxes()[0].getAttribute('onchange') || ''),
boxes()[0] && boxes()[0].getAttribute('onchange'));
// Bar hidden with no selection.
const bar = d.getElementById('bulkDelBar');
ok('bar exists', !!bar);
ok('bar hidden when nothing selected', bar && bar.style.display === 'none',
bar && bar.style.display);
// Tick team 2 -> bar appears, count updates.
boxes()[1].checked = true;
boxes()[1].dispatchEvent(new w.Event('change', { bubbles: true }));
ok('bar shows after first tick', bar.style.display !== 'none', bar.style.display);
ok('TEAM_SELECTED holds team2', sel_set().has(2) && sel_set().size === 1,
'size=' + sel_set().size);
ok('bar mentions 1 team', /1\b/.test(bar.textContent), JSON.stringify(bar.textContent.trim().slice(0, 60)));
// Select all -> every team ticked. The button drives selectAllTeams(true).
const allBtn = Array.from(d.querySelectorAll('button'))
.find(b => /selectAllTeams\(true\)/.test(b.getAttribute('onclick') || ''));
ok('select-all button exists', !!allBtn);
allBtn.click();
ok('select-all ticks every box', boxes().every(b => b.checked));
ok('TEAM_SELECTED has all 3', sel_set().size === 3, 'size=' + sel_set().size);
ok('bar shows 3 teams', /3/.test(bar.textContent));
// Bulk delete issues ONE POST with the selected indices.
calls.length = 0;
const goBtn = Array.from(d.querySelectorAll('#bulkDelBar button'))
.find(b => /bulkDeleteTeams\(\)/.test(b.getAttribute('onclick') || ''));
ok('bulk delete button appears in the bar', !!goBtn);
goBtn.click();
await new Promise(r => setTimeout(r, 400));
const post = calls.find(c => c.url.includes('bulk-delete') && c.method === 'POST');
ok('bulk delete POSTs once', !!post, JSON.stringify(calls.map(c => c.method + ' ' + c.url)));
if (post) {
const payload = JSON.parse(post.body);
ok('payload carries all 3 indices', JSON.stringify(payload.indices) === '[1,2,3]',
JSON.stringify(payload));
}
// Clear selection hides the bar again.
call('clearTeamSelection');
ok('clear empties selection', sel_set().size === 0);
ok('bar hidden after clear', bar.style.display === 'none', bar.style.display);
ok('checkboxes cleared too', boxes().every(b => !b.checked));
console.log(`\n ${pass} passed, ${fail} failed`);
process.exit(fail === 0 ? 0 : 1);
})();
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Verify /api/credential reports the SSH user the container ACTUALLY has.
The panel proxies to the global receiver, which only knows the 6 native
GEMASTIK XVIII challenges -- the 10 imported XVI/XVII ones 500 there. For those
the UI must read the credential from the TEAM's own receiver (which is the
one that actually runs the checkers), not from :18080.
This test reports, per team, the username /credential would show vs the
`ssh_user` the registry (and chpasswd) uses.
"""
import json
import os
import subprocess
import sys
import urllib.request
import urllib.error
import base64
from pathlib import Path
BASE = Path("/opt/gemastik18-final")
ENV = BASE / "panel/.env"
cfg = {}
for line in ENV.read_text().splitlines():
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
cfg[k.strip()] = v.strip()
PANEL = "http://127.0.0.1:18081"
def post(path, payload, cookie=None):
data = json.dumps(payload).encode()
req = urllib.request.Request(PANEL + path, data=data, method="POST",
headers={"Content-Type": "application/json"})
if cookie:
req.add_header("Cookie", cookie)
with urllib.request.urlopen(req, timeout=30) as r:
return r.read().decode(), r.headers.get("Set-Cookie", "")
body, setc = post("/api/login", {"user": cfg.get("PANEL_ADMIN_USER", "admin"),
"pass": cfg.get("PANEL_ADMIN_PASS", "")})
cookie = "; ".join(s.split(";")[0] for s in setc.split(",") if "=" in s)
print("login:", body)
def get(path):
req = urllib.request.Request(PANEL + path, headers={"Cookie": cookie})
try:
with urllib.request.urlopen(req, timeout=60) as r:
return r.read().decode()
except urllib.error.HTTPError as e:
return f"HTTP {e.code}"
reg = json.loads((BASE / "teams/challenge_registry.json").read_text())
ssh_users = {c["name"]: c.get("ssh_user", "ctfuser") for c in reg.get("challenges", [])}
teams = json.loads(get("/api/teams"))["teams"]
ok = bad = 0
for t in teams:
idx = t["index"]
st = json.loads((BASE / f"teams/team{idx}/state.json").read_text())
names = list(st["ports"].keys())
names = [n for n in names if n not in ("receiver", "panel")]
print(f"\n=== team{idx} ({t.get('label')}) — {len(names)} challenges ===")
for n in sorted(names):
raw = get(f"/api/credential/{n}?team={idx}")
try:
d = json.loads(raw)
except Exception:
d = {"error": raw[:40]}
want = ssh_users.get(n, "?")
got = d.get("username")
haspw = bool(d.get("password"))
if got == want and haspw:
print(f" {n:<15} {got:<8} pw={'yes' if haspw else 'NO '} OK")
ok += 1
else:
note = "" if got else f" <- {d.get('error', raw)[:30]}"
print(f" {n:<15} {str(got):<8} want={want:<8} pw={'yes' if haspw else 'NO '}{note}")
bad += 1
print(f"\n{ok} correct, {bad} wrong/missing")
sys.exit(0)
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""End-to-end: does the credential the panel SHOWS actually log in over SSH?
The bug being regression-tested: the panel/terminal reported `ctfuser` for all
16 challenges, but 10 of them (the imported XVI/XVII images) only provision
`root`, so every participant login was refused. A correct-looking JSON payload
proves nothing -- this opens a real paramiko session per challenge with exactly
the username/password/port the UI hands out.
"""
import json
import sys
import paramiko
from pathlib import Path
BASE = Path("/opt/gemastik18-final")
sys.path.insert(0, str(BASE / "panel"))
import teams # noqa: E402
TEAM = int(sys.argv[1]) if len(sys.argv) > 1 else 1
st = teams.team_state(TEAM)
if not st:
print(f"team{TEAM} has no state.json")
sys.exit(1)
users = teams.challenge_ssh_users()
ok = bad = 0
failures = []
for name, _coff, _soff in teams.CHALLENGES:
p = st.get("ports", {}).get(name)
if not p:
continue
user = users.get(name, "ctfuser")
pw = st.get("chall_passwords", {}).get(name) or ""
port = p["ssh"]
cli = paramiko.SSHClient()
cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
cli.connect("127.0.0.1", port=port, username=user, password=pw,
timeout=12, allow_agent=False, look_for_keys=False)
_, out, _ = cli.exec_command("whoami; hostname", timeout=12)
got = out.read().decode().strip().replace("\n", " | ")
# The container must actually be the account we claim it is.
actual = got.split(" | ")[0].strip() if got else "?"
if actual == user:
print(f" {name:<15} {user:<8} :{port} OK -> {got}")
ok += 1
else:
print(f" {name:<15} {user:<8} :{port} WHOAMI MISMATCH -> {got}")
failures.append((name, user, actual))
bad += 1
except Exception as e:
msg = type(e).__name__
print(f" {name:<15} {user:<8} :{port} LOGIN FAILED ({msg})")
failures.append((name, user, msg))
bad += 1
finally:
try:
cli.close()
except Exception:
pass
print(f"\nteam{TEAM}: {ok} logins OK, {bad} failed")
if failures:
print("failures:")
for f in failures:
print(" ", f)
sys.exit(1 if bad else 0)