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

+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'; };