diff --git a/panel/main.py b/panel/main.py index d47d634..672b634 100644 --- a/panel/main.py +++ b/panel/main.py @@ -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") diff --git a/panel/static/index.html b/panel/static/index.html index a541d7e..df74dce 100644 --- a/panel/static/index.html +++ b/panel/static/index.html @@ -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 @@
-
+
+
πŸ’‘ Geser node untuk mengatur layout β€’ βš”οΈ garis merah = serangan (panah attacker β†’ target) β€’ garis putus-putus = serangan baru (60 detik terakhir)
@@ -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 += ``; if (e.label) { const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2 - 6; - html += `${e.label}`; + html += `${esc(e.label)}`; } }); + // ---- 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 += ``; + const mx = (a.x + b.x) / 2 + 8, my = (a.y + b.y) / 2 - 10; + const icon = isHot ? 'βš”οΈ' : 'βš”'; + html += `${icon} ${c.ok}βœ“ ${c.fail}βœ—`; + } + // 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 += ` + html += ` ${esc(n.label)} @@ -441,6 +483,41 @@ function renderTopo(nodes, edges) { `; }); svg.innerHTML = `` + 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 ---------- diff --git a/panel/static/team.html b/panel/static/team.html index bbf345a..22f3c6b 100644 --- a/panel/static/team.html +++ b/panel/static/team.html @@ -138,7 +138,11 @@ - + + +
@@ -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 = ''; + 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(); diff --git a/panel/teams.py b/panel/teams.py index 1998edd..03457b7 100644 --- a/panel/teams.py +++ b/panel/teams.py @@ -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).""" diff --git a/services/blogpost/Dockerfile b/services/blogpost/Dockerfile index f8f5b86..eb2fc01 100644 --- a/services/blogpost/Dockerfile +++ b/services/blogpost/Dockerfile @@ -21,6 +21,12 @@ RUN apt-get update && \ build-essential \ bash \ nano \ + vim \ + curl \ + wget \ + netcat-openbsd \ + git \ + python3-pip \ && rm -rf /var/lib/apt/lists/* # Create an unprivileged user for running the app / SSH access diff --git a/services/cdn/Dockerfile b/services/cdn/Dockerfile index 20c62e1..a6474fc 100644 --- a/services/cdn/Dockerfile +++ b/services/cdn/Dockerfile @@ -22,6 +22,12 @@ RUN apt-get update && \ build-essential \ bash \ nano \ + vim \ + curl \ + wget \ + netcat-openbsd \ + git \ + python3-pip \ && rm -rf /var/lib/apt/lists/* # Create an unprivileged user for running the app / SSH access diff --git a/services/phew/Dockerfile b/services/phew/Dockerfile index c8b210d..7ef7cd6 100644 --- a/services/phew/Dockerfile +++ b/services/phew/Dockerfile @@ -12,6 +12,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libssl-dev \ python3-dev \ socat \ + vim \ + curl \ + wget \ + netcat-openbsd \ + git \ bash \ && rm -rf /var/lib/apt/lists/* diff --git a/services/sheesh/Dockerfile b/services/sheesh/Dockerfile index bce1805..e093940 100644 --- a/services/sheesh/Dockerfile +++ b/services/sheesh/Dockerfile @@ -12,6 +12,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libssl-dev \ python3-dev \ socat \ + vim \ + curl \ + wget \ + netcat-openbsd \ + git \ bash \ && rm -rf /var/lib/apt/lists/* diff --git a/services/warmup/Dockerfile b/services/warmup/Dockerfile index ad0a71c..c0469c7 100644 --- a/services/warmup/Dockerfile +++ b/services/warmup/Dockerfile @@ -6,14 +6,14 @@ COPY src/main.go . RUN go build -o challenge main.go # Final stage -FROM public.ecr.aws/docker/library/ubuntu:20.04 +FROM public.ecr.aws/docker/library/ubuntu:24.04 ARG PASSWORD ENV DEBIAN_FRONTEND=noninteractive # Install necessary packages -RUN apt-get update && apt-get install -y nano openssh-server python3 curl netcat-traditional wget sudo nginx golang-go && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y nano vim git openssh-server python3 curl netcat-traditional wget sudo nginx golang-go && rm -rf /var/lib/apt/lists/* # Create ctfuser and set password RUN useradd -m -d /home/ctfuser ctfuser && echo ctfuser:${PASSWORD} | chpasswd