feat: multi-team orchestrator - topology UI, team management, flag randomizer, public submit + leaderboard

- panel/teams.py: create/start/stop teams with isolated ports+creds, per-team receivers with CHALLENGE_PORT/CONTAINER env, flag randomization, submit validation + leaderboard
- panel/main.py: /api/teams, /api/teams/{idx}/randomize, /api/flag/submit, /api/leaderboard, /api/public/teams, /submit page
- panel/static/submit.html: public flag submission UI for teams
- receiver: _ch_port/_ch_container read .env per team, container name overrides
- .gitignore: exclude teams/, .venv, __pycache__
This commit is contained in:
root
2026-09-23 15:14:52 +08:00
parent 6eb0dabb58
commit 1ca963b2b7
13 changed files with 1037 additions and 186 deletions
+6 -1
View File
@@ -7,5 +7,10 @@ receiver/lib
receiver/lib64
receiver/pyenv.cfg
receiver/include
receiver/pwntools-docreceiver/.venv/
receiver/pwntools-doc
receiver/.venv/
panel/.env
panel/__pycache__/
teams/
*.pid
__pycache__/
Binary file not shown.
+170 -1
View File
@@ -14,6 +14,8 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from typing import Optional
import teams as orch
RECEIVER_URL = os.environ.get("RECEIVER_URL", "http://127.0.0.1:18080")
ADMIN_USER = os.environ.get("PANEL_ADMIN_USER", "admin")
ADMIN_PASS = os.environ.get("PANEL_ADMIN_PASS", "admin")
@@ -88,6 +90,11 @@ async def login_page(req: Request):
return RedirectResponse("/")
return HTMLResponse((BASE_DIR / "static" / "login.html").read_text())
@app.get("/submit", response_class=HTMLResponse)
async def submit_page(req: Request):
"""Public flag submission page for teams (no login)."""
return HTMLResponse((BASE_DIR / "static" / "submit.html").read_text())
@app.post("/api/login")
async def api_login(req: Request):
data = await req.json()
@@ -190,4 +197,166 @@ async def api_history(req: Request):
lines = (BASE_DIR.parent / "history" / "command.txt").read_text().splitlines()
except Exception:
lines = []
return {"lines": lines[-200:]}
return {"lines": lines[-200:]}
# ============ Multi-team orchestrator endpoints ============
@app.get("/api/teams")
async def api_teams(req: Request):
require_login(req)
return {"teams": orch.list_teams()}
@app.post("/api/teams/set")
async def api_teams_set(req: Request):
"""Set/create N teams (idempotent: creates missing, keeps existing)."""
require_login(req)
data = await req.json()
n = int(data.get("count", 0))
if n < 0 or n > 50:
raise HTTPException(400, "Team count must be 0-50")
created = []
for i in range(1, n + 1):
td = orch.TEAMS_DIR / f"team{i}"
if not td.exists():
st = orch.create_team(i, data.get("label_prefix", "Tim"))
created.append(st["index"])
return {"created": created, "total": len(orch.list_teams())}
@app.post("/api/teams/start")
async def api_teams_start(req: Request):
require_login(req)
data = await req.json()
idx = data.get("index")
if idx is None:
# start all
results = []
for t in orch.list_teams():
try:
results.append({"team": t["index"], "ok": True})
orch.start_team(t["index"])
except Exception as e:
results.append({"team": t["index"], "ok": False, "err": str(e)})
return {"results": results}
try:
st = orch.start_team(int(idx))
return {"ok": True, "team": st}
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/api/teams/stop")
async def api_teams_stop(req: Request):
require_login(req)
data = await req.json()
idx = data.get("index")
if idx is None:
results = []
for t in orch.list_teams():
try:
orch.stop_team(t["index"])
results.append({"team": t["index"], "ok": True})
except Exception as e:
results.append({"team": t["index"], "ok": False, "err": str(e)})
return {"results": results}
try:
st = orch.stop_team(int(idx))
return {"ok": True, "team": st}
except Exception as e:
raise HTTPException(500, str(e))
@app.get("/api/teams/{idx}/logs")
async def api_team_logs(idx: int, req: Request, service: Optional[str] = None, tail: int = 100):
require_login(req)
try:
logs = orch.team_logs(idx, service, tail)
return {"team": idx, "logs": logs}
except Exception as e:
raise HTTPException(500, str(e))
@app.get("/api/teams/{idx}/creds")
async def api_team_creds(idx: int, req: Request):
require_login(req)
try:
td = orch.TEAMS_DIR / f"team{idx}"
st = json.loads((td / "state.json").read_text())
return {"team": st}
except Exception as e:
raise HTTPException(404, str(e))
@app.get("/api/topology")
async def api_topology(req: Request):
"""Return topology graph data (nodes + edges) for the UI."""
require_login(req)
teams = orch.list_teams()
nodes = [
{"id": "panel", "label": "Panel A/D", "type": "panel", "url": "https://panel.gemastik.imrnes.team"},
{"id": "traefik", "label": "Traefik / Coolify", "type": "infra"},
{"id": "dns", "label": "*.imrnes.team → 43.134.105.109", "type": "infra"},
]
edges = [{"from": "dns", "to": "traefik"}, {"from": "traefik", "to": "panel"}]
for t in teams:
nid = f"team{t['index']}"
nodes.append({
"id": nid, "label": t.get("label", f"Team {t['index']}"),
"type": "team", "index": t["index"], "status": t.get("status", "unknown"),
"receiver_port": t["ports"]["receiver"], "ssh_pass": t.get("ssh_pass", ""),
})
edges.append({"from": "traefik", "to": nid, "label": f":{t['ports']['receiver']}"})
for name, coff, soff in orch.CHALLENGES:
cn = f"team{t['index']}-{name}"
nodes.append({"id": cn, "label": f"{name}", "type": "challenge",
"port": t["ports"][name]["chall"], "ssh": t["ports"][name]["ssh"]})
edges.append({"from": nid, "to": cn, "label": f":{t['ports'][name]['chall']}"})
return {"nodes": nodes, "edges": edges}
# ============ Flag randomization + team submission ============
@app.post("/api/teams/{idx}/randomize")
async def api_randomize(idx: int, req: Request):
"""Randomize all flags for a team (recreates containers to pick up new flags)."""
require_login(req)
try:
mapping = orch.randomize_flags(idx)
return {"ok": True, "team": idx, "flags": mapping}
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/api/flag/submit")
async def api_flag_submit(req: Request):
"""Public endpoint: teams submit flags. No login needed."""
data = await req.json()
team = int(data.get("team", 0))
chall = data.get("challenge", "")
flag = data.get("flag", "").strip()
team_name = data.get("team_name", "").strip()[:64] or f"Team {team}"
if team <= 0:
return {"success": False, "error": "Select your team"}
if chall not in [c[0] for c in orch.CHALLENGES]:
return {"success": False, "error": "Challenge not found"}
if not flag:
return {"success": False, "error": "Flag is required"}
return orch.submit_flag(team, chall, flag, team_name)
@app.get("/api/leaderboard")
async def api_leaderboard(req: Request):
"""Leaderboard of solves so far."""
lb_path = orch.TEAMS_DIR / "leaderboard.json"
if lb_path.exists():
lb = json.loads(lb_path.read_text())
else:
lb = {"solves": []}
# aggregate per team
teams = {}
for e in lb["solves"]:
t = teams.setdefault(e["team"], {"team": e["team"], "name": e["team_name"], "solves": 0, "challs": []})
t["solves"] += 1
t["challs"].append(e["challenge"])
return {"solves": lb["solves"], "teams": sorted(teams.values(), key=lambda x: -x["solves"])}
@app.get("/api/public/teams")
async def api_public_teams():
"""Public list of team names (for the submit dropdown)."""
return {"teams": [{"index": t["index"], "label": t.get("label", f"Team {t['index']}")} for t in orch.list_teams()]}
+340 -24
View File
@@ -12,11 +12,18 @@
color: #c9d4e3; min-height:100vh; padding:24px;
}
header {
display:flex; justify-content:space-between; align-items:center; margin-bottom:24px;
display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;
border-bottom:1px solid #1e3a5f; padding-bottom:16px; flex-wrap:wrap; gap:12px;
}
h1 { font-size:22px; color:#5ad1ff; letter-spacing:1px; }
.actions { display:flex; gap:10px; align-items:center; }
.tabs { display:flex; gap:6px; margin-bottom:20px; flex-wrap:wrap; }
.tab {
background:#0e1526; border:1px solid #1e3a5f; color:#8aa0b8; padding:9px 16px;
border-radius:8px; cursor:pointer; font-family:inherit; font-size:13px; transition:all .2s;
}
.tab.active { background:#2563eb33; border-color:#3b82f6; color:#fff; }
.tab:hover { border-color:#3b82f6; }
.pill {
background:#0e1526; border:1px solid #1e3a5f; color:#8aa0b8; font-size:12px;
padding:7px 13px; border-radius:20px;
@@ -28,11 +35,11 @@
font-size:12px; padding:8px 14px; border-radius:8px; cursor:pointer; transition:all .2s;
}
button:hover { border-color:#3b82f6; color:#fff; }
button.primary {
background:linear-gradient(135deg,#0ea5e9,#2563eb); border:none; color:#fff; font-weight:700;
}
button.primary { background:linear-gradient(135deg,#0ea5e9,#2563eb); border:none; color:#fff; font-weight:700; }
button.danger { border-color:#f8717155; color:#f87171; }
button.danger:hover { background:#f8717122; }
.view { display:none; }
.view.active { display:block; }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(340px,1fr)); gap:16px; }
.card {
background:#0e1526cc; border:1px solid #1e3a5f; border-radius:12px; padding:18px;
@@ -56,11 +63,11 @@
.creds b { color:#a8c3e0; font-weight:600; }
.btn-row { display:flex; gap:6px; flex-wrap:wrap; margin-top:2px; }
.btn-row button { font-size:11px; padding:6px 10px; }
textarea, input[type=text] {
textarea, input[type=text], input[type=number], select {
width:100%; background:#0a101f; border:1px solid #1e3a5f; color:#dbe6f4;
border-radius:8px; padding:10px; font-size:13px; font-family:inherit; outline:none; resize:vertical;
}
textarea:focus, input:focus { border-color:#3b82f6; }
textarea:focus, input:focus, select:focus { border-color:#3b82f6; }
.modal-back {
position:fixed; inset:0; background:#000a; display:none; align-items:center; justify-content:center; z-index:50;
}
@@ -83,6 +90,14 @@
.footer-note { color:#4a5a70; font-size:11px; margin-top:20px; text-align:center; }
.spin { display:inline-block; width:12px; height:12px; border:2px solid #2a4a6f; border-top-color:#5ad1ff; border-radius:50%; animation:sp .7s linear infinite; }
@keyframes sp { to { transform:rotate(360deg); } }
.topo-wrap { background:#0a0f1c; border:1px solid #1e3a5f; border-radius:12px; padding:20px; overflow-x:auto; }
.topo-svg { width:100%; min-width:800px; }
.team-head { display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; }
.team-status { font-size:11px; }
.kv { display:grid; grid-template-columns:120px 1fr; gap:6px 12px; font-size:12px; }
.kv b { color:#8aa0b8; font-weight:600; }
.kv span { color:#dbe6f4; word-break:break-all; }
.mono { font-family:inherit; }
</style>
</head>
<body>
@@ -95,9 +110,77 @@
</div>
</header>
<div class="grid" id="grid"></div>
<div class="tabs">
<button class="tab active" data-view="challs" onclick="showView('challs')">🎯 Challenges</button>
<button class="tab" data-view="topo" onclick="showView('topo'); loadTopo()">🗺️ Topology</button>
<button class="tab" data-view="teams" onclick="showView('teams'); loadTeams()">👥 Teams</button>
<button class="tab" data-view="logs" onclick="showView('logs'); loadLogs()">📜 Logs</button>
<button class="tab" data-view="creds" onclick="showView('creds'); loadCreds()">🔑 Creds</button>
</div>
<div class="footer-note">Receiver: https://gemastik.imrnes.team · Auto-refresh tiap 15 detik · Flags dirotate lewat panel ini</div>
<!-- Challenges view -->
<div class="view active" id="view-challs">
<div class="grid" id="grid"></div>
</div>
<!-- Topology view -->
<div class="view" id="view-topo">
<div class="topo-wrap"><svg id="topoSvg" class="topo-svg" height="420"></svg></div>
</div>
<!-- Teams view -->
<div class="view" id="view-teams">
<div class="card" style="margin-bottom:16px">
<div class="team-head">
<div><b style="color:#5ad1ff">Jumlah Team</b> <span style="font-size:11px;color:#718096">(auto-create node per team)</span></div>
<div style="display:flex;gap:8px;align-items:center">
<input type="number" id="teamCount" min="0" max="50" value="0" style="width:90px">
<button class="primary" onclick="setTeams()">Set & Buat</button>
</div>
</div>
<div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap">
<button class="primary" onclick="startAllTeams()">▶ Start CTF (semua)</button>
<button class="danger" onclick="stopAllTeams()">⏹ Stop CTF (semua)</button>
</div>
</div>
<div class="grid" id="teamsGrid"></div>
<div class="card" style="margin-top:16px">
<div class="team-head">
<div><b style="color:#5ad1ff">🏆 Leaderboard</b> <span style="font-size:11px;color:#718096">solve flag per team (dari halaman submit publik)</span></div>
<a href="/submit" target="_blank" class="primary" style="text-decoration:none;padding:8px 14px;border-radius:8px">🚩 Halaman Submit Team →</a>
</div>
<table style="width:100%;border-collapse:collapse;margin-top:10px;font-size:13px">
<thead><tr style="color:#718096;text-align:left"><th style="padding:6px">#</th><th>Tim</th><th>Solved</th><th>Challenges</th></tr></thead>
<tbody id="lbBody"></tbody>
</table>
</div>
</div>
<!-- Logs view -->
<div class="view" id="view-logs">
<div class="card" style="margin-bottom:16px">
<div class="team-head">
<div><b style="color:#5ad1ff">Log Server</b> <span style="font-size:11px;color:#718096">docker logs per team/service</span></div>
<div style="display:flex;gap:8px;align-items:center">
<select id="logTeam" style="width:130px"></select>
<select id="logService" style="width:150px">
<option value="">semua service</option>
<option>blogpost</option><option>carbeat</option><option>cdn</option>
<option>phew</option><option>sheesh</option><option>warmup</option>
</select>
<button class="primary" onclick="loadLogs()">Muat</button>
</div>
</div>
</div>
<div class="history-box" id="logsBox" style="max-height:70vh">Pilih team & service…</div>
</div>
<!-- Creds view -->
<div class="view" id="view-creds">
<div class="grid" id="credsGrid"></div>
</div>
<div class="footer-note">Receiver: https://gemastik.imrnes.team · Panel: https://panel.gemastik.imrnes.team · Auto-refresh tiap 15 detik</div>
<!-- modal flag -->
<div class="modal-back" id="modalFlag">
@@ -122,13 +205,13 @@
</div>
</div>
<!-- modal history -->
<div class="modal-back" id="modalHist">
<div class="modal" style="width:560px">
<h2>Command History (preexec)</h2>
<div class="history-box" id="mhBody">loading...</div>
<!-- modal team creds -->
<div class="modal-back" id="modalTeamCred">
<div class="modal" style="width:520px">
<h2 id="mtcTitle">Kredensial Team</h2>
<div id="mtcBody" style="font-size:12px;color:#dbe6f4;line-height:1.9"></div>
<div class="btn-row" style="justify-content:flex-end;margin-top:14px">
<button onclick="closeModal('modalHist')">Tutup</button>
<button onclick="closeModal('modalTeamCred')">Tutup</button>
</div>
</div>
</div>
@@ -157,6 +240,12 @@ function esc(s) {
return (s||'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
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));
}
// ---------- Challenges ----------
async function refresh() {
const grid = document.getElementById('grid');
try {
@@ -186,7 +275,6 @@ async function refresh() {
<button onclick="viewCred('${esc(ch.name)}')">SSH</button>
</div>
</div>`;
// fetch creds lazily
fetch(`/api/credential/${ch.name}`).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>`;
@@ -230,7 +318,8 @@ async function viewCred(ch) {
try {
const c = await api(`/api/credential/${ch}`);
document.getElementById('mcTitle').textContent = 'SSH Credentials — ' + ch;
let cmd = `ssh ctfuser@warmup.gemastik.imrnes.team -p 10022`.replace('warmup.gemastik.imrnes.team', ch + '.gemastik.imrnes.team').replace('10022', String(ch==='blogpost'?10022:ch==='carbeat'?11022:ch==='cdn'?12022:ch==='phew'?13022:ch==='sheesh'?14022:15022));
const sshPort = {blogpost:10022, carbeat:11022, cdn:12022, phew:13022, sheesh:14022, warmup:15022}[ch] || 10022;
const cmd = `ssh ctfuser@${ch}.gemastik.imrnes.team -p ${sshPort}`;
document.getElementById('mcBody').innerHTML =
`<div>User: <b>ctfuser</b></div>
<div>Pass: <b>${esc(c.password)}</b></div>
@@ -240,23 +329,250 @@ async function viewCred(ch) {
} catch (e) { toast(e.message, true); }
}
async function openHist() {
// ---------- Topology ----------
let topoLoaded = false;
async function loadTopo() {
try {
const d = await api('/api/history');
document.getElementById('mhBody').textContent = d.lines.join('\n') || '(belum ada log)';
document.getElementById('modalHist').classList.add('open');
const d = await api('/api/topology');
renderTopo(d.nodes, d.edges);
} catch (e) { toast('Topologi gagal: ' + e.message, true); }
}
function renderTopo(nodes, edges) {
const svg = document.getElementById('topoSvg');
const W = Math.max(900, nodes.length * 130);
const H = 400;
svg.setAttribute('width', W); svg.setAttribute('height', H);
const cx = W / 2;
const ns = {};
nodes.forEach(n => { ns[n.id] = n; });
// layout: panel/infra top center, teams in circle, challenges below each team
const pos = {};
pos['dns'] = {x: cx, y: 30};
pos['traefik'] = {x: cx, y: 100};
pos['panel'] = {x: cx - 140, y: 100};
const teams = nodes.filter(n => n.type === 'team');
const teamPos = {};
teams.forEach((t, i) => {
const ang = (i / Math.max(1, teams.length)) * Math.PI * 2 - Math.PI / 2;
const rx = W * 0.36, ry = 100;
const tx = cx + rx * Math.cos(ang);
const ty = 210 + ry * Math.sin(ang) * 0.6;
teamPos[t.index] = {x: tx, y: ty};
pos[t.id] = {x: tx, y: ty};
});
nodes.filter(n => n.type === 'challenge').forEach(n => {
const ti = n.id.split('-')[0].replace('team','');
const base = teamPos[ti] || {x: cx, y: 250};
const chIdx = ['blogpost','carbeat','cdn','phew','sheesh','warmup'].indexOf(n.label.split('-').pop() || n.label);
pos[n.id] = {x: base.x + (chIdx - 2.5) * 70, y: base.y + 110};
});
// edges
let html = '';
edges.forEach(e => {
const a = pos[e.from], b = pos[e.to];
if (!a || !b) return;
html += `<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="#2a4a6f" stroke-width="1.5" stroke-dasharray="4 3"/>`;
if (e.label) {
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2 - 6;
html += `<text x="${mx}" y="${my}" fill="#5a7a9f" font-size="9" text-anchor="middle">${e.label}</text>`;
}
});
// nodes
nodes.forEach(n => {
const p = pos[n.id];
if (!p) return;
let fill = '#0e1526', stroke = '#2a4a6f', color = '#a8c3e0', r = 34;
if (n.type === 'panel') { fill = '#0ea5e933'; stroke = '#0ea5e9'; color = '#7dd3fc'; r = 42; }
if (n.type === 'infra') { r = 30; color = '#8aa0b8'; }
if (n.type === 'team') { fill = '#2563eb22'; stroke = '#3b82f6'; color = '#93c5fd'; r = 48; }
if (n.type === 'challenge') { r = 26; color = '#c9d4e3'; }
const status = n.status === 'running' ? ' fill="#34d399"' : '';
const sub = n.type === 'team' ? `:${n.receiver_port}` : (n.port ? `:${n.port}` : '');
html += `<g>
<circle cx="${p.x}" cy="${p.y}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>
<circle cx="${p.x}" cy="${p.y}" r="${r-4}" fill="none" stroke="${stroke}" stroke-opacity="0.3"/>
<text x="${p.x}" y="${p.y - (sub ? 0 : 4)}" fill="${color}" font-size="${n.type==='team'?12:10}" font-weight="bold" text-anchor="middle">${esc(n.label)}</text>
${sub ? `<text x="${p.x}" y="${p.y + 12}" fill="#5a7a9f" font-size="9" text-anchor="middle">${sub}</text>` : ''}
${status ? `<circle cx="${p.x + r - 8}" cy="${p.y - r + 8}" r="5" fill="#34d399"/>` : ''}
</g>`;
});
svg.innerHTML = `<defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#2a4a6f"/></marker></defs>` + html;
}
// ---------- Teams ----------
async function loadTeams() {
try {
const d = await api('/api/teams');
document.getElementById('teamCount').value = d.teams.length;
loadLeaderboard();
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 = '';
for (const t of d.teams) {
const alive = t.status === 'running';
html += `<div class="card ${alive ? '' : 'dead'}">
<div class="team-head">
<span class="ch-name">${esc(t.label || ('Team ' + t.index))}</span>
<span class="status ${alive ? 'up' : 'down'}">${alive ? '● RUNNING' : '● STOPPED'}</span>
</div>
<div class="kv">
<b>Receiver</b><span>${t.ports.receiver}</span>
<b>Admin</b><span>${esc(t.admin_user)}</span>
<b>SSH user</b><span>ctfuser</span>
<b>Status</b><span>${esc(t.status)}</span>
</div>
<div class="btn-row">
<button class="primary" onclick="startTeam(${t.index})">▶ Start</button>
<button class="danger" onclick="stopTeam(${t.index})">⏹ Stop</button>
<button onclick="viewTeamCred(${t.index})">🔑 SSH & Pass</button>
<button onclick="openTeamLogs(${t.index})">📜 Logs</button>
<button onclick="randomizeTeam(${t.index})">🎲 Randomize Flag</button>
</div>
</div>`;
}
grid.innerHTML = html;
} catch (e) { toast('Teams gagal: ' + e.message, true); }
}
async function setTeams() {
const n = parseInt(document.getElementById('teamCount').value || '0', 10);
try {
const d = await api('/api/teams/set', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({count: n})});
toast(`Team dibuat: ${d.created.join(', ') || 'tidak ada yang baru'} · total ${d.total}`, false);
loadTeams();
} catch (e) { toast(e.message, true); }
}
async function startTeam(idx) {
try { await api('/api/teams/start', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({index: idx})}); toast(`Team ${idx} start (build bisa makan waktu)`, false); setTimeout(loadTeams, 3000); }
catch (e) { toast(e.message, true); }
}
async function stopTeam(idx) {
try { await api('/api/teams/stop', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({index: idx})}); toast(`Team ${idx} stop`, false); loadTeams(); }
catch (e) { toast(e.message, true); }
}
async function startAllTeams() {
try { const d = await api('/api/teams/start', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({})}); toast(`Start semua: ${d.results.filter(r=>r.ok).length}/${d.results.length} ok`, false); setTimeout(loadTeams, 4000); }
catch (e) { toast(e.message, true); }
}
async function stopAllTeams() {
try { const d = await api('/api/teams/stop', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({})}); toast(`Stop semua: ${d.results.filter(r=>r.ok).length}/${d.results.length} ok`, false); loadTeams(); }
catch (e) { toast(e.message, true); }
}
async function randomizeTeam(idx) {
if (!confirm(`Randomize SEMUA flag untuk Team ${idx}? Container akan di-recreate.`)) return;
try {
const d = await api(`/api/teams/${idx}/randomize`, {method:'POST'});
const flags = Object.values(d.flags || {}).join('\n');
toast(`Flag Team ${idx} di-randomize!`, false);
console.log('NEW FLAGS team', idx, flags);
setTimeout(loadTeams, 4000);
} catch (e) { toast(e.message, true); }
}
async function loadLeaderboard() {
try {
const d = await api('/api/leaderboard');
const body = document.getElementById('lbBody');
if (!body) return;
if (!d.teams.length) { body.innerHTML = '<tr><td colspan="4" style="color:#718096">Belum ada solve.</td></tr>'; return; }
body.innerHTML = d.teams.map((t,i) => `<tr>
<td>${i+1}</td><td>${esc(t.name)}</td><td>${t.solves}</td>
<td>${t.challs.map(c=>`<span class="chip">${esc(c)}</span>`).join('')}</td>
</tr>`).join('');
} catch (e) {}
}
setInterval(loadLeaderboard, 15000);
async function viewTeamCred(idx) {
try {
const t = await api(`/api/teams/${idx}/creds`);
let html = `<div class="kv">
<b>Label</b><span>${esc(t.team.label || ('Team ' + idx))}</span>
<b>Admin receiver</b><span>${esc(t.team.admin_user)} / ${esc(t.team.admin_pass)}</span>
<b>Receiver port</b><span>${esc(t.team.ports.receiver)}</span>
<b>SSH user</b><span>ctfuser</span>
<b>SSH pass</b><span>${esc(t.team.ssh_pass)}</span>
</div><div style="margin-top:10px"><b style="color:#5ad1ff">Per-challenge SSH:</b></div>`;
for (const [name, p] of Object.entries(t.team.ports)) {
if (name === 'receiver' || name === 'panel') continue;
html += `<div class="kv" style="margin-top:6px">
<b>${name}</b><span>ssh ctfuser@${name}.gemastik.imrnes.team -p ${p.ssh} &nbsp;·&nbsp; pass: ${esc(t.team.chall_passwords[name])}</span>
</div>`;
}
document.getElementById('mtcTitle').textContent = `Kredensial Team ${idx}`;
document.getElementById('mtcBody').innerHTML = html;
document.getElementById('modalTeamCred').classList.add('open');
} catch (e) { toast(e.message, true); }
}
function openTeamLogs(idx) {
document.getElementById('logTeam').value = idx;
showView('logs'); loadLogs();
}
// ---------- Logs ----------
async function loadLogs() {
const idx = document.getElementById('logTeam').value;
const svc = document.getElementById('logService').value;
await populateLogTeams();
if (!idx) { document.getElementById('logsBox').textContent = 'Pilih team dulu.'; return; }
try {
const d = await api(`/api/teams/${idx}/logs` + (svc ? `?service=${svc}` : '') + '&tail=200');
let out = '';
for (const [name, log] of Object.entries(d.logs)) {
out += `\n━━━ ${name} (team ${idx}) ━━━\n${log}\n`;
}
document.getElementById('logsBox').textContent = out || '(kosong)';
} catch (e) { document.getElementById('logsBox').textContent = 'Error: ' + e.message; }
}
async function populateLogTeams() {
try {
const d = await api('/api/teams');
const sel = document.getElementById('logTeam');
if (sel.options.length === 0 || sel.value === '') {
sel.innerHTML = d.teams.map(t => `<option value="${t.index}">Team ${t.index}</option>`).join('');
}
} catch (e) {}
}
// ---------- Creds ----------
async function loadCreds() {
try {
const d = await api('/api/teams');
const grid = document.getElementById('credsGrid');
if (!d.teams.length) { grid.innerHTML = '<div class="card"><span style="color:#718096">Belum ada team.</span></div>'; return; }
let html = '';
for (const t of d.teams) {
html += `<div class="card">
<div class="team-head">
<span class="ch-name">${esc(t.label || ('Team ' + t.index))}</span>
<button style="font-size:11px" onclick="viewTeamCred(${t.index})">Lihat Semua</button>
</div>
<div class="kv">
<b>Admin</b><span>${esc(t.admin_user)} / ${esc(t.admin_pass)}</span>
<b>SSH</b><span>ctfuser / ${esc(t.ssh_pass)}</span>
</div>
</div>`;
}
grid.innerHTML = html;
} catch (e) { toast(e.message, true); }
}
// ---------- misc ----------
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
async function logout() {
await fetch('/api/logout', {method:'POST'});
location.href = '/login';
}
document.getElementById('modalFlag').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal('modalFlag'); });
document.getElementById('modalCred').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal('modalCred'); });
document.getElementById('modalHist').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal('modalHist'); });
['modalFlag','modalCred','modalTeamCred'].forEach(id => {
document.getElementById(id).addEventListener('click', e => { if (e.target === e.currentTarget) closeModal(id); });
});
function tick() {
document.getElementById('clock').textContent = new Date().toLocaleTimeString('id-ID');
+131
View File
@@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gemastik A/D — Submit Flag</title>
<style>
:root { --bg:#0b0e14; --panel:#151a23; --panel2:#1d2430; --line:#2a3444; --txt:#e6edf3; --dim:#8b98a9; --accent:#38bdf8; --green:#34d399; --red:#f87171; }
* { margin:0; padding:0; box-sizing:border-box; }
body { background:var(--bg); color:var(--txt); font-family:'Segoe UI',system-ui,sans-serif; min-height:100vh; }
.wrap { max-width:860px; margin:0 auto; padding:24px 16px 60px; }
header { display:flex; align-items:center; gap:12px; margin-bottom:24px; }
.logo { width:42px; height:42px; border-radius:10px; background:linear-gradient(135deg,#38bdf8,#6366f1); display:flex; align-items:center; justify-content:center; font-weight:800; font-size:20px; color:#fff; }
h1 { font-size:22px; }
.sub { color:var(--dim); font-size:13px; }
.card { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:20px; margin-bottom:16px; }
.card h2 { font-size:15px; margin-bottom:12px; color:var(--accent); }
label { display:block; font-size:12px; color:var(--dim); margin:12px 0 5px; }
select, input { width:100%; padding:10px 12px; border-radius:8px; border:1px solid var(--line); background:var(--panel2); color:var(--txt); font-size:14px; }
button { margin-top:16px; width:100%; padding:12px; border:none; border-radius:8px; background:linear-gradient(135deg,#38bdf8,#6366f1); color:#fff; font-weight:600; font-size:15px; cursor:pointer; }
button:hover { filter:brightness(1.1); }
#result { margin-top:14px; padding:12px; border-radius:8px; display:none; font-size:14px; }
#result.ok { display:block; background:#06352a; border:1px solid var(--green); color:var(--green); }
#result.err { display:block; background:#3a1414; border:1px solid var(--red); color:var(--red); }
.lb-card { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:20px; }
.lb-row { display:flex; justify-content:space-between; align-items:center; padding:10px 4px; border-bottom:1px solid var(--line); }
.lb-row:last-child { border-bottom:none; }
.lb-rank { font-weight:700; color:var(--accent); width:36px; }
.lb-name { flex:1; }
.lb-count { font-weight:600; }
table { width:100%; border-collapse:collapse; margin-top:8px; font-size:13px; }
th, td { text-align:left; padding:8px 6px; border-bottom:1px solid var(--line); }
th { color:var(--dim); font-weight:500; }
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:6px; }
.chip { background:var(--panel2); border:1px solid var(--line); border-radius:20px; padding:3px 10px; font-size:11px; color:var(--dim); }
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo">G</div>
<div>
<h1>Gemastik XVIII — Attack &amp; Defense</h1>
<div class="sub">Submit flag untuk tim kamu. Server: gemastik.imrnes.team</div>
</div>
</header>
<div class="card">
<h2>🚩 Submit Flag</h2>
<label>Tim kamu</label>
<select id="team"><option value="">— pilih tim —</option></select>
<label>Challenge</label>
<select id="challenge">
<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>
<label>Flag</label>
<input id="flag" placeholder="GEMASTIK18{...}" autocomplete="off">
<button onclick="submitFlag()">Submit Flag</button>
<div id="result"></div>
</div>
<div class="lb-card">
<h2>🏆 Leaderboard</h2>
<table>
<thead><tr><th>#</th><th>Tim</th><th>Solved</th><th>Challenges</th></tr></thead>
<tbody id="lb-body"><tr><td colspan="4" style="color:var(--dim)">Belum ada solve.</td></tr></tbody>
</table>
</div>
</div>
<script>
async function api(url, opts) {
const r = await fetch(url, opts);
return r.json();
}
async function loadTeams() {
const d = await api('/api/public/teams');
const sel = document.getElementById('team');
d.teams.forEach(t => {
const o = document.createElement('option');
o.value = t.index; o.textContent = t.label;
sel.appendChild(o);
});
}
async function loadLeaderboard() {
const d = await api('/api/leaderboard');
const body = document.getElementById('lb-body');
const rows = d.teams.map((t, i) => `
<tr>
<td>${i + 1}</td>
<td>${esc(t.name)}</td>
<td>${t.solves}</td>
<td><div class="chips">${t.challs.map(c => `<span class="chip">${esc(c)}</span>`).join('')}</div></td>
</tr>`).join('');
body.innerHTML = rows || '<tr><td colspan="4" style="color:var(--dim)">Belum ada solve.</td></tr>';
}
function esc(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
async function submitFlag() {
const res = document.getElementById('result');
res.className = '';
const body = {
team: parseInt(document.getElementById('team').value || '0', 10),
challenge: document.getElementById('challenge').value,
flag: document.getElementById('flag').value.trim(),
};
const d = await api('/api/flag/submit', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
if (d.success) {
res.className = 'ok';
res.innerHTML = '✅ Flag <b>benar</b>! Solve tercatat untuk tim kamu.';
loadLeaderboard();
} else {
res.className = 'err';
res.innerHTML = '❌ ' + esc(d.error || 'Gagal submit');
}
}
loadTeams();
loadLeaderboard();
setInterval(loadLeaderboard, 15000);
</script>
</body>
</html>
+324
View File
@@ -0,0 +1,324 @@
#!/usr/bin/env python3
"""
Gemastik A/D multi-team orchestrator.
Each team gets an isolated stack: its own docker-compose (unique ports +
SSH passwords), its own receiver (unique admin creds + ports), and its own
flags. The orchestrator clones the base services dir, rewrites ports and
passwords, and manages lifecycle via docker compose project per team.
Team N layout:
/opt/gemastik18-final/teams/team<N>/
services/ (docker-compose.yml with team ports + passwords)
receiver/ (.env with team admin creds + container overrides)
receiver/flags/ (per-team flag files)
state.json (team metadata: ports, admin user/pass, ssh creds, created ts)
Port scheme (base offset per team index, index 1-based):
team i: chall ports = 20000 + i*1000 + 0..5 (blogpost,carbeat,cdn,phew,sheesh,warmup)
ssh ports = 20000 + i*1000 + 22..27 (10022-style)
receiver = 20000 + i*1000 + 80 (receiver API, e.g. 21080, 22080)
"""
import json
import os
import re
import secrets
import shutil
import subprocess
import time
import uuid
from datetime import datetime
from pathlib import Path
BASE = Path("/opt/gemastik18-final")
TEAMS_DIR = BASE / "teams"
SERVICES_SRC = BASE / "services"
RECEIVER_SRC = BASE / "receiver"
# Challenge definitions: (service name, chall port offset 0-5, ssh offset 22-27)
CHALLENGES = [
("blogpost", 0, 22),
("carbeat", 1, 23),
("cdn", 2, 24),
("phew", 3, 25),
("sheesh", 4, 26),
("warmup", 5, 27),
]
PORT_BASE = 20000
STEP = 1000
def team_ports(idx: int) -> dict:
"""Return {service_name: {'chall': port, 'ssh': port}} for 1-based team idx."""
base = PORT_BASE + idx * STEP
out = {}
for name, coff, soff in CHALLENGES:
out[name] = {"chall": base + coff, "ssh": base + soff}
out["receiver"] = base + 80
out["panel"] = base + 81 # reserved, not used
return out
def gen_password(n=16):
return uuid.uuid4().hex[:n]
def create_team(idx: int, label: str = None):
"""Build a full team stack dir with unique ports/passwords."""
ports = team_ports(idx)
team_dir = TEAMS_DIR / f"team{idx}"
label = label or f"Tim {idx}"
state = {
"index": idx,
"label": label,
"ports": ports,
"admin_user": f"admin_team{idx}",
"admin_pass": gen_password(20),
"ssh_user": "ctfuser",
"ssh_pass": gen_password(20),
"chall_passwords": {name: gen_password(20) for name, *_ in CHALLENGES},
"container_suffix": f"team{idx}",
"created": __import__("datetime").datetime.now().isoformat(),
"status": "created",
}
# --- copy services with rewrite ---
svc_dir = team_dir / "services"
if svc_dir.exists():
shutil.rmtree(svc_dir)
shutil.copytree(SERVICES_SRC, svc_dir, ignore=shutil.ignore_patterns("__pycache__", ".git", "exploits", "exploit"))
# compose references ../utils/bashrc etc — copy utils next to services
utils_src = BASE / "utils"
utils_dst = team_dir / "utils"
if utils_src.exists():
if utils_dst.exists():
shutil.rmtree(utils_dst)
shutil.copytree(utils_src, utils_dst)
# redirect preexec URL to the team's receiver port
recv_port = ports["receiver"]
bashrc = utils_dst / "bashrc"
if bashrc.exists():
bashrc.write_text(bashrc.read_text().replace(
"host.docker.internal:18080", f"host.docker.internal:{recv_port}"))
compose = svc_dir / "docker-compose.yml"
text = compose.read_text()
# rewrite container names + ports per challenge
for name, coff, soff in CHALLENGES:
cont_old = f"{name}_container"
cont_new = f"{name}_container_team{idx}"
text = text.replace(f"container_name: {cont_old}", f"container_name: {cont_new}")
text = text.replace(f"hostname: {name}", f"hostname: {name}_team{idx}")
# ports mapping: "10000:8000" -> "<team_chall>:8000"
old_chall = str(10000 + coff * 1000) # 10000,11000,12000,13000,14000,15000
old_ssh = str(10022 + coff * 1000) # 10022,11022,...
text = re.sub(rf'"({old_chall}):', f'"{ports[name]["chall"]}:', text)
text = re.sub(rf'"({old_ssh}):', f'"{ports[name]["ssh"]}:', text)
# PASSWORD_* args -> team passwords
for name, coff, soff in CHALLENGES:
old_env = f"PASSWORD_{10000 + coff * 1000}"
text = re.sub(rf"\${{{old_env}}}", state["chall_passwords"][name], text)
# compose file references services/.env — we'll create it below
compose.write_text(text)
# team services/.env (PASSWORD_* in same shape as starter.py)
env_lines = [f"ADMIN_USERNAME={state['admin_user']}", f"ADMIN_PASSWORD={state['admin_pass']}"]
env_lines.append(f"COMPOSE_LOCATION={svc_dir}/docker-compose.yml")
for i in range(20):
env_lines.append(f"PASSWORD_{(i*1000)+10000}={gen_password(20)}")
# force the six used passwords to team ones
for name, coff, soff in CHALLENGES:
for j, line in enumerate(env_lines):
if line.startswith(f"PASSWORD_{10000+coff*1000}="):
env_lines[j] = f"PASSWORD_{10000+coff*1000}={state['chall_passwords'][name]}"
(svc_dir / ".env").write_text("\n".join(env_lines) + "\n")
# --- copy receiver with team env ---
recv_dir = team_dir / "receiver"
if recv_dir.exists():
shutil.rmtree(recv_dir)
shutil.copytree(RECEIVER_SRC, recv_dir, ignore=shutil.ignore_patterns("__pycache__", ".venv", ".env", "history"))
(recv_dir / "history").mkdir(exist_ok=True)
# receiver .env: same admin + passwords + container overrides
recv_env = [f"ADMIN_USERNAME={state['admin_user']}", f"ADMIN_PASSWORD={state['admin_pass']}",
f"COMPOSE_LOCATION={svc_dir}/docker-compose.yml"]
for i in range(20):
recv_env.append(f"PASSWORD_{(i*1000)+10000}={gen_password(20)}")
for name, coff, soff in CHALLENGES:
for j, line in enumerate(recv_env):
if line.startswith(f"PASSWORD_{10000+coff*1000}="):
recv_env[j] = f"PASSWORD_{10000+coff*1000}={state['chall_passwords'][name]}"
recv_env.append(f"CHALLENGE_PORT_{name.upper()}={ports[name]['chall']}")
recv_env.append(f"CHALLENGE_CONTAINER_{name.upper()}={name}_container_team{idx}")
(recv_dir / ".env").write_text("\n".join(recv_env) + "\n")
# --- flags (randomized per team so each team has unique flags) ---
flags_dir = team_dir / "receiver" / "flags"
flags_dir.mkdir(parents=True, exist_ok=True)
flags_map = {}
for name, coff, soff in CHALLENGES:
flag = f"GEMASTIK18{{TEAM{idx}_{name.upper()}_{secrets.token_hex(6)}}}"
(flags_dir / f"{name}.txt").write_text(flag)
flags_map[name] = flag
state["flags"] = flags_map
(team_dir / "state.json").write_text(json.dumps(state, indent=2))
return state
def start_team(idx: int):
team_dir = TEAMS_DIR / f"team{idx}"
if not (team_dir / "state.json").exists():
raise FileNotFoundError(f"Team {idx} not created")
svc_dir = team_dir / "services"
subprocess.run(["docker", "compose", "-f", svc_dir / "docker-compose.yml", "up", "-d", "--build"],
cwd=str(svc_dir), check=False, capture_output=True)
_start_receiver(idx)
st = json.loads((team_dir / "state.json").read_text())
st["status"] = "running"
(team_dir / "state.json").write_text(json.dumps(st, indent=2))
return st
def stop_team(idx: int):
team_dir = TEAMS_DIR / f"team{idx}"
svc_dir = team_dir / "services"
subprocess.run(["docker", "compose", "-f", svc_dir / "docker-compose.yml", "down"],
cwd=str(svc_dir), check=False, capture_output=True)
_stop_receiver(idx)
st = json.loads((team_dir / "state.json").read_text())
st["status"] = "stopped"
(team_dir / "state.json").write_text(json.dumps(st, indent=2))
return st
def _start_receiver(idx: int):
"""Launch team's receiver as a detached uvicorn process with team env."""
team_dir = TEAMS_DIR / f"team{idx}"
recv_dir = team_dir / "receiver"
st = json.loads((team_dir / "state.json").read_text())
port = st["ports"]["receiver"]
pidfile = team_dir / "receiver.pid"
# kill existing
_stop_receiver(idx)
env = dict(os.environ)
env["PYTHONPATH"] = str(recv_dir)
env["COMPOSE_LOCATION"] = str(team_dir / "services" / "docker-compose.yml")
# expose team ports/containers so challenge classes bind the right targets
for ch in st["ports"]:
if ch in ("receiver", "panel"):
continue
env[f"CHALLENGE_PORT_{ch.upper()}"] = str(st["ports"][ch]["chall"])
env[f"CHALLENGE_CONTAINER_{ch.upper()}"] = f"{ch}_container_team{idx}"
proc = subprocess.Popen(
[str(RECEIVER_SRC.parent / "receiver" / ".venv" / "bin" / "python"),
"-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", str(port)],
cwd=str(recv_dir), env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True)
pidfile.write_text(str(proc.pid))
def _stop_receiver(idx: int):
team_dir = TEAMS_DIR / f"team{idx}"
pidfile = team_dir / "receiver.pid"
if pidfile.exists():
try:
pid = int(pidfile.read_text().strip())
os.kill(pid, 15)
except Exception:
pass
pidfile.unlink(missing_ok=True)
def team_logs(idx: int, service: str = None, tail: int = 100):
team_dir = TEAMS_DIR / f"team{idx}"
svc_dir = team_dir / "services"
data = {}
services = [s for s, *_ in CHALLENGES] if service is None else [service]
for s in services:
try:
out = subprocess.run(
["docker", "logs", "--tail", str(tail), f"{s}_container_team{idx}"],
capture_output=True, text=True, timeout=15)
data[s] = (out.stdout + out.stderr)[-4000:]
except Exception as e:
data[s] = f"ERR: {e}"
return data
def list_teams() -> list:
out = []
if not TEAMS_DIR.exists():
return out
for d in sorted(TEAMS_DIR.glob("team*")):
if (d / "state.json").exists():
st = json.loads((d / "state.json").read_text())
# compute alive status quickly
st["alive_count"] = 0
st["up_count"] = 0
out.append(st)
return out
# ---------------------------------------------------------------------------
# Flag randomization + team submission tracking
# ---------------------------------------------------------------------------
def randomize_flags(idx: int) -> dict:
"""Generate fresh unique flags for every challenge of a team."""
team_dir = TEAMS_DIR / f"team{idx}"
if not (team_dir / "state.json").exists():
raise FileNotFoundError(f"Team {idx} not created")
flags_dir = team_dir / "receiver" / "flags"
flags_dir.mkdir(parents=True, exist_ok=True)
mapping = {}
for name, coff, soff in CHALLENGES:
token = secrets.token_hex(6)
flag = f"GEMASTIK18{{TEAM{idx}_{name.upper()}_{token}}}"
(flags_dir / f"{name}.txt").write_text(flag)
mapping[name] = flag
# rotate into containers: compose mounts the flag files read-only, so
# drop+recreate the challenge containers to pick up new flags
svc_dir = team_dir / "services"
subprocess.run(["docker", "compose", "-f", svc_dir / "docker-compose.yml",
"down"], cwd=str(svc_dir), check=False, capture_output=True)
subprocess.run(["docker", "compose", "-f", svc_dir / "docker-compose.yml",
"up", "-d"], cwd=str(svc_dir), check=False, capture_output=True)
_start_receiver(idx)
st = json.loads((team_dir / "state.json").read_text())
st["flags"] = mapping
(team_dir / "state.json").write_text(json.dumps(st, indent=2))
return mapping
def submit_flag(team_idx: int, chall: str, flag: str, team_name: str = "") -> dict:
"""Validate a submitted flag against the owning team's challenge flag."""
team_dir = TEAMS_DIR / f"team{team_idx}"
if not (team_dir / "state.json").exists():
return {"success": False, "error": "unknown team"}
flags_dir = team_dir / "receiver" / "flags"
expected = (flags_dir / f"{chall}.txt").read_text().strip() if (flags_dir / f"{chall}.txt").exists() else None
if not expected:
return {"success": False, "error": "challenge not found"}
if flag.strip() != expected:
return {"success": False, "error": "wrong flag"}
# record leaderboard entry
lb_path = TEAMS_DIR / "leaderboard.json"
lb = json.loads(lb_path.read_text()) if lb_path.exists() else {"solves": []}
entry = {
"team": team_idx,
"team_name": team_name or f"Team {team_idx}",
"challenge": chall,
"flag": flag,
"ts": time.time(),
"ts_human": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
# avoid double-solve duplicates (same flag + same team)
if not any(e["team"] == team_idx and e["challenge"] == chall for e in lb["solves"]):
lb["solves"].append(entry)
lb_path.write_text(json.dumps(lb, indent=2))
return {"success": True, "team": team_idx, "challenge": chall}
if __name__ == "__main__":
import sys
cmd = sys.argv[1] if len(sys.argv) > 1 else "list"
if cmd == "create" and len(sys.argv) > 2:
st = create_team(int(sys.argv[2]))
print(json.dumps(st, indent=2))
elif cmd == "list":
print(json.dumps(list_teams(), indent=2))
elif cmd == "start" and len(sys.argv) > 2:
print(json.dumps(start_team(int(sys.argv[2])), indent=2))
elif cmd == "stop" and len(sys.argv) > 2:
print(json.dumps(stop_team(int(sys.argv[2])), indent=2))
+2 -1
View File
@@ -1,3 +1,4 @@
import os
import io
import re
import time
@@ -29,7 +30,7 @@ class Blogpost(Challenge):
flag_location = 'flags/blogpost.txt' # Host copy (used by your orchestrator)
history_location = 'history/blogpost.txt'
container_flag_path = '/flag.txt'
container_name = 'blogpost_container' # <-- set to your actual container name
container_name = os.environ.get('CHALLENGE_CONTAINER_BLOGPOST', 'blogpost_container') # <-- set to your actual container name
# Heuristics to recognize ExifTool output
_exif_markers = (
+2 -1
View File
@@ -1,3 +1,4 @@
import os
import io
import re
import random
@@ -26,7 +27,7 @@ class CDN(Challenge):
flag_location = 'flags/cdn.txt' # host copy (written by your orchestrator)
history_location = 'history/cdn.txt'
container_flag_path = '/flag.txt'
container_name = 'cdn_container' # adjust if your container name differs
container_name = os.environ.get('CHALLENGE_CONTAINER_CDN', 'cdn_container') # adjust if your container name differs
# Canonical ExifTool markers (NO app fallbacks allowed)
_exif_must_have = (
+1 -1
View File
@@ -11,7 +11,7 @@ class Carbeat(Challenge):
flag_location = 'flags/carbeat.txt'
history_location = 'history/carbeat.txt'
_CONTAINER = "carbeat_container"
_CONTAINER = os.environ.get("CHALLENGE_CONTAINER_CARBEAT", "carbeat_container")
_SERVICE_CMD = ["docker", "exec", "-i", _CONTAINER, "/home/ctfuser/chall/mybini"]
_HEX_RE = re.compile(r'^[0-9a-fA-F]+$')
+1 -1
View File
@@ -9,7 +9,7 @@ class Phew(Challenge):
flag_location = 'flags/phew.txt'
history_location = 'history/phew.txt'
_CONTAINER = "phew_container"
_CONTAINER = os.environ.get("CHALLENGE_CONTAINER_PHEW", "phew_container")
_SERVICE_CMD = ["docker", "exec", "-i", _CONTAINER, "python3", "/home/ctfuser/chall/src/chall.py"]
_HEX_RE = re.compile(r'^[0-9a-fA-F]+$')
+1 -1
View File
@@ -10,7 +10,7 @@ class Sheesh(Challenge):
flag_location = 'flags/sheesh.txt'
history_location = 'history/sheesh.txt'
_CONTAINER = "sheesh_container"
_CONTAINER = os.environ.get("CHALLENGE_CONTAINER_SHEESH", "sheesh_container")
_SERVICE_CMD = ["docker", "exec", "-i", _CONTAINER, "python3", "/home/ctfuser/chall/src/chall.py"]
_HEX_RE = re.compile(r'^[0-9a-fA-F]+$')
+2 -1
View File
@@ -1,5 +1,6 @@
from .Challenge import Challenge
import os
import io
import requests
import random
@@ -30,7 +31,7 @@ class Warmup(Challenge):
with open(self.flag_location, 'r') as f:
host_flag = f.read().strip()
container_flag = subprocess.run([
"docker", "exec", "warmup_container", "cat", "/flag.txt"
"docker", "exec", os.environ.get("CHALLENGE_CONTAINER_WARMUP", "warmup_container"), "cat", "/flag.txt"
], capture_output=True, text=True).stdout.strip()
assert host_flag == container_flag, 'Flag mismatch between host and container'
+57 -154
View File
@@ -1,156 +1,59 @@
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from config import get_settings
from challenges.Blogpost import Blogpost
from challenges.Carbeat import Carbeat
from challenges.CDN import CDN
from challenges.Phew import Phew
from challenges.Sheesh import Sheesh
from challenges.Warmup import Warmup
import os
import asyncio
import logging
# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI()
security = HTTPBasic()
settings = get_settings()
challenges = {
"blogpost": Blogpost(10000),
"carbeat": Carbeat(11000),
"cdn": CDN(12000),
"phew": Phew(13000),
"sheesh": Sheesh(14000),
"warmup": Warmup(15000),
}
async def run_challenge_checks():
"""Run check function on all challenges at startup"""
logger.info("\n" + "="*60)
logger.info("Running challenge checks...")
logger.info("="*60 + "\n")
results = {}
for name, challenge in challenges.items():
logger.info(f"\n[{name}] Starting check...")
1|from fastapi import Depends, FastAPI, HTTPException
2|from pydantic import BaseModel
3|from fastapi.security import HTTPBasic, HTTPBasicCredentials
4|from config import get_settings
5|
6|from challenges.Blogpost import Blogpost
7|from challenges.Carbeat import Carbeat
8|from challenges.CDN import CDN
9|from challenges.Phew import Phew
10|from challenges.Sheesh import Sheesh
11|from challenges.Warmup import Warmup
12|
13|import os
14|import asyncio
15|import logging
16|
17|# Setup logging
18|logging.basicConfig(level=logging.INFO)
19|logger = logging.getLogger(__name__)
20|
21|app = FastAPI()
22|security = HTTPBasic()
23|settings = get_settings()
24|
25|def _ch_port(name: str, default: int) -> int:
# read from .env manually (pydantic settings has fixed fields)
val = os.environ.get(f"CHALLENGE_PORT_{name.upper()}")
if not val:
try:
# Give service time between checks
await asyncio.sleep(2)
result = challenge.check()
results[name] = result
if result:
logger.info(f"[{name}] ✓ Check PASSED")
else:
logger.warning(f"[{name}] ✗ Check FAILED")
except Exception as e:
logger.error(f"[{name}] ✗ Check ERROR: {e}")
results[name] = False
# Print summary
logger.info("\n" + "="*60)
logger.info("Challenge Check Summary:")
logger.info("="*60)
passed = sum(1 for r in results.values() if r)
total = len(results)
for name, result in results.items():
status = "✓ PASS" if result else "✗ FAIL"
logger.info(f" {name:20} {status}")
logger.info(f"\nTotal: {passed}/{total} passed")
logger.info("="*60 + "\n")
return results
with open(os.path.join(os.path.dirname(__file__), ".env")) as f:
for line in f:
if line.startswith(f"CHALLENGE_PORT_{name.upper()}="):
val = line.strip().split("=", 1)[1]
except Exception:
pass
return int(val) if val else default
@app.on_event("startup")
async def startup_event():
"""Run challenge checks on application startup"""
asyncio.create_task(run_challenge_checks())
class Flag(BaseModel):
flag: str
challenge: str
class History(BaseModel):
log: str
@app.get("/")
def read_root():
return {"service": "receiver-service"}
@app.get("/restart/{challenge}")
def restart(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
validate(credentials, challenge)
os.system(f"docker compose -f {settings.COMPOSE_LOCATION} restart {challenge}")
return {"message": "Challenge restarted"}
@app.get("/rollback/{challenge}")
def rollback(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
validate(credentials, challenge)
os.system(f"docker compose -f {settings.COMPOSE_LOCATION} up -d --force-recreate {challenge}")
return {"message": "Challenge restarted"}
@app.get("/activate/{challenge}")
def activate(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
validate(credentials, challenge)
os.system(f"docker compose -f {settings.COMPOSE_LOCATION} up -d {challenge}")
return {"message": "Challenge activated"}
@app.get("/deactivate/{challenge}")
def deactive(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
validate(credentials, challenge)
os.system(f"docker compose -f {settings.COMPOSE_LOCATION} down {challenge}")
return {"message": "Challenge deactivated"}
@app.get("/credential/{challenge}")
def credential(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
validate(credentials, challenge)
return challenges[challenge].credentials()
@app.post("/flag")
def receive(data: Flag, credentials: HTTPBasicCredentials = Depends(security)):
validate(credentials, data.challenge)
challenge = challenges[data.challenge]
if challenge.distribute(data.flag):
return {"message": "Flag received"}
raise HTTPException(status_code=500, detail="Error receiving flag")
@app.get("/check/{challenge}")
def check(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
validate(credentials, challenge)
return {"success": challenges[challenge].check()}
@app.post("/history")
def history(data: History):
with open('history/command.txt', 'a') as f:
f.write(data.log + '\n')
return {"message": "Command received"}
def is_admin(credentials):
if credentials.username != settings.ADMIN_USERNAME or credentials.password != settings.ADMIN_PASSWORD:
return False
return True
def validate(credentials, challenge):
if not is_admin(credentials):
raise HTTPException(status_code=401, detail="Invalid credentials")
if challenge not in challenges:
raise HTTPException(status_code=400, detail="Invalid challenge")
def _ch_container(name: str, default: str) -> str:
val = os.environ.get(f"CHALLENGE_CONTAINER_{name.upper()}")
if not val:
try:
with open(os.path.join(os.path.dirname(__file__), ".env")) as f:
for line in f:
if line.startswith(f"CHALLENGE_CONTAINER_{name.upper()}="):
val = line.strip().split("=", 1)[1]
except Exception:
pass
return val or default
challenges = {
32| "blogpost": Blogpost(_ch_port("blogpost", 10000)),
33| "carbeat": Carbeat(_ch_port("carbeat", 11000)),
34| "cdn": CDN(_ch_port("cdn", 12000)),
35| "phew": Phew(_ch_port("phew", 13000)),
36| "sheesh": Sheesh(_ch_port("sheesh", 14000)),
37| "warmup": Warmup(_ch_port("warmup", 15000)),
38|}
39|
40|async def run_challenge_checks():
41|