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

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