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
@@ -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 <PORT_SSH></div>
|
||||
<div class="sshbox">ssh <USER>@43.134.105.109 -p <PORT_SSH></div>
|
||||
<p>Contoh: untuk challenge <code>blogpost</code> tim 1, port SSH = <code>31022</code>.</p>
|
||||
</div>
|
||||
|
||||
|
||||
+115
-8
@@ -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
@@ -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 <PORT_SSH>
|
||||
<div class="sshbox">ssh <USER>@43.134.105.109 -p <PORT_SSH>
|
||||
# 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'; };
|
||||
|
||||
Reference in new issue
Block a user