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
+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); }
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user