fix(ssh): per-challenge SSH login + phew buffering/leak/timeout

Passwords failed on 10/16 challenges while state.json looked correct:
- only the 6 native GEMASTIK XVIII images provision 'ctfuser'; every imported
  XVI/XVII image does 'echo root:${PASSWORD} | chpasswd' and logs in as root.
  set_ssh_passwords() hardcoded ctfuser, so chpasswd set a password on an
  account nobody uses -> 'Permission denied' everywhere.
  Registry gains a per-challenge 'ssh_user'; chpasswd now targets the real
  login (and ctfuser/ctf when present) and reports failures loudly.
- phew checker: chall.py block-buffers stdout through the docker exec pipe
  (PYTHONUNBUFFERED now set) and leaks chall.py inside the container on
  timeout (26 orphans, container saturated) -> reaps the whole exec process
  group. Startup does a fresh Pailier keygen (~12 s) so crypto reads need
  _CRYPTO_TIMEOUT, not the 5 s prompt default.

Adds panel/verify_ssh_creds.py (proves the state->container binding from
inside via a real login), audit_ssh_users.sh, reset_runtime.sh.
This commit is contained in:
Cyrene
2026-09-26 14:40:10 +08:00
parent ef385c3397
commit ae50acfe40
33 changed files with 1398 additions and 289 deletions
+43
View File
@@ -318,6 +318,12 @@ function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// Alias used by the Challenge Manager render. Kept as a separate name so
// existing esc() call sites stay untouched, but it MUST exist: a missing
// helper throws ReferenceError mid-render and the tab hangs on "Memuat…"
// forever with no visible error.
function escapeHtml(s) { return esc(s); }
function showView(v) {
document.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.view === v));
document.querySelectorAll('.view').forEach(x => x.classList.toggle('active', x.id === 'view-' + v));
@@ -714,11 +720,15 @@ function topoZoom(factor) {
function topoReset() { topoScale = 1; topoPanX = 0; topoPanY = 0; applyTopoView(); }
// ---------- Teams ----------
let TEAM_LABELS = {}; // idx -> label, filled by loadTeams (for confirm dialogs)
async function loadTeams() {
try {
const d = await api('/api/teams');
document.getElementById('teamCount').value = d.teams.length;
loadLeaderboard();
TEAM_LABELS = {};
for (const t of d.teams) TEAM_LABELS[t.index] = t.label || ('Team ' + t.index);
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; }
let html = '';
@@ -743,6 +753,7 @@ async function loadTeams() {
<button onclick="editTeam(${t.index})">✏️ Edit Nama/Domain</button>
<button onclick="openTeamLogs(${t.index})">📜 Logs</button>
<button onclick="randomizeTeam(${t.index})">🎲 Randomize Flag</button>
<button class="danger" onclick="deleteTeam(${t.index})">🗑️ Hapus Team</button>
</div>
</div>`;
}
@@ -859,6 +870,38 @@ async function randomizeTeam(idx) {
} catch (e) { toast(e.message, true); }
}
async function deleteTeam(idx) {
// Per-team delete. Deliberately NOT the same as resetEnv: this removes ONE
// team (containers, network, receiver unit, ports, dir, domain) and leaves
// the other teams untouched.
const label = TEAM_LABELS[idx] || ('Team ' + idx);
if (!confirm(
`🗑️ HAPUS PERMANEN Team ${idx} (${label})?\n\n` +
`Yang akan dihapus:\n` +
`• Semua container challenge team ini\n` +
`• Receiver team + systemd unit\n` +
`• Folder team (port, flag, kredensial)\n` +
`• Port firewall UFW team ini\n` +
`• Domain ${label}.attackdefense.imrnes.team\n` +
`• Skor & riwayat di leaderboard\n\n` +
`Tindakan ini TIDAK BISA dibatalkan.\n` +
`Team lain tidak terpengaruh.`)) return;
// second gate: type the team number to confirm
if (prompt(`Ketik angka ${idx} untuk konfirmasi hapus:`)?.trim() !== String(idx)) {
toast('Dibatalkan', false);
return;
}
showLoading(`Menghapus Team ${idx} (${label})…<br>Stop container + receiver, hapus port & domain`);
try {
const d = await api(`/api/teams/${idx}`, {method:'DELETE', headers:{'Content-Type':'application/json'}, body: JSON.stringify({purge_scores: true})});
const n = (d.purged ? Object.values(d.purged).reduce((a, b) => a + b, 0) : 0);
toast(`Team ${idx} (${label}) dihapus: ${(d.steps || []).join(' · ')}${n ? ` · ${n} skor dibersihkan` : ''}`, false);
loadTeams();
if (document.getElementById('view-topo').classList.contains('active')) loadTopo();
} catch (e) { toast('Hapus team gagal: ' + e.message, true); }
finally { hideLoading(); }
}
async function loadLeaderboard() {
try {
const d = await api('/api/leaderboard');