draggable topology + attack visualizer + tools in containers
- topology nodes draggable (pointer events, SVG transform), layout hint shown - attack visualizer: /api/attacks logs attacker->target events; red pulsing dashed arcs on recent attacks (60s hot), ⚔ counts ok/fail - submit_flag now takes attacker_idx vs target_idx (A/D semantics); UI has target dropdown (enemy teams), leaderboard records target - containers get vim+curl+wget+netcat+git+pip3 (Dockerfiles blogpost/cdn/ phew/sheesh/warmup); warmup base ubuntu:20.04 EOL -> 24.04 - team portal: target dropdown refreshed after login (was empty pre-auth)
This commit is contained in:
+18
-3
@@ -223,6 +223,8 @@ async def api_team_targets(idx: int, req: Request):
|
||||
if not p:
|
||||
continue
|
||||
out.append({
|
||||
"team_idx": st.get("index"),
|
||||
"team_label": st.get("label", f"Team {st.get('index')}"),
|
||||
"domain": st.get("domain") or (st.get("slug", f"team{st.get('index')}") + ".gemastik.imrnes.team"),
|
||||
"port": p["chall"],
|
||||
})
|
||||
@@ -521,9 +523,11 @@ async def api_reset_environment(req: Request):
|
||||
|
||||
@app.post("/api/flag/submit")
|
||||
async def api_flag_submit(req: Request):
|
||||
"""Public endpoint: teams submit flags. No login needed."""
|
||||
"""Public endpoint: teams submit flags. No login needed.
|
||||
body: {team: attacker, target?: victim, challenge, flag}"""
|
||||
data = await req.json()
|
||||
team = int(data.get("team", 0))
|
||||
team = int(data.get("team", 0)) # attacker (tim yang submit)
|
||||
target = int(data.get("target") or 0) or team # victim team (flag dicuri dari sini)
|
||||
chall = data.get("challenge", "")
|
||||
flag = data.get("flag", "").strip()
|
||||
team_name = data.get("team_name", "").strip()[:64] or f"Team {team}"
|
||||
@@ -533,7 +537,18 @@ async def api_flag_submit(req: Request):
|
||||
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)
|
||||
return orch.submit_flag(target, chall, flag, attacker_idx=team, attacker_name=team_name)
|
||||
|
||||
@app.get("/api/attacks")
|
||||
async def api_attacks(req: Request):
|
||||
"""Admin: recent attack events (attacker -> target) for the topology visualizer."""
|
||||
require_login(req)
|
||||
ap = orch.TEAMS_DIR / "attacks.json"
|
||||
if ap.exists():
|
||||
log = json.loads(ap.read_text())
|
||||
else:
|
||||
log = {"events": []}
|
||||
return {"events": log.get("events", [])}
|
||||
|
||||
|
||||
@app.get("/api/leaderboard")
|
||||
|
||||
+85
-8
@@ -101,6 +101,12 @@
|
||||
@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; }
|
||||
/* attack visualizer: recent attacks pulse */
|
||||
@keyframes attackPulse { 0%,100% { stroke-opacity:0.4; } 50% { stroke-opacity:1; } }
|
||||
@keyframes attackBlink { 0%,100% { opacity:1; } 50% { opacity:0.35; } }
|
||||
.attack-line { animation: attackPulse 1.2s ease-in-out infinite; }
|
||||
.attack-icon { animation: attackBlink 1s ease-in-out infinite; }
|
||||
.topo-hint { font-size:12px; color:var(--dim); margin-top:8px; }
|
||||
.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; }
|
||||
@@ -137,7 +143,8 @@
|
||||
|
||||
<!-- Topology view -->
|
||||
<div class="view" id="view-topo">
|
||||
<div class="topo-wrap"><svg id="topoSvg" class="topo-svg" height="420"></svg></div>
|
||||
<div class="topo-wrap"><svg id="topoSvg" class="topo-svg" height="520"></svg></div>
|
||||
<div class="topo-hint">💡 Geser node untuk mengatur layout • ⚔️ garis merah = serangan (panah attacker → target) • garis <b>putus-putus</b> = serangan baru (60 detik terakhir)</div>
|
||||
</div>
|
||||
|
||||
<!-- Teams view -->
|
||||
@@ -283,7 +290,15 @@ function 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));
|
||||
if (v === 'topo') loadTopo(); // refresh attacks immediately on tab switch + every 10s
|
||||
}
|
||||
let topoTimer = null;
|
||||
function startTopoTimer() {
|
||||
if (!topoTimer) topoTimer = setInterval(() => {
|
||||
if (document.getElementById('view-topo').classList.contains('active')) loadTopo();
|
||||
}, 10000);
|
||||
}
|
||||
startTopoTimer();
|
||||
|
||||
// ---------- Challenges ----------
|
||||
async function refresh() {
|
||||
@@ -371,9 +386,11 @@ async function viewCred(ch) {
|
||||
|
||||
// ---------- Topology ----------
|
||||
let topoLoaded = false;
|
||||
let topoAttacks = [];
|
||||
async function loadTopo() {
|
||||
try {
|
||||
const d = await api('/api/topology');
|
||||
const [d, a] = await Promise.all([api('/api/topology'), api('/api/attacks').catch(() => ({events: []}))]);
|
||||
topoAttacks = a.events || [];
|
||||
renderTopo(d.nodes, d.edges);
|
||||
} catch (e) { toast('Topologi gagal: ' + e.message, true); }
|
||||
}
|
||||
@@ -381,7 +398,7 @@ async function loadTopo() {
|
||||
function renderTopo(nodes, edges) {
|
||||
const svg = document.getElementById('topoSvg');
|
||||
const W = Math.max(900, nodes.length * 130);
|
||||
const H = 400;
|
||||
const H = 500;
|
||||
svg.setAttribute('width', W); svg.setAttribute('height', H);
|
||||
const cx = W / 2;
|
||||
const ns = {};
|
||||
@@ -398,15 +415,26 @@ function renderTopo(nodes, edges) {
|
||||
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;
|
||||
const ty = 250 + 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 base = teamPos[ti] || {x: cx, y: 300};
|
||||
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};
|
||||
pos[n.id] = {x: base.x + (chIdx - 2.5) * 70, y: base.y + 130};
|
||||
});
|
||||
|
||||
// ---- attack arcs (attacker team -> target team), recent only (last 10 min) ----
|
||||
const now = Date.now() / 1000;
|
||||
const recentAttacks = topoAttacks.filter(e => now - (e.ts || 0) < 600);
|
||||
const attackCounts = {}; // "attacker:target" -> {ok, fail, latest}
|
||||
recentAttacks.forEach(e => {
|
||||
const k = `${e.attacker}:${e.target}`;
|
||||
attackCounts[k] = attackCounts[k] || {ok: 0, fail: 0, latest: e.ts};
|
||||
attackCounts[k][e.success ? 'ok' : 'fail']++;
|
||||
attackCounts[k].latest = Math.max(attackCounts[k].latest, e.ts || 0);
|
||||
});
|
||||
|
||||
// edges
|
||||
@@ -417,10 +445,24 @@ function renderTopo(nodes, edges) {
|
||||
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>`;
|
||||
html += `<text x="${mx}" y="${my}" fill="#5a7a9f" font-size="9" text-anchor="middle">${esc(e.label)}</text>`;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- attack visualizer ----
|
||||
for (const [k, c] of Object.entries(attackCounts)) {
|
||||
const [atk, tgt] = k.split(':');
|
||||
const a = pos['team' + atk], b = pos['team' + tgt];
|
||||
if (!a || !b || atk === tgt) continue;
|
||||
const isHot = now - c.latest < 60;
|
||||
const color = c.ok > 0 ? '#f87171' : '#fb923c';
|
||||
const dash = isHot ? '6 3' : '4 4';
|
||||
html += `<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="${color}" stroke-width="${isHot ? 3 : 2}" stroke-dasharray="${dash}" opacity="0.9" class="attack-line"/>`;
|
||||
const mx = (a.x + b.x) / 2 + 8, my = (a.y + b.y) / 2 - 10;
|
||||
const icon = isHot ? '⚔️' : '⚔';
|
||||
html += `<text x="${mx}" y="${my}" fill="${color}" font-size="12" text-anchor="middle" class="attack-icon">${icon} ${c.ok}✓ ${c.fail}✗</text>`;
|
||||
}
|
||||
|
||||
// nodes
|
||||
nodes.forEach(n => {
|
||||
const p = pos[n.id];
|
||||
@@ -432,7 +474,7 @@ function renderTopo(nodes, edges) {
|
||||
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>
|
||||
html += `<g data-node="${esc(n.id)}" style="cursor:grab">
|
||||
<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>
|
||||
@@ -441,6 +483,41 @@ function renderTopo(nodes, edges) {
|
||||
</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;
|
||||
enableTopoDrag(svg, pos);
|
||||
}
|
||||
|
||||
// ---- draggable topology ----
|
||||
function enableTopoDrag(svg, pos) {
|
||||
let dragEl = null, dx = 0, dy = 0;
|
||||
const onMove = (ev) => {
|
||||
if (!dragEl) return;
|
||||
const ctm = svg.getScreenCTM();
|
||||
if (!ctm) return;
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = ev.clientX; pt.y = ev.clientY;
|
||||
const p = pt.matrixTransform(ctm.inverse());
|
||||
dragEl.setAttribute('transform', `translate(${p.x - dx}, ${p.y - dy})`);
|
||||
};
|
||||
const onUp = () => { dragEl = null; svg.style.cursor = ''; };
|
||||
svg.addEventListener('mousedown', (ev) => {
|
||||
const g = ev.target.closest('g[data-node]');
|
||||
if (!g) return;
|
||||
ev.preventDefault();
|
||||
const ctm = svg.getScreenCTM();
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = ev.clientX; pt.y = ev.clientY;
|
||||
const p = pt.matrixTransform(ctm.inverse());
|
||||
const c = g.querySelector('circle');
|
||||
const cx = parseFloat(c.getAttribute('cx'));
|
||||
const cy = parseFloat(c.getAttribute('cy'));
|
||||
dx = p.x - cx; dy = p.y - cy;
|
||||
dragEl = g;
|
||||
svg.style.cursor = 'grabbing';
|
||||
g.setPointerCapture && g.setPointerCapture(ev.pointerId);
|
||||
});
|
||||
svg.addEventListener('pointermove', onMove);
|
||||
svg.addEventListener('pointerup', onUp);
|
||||
svg.addEventListener('pointercancel', onUp);
|
||||
}
|
||||
|
||||
// ---------- Teams ----------
|
||||
|
||||
+30
-1
@@ -138,7 +138,11 @@
|
||||
<option value="sheesh">sheesh (crypto)</option>
|
||||
<option value="warmup">warmup</option>
|
||||
</select>
|
||||
<label>Flag</label>
|
||||
<label>Target (tim musuh yang diserang)</label>
|
||||
<select id="target">
|
||||
<option value="">Memuat target…</option>
|
||||
</select>
|
||||
<label>Flag (yang kamu curi dari target)</label>
|
||||
<input type="text" id="flag" placeholder="GEMASTIK18{...}" autocomplete="off">
|
||||
<button onclick="submitFlag()" style="width:100%;margin-top:16px">Submit Flag</button>
|
||||
<div id="result"></div>
|
||||
@@ -229,6 +233,8 @@ async function doLogin() {
|
||||
document.getElementById('portalScreen').style.display = 'block';
|
||||
document.getElementById('logoutBtn').style.display = '';
|
||||
loadInfo();
|
||||
loadTargetDropdown(); // target dropdown needs auth — refresh after login
|
||||
loadTargets();
|
||||
} else {
|
||||
err.textContent = 'Password salah. Coba lagi.';
|
||||
err.style.display = 'block';
|
||||
@@ -305,6 +311,7 @@ async function submitFlag() {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
team: TEAM_ID,
|
||||
target: parseInt(document.getElementById('target').value || '0', 10) || TEAM_ID,
|
||||
challenge: document.getElementById('challenge').value,
|
||||
flag: document.getElementById('flag').value.trim(),
|
||||
}),
|
||||
@@ -313,6 +320,28 @@ async function submitFlag() {
|
||||
else { res.className = 'err'; res.textContent = '❌ ' + (d.error || 'Gagal'); }
|
||||
}
|
||||
|
||||
// target dropdown: all enemy teams (exclude own)
|
||||
async function loadTargetDropdown() {
|
||||
try {
|
||||
const d = await api(`/api/team/${TEAM_ID}/targets`);
|
||||
const sel = document.getElementById('target');
|
||||
const targets = (d.targets || []).filter(t => t.team_idx !== TEAM_ID);
|
||||
const seen = new Map();
|
||||
for (const t of targets) seen.set(t.team_idx, t.team_label || ('Team ' + t.team_idx));
|
||||
sel.innerHTML = '';
|
||||
if (!seen.size) {
|
||||
sel.innerHTML = '<option value="">Tidak ada tim musuh</option>';
|
||||
return;
|
||||
}
|
||||
for (const [idx, label] of seen) {
|
||||
const o = document.createElement('option');
|
||||
o.value = idx; o.textContent = `${label} (tim ${idx})`;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
} catch (e) { /* non-fatal */ }
|
||||
}
|
||||
loadTargetDropdown();
|
||||
|
||||
checkSession();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+39
-9
@@ -374,9 +374,15 @@ def randomize_flags(idx: int) -> dict:
|
||||
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}"
|
||||
def submit_flag(target_idx: int, chall: str, flag: str,
|
||||
attacker_idx: int = None, attacker_name: str = "") -> dict:
|
||||
"""Validate a submitted flag against the TARGET team's challenge flag.
|
||||
|
||||
A/D semantics: attacker_idx scores by stealing target_idx's flag.
|
||||
Back-compat: attacker_idx defaults to target_idx (self-submit).
|
||||
"""
|
||||
attacker_idx = attacker_idx if attacker_idx else target_idx
|
||||
team_dir = TEAMS_DIR / f"team{target_idx}"
|
||||
if not (team_dir / "state.json").exists():
|
||||
return {"success": False, "error": "unknown team"}
|
||||
flags_dir = team_dir / "receiver" / "flags"
|
||||
@@ -384,23 +390,47 @@ def submit_flag(team_idx: int, chall: str, flag: str, team_name: str = "") -> di
|
||||
if not expected:
|
||||
return {"success": False, "error": "challenge not found"}
|
||||
if flag.strip() != expected:
|
||||
_log_attack(attacker_idx, target_idx, chall, flag[:24], success=False)
|
||||
return {"success": False, "error": "wrong flag"}
|
||||
# record leaderboard entry
|
||||
_log_attack(attacker_idx, target_idx, chall, flag, success=True)
|
||||
# record leaderboard entry — score goes to the ATTACKER
|
||||
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}",
|
||||
"team": attacker_idx,
|
||||
"team_name": attacker_name or f"Team {attacker_idx}",
|
||||
"challenge": chall,
|
||||
"target": target_idx,
|
||||
"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"]):
|
||||
# avoid double-solve duplicates (same flag + same attacker team)
|
||||
if not any(e["team"] == attacker_idx and e["challenge"] == chall
|
||||
and e.get("target") == target_idx 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}
|
||||
return {"success": True, "team": attacker_idx, "target": target_idx, "challenge": chall}
|
||||
|
||||
def _log_attack(attacker_team: int, target_team: int, target_chall: str, flag_hint: str, success: bool):
|
||||
"""Append a row to the attack log (used by the topology attack visualizer)."""
|
||||
try:
|
||||
ap = TEAMS_DIR / "attacks.json"
|
||||
log = json.loads(ap.read_text()) if ap.exists() else {"events": []}
|
||||
log["events"].append({
|
||||
"attacker": attacker_team,
|
||||
"target": target_team,
|
||||
"challenge": target_chall,
|
||||
"flag_hint": flag_hint,
|
||||
"success": success,
|
||||
"ts": time.time(),
|
||||
"ts_human": datetime.now().strftime("%H:%M:%S"),
|
||||
})
|
||||
# keep last 200
|
||||
log["events"] = log["events"][-200:]
|
||||
ap.write_text(json.dumps(log, indent=2))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def reset_scores() -> dict:
|
||||
"""Wipe the leaderboard (all solves removed)."""
|
||||
|
||||
Reference in New Issue
Block a user