Compare commits

..
10 Commits
Author SHA1 Message Date
MythEclipse c35b23a37f feat: SLA dashboard per team + points system (100/flag, +50 SLA bonus) + badges juara/runner-up/3rd + scoreboard API public+admin
Panel: /api/scoreboard + /api/public/scoreboard; teams.py: points.json ledger, probe_team_sla_fast, sla_status_all w/ background refresher; team.html: SLA & Skor tab; index.html: admin SLA tab; leaderboard shows points; Phew checker timeouts raised for slow Paillier keygen
2026-09-24 00:49:05 +08:00
root 3ef905b3bb feat: team activity feed (attacks in/out) + activity tab + auto-refresh + leaderboard tab on team portal 2026-09-23 22:56:07 +08:00
root 877f14ecf3 fix: editTeam endpoint + leaderboard live name resolution + domains in team set + apt-insecure.conf in all service dirs 2026-09-23 21:50:16 +08:00
root 029b0f809a tools in all containers + apt GPG fix for 2026 clock + target dropdown fixed
- all 6 Dockerfiles: vim curl wget netcat git python3-pip now installed
- apt-insecure.conf (AllowInsecureRepositories) copied into images so
  participants can apt-get install despite expired Ubuntu/Debian GPG keys
- warmup base ubuntu:20.04 (EOL, GPG expired) -> ubuntu:24.04
- installed vim+git live into all 18 running team containers
- team portal target dropdown reloads after login (was empty pre-auth)
- attack log endpoint + A/D submit (attacker vs target) verified e2e
2026-09-23 18:54:03 +08:00
root 24dbf0a662 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)
2026-09-23 18:05:44 +08:00
root 44dc1ac852 feat: target matrix + reset scores/environment buttons
- /api/targets (admin): matrix of all teams' domain:port targets
- /api/reset/scores: wipe leaderboard (admin, confirm dialog)
- /api/reset/environment: stop all teams, remove containers+receivers+
  team dirs+systemd units, wipe scores, drop domains (admin, confirm)
- portal targets now only domain+port (no ssh/labels)
- UI: tab Target Matrix, header buttons Reset Skor / Reset Environment
  with confirm() alerts 'apakah anda yakin ingin mereset...'
2026-09-23 17:14:42 +08:00
root b66530e7fd fix: per-team receivers as independent systemd services
Receivers were child Popen processes of gemastik-panel systemd cgroup;
restarting the panel killed all team receivers (SLA -> 0/6, 401 on
/api/team/N/status proxy). Now each team receiver is a systemd service
(gemastik-receiver-teamN.service) generated by gen_receiver_services.py with
per-team env (ports, containers, COMPOSE_LOCATION, .env). Verified: panel
restart no longer kills receivers; 18/18 SLA stays UP.
2026-09-23 17:06:24 +08:00
root 72382c43d0 fix: guide link IDOR, full team-api IDOR hardening, loading overlay
- guide link now server-side replaced to /team/<idx>/guide (no /team/0 403)
- _check_team_host() applied to ALL team endpoints (login, info, targets,
  status, guide, portal, ssh-ws): host must match team domain; panel/gemastik
  host only with admin session. Cross-domain session reuse -> 403.
- host check BEFORE auth on info/targets (no team-existence oracle)
- loading overlay (spinner + text) on start/stop all-team/set; JS util
  showLoading/hideLoading
2026-09-23 17:02:52 +08:00
root 01ffc05a11 fix: simplify team domain link (root = portal) 2026-09-23 16:38:30 +08:00
root 43ed3df557 feat: team portal with own login, SSH web terminal, targets & guide
- Portal tim punya login sendiri (password = ssh_pass, session team_token)
- SSH Web GUI: /api/team/{idx}/ssh/ws (WebSocket+paramiko) → xterm.js terminal
  (fix: ssh_to_ws non-blocking poll, chall_passwords per-container auth)
- Root <slug>.gemastik.imrnes.team → portal tim (bukan login admin)
- Host validation: /team/{idx} & /team/{idx}/guide 403 kalau host != team domain
- /api/team/{idx}/targets: daftar service tim musuh (attack target)
- guide.html: panduan SSH/attack/defense untuk peserta
- fix esc() String(s) (bug: (s||'').replace is not a function saat port number)
- set_ssh_passwords retry loop (container boot race)
2026-09-23 16:37:58 +08:00
19 changed files with 1788 additions and 151 deletions

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Generate + (re)start per-team receiver systemd services.
Each team receiver becomes gemastik-receiver-teamN.service, independent of
the panel service. This fixes the bug where restarting gemastik-panel killed
all team receivers (they were child processes of the panel systemd cgroup).
"""
import json
import os
import subprocess
import sys
from pathlib import Path
TEAMS_DIR = Path("/opt/gemastik18-final/teams")
RECEIVER_VENV = "/opt/gemastik18-final/receiver/.venv/bin/python"
UNIT_DIR = Path("/etc/systemd/system")
def write_unit(idx: int, st: dict):
port = st["ports"]["receiver"]
recv_dir = TEAMS_DIR / f"team{idx}" / "receiver"
env = {}
# ports/containers for challenge classes
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}"
env["COMPOSE_LOCATION"] = str(TEAMS_DIR / f"team{idx}" / "services" / "docker-compose.yml")
env_lines = "\n".join(f'Environment="{k}={v}"' for k, v in env.items())
unit = f"""[Unit]
Description=Gemastik A/D receiver for team {idx}
After=docker.service
Wants=docker.service
[Service]
Type=simple
WorkingDirectory={recv_dir}
EnvironmentFile={recv_dir}/.env
{env_lines}
ExecStart={RECEIVER_VENV} -m uvicorn main:app --host 0.0.0.0 --port {port}
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
"""
(UNIT_DIR / f"gemastik-receiver-team{idx}.service").write_text(unit)
def main():
action = sys.argv[1] if len(sys.argv) > 1 else "start"
for d in sorted(TEAMS_DIR.glob("team*")):
sf = d / "state.json"
if not sf.exists():
continue
st = json.loads(sf.read_text())
idx = st["index"]
write_unit(idx, st)
subprocess.run(["systemctl", "daemon-reload"], check=False)
subprocess.run(["systemctl", "enable", f"gemastik-receiver-team{idx}.service"], check=False)
if action == "stop":
subprocess.run(["systemctl", "stop", f"gemastik-receiver-team{idx}.service"], check=False)
else:
subprocess.run(["systemctl", "restart", f"gemastik-receiver-team{idx}.service"], check=False)
print(f"gemastik-receiver-team{idx}: {action} (port {st['ports']['receiver']})")
if __name__ == "__main__":
main()
+403 -24
View File
@@ -7,9 +7,10 @@ admin credentials stay out of the browser.
import os
import json
import time
import asyncio
import httpx
from pathlib import Path
from fastapi import FastAPI, Request, HTTPException
from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from typing import Optional
@@ -24,6 +25,15 @@ BASE_DIR = Path(__file__).parent
app = FastAPI(title="Gemastik A/D Panel")
@app.on_event("startup")
async def _start_background():
"""Warm the SLA scoreboard cache in the background."""
import threading
threading.Thread(target=orch._build_scoreboard, daemon=True,
name="sla-warmup").start()
orch.start_sla_refresher()
CHALLENGES = [
{"name": "blogpost", "port": 10000, "ssh": 10022, "category": "web", "desc": "Flask blog with exiftool + SSTI"},
{"name": "carbeat", "port": 11000, "ssh": 11022, "category": "pwn", "desc": "Binary exploitation menu"},
@@ -79,6 +89,17 @@ async def _proxy(method: str, path: str, body: dict = None):
@app.get("/", response_class=HTMLResponse)
async def index(req: Request):
host = (req.headers.get("host") or "").split(":")[0]
# Team portal domains: <slug>.gemastik.imrnes.team -> their team portal (no admin login)
if host.endswith(".gemastik.imrnes.team") and host != "gemastik.imrnes.team" and host != "panel.gemastik.imrnes.team":
slug = host.split(".")[0]
for t in orch.list_teams():
if t.get("slug") == slug:
html = (BASE_DIR / "static" / "team.html").read_text()
html = html.replace('name="team-id" content="0"', f'name="team-id" content="{t["index"]}"')
html = html.replace('href="/team/0/guide"', f'href="/team/{t["index"]}/guide"')
return HTMLResponse(html)
return HTMLResponse("<h2 style='font-family:sans-serif;color:#888;padding:40px'>Team tidak ditemukan: " + slug + "</h2>", status_code=404)
if not _authorized(req):
return RedirectResponse("/login")
html = (BASE_DIR / "static" / "index.html").read_text()
@@ -100,20 +121,135 @@ async def submit_page(req: Request):
@app.get("/team/{idx}", response_class=HTMLResponse)
async def team_portal(idx: int, req: Request):
"""Public portal page for a team (served at <slug>.gemastik.imrnes.team/team/<idx>)."""
html = (BASE_DIR / "static" / "team.html").read_text()
html = html.replace('name="team-id" content="0"', f'name="team-id" content="{idx}"')
return HTMLResponse(html)
@app.get("/api/team/{idx}/info")
async def api_team_info(idx: int):
"""Public: basic team info for the portal (no secrets besides per-team SSH creds)."""
"""Public portal page for a team. Must be accessed via that team's own domain."""
td = orch.TEAMS_DIR / f"team{idx}"
if not (td / "state.json").exists():
raise HTTPException(404, "Team not found")
st = json.loads((td / "state.json").read_text())
# expose only what a team needs: label, domain, ports, ssh creds, status
if not _check_team_host(req, st):
raise HTTPException(403, "Akses team lain tidak diizinkan")
if _team_authorized(req, idx): # logged in -> show portal directly
html = (BASE_DIR / "static" / "team.html").read_text()
html = html.replace('name="team-id" content="0"', f'name="team-id" content="{idx}"')
# server-side: fix guide link so non-JS / pre-JS clicks never hit /team/0
html = html.replace('href="/team/0/guide"', f'href="/team/{idx}/guide"')
return HTMLResponse(html)
# not logged in -> show a stripped landing/login page (no admin info)
html = (BASE_DIR / "static" / "team.html").read_text()
html = html.replace('name="team-id" content="0"', f'name="team-id" content="{idx}"')
html = html.replace('href="/team/0/guide"', f'href="/team/{idx}/guide"')
return HTMLResponse(html)
@app.get("/team/{idx}/guide", response_class=HTMLResponse)
async def team_guide(idx: int, req: Request):
"""Public SSH/attack guide for a team. Must be accessed via that team's own domain."""
td = orch.TEAMS_DIR / f"team{idx}"
if not (td / "state.json").exists():
raise HTTPException(404, "Team not found")
st = json.loads((td / "state.json").read_text())
if not _check_team_host(req, st):
raise HTTPException(403, "Akses team lain tidak diizinkan")
html = (BASE_DIR / "static" / "guide.html").read_text()
html = html.replace('name="team-id" content="0"', f'name="team-id" content="{idx}"')
return HTMLResponse(html)
# ---- Team portal login (separate from admin; password = team ssh_pass) ----
_team_sessions: dict[str, tuple[int, float]] = {} # token -> (idx, expiry)
@app.post("/api/team/{idx}/login")
async def api_team_login(idx: int, req: Request):
td = orch.TEAMS_DIR / f"team{idx}"
if not (td / "state.json").exists():
raise HTTPException(404, "Team not found")
st = json.loads((td / "state.json").read_text())
if not _check_team_host(req, st):
raise HTTPException(403, "Akses team lain tidak diizinkan")
data = await req.json()
pw = data.get("pass", "")
if pw != st.get("ssh_pass"):
raise HTTPException(401, "Password salah")
token = os.urandom(16).hex()
_team_sessions[token] = (idx, time.time() + 12 * 3600)
resp = JSONResponse({"ok": True, "team": idx})
resp.set_cookie("team_token", token, httponly=True, samesite="lax", max_age=12 * 3600)
return resp
@app.post("/api/team/logout")
async def api_team_logout(req: Request):
token = req.cookies.get("team_token")
if token:
_team_sessions.pop(token, None)
return {"ok": True}
def _team_authorized(req: Request, idx: int) -> bool:
token = req.cookies.get("team_token")
if not token:
return False
e = _team_sessions.get(token)
if not e or e[0] != idx or e[1] < time.time():
_team_sessions.pop(token, None)
return False
return True
def _check_team_host(req: Request, st: dict) -> bool:
"""IDOR guard: host must be this team's own domain (or localhost).
panel.gemastik / gemastik.imrnes.team only allowed with a valid ADMIN session."""
host = (req.headers.get("host") or "").split(":")[0]
if host == st.get("domain"):
return True
if host.startswith("127.0.0.1") or host.startswith("localhost"):
return True
if host in ("panel.gemastik.imrnes.team", "gemastik.imrnes.team"):
return _authorized(req) # admin preview only
return False
@app.get("/api/team/{idx}/session")
async def api_team_session(idx: int, req: Request):
"""True when this browser has a valid team session for idx."""
return {"authed": _team_authorized(req, idx)}
@app.get("/api/team/{idx}/targets")
async def api_team_targets(idx: int, req: Request):
"""Team targets — requires team login + own host. Only domain + port."""
td = orch.TEAMS_DIR / f"team{idx}"
if not (td / "state.json").exists():
raise HTTPException(404, "Team not found")
st_self = json.loads((td / "state.json").read_text())
if not _check_team_host(req, st_self):
raise HTTPException(403, "Akses team lain tidak diizinkan")
if not _team_authorized(req, idx):
raise HTTPException(401, "Login portal team dulu")
out = []
for d in sorted(orch.TEAMS_DIR.glob("team*")):
if not (d / "state.json").exists():
continue
st = json.loads((d / "state.json").read_text())
if st.get("index") == idx:
continue # skip self
for name, coff, soff in orch.CHALLENGES:
p = st["ports"].get(name)
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"],
})
return {"targets": out}
@app.get("/api/team/{idx}/info")
async def api_team_info(idx: int, req: Request):
"""Team portal info — requires team login + own host; no admin secrets."""
td = orch.TEAMS_DIR / f"team{idx}"
if not (td / "state.json").exists():
raise HTTPException(404, "Team not found")
st = json.loads((td / "state.json").read_text())
if not _check_team_host(req, st):
raise HTTPException(403, "Akses team lain tidak diizinkan")
if not _team_authorized(req, idx):
raise HTTPException(401, "Login portal team dulu")
return {"team": {
"index": st.get("index"),
"label": st.get("label"),
@@ -122,17 +258,19 @@ async def api_team_info(idx: int):
"status": st.get("status"),
"ports": st.get("ports"),
"ssh_user": st.get("ssh_user"),
"ssh_pass": st.get("ssh_pass"),
"ssh_pass": st.get("ssh_pass"), # same password used to login portal + SSH
}}
@app.get("/api/team/{idx}/status")
async def api_team_status(idx: int):
"""Public: SLA status for a team's challenges (no auth — read-only health)."""
async def api_team_status(idx: int, req: Request):
"""SLA status for a team's challenges (read-only health, own-host only)."""
td = orch.TEAMS_DIR / f"team{idx}"
if not (td / "state.json").exists():
raise HTTPException(404, "Team not found")
st = json.loads ((td / "state.json").read_text())
if not _check_team_host(req, st):
raise HTTPException(403, "Akses team lain tidak diizinkan")
recv_port = st["ports"]["receiver"]
# use team's receiver admin creds (server-side only; never sent to browser)
au, ap = st.get("admin_user", ""), st.get("admin_pass", "")
@@ -147,7 +285,50 @@ async def api_team_status(idx: int):
ok = False
results.append({"name": name, "port": st["ports"][name]["chall"],
"ssh": st["ports"][name]["ssh"], "alive": ok})
return {"results": results}
# award SLA bonus when all services are UP (throttled, see add_sla_bonus)
alive = sum(1 for r in results if r["alive"])
bonus = orch.add_sla_bonus(idx, alive, len(results))
return {"results": results, "sla": {"alive": alive, "total": len(results),
"pct": round(100 * alive / max(len(results), 1), 1),
"points": orch.get_team_points(idx),
"rank": orch.team_rank(idx), "badge": orch.team_badge(idx),
"bonus": bonus}}
@app.get("/api/team/{idx}/activity")
async def api_team_activity(idx: int, req: Request):
"""Team-scoped activity feed: attack events where this team is attacker or
target (i.e. "ada serangan ke service kita" / "kita menyerang"), plus a
snapshot of own service health. Requires team login + own host."""
td = orch.TEAMS_DIR / f"team{idx}"
if not (td / "state.json").exists():
raise HTTPException(404, "Team not found")
st_self = json.loads((td / "state.json").read_text())
if not _check_team_host(req, st_self):
raise HTTPException(403, "Akses team lain tidak diizinkan")
if not _team_authorized(req, idx):
raise HTTPException(401, "Login portal team dulu")
# live label lookup (rename team = activity updates everywhere)
def label(i):
try:
s = json.loads((orch.TEAMS_DIR / f"team{i}" / "state.json").read_text())
return s.get("label") or f"Team {i}"
except Exception:
return f"Team {i}"
ap = orch.TEAMS_DIR / "attacks.json"
try:
events = json.loads(ap.read_text()).get("events", []) if ap.exists() else []
except Exception:
events = []
mine = []
for e in reversed(events): # newest first
if e.get("attacker") == idx or e.get("target") == idx:
e = dict(e)
e["attacker_label"] = label(e.get("attacker"))
e["target_label"] = label(e.get("target"))
mine.append(e)
return {"events": mine[:100], "team_label": st_self.get("label") or f"Team {idx}"}
@app.post("/api/login")
async def api_login(req: Request):
@@ -262,23 +443,46 @@ async def api_teams(req: Request):
@app.post("/api/teams/set")
async def api_teams_set(req: Request):
"""Set/create N teams (idempotent: creates missing, keeps existing)."""
"""Set/create N teams (idempotent: creates missing, keeps existing).
body: {count, labels: {"1": "Tim Satu"}, domains: {"1": "tim-satu"}}"""
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")
labels = data.get("labels") or {} # { "1": "Tim Satu", ... }
domains = data.get("domains") or {} # { "1": "mycustom", ... }
created = []
for i in range(1, n + 1):
td = orch.TEAMS_DIR / f"team{i}"
if not td.exists():
label = labels.get(str(i)) or labels.get(i) or f"Tim {i}"
st = orch.create_team(i, label)
dom = domains.get(str(i)) or domains.get(i) or None
st = orch.create_team(i, label, domain=dom)
created.append(st["index"])
else:
# team exists: apply any label/domain overrides
label = labels.get(str(i)) or labels.get(i)
dom = domains.get(str(i)) or domains.get(i)
if label or dom:
orch.update_team(i, label=label, domain=dom)
orch.ensure_team_domains()
return {"created": created, "total": len(orch.list_teams())}
@app.put("/api/teams/{idx}")
async def api_team_update(idx: int, req: Request):
"""Update a team's custom name and/or domain (applies live)."""
require_login(req)
if not (orch.TEAMS_DIR / f"team{idx}" / "state.json").exists():
raise HTTPException(404, "Team not found")
data = await req.json()
try:
st = orch.update_team(idx, label=data.get("label"), domain=data.get("domain"))
return {"ok": True, "team": st}
except FileNotFoundError as e:
raise HTTPException(404, str(e))
@app.post("/api/teams/start")
async def api_teams_start(req: Request):
require_login(req)
@@ -379,11 +583,26 @@ async def api_randomize(idx: int, req: Request):
raise HTTPException(500, str(e))
@app.post("/api/reset/scores")
async def api_reset_scores(req: Request):
"""Admin: wipe leaderboard (all solves)."""
require_login(req)
return orch.reset_scores()
@app.post("/api/reset/environment")
async def api_reset_environment(req: Request):
"""Admin: full environment reset (stop all, remove teams/containers/receivers)."""
require_login(req)
return orch.reset_environment()
@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}"
@@ -393,27 +612,187 @@ 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")
async def api_leaderboard(req: Request):
"""Leaderboard of solves so far."""
"""Leaderboard of solves so far. Team names resolved LIVE from state.json
so renaming a team updates leaderboard + topology everywhere."""
lb_path = orch.TEAMS_DIR / "leaderboard.json"
if lb_path.exists():
lb = json.loads(lb_path.read_text())
else:
lb = {"solves": []}
# live name lookup: state.json label fallback to snapshot
def team_name(idx):
try:
st = json.loads((orch.TEAMS_DIR / f"team{idx}" / "state.json").read_text())
return st.get("label") or f"Team {idx}"
except Exception:
return f"Team {idx}"
# aggregate per team
teams = {}
for e in lb["solves"]:
t = teams.setdefault(e["team"], {"team": e["team"], "name": e["team_name"], "solves": 0, "challs": []})
name = team_name(e["team"])
t = teams.setdefault(e["team"], {"team": e["team"], "name": name, "solves": 0, "challs": [], "points": 0})
t["name"] = name
t["solves"] += 1
t["challs"].append(e["challenge"])
return {"solves": lb["solves"], "teams": sorted(teams.values(), key=lambda x: -x["solves"])}
# attach live points from points.json
for tid, t in teams.items():
t["points"] = orch.get_team_points(tid)
return {"solves": lb["solves"], "teams": sorted(teams.values(), key=lambda x: (-x["points"], -x["solves"]))}
@app.get("/api/scoreboard")
async def api_scoreboard(req: Request):
"""Admin + team: full scoreboard with points, SLA, rank, badges."""
require_login(req)
return orch.sla_status_all()
@app.get("/api/public/scoreboard")
async def api_public_scoreboard():
"""Public scoreboard (no login) — used by the team portal status tab."""
d = orch.sla_status_all()
# strip receiver creds / ports internals for the public view
for t in d["teams"]:
t.pop("domain", None)
return d
@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()]}
return {"teams": [{"index": t["index"], "label": t.get("label", f"Team {t['index']}")} for t in orch.list_teams()]}
@app.get("/api/targets")
async def api_admin_targets(req: Request):
"""Admin: matrix of every team's service domain:port (public targets)."""
require_login(req)
out = []
for d in sorted(orch.TEAMS_DIR.glob("team*")):
if not (d / "state.json").exists():
continue
st = json.loads((d / "state.json").read_text())
dom = st.get("domain") or (st.get("slug", f"team{st.get('index')}") + ".gemastik.imrnes.team")
for name, coff, soff in orch.CHALLENGES:
p = st["ports"].get(name)
if not p:
continue
out.append({
"team": st.get("index"),
"team_label": st.get("label", f"Team {st.get('index')}"),
"challenge": name,
"domain": dom,
"port": p["chall"],
})
return {"targets": out}
# ============ WebSocket SSH terminal (team portal) ============
import paramiko
import websockets
@app.websocket("/api/team/{idx}/ssh/ws")
async def team_ssh_ws(ws: WebSocket, idx: int):
chall = ws.query_params.get("chall", "")
# auth: team session cookie must match this team
token = ws.cookies.get("team_token")
e = _team_sessions.get(token or "")
if not e or e[0] != idx or e[1] < time.time():
await ws.close(code=4001, reason="unauthorized")
return
td = orch.TEAMS_DIR / f"team{idx}"
st = json.loads((td / "state.json").read_text())
# host check (IDOR): only this team's domain can open its SSH
host = (ws.headers.get("host") or "").split(":")[0]
if not (host == st.get("domain") or host.startswith("127.0.0.1") or host.startswith("localhost")):
await ws.close(code=4003, reason="wrong host")
return
if chall not in st.get("ports", {}):
await ws.close(code=4002, reason="unknown challenge")
return
recv_port = st["ports"][chall]["ssh"]
user = st.get("ssh_user", "ctfuser")
# each challenge container has its own password (chall_passwords);
# ssh_pass is the portal login password (may differ).
pw = st.get("chall_passwords", {}).get(chall) or st.get("ssh_pass", "")
await ws.accept()
chan = client = None
try:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None, lambda: client.connect("127.0.0.1", port=recv_port, username=user,
password=pw, timeout=10, allow_agent=False,
look_for_keys=False))
chan = client.invoke_shell(term="xterm-256color", width=120, height=32)
async def ws_to_ssh():
while True:
try:
msg = await ws.receive_text()
except Exception:
break
if msg.startswith("__resize__"):
try:
_, cols, rows = msg.split(":", 2)
chan.resize_pty(int(cols), int(rows))
except Exception:
pass
else:
try:
chan.send(msg)
except Exception:
break
async def ssh_to_ws():
# non-blocking poll: chan.recv() di dalam async task akan
# memblokir seluruh event loop (deadlock) — jadi poll recv_ready.
while True:
try:
if chan.recv_ready():
data = chan.recv(4096)
if not data:
break
await ws.send_text(data.decode("utf-8", "replace"))
elif chan.closed:
break
except Exception:
break
await asyncio.sleep(0.03)
t1 = asyncio.create_task(ws_to_ssh())
t2 = asyncio.create_task(ssh_to_ws())
done, pending = await asyncio.wait({t1, t2}, return_when=asyncio.FIRST_COMPLETED)
for t in pending:
t.cancel()
except Exception as e:
try:
await ws.send_text(f"\r\n[ssh error] {e}\r\n")
except Exception:
pass
finally:
try:
if chan: chan.close()
except Exception: pass
try:
if client: client.close()
except Exception: pass
try:
await ws.close()
except Exception: pass
+108
View File
@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="team-id" content="0">
<title>Panduan SSH — Gemastik A/D</title>
<style>
:root { --bg:#0b0e14; --panel:#151a23; --panel2:#1d2430; --line:#2a3444; --txt:#e6edf3; --dim:#8b98a9; --accent:#38bdf8; --green:#34d399; --red:#f87171; --purple:#a78bfa; }
* { 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:800px; margin:0 auto; padding:24px 16px 60px; }
header { display:flex; align-items:center; gap:12px; margin-bottom:24px; flex-wrap:wrap; }
.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); }
h3 { color:var(--purple); margin:18px 0 8px; font-size:14px; }
p, li { font-size:13px; color:#c9d4e3; line-height:1.7; }
ol, ul { padding-left:20px; }
.sshbox { background:var(--panel2); border:1px solid var(--line); border-radius:8px; padding:10px 12px; font-family:ui-monospace,monospace; font-size:12px; overflow-x:auto; margin:6px 0; }
code { background:var(--panel2); padding:2px 6px; border-radius:5px; font-size:12px; }
.warn { background:#3a1414; border:1px solid var(--red); border-radius:8px; padding:12px; font-size:13px; color:#fca5a5; margin:10px 0; }
a { color:var(--accent); }
table { width:100%; border-collapse:collapse; font-size:13px; margin:8px 0; }
th, td { text-align:left; padding:8px 6px; border-bottom:1px solid var(--line); }
th { color:var(--dim); font-weight:500; }
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo">G</div>
<div style="flex:1">
<h1>📖 Panduan SSH & Attack</h1>
<div class="sub">Gemastik 18 Final — Attack & Defense</div>
</div>
</header>
<div class="card">
<h2>🎯 Konsep Attack & Defense</h2>
<p>Setiap tim punya <b>6 challenge container</b> yang harus dijaga (<b>defense</b>) dan bisa menyerang container tim lain (<b>attack</b>). Setiap container menyimpan <b>flag</b> yang nilainya dicek panitia secara berkala (SLA). Kalau service mati atau flag berubah, tim kamu <b>kehilangan poin SLA</b>.</p>
<p style="margin-top:8px">Kamu menang dengan: (1) menjaga service tetap hidup, (2) <b>mencuri flag</b> dari tim lawan dan submit, (3) mencegah tim lawan mencuri flag kamu.</p>
</div>
<div class="card">
<h2>🔐 Login SSH</h2>
<h3>Cara 1 — Web Terminal di Portal Tim</h3>
<ol>
<li>Buka <b>portal tim kamu</b> (domain dari panitia).</li>
<li>Login dengan <b>password SSH tim</b>.</li>
<li>Tab <b>🖥️ Terminal SSH</b> → pilih challenge → <b>Sambung</b>.</li>
<li>Langsung masuk sebagai <code>ctfuser</code> — tanpa perlu aplikasi SSH.</li>
</ol>
<h3>Cara 2 — SSH Client (opsional)</h3>
<div class="sshbox">ssh ctfuser@43.134.105.109 -p &lt;PORT_SSH&gt;</div>
<p>Contoh: untuk challenge <code>blogpost</code> tim 1, port SSH = <code>31022</code>.</p>
</div>
<div class="card">
<h2>🧭 Command Penting</h2>
<table>
<tr><th>Command</th><th>Fungsi</th></tr>
<tr><td><code>cat /flag.txt</code></td><td>Lihat flag tim kamu sendiri</td></tr>
<tr><td><code>ls /app /srv /opt /home</code></td><td>Cari file source/service</td></tr>
<tr><td><code>ps aux</code></td><td>Lihat proses yang berjalan</td></tr>
<tr><td><code>ss -tlnp</code></td><td>Lihat port yang dibuka service</td></tr>
<tr><td><code>systemctl status</code></td><td>Cek service (kalau ada)</td></tr>
<tr><td><code>cat /etc/passwd</code></td><td>Daftar user lokal</td></tr>
</table>
</div>
<div class="card">
<h2>⚔️ Alur Attack</h2>
<ol>
<li>Buka tab <b>🎯 Target Musuh</b> di portal — lihat daftar domain/port tim lain.</li>
<li>Analisa service lawan dari <b>source code di container kamu sendiri</b> (biasanya sama).</li>
<li>Cari vuln (SSTI, path traversal, RCE, crypto oracle, dsb).</li>
<li>Exploit service lawan → baca <code>/flag.txt</code> mereka.</li>
<li>Submit flag di tab <b>🚩 Submit Flag</b> → <b>poin masuk ke leaderboard</b>.</li>
</ol>
<div class="warn">⚠️ Jangan sampai flag tim kamu bocor! Ganti password SSH rutin (via panitia), dan jangan tinggalkan flag di tempat umum.</div>
</div>
<div class="card">
<h2>🛡️ Alur Defense</h2>
<ol>
<li>Pastikan service <b>selalu hidup</b> — SLA dicek panitia berkala.</li>
<li>Patch vuln yang bisa dipakai lawan (jika tahu).</li>
<li>Jangan ubah <code>/flag.txt</code> — mount <b>read-only</b>, tidak bisa diubah dari dalam container.</li>
<li>Pantau <code>ps aux</code> / log — kalau ada aktivitas mencurigakan, restart via panitia.</li>
</ol>
</div>
<div class="card">
<h2>📞 Butuh Bantuan?</h2>
<p>Hubungi panitia untuk: reset password SSH, restart container, atau lapor service down. Portal ini untuk peserta — panel admin terpisah.</p>
<p style="margin-top:8px"><a href="/team/0" data-portal-link>← Kembali ke portal tim</a></p>
</div>
</div>
<script>
const TEAM_ID = parseInt(document.querySelector('meta[name="team-id"]').content || '0', 10);
document.querySelectorAll('[data-portal-link]').forEach(a => a.href = `/team/${TEAM_ID}`);
</script>
</body>
</html>
+313 -19
View File
@@ -83,6 +83,15 @@
}
.toast.show { opacity:1; transform:translateY(0); }
.toast.err { border-color:#f87171; color:#fca5a5; }
/* loading overlay */
#loadingOverlay {
display:none; position:fixed; inset:0; background:rgba(4,8,14,0.82); z-index:2000;
align-items:center; justify-content:center; flex-direction:column; gap:18px;
}
#loadingOverlay.show { display:flex; }
.loader { width:44px; height:44px; border:4px solid #2a3444; border-top-color:#38bdf8; border-radius:50%; animation:spin 0.9s linear infinite; }
@keyframes spin { to { transform:rotate(360deg); } }
#loadingText { color:#dbe6f4; font-size:14px; text-align:center; padding:0 24px; line-height:1.6; }
.history-box {
background:#0a101f; border:1px solid #1e3a5f; border-radius:10px; padding:14px;
font-size:11px; color:#7dd3fc; max-height:300px; overflow-y:auto; white-space:pre-wrap;
@@ -92,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; }
@@ -106,6 +121,8 @@
<div class="actions">
<span class="pill" id="clock">--:--:--</span>
<button onclick="refresh()">🔄 Refresh</button>
<button class="danger" onclick="resetScores()" style="background:#dc2626;border:none;border-radius:8px;padding:8px 14px;color:#fff;font-weight:600;cursor:pointer">🧹 Reset Skor</button>
<button class="danger" onclick="resetEnv()" style="background:#7f1d1d;border:none;border-radius:8px;padding:8px 14px;color:#fff;font-weight:600;cursor:pointer">💥 Reset Environment</button>
<button onclick="logout()">Keluar</button>
</div>
</header>
@@ -116,6 +133,8 @@
<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>
<button class="tab" data-view="targets" onclick="showView('targets'); loadTargetMatrix()">🎯 Target Matrix</button>
<button class="tab" data-view="sla" onclick="showView('sla'); loadSla()">📊 SLA & Skor</button>
</div>
<!-- Challenges view -->
@@ -125,7 +144,14 @@
<!-- 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-toolbar" style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap">
<button onclick="topoZoom(1.25)">🔍 + Zoom In</button>
<button onclick="topoZoom(0.8)">🔎 Zoom Out</button>
<button onclick="topoReset()">⟳ Reset</button>
<span id="topoZoomLabel" style="font-size:12px;color:var(--dim);min-width:40px">100%</span>
</div>
<div class="topo-wrap"><svg id="topoSvg" class="topo-svg" height="520"></svg></div>
<div class="topo-hint">💡 <b>Geser node</b> untuk atur layout • <b>scroll / ctrl+scroll</b> untuk zoom in-out • <b>drag area kosong</b> untuk geser canvas • ⚔️ garis merah = serangan (panah attacker → target) • garis <b>putus-putus</b> = serangan baru (60 detik terakhir)</div>
</div>
<!-- Teams view -->
@@ -181,6 +207,28 @@
<div class="grid" id="credsGrid"></div>
</div>
<!-- Target Matrix view -->
<div class="view" id="view-targets">
<div class="card">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<b style="color:#5ad1ff">🎯 Target Matrix</b>
<span style="font-size:11px;color:#718096">Domain:port semua service semua tim — buat dibagikan ke peserta sebagai daftar target</span>
</div>
<div id="targetMatrix" style="margin-top:14px;font-family:ui-monospace,monospace;font-size:12px;line-height:1.9;color:#dbe6f4;background:#0a101f;border:1px solid #1e3a5f;border-radius:10px;padding:14px;overflow-x:auto">Memuat…</div>
</div>
</div>
<!-- SLA & Scoreboard view -->
<div class="view" id="view-sla">
<div class="card">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<b style="color:#5ad1ff">📊 SLA & Skor Tim</b>
<span style="font-size:11px;color:#718096">Status service tiap tim (dicek otomatis tiap ~30 detik) + poin: +100/flag dicuri, +50 bonus SLA saat 6/6 UP. Auto-refresh 30 detik.</span>
</div>
<div id="slaAdminList" style="margin-top:14px;font-family:ui-monospace,monospace;font-size:12px;line-height:1.9;color:#dbe6f4;background:#0a101f;border:1px solid #1e3a5f;border-radius:10px;padding:14px;overflow-x:auto">Memuat…</div>
</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 -->
@@ -219,6 +267,12 @@
<div class="toast" id="toast"></div>
<!-- loading overlay -->
<div id="loadingOverlay">
<div class="loader"></div>
<div id="loadingText">Memproses…</div>
</div>
<script>
let CURRENT = null;
let REFRESH_MS = 15000;
@@ -237,13 +291,60 @@ function toast(msg, err=false) {
setTimeout(() => t.className = 'toast', 3000);
}
function showLoading(html) {
const ov = document.getElementById('loadingOverlay');
document.getElementById('loadingText').innerHTML = html;
ov.classList.add('show');
return ov;
}
function hideLoading() {
document.getElementById('loadingOverlay').classList.remove('show');
}
function esc(s) {
return (s||'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
return String(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));
if (v === 'topo') loadTopo(); // refresh attacks immediately on tab switch + every 10s
if (v === 'sla') loadSla();
}
let topoTimer = null;
function startTopoTimer() {
if (!topoTimer) topoTimer = setInterval(() => {
if (document.getElementById('view-topo').classList.contains('active')) loadTopo();
}, 10000);
}
startTopoTimer();
let slaTimer = null;
function startSlaTimer() {
if (!slaTimer) slaTimer = setInterval(() => {
if (document.getElementById('view-sla').classList.contains('active')) loadSla();
}, 30000);
}
startSlaTimer();
async function loadSla() {
const box = document.getElementById('slaAdminList');
if (!box) return;
box.innerHTML = 'Memuat…';
try {
const d = await api('/api/scoreboard');
const rows = (d.teams || []).map(t => {
const pct = Math.round(t.sla_pct || 0);
const color = pct === 100 ? '#2ecc71' : (pct >= 60 ? '#f1c40f' : '#e74c3c');
const bar = `<span style="display:inline-block;vertical-align:middle;width:120px;height:8px;background:#1e3a5f;border-radius:4px;margin:0 8px;overflow:hidden"><span style="display:block;height:100%;width:${pct}%;background:${color}"></span></span>`;
return `<div style="padding:8px 4px;border-bottom:1px solid #16233a">
<b>${t.badge ? t.badge + ' ' : '#' + t.rank + ' '}</b>${esc(t.label)} ${bar}<span style="color:${color}">${t.alive}/${t.total} (${pct}%)</span>
<span style="float:right;color:#5ad1ff">⚑ ${t.solves} flag · 🏆 ${t.points} pts</span>
</div>`;
}).join('');
box.innerHTML = rows || '<div style="color:#718096">Belum ada tim.</div>';
} catch (e) {
box.innerHTML = '<div style="color:#e74c3c">Gagal memuat SLA.</div>';
}
}
// ---------- Challenges ----------
@@ -332,9 +433,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); }
}
@@ -342,7 +445,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 = {};
@@ -359,15 +462,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
@@ -378,10 +492,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];
@@ -393,7 +521,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>
@@ -401,9 +529,99 @@ function renderTopo(nodes, edges) {
${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;
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><g data-root>${html}</g>`;
enableTopoDrag(svg, pos);
applyTopoView();
}
// ---- draggable topology + zoom/pan ----
let topoScale = 1;
let topoPanX = 0, topoPanY = 0;
function enableTopoDrag(svg, pos) {
const gMain = svg.querySelector('g[data-root]') || svg;
let dragEl = null, dx = 0, dy = 0;
let panning = false, px = 0, py = 0;
const toLocal = (ev) => {
const ctm = svg.getScreenCTM();
if (!ctm) return {x: 0, y: 0};
const pt = svg.createSVGPoint();
pt.x = ev.clientX; pt.y = ev.clientY;
return pt.matrixTransform(ctm.inverse());
};
const onMove = (ev) => {
if (dragEl) {
const p = toLocal(ev);
dragEl.setAttribute('transform', `translate(${p.x - dx}, ${p.y - dy})`);
} else if (panning) {
topoPanX += ev.clientX - px;
topoPanY += ev.clientY - py;
px = ev.clientX; py = ev.clientY;
applyTopoView();
}
};
const onUp = () => { dragEl = null; panning = false; svg.style.cursor = ''; };
svg.addEventListener('mousedown', (ev) => {
const g = ev.target.closest('g[data-node]');
if (g) {
ev.preventDefault();
const p = toLocal(ev);
const c = g.querySelector('circle');
dx = p.x - parseFloat(c.getAttribute('cx'));
dy = p.y - parseFloat(c.getAttribute('cy'));
dragEl = g;
svg.style.cursor = 'grabbing';
g.setPointerCapture && g.setPointerCapture(ev.pointerId);
} else {
// empty area -> pan the canvas
panning = true;
px = ev.clientX; py = ev.clientY;
svg.style.cursor = 'move';
}
});
svg.addEventListener('pointermove', onMove);
svg.addEventListener('pointerup', onUp);
svg.addEventListener('pointercancel', onUp);
// wheel zoom (ctrl+wheel or plain wheel on empty area)
svg.addEventListener('wheel', (ev) => {
ev.preventDefault();
const factor = ev.deltaY < 0 ? 1.12 : 1 / 1.12;
const ns = Math.min(3, Math.max(0.3, topoScale * factor));
// zoom around cursor
const rect = svg.getBoundingClientRect();
const mx = ev.clientX - rect.left, my = ev.clientY - rect.top;
const W = svg.clientWidth, H = svg.clientHeight;
topoPanX = mx - (mx - topoPanX) * (ns / topoScale);
topoPanY = my - (my - topoPanY) * (ns / topoScale);
topoScale = ns;
applyTopoView();
}, {passive: false});
}
function applyTopoView() {
const svg = document.getElementById('topoSvg');
const g = svg.querySelector('g[data-root]');
if (!g) return;
g.setAttribute('transform', `translate(${topoPanX}, ${topoPanY}) scale(${topoScale})`);
// update hint + zoom % label
const zl = document.getElementById('topoZoomLabel');
if (zl) zl.textContent = Math.round(topoScale * 100) + '%';
}
function topoZoom(factor) {
const svg = document.getElementById('topoSvg');
const rect = svg.getBoundingClientRect();
const ns = Math.min(3, Math.max(0.3, topoScale * factor));
topoPanX = rect.width / 2 - (rect.width / 2 - topoPanX) * (ns / topoScale);
topoPanY = rect.height / 2 - (rect.height / 2 - topoPanY) * (ns / topoScale);
topoScale = ns;
applyTopoView();
}
function topoReset() { topoScale = 1; topoPanX = 0; topoPanY = 0; applyTopoView(); }
// ---------- Teams ----------
async function loadTeams() {
try {
@@ -425,12 +643,13 @@ async function loadTeams() {
<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>${dom ? `<b>Domain</b><span><a href="https://${esc(dom)}/team/${t.index}" target="_blank" style="color:#5ad1ff">${esc(dom)} →</a></span>` : ''}
<b>Status</b><span>${esc(t.status)}</span>${dom ? `<b>Domain</b><span><a href="https://${esc(dom)}" target="_blank" style="color:#5ad1ff">${esc(dom)} →</a></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="editTeam(${t.index})">✏️ Edit Nama/Domain</button>
<button onclick="openTeamLogs(${t.index})">📜 Logs</button>
<button onclick="randomizeTeam(${t.index})">🎲 Randomize Flag</button>
</div>
@@ -442,46 +661,100 @@ async function loadTeams() {
async function setTeams() {
const n = parseInt(document.getElementById('teamCount').value || '0', 10);
const labels = {};
const labels = {}, domains = {};
for (let i = 1; i <= n; i++) {
const inp = document.getElementById('teamNameInputs')?.querySelector(`input[data-idx="${i}"]`);
if (inp && inp.value.trim()) labels[i] = inp.value.trim();
const inpN = document.getElementById('teamNameInputs')?.querySelector(`input[data-idx="${i}"][data-field="name"]`);
if (inpN && inpN.value.trim()) labels[i] = inpN.value.trim();
const inpD = document.getElementById('teamNameInputs')?.querySelector(`input[data-idx="${i}"][data-field="domain"]`);
if (inpD && inpD.value.trim()) domains[i] = inpD.value.trim();
}
try {
const d = await api('/api/teams/set', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({count: n, labels})});
showLoading(`Membuat ${n} team…<br>Generate compose, flags, domain, Traefik config`);
const d = await api('/api/teams/set', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({count: n, labels, domains})});
toast(`Team dibuat: ${d.created.join(', ') || 'tidak ada yang baru'} · total ${d.total}`, false);
loadTeams();
} catch (e) { toast(e.message, true); }
finally { hideLoading(); }
}
function teamCountChanged() {
const n = parseInt(document.getElementById('teamCount').value || '0', 10);
const box = document.getElementById('teamNameInputs');
let html = '';
let html = '<div style="font-size:11px;color:#718096;margin-bottom:4px">Nama team & domain custom (opsional — kosongkan untuk auto dari nama)</div>';
for (let i = 1; i <= n; i++) {
html += `<div style="display:flex;align-items:center;gap:8px">
html += `<div style="display:flex;align-items:center;gap:6px">
<span style="color:#718096;font-size:12px;width:60px">Team ${i}</span>
<input data-idx="${i}" placeholder="Nama team (jd domain: nama.gemastik.imrnes.team)" style="flex:1">
<input data-idx="${i}" data-field="name" placeholder="Nama team (contoh: Cyber Warriors)" style="flex:1;min-width:120px">
<input data-idx="${i}" data-field="domain" placeholder="domain custom (contoh: team-cyber)" style="flex:1;min-width:120px">
<span style="color:#3b4a63;font-size:11px">.gemastik.imrnes.team</span>
</div>`;
}
box.innerHTML = html;
}
async function editTeam(idx) {
// fetch current team info
let t = null;
try { t = (await api(`/api/team/${idx}/creds`)).team; } catch (e) { toast('Gagal ambil data team', true); return; }
const newLabel = prompt(`✏️ Nama team ${idx} (sekarang: ${t.label || ('Team ' + idx)}):`, t.label || '');
if (newLabel === null) return;
const newDomain = prompt(`🌐 Domain custom team ${idx} (sekarang: ${t.domain || ''} — ketik subdomain saja, contoh: team-cyber):`, (t.domain || '').split('.')[0]);
if (newDomain === null) return;
try {
showLoading('Update nama/domain team…');
const d = await api(`/api/teams/${idx}`, {method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({label: newLabel.trim(), domain: newDomain.trim()})});
toast(`Team ${idx} diupdate: ${d.team.label} @ ${d.team.domain}`, false);
loadTeams();
if (document.getElementById('view-topo').classList.contains('active')) loadTopo();
} catch (e) { toast(e.message, true); }
finally { hideLoading(); }
}
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); }
const ov = showLoading(`Menyiapkan Team ${idx}…<br>Membangun & start container (bisa butuh 1-3 menit)`);
try { await api('/api/teams/start', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({index: idx})}); toast(`Team ${idx} start`, false); }
catch (e) { toast(e.message, true); }
finally { hideLoading(); setTimeout(loadTeams, 2500); }
}
async function stopTeam(idx) {
const ov = showLoading(`Menghentikan Team ${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); }
finally { hideLoading(); }
}
async function startAllTeams() {
const ov = showLoading('Start SEMUA team…<br>Ini bisa butuh beberapa menit (build + boot + set password)');
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); }
finally { hideLoading(); }
}
async function stopAllTeams() {
const ov = showLoading('Menghentikan SEMUA team…');
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); }
finally { hideLoading(); }
}
async function resetScores() {
if (!confirm('⚠️ Apakah anda yakin ingin mereset skor?\n\nSemua poin & solve di leaderboard akan DIHAPUS.\nTindakan ini tidak bisa dibatalkan.')) return;
showLoading('Menghapus semua skor…');
try {
const d = await api('/api/reset/scores', {method:'POST', headers:{'Content-Type':'application/json'}, body: '{}'});
toast('Skor direset: semua solve dihapus', false);
} catch (e) { toast(e.message, true); }
finally { hideLoading(); }
}
async function resetEnv() {
if (!confirm('⚠️ Apakah anda yakin ingin mereset environment?\n\nSEMUA team akan DISTOP:\n• Container challenge dihapus\n• Receiver team dihapus\n• Folder team dihapus (perlu create ulang)\n• Skor leaderboard direset\n\nTindakan ini TIDAK BISA dibatalkan.')) return;
showLoading('Reset environment…\nMenghentikan semua team + menghapus container/receiver (beberapa menit)');
try {
const d = await api('/api/reset/environment', {method:'POST', headers:{'Content-Type':'application/json'}, body: '{}'});
const n = (d.stopped || []).length;
toast(`Environment direset: ${n} team dihentikan & dihapus`, false);
setTimeout(() => location.reload(), 1200);
} catch (e) { toast(e.message, true); }
finally { hideLoading(); }
}
async function randomizeTeam(idx) {
@@ -584,6 +857,27 @@ async function loadCreds() {
} catch (e) { toast(e.message, true); }
}
// ---------- Target Matrix ----------
async function loadTargetMatrix() {
const box = document.getElementById('targetMatrix');
try {
const d = await api('/api/targets');
const targets = d.targets || [];
if (!targets.length) { box.textContent = 'Belum ada team.'; return; }
// group by team
const rows = [];
let curTeam = null;
for (const t of targets) {
if (t.team !== curTeam) {
rows.push(`\n[${esc(t.team_label || ('Team ' + t.team))}]`);
curTeam = t.team;
}
rows.push(` ${esc(t.domain)}:${t.port} (${esc(t.challenge)})`);
}
box.textContent = rows.join('\n');
} catch (e) { box.textContent = 'Gagal: ' + e.message; }
}
// ---------- misc ----------
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
async function logout() {
+442 -56
View File
@@ -3,17 +3,21 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Portal Team — Gemastik A/D</title>
<meta name="team-id" content="0">
<title>Portal Tim — Gemastik A/D</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css">
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
<style>
:root { --bg:#0b0e14; --panel:#151a23; --panel2:#1d2430; --line:#2a3444; --txt:#e6edf3; --dim:#8b98a9; --accent:#38bdf8; --green:#34d399; --red:#f87171; }
:root { --bg:#0b0e14; --panel:#151a23; --panel2:#1d2430; --line:#2a3444; --txt:#e6edf3; --dim:#8b98a9; --accent:#38bdf8; --green:#34d399; --red:#f87171; --purple:#a78bfa; }
* { 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:900px; margin:0 auto; padding:24px 16px 60px; }
.wrap { max-width:1000px; margin:0 auto; padding:24px 16px 60px; }
header { display:flex; align-items:center; gap:12px; margin-bottom:24px; flex-wrap:wrap; }
.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; }
.badge { background:#06352a; border:1px solid var(--green); color:var(--green); border-radius:20px; padding:3px 12px; font-size:12px; font-weight:600; }
.badge.stopped { background:#3a1414; border-color:var(--red); color:var(--red); }
.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); }
.kv { display:grid; grid-template-columns:150px 1fr; gap:6px 12px; font-size:13px; margin-bottom:4px; }
@@ -25,13 +29,60 @@
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; }
.dot.up { background:var(--green); } .dot.down { background:var(--red); }
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; }
select, input[type=text], input[type=password] { width:100%; padding:10px 12px; border-radius:8px; border:1px solid var(--line); background:var(--panel2); color:var(--txt); font-size:14px; }
button { padding:12px 16px; border:none; border-radius:8px; background:linear-gradient(135deg,#38bdf8,#6366f1); color:#fff; font-weight:600; font-size:14px; cursor:pointer; }
button:hover { filter:brightness(1.1); }
button.ghost { background:var(--panel2); border:1px solid var(--line); color:var(--txt); }
#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); }
.sshbox { background:var(--panel2); border:1px solid var(--line); border-radius:8px; padding:10px 12px; font-family:ui-monospace,monospace; font-size:12px; overflow-x:auto; }
/* login */
#loginScreen { max-width:420px; margin:80px auto; }
#loginScreen .card { padding:32px; }
#loginScreen h2 { text-align:center; margin-bottom:6px; }
#loginScreen .hint { text-align:center; color:var(--dim); font-size:12px; margin-bottom:18px; }
.login-err { color:var(--red); font-size:13px; margin-top:10px; display:none; }
/* tabs */
.tabs { display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap; }
.tabs button { background:var(--panel2); border:1px solid var(--line); color:var(--dim); font-weight:500; }
.tabs button.active { background:linear-gradient(135deg,#38bdf8,#6366f1); color:#fff; border-color:transparent; }
.view { display:none; }
.view.active { display:block; }
/* terminal */
#termWrap { background:#0d1117; border:1px solid var(--line); border-radius:10px; padding:8px; height:480px; }
#term { height:100%; }
.term-toolbar { display:flex; gap:8px; align-items:center; margin-bottom:10px; flex-wrap:wrap; }
#termStatus { font-size:12px; color:var(--dim); }
code { background:var(--panel2); padding:2px 6px; border-radius:5px; font-size:12px; }
.guide h3 { color:var(--purple); margin:18px 0 8px; font-size:14px; }
.guide p, .guide li { font-size:13px; color:#c9d4e3; line-height:1.7; }
.guide ol, .guide ul { padding-left:20px; }
.login-note { font-size:12px; color:var(--dim); margin-top:14px; text-align:center; }
.login-note a { color:var(--accent); }
/* activity feed */
.feed { display:flex; flex-direction:column; gap:10px; max-height:420px; overflow-y:auto; }
.feed-item { background:var(--panel2); border:1px solid var(--line); border-left:3px solid var(--line); border-radius:10px; padding:10px 14px; font-size:13px; line-height:1.5; }
.feed-item.in { border-left-color:var(--red); }
.feed-item.out { border-left-color:var(--green); }
.feed-item.ok { border-left-color:var(--green); }
.feed-item.fail { border-left-color:var(--red); }
.feed-time { color:var(--dim); font-size:11px; margin-left:8px; }
.feed-tag { font-size:11px; border-radius:5px; padding:1px 7px; margin-right:6px; font-weight:700; }
.tag-in { background:#3a1414; color:var(--red); }
.tag-out { background:#06352a; color:var(--green); }
code { background:var(--panel2); padding:2px 6px; border-radius:5px; font-size:12px; }
.lb-row { display:flex; align-items:center; gap:12px; padding:10px 12px; border-bottom:1px solid var(--line); font-size:14px; }
.lb-row.me { background:rgba(56,189,248,.08); border-left:3px solid var(--accent); }
.lb-rank { width:28px; font-weight:800; color:var(--dim); }
.lb-name { flex:1; font-weight:600; }
.lb-name small { color:var(--dim); font-weight:400; margin-left:6px; }
.lb-solves { background:var(--panel2); border:1px solid var(--line); border-radius:20px; padding:3px 12px; font-size:12px; color:var(--green); }
.lb-empty { color:var(--dim); font-size:13px; padding:20px; text-align:center; }
.svc-health { display:flex; flex-wrap:wrap; gap:8px; margin-top:12px; }
.svc-chip { border:1px solid var(--line); border-radius:8px; padding:4px 10px; font-size:12px; background:var(--panel2); }
.svc-chip.up { border-color:var(--green); color:var(--green); }
.svc-chip.down { border-color:var(--red); color:var(--red); }
</style>
</head>
<body>
@@ -43,70 +94,275 @@
<div class="sub" id="teamDomain"></div>
</div>
<span class="badge" id="teamStatus">…</span>
<button class="ghost" id="logoutBtn" style="display:none" onclick="logout()">Keluar</button>
</header>
<div class="card">
<h2>🌐 Akses Server Tim Kamu</h2>
<div id="accessBox" class="sshbox">Memuat…</div>
<div style="margin-top:12px;font-size:12px;color:var(--dim)">
Tiap challenge punya port sendiri. SSH login: <b>ctfuser</b> + password dari panel panitia.
<!-- LOGIN -->
<div id="loginScreen">
<div class="card">
<h2>🔐 Login Portal Tim</h2>
<div class="hint" id="loginHint">Masukkan <b>password SSH tim</b> kamu (dari panitia)</div>
<label>Password Tim</label>
<input type="password" id="loginPass" placeholder="password SSH tim" autocomplete="off">
<button onclick="doLogin()" style="width:100%;margin-top:16px">Masuk Portal</button>
<div class="login-err" id="loginErr"></div>
<div class="login-note">Belum punya password? Hubungi panitia.<br>Tutorial SSH: <a href="/team/0/guide" data-guide-link>baca panduan</a></div>
</div>
</div>
<div class="card">
<h2>📊 Status Challenge</h2>
<table>
<thead><tr><th>Challenge</th><th>Port</th><th>SSH</th><th>Status</th></tr></thead>
<tbody id="statusBody"></tbody>
</table>
</div>
<!-- PORTAL -->
<div id="portalScreen" style="display:none">
<div class="tabs">
<button data-view="term" onclick="showView('term')">🖥️ Terminal SSH</button>
<button data-view="targets" onclick="showView('targets')">🎯 Target Musuh</button>
<button data-view="submit" onclick="showView('submit')">🚩 Submit Flag</button>
<button data-view="activity" onclick="showView('activity')">📡 Aktivitas</button>
<button data-view="leaderboard" onclick="showView('leaderboard')">🏆 Leaderboard</button>
<button data-view="sla" onclick="showView('sla'); loadSla()">📊 SLA & Skor</button>
<button data-view="guide" onclick="showView('guide')">📖 Panduan SSH</button>
</div>
<div class="card">
<h2>🚩 Submit Flag</h2>
<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 class="view active" id="view-term">
<div class="card">
<h2>🖥️ SSH Terminal — pilih challenge</h2>
<div class="term-toolbar">
<select id="termChall" style="width:200px" onchange="connectTerm()">
<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>
<button class="ghost" onclick="connectTerm()">🔄 Sambung</button>
<span id="termStatus">Belum tersambung</span>
</div>
<div id="termWrap"><div id="term"></div></div>
<div style="margin-top:10px;font-size:12px;color:var(--dim)">
SSH otomatis login sebagai <code>ctfuser</code> ke container challenge timmu. Koneksi diputus setelah idle.
</div>
</div>
</div>
<div class="view" id="view-targets">
<div class="card">
<h2>🎯 Target Musuh</h2>
<p style="font-size:13px;color:var(--dim);margin-bottom:12px">Service challenge tim lain — inilah yang harus kamu attack & curi flagnya.</p>
<div id="targetList" class="sshbox">Memuat…</div>
</div>
</div>
<div class="view" id="view-submit">
<div class="card">
<h2>🚩 Submit Flag</h2>
<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>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>
</div>
</div>
<div class="view" id="view-activity">
<div class="card">
<h2>📡 Aktivitas &amp; Serangan</h2>
<p style="font-size:13px;color:var(--dim);margin-bottom:12px">
🔴 <b>masuk</b> = ada yang menyerang service timmu · 🟢 <b>keluar</b> = kamu menyerang tim lain.
Update otomatis tiap 15 detik.
</p>
<div id="activityFeed" class="feed"><div class="lb-empty">Memuat aktivitas…</div></div>
<div class="svc-health" id="svcHealth"></div>
</div>
</div>
<div class="view" id="view-leaderboard">
<div class="card">
<h2>🏆 Leaderboard</h2>
<p style="font-size:13px;color:var(--dim);margin-bottom:12px">Papan skor flag yang berhasil dicuri. Kamu = baris biru.</p>
<div id="lbList"></div>
</div>
</div>
<div class="view" id="view-sla">
<div class="card">
<h2>📊 SLA & Skor Tim</h2>
<p style="font-size:13px;color:var(--dim);margin-bottom:12px">
💚 <b>SLA</b> = service timmu hidup (dicek tiap ~30 detik) · 🏆 <b>poin</b> = dapat dari mencuri flag lawan (+100/flag) & bonus SLA saat seluruh service UP (+50).
</p>
<div id="slaList"><div class="lb-empty">Memuat…</div></div>
</div>
</div>
<div class="view" id="view-guide">
<div class="card guide">
<h2>📖 Panduan SSH & Attack</h2>
<h3>1. Apa itu SSH di sini?</h3>
<p>Setiap challenge punya service yang harus kamu <b>pertahankan</b> (defense) dan service tim lain yang harus kamu <b>serang</b> (attack). SSH container adalah akses administratif ke challenge tim kamu — untuk riset, patch, atau recovery.</p>
<h3>2. Login via Web Terminal</h3>
<ol>
<li>Buka tab <b>🖥️ Terminal SSH</b>.</li>
<li>Pilih challenge (misal <code>blogpost</code>).</li>
<li>Klik <b>Sambung</b> — otomatis login sebagai <code>ctfuser</code>.</li>
</ol>
<h3>3. Login via SSH biasa (opsional)</h3>
<div class="sshbox">ssh ctfuser@43.134.105.109 -p &lt;PORT_SSH&gt;
# password = password tim kamu</div>
<h3>4. Command yang berguna</h3>
<ul>
<li><code>cat /flag.txt</code> — flag <b>tim kamu sendiri</b> (jangan bocorin!)</li>
<li><code>ls /app /srv /opt</code> — cari source code challenge</li>
<li><code>ps aux</code> — lihat proses service</li>
<li><code>cat /flag.txt | nc &lt;ip_tim_lawan&gt; &lt;port_chall&gt;</code> — kirim flag kamu 🤫</li>
</ul>
<h3>5. Alur Attack</h3>
<ol>
<li>Cari vuln di challenge (SSTI, path traversal, crypto oracle, dll).</li>
<li>Exploit service <b>tim lawan</b> — lihat tab <b>🎯 Target Musuh</b>.</li>
<li>Baca flag dari container/service lawan.</li>
<li>Submit flag di tab <b>🚩 Submit Flag</b> → dapat poin.</li>
</ol>
<h3>6. Aturan</h3>
<ul>
<li>Jangan ubah flag tim sendiri (mount read-only).</li>
<li>Jangan matikan service tim sendiri — SLA dicek panitia.</li>
<li>Flag yang valid: <code>GEMASTIK18{...}</code>.</li>
</ul>
</div>
</div>
</div>
</div>
<script>
const TEAM_ID = parseInt(document.querySelector('meta[name="team-id"]').content || '0', 10);
let term = null, ws = null;
// fix all guide links immediately (no auth needed) — /team/0 would 403
document.querySelectorAll('[data-guide-link]').forEach(a => a.href = `/team/${TEAM_ID}/guide`);
async function api(url, opts) { const r = await fetch(url, opts); return r.json(); }
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function showView(v) {
document.querySelectorAll('.tabs button').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 === 'term' && term) term.focus();
if (v === 'targets') loadTargets();
if (v === 'activity') loadActivity();
if (v === 'leaderboard') loadLeaderboard();
if (v === 'sla') loadSla();
}
async function load() {
try {
const t = await api(`/api/team/${TEAM_ID}/info`);
document.getElementById('teamTitle').textContent = t.team.label || ('Team ' + TEAM_ID);
document.getElementById('teamDomain').textContent = t.team.domain || '';
document.getElementById('teamStatus').textContent = t.team.status === 'running' ? '● RUNNING' : '○ STOPPED';
const access = [];
for (const [name, p] of Object.entries(t.team.ports)) {
if (name === 'receiver' || name === 'panel') continue;
access.push(`${name}: ${window.location.hostname}:${p.chall} (ssh :${p.ssh})`);
}
document.getElementById('accessBox').textContent = access.join('\n');
// status table
const st = await api(`/api/team/${TEAM_ID}/status`);
document.getElementById('statusBody').innerHTML = (st.results || []).map(c => `
<tr><td>${esc(c.name)}</td><td>${c.port}</td><td>${c.ssh}</td>
<td><span class="dot ${c.alive ? 'up' : 'down'}"></span>${c.alive ? 'UP' : 'DOWN'}</td></tr>`).join('');
} catch (e) {
document.getElementById('accessBox').textContent = 'Gagal memuat: ' + e.message;
// ---------- auth ----------
async function checkSession() {
const s = await api(`/api/team/${TEAM_ID}/session`);
if (s.authed) {
document.getElementById('loginScreen').style.display = 'none';
document.getElementById('portalScreen').style.display = 'block';
document.getElementById('logoutBtn').style.display = '';
loadInfo();
}
}
async function doLogin() {
const pass = document.getElementById('loginPass').value;
const err = document.getElementById('loginErr');
err.style.display = 'none';
const d = await api(`/api/team/${TEAM_ID}/login`, {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({pass}),
});
if (d.ok) {
document.getElementById('loginScreen').style.display = 'none';
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';
}
}
async function logout() {
await api('/api/team/logout', {method:'POST'});
location.reload();
}
// ---------- info & targets ----------
async function loadInfo() {
try {
const t = (await api(`/api/team/${TEAM_ID}/info`)).team;
document.getElementById('teamTitle').textContent = t.label || ('Team ' + TEAM_ID);
document.getElementById('teamDomain').textContent = t.domain || '';
const st = document.getElementById('teamStatus');
if (t.status === 'running') {
st.textContent = '● RUNNING'; st.className = 'badge';
} else {
st.textContent = '○ STOPPED'; st.className = 'badge stopped';
}
document.getElementById('loginHint').innerHTML = 'Masukkan <b>password SSH tim</b> kamu (dari panitia)';
document.getElementById('guideLink').href = `/team/${TEAM_ID}/guide`;
} catch (e) {}
}
async function loadTargets() {
const box = document.getElementById('targetList');
const d = await api(`/api/team/${TEAM_ID}/targets`);
const targets = d.targets || [];
if (!targets.length) { box.textContent = 'Belum ada tim musuh.'; return; }
let html = '=== TARGET MUSUH ===\n\n';
for (const t of targets) {
html += `${esc(t.domain)}:${t.port}\n`;
}
box.textContent = html;
}
// ---------- SSH terminal ----------
function connectTerm() {
const chall = document.getElementById('termChall').value;
const status = document.getElementById('termStatus');
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/api/team/${TEAM_ID}/ssh/ws?chall=${chall}`;
if (ws) { try { ws.close(); } catch(e){} }
if (term) term.dispose();
term = new Terminal({cursorBlink:true, fontSize:13, theme:{background:'#0d1117'}});
term.open(document.getElementById('term'));
ws = new WebSocket(url);
status.textContent = 'Menghubungkan…';
ws.onopen = () => { status.textContent = `● tersambung ke ${chall} (ctfuser)`; term.focus(); };
ws.onmessage = ev => term.write(ev.data);
ws.onclose = ev => { status.textContent = '✖ terputus (' + (ev.reason || 'closed') + ')'; };
ws.onerror = () => { status.textContent = '✖ error koneksi'; };
term.onData(d => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(d);
});
term.onResize(size => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(`__resize__:${size.cols}:${size.rows}`);
});
document.getElementById('view-term').querySelector('#termWrap').style.height = '480px';
}
// ---------- submit ----------
async function submitFlag() {
const res = document.getElementById('result');
res.className = '';
@@ -114,16 +370,146 @@ 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(),
}),
});
if (d.success) { res.className = 'ok'; res.innerHTML = '✅ Flag benar!'; }
else { res.className = 'err'; res.innerHTML = '❌ ' + esc(d.error || 'Gagal'); }
if (d.success) { res.className = 'ok'; res.textContent = '✅ Flag benar! Poin masuk.'; }
else { res.className = 'err'; res.textContent = '❌ ' + (d.error || 'Gagal'); }
}
load();
setInterval(load, 15000);
// 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 */ }
}
// ---------- activity feed (attacks involving this team) ----------
async function loadActivity() {
const box = document.getElementById('activityFeed');
const healthBox = document.getElementById('svcHealth');
try {
const d = await api(`/api/team/${TEAM_ID}/activity`);
const events = d.events || [];
if (!events.length) {
box.innerHTML = '<div class="lb-empty">Belum ada aktivitas serangan. Aman… untuk sekarang 😌</div>';
} else {
box.innerHTML = events.map(e => {
const inbound = e.target === TEAM_ID;
const cls = inbound ? 'in' : 'out';
const tag = inbound
? `<span class="feed-tag tag-in">⬇ MASUK</span>${esc(e.attacker_label)} menyerang`
: `<span class="feed-tag tag-out">⬆ KELUAR</span>kamu menyerang ${esc(e.target_label)}`;
const verdict = e.success
? '<span style="color:var(--green)">✅ flag benar</span>'
: '<span style="color:var(--red)">❌ flag salah</span>';
return `<div class="feed-item ${cls}">${tag} <code>${esc(e.challenge || '')}</code> ${verdict}` +
`<span class="feed-time">${esc(e.ts_human || '')}</span></div>`;
}).join('');
}
// own service health chips (all values escaped above via esc())
try {
const st = await api(`/api/team/${TEAM_ID}/status`);
if (st.results) {
healthBox.innerHTML = st.results.map(r =>
`<span class="svc-chip ${r.alive ? 'up' : 'down'}">${esc(r.name)} ${r.alive ? '● UP' : '○ DOWN'} :${r.port}</span>`
).join('');
}
} catch (e) {}
} catch (e) {
box.innerHTML = '<div class="lb-empty">Gagal memuat aktivitas.</div>';
}
}
// ---------- SLA & scoreboard (public endpoint) ----------
async function loadSla() {
const box = document.getElementById('slaList');
try {
const d = await api('/api/public/scoreboard');
const teams = d.teams || [];
if (!teams.length) {
box.innerHTML = '<div class="lb-empty">Belum ada data SLA.</div>';
return;
}
box.innerHTML = teams.map(t => {
const isMe = t.team === TEAM_ID;
const pct = Math.round(t.sla_pct || 0);
const color = pct === 100 ? '#2ecc71' : (pct >= 60 ? '#f1c40f' : '#e74c3c');
const bar = `<div style="height:8px;background:#1e2a3a;border-radius:4px;margin:6px 0;overflow:hidden"><div style="height:100%;width:${pct}%;background:${color};transition:width .5s"></div></div>`;
return `<div class="lb-row ${isMe ? 'me' : ''}">
<span class="lb-rank" style="width:34px">${esc(t.badge || t.rank)}</span>
<span class="lb-name">${esc(t.label)} ${isMe ? '<small>(kamu)</small>' : ''}</span>
<span class="lb-solves" style="width:170px;text-align:right">
<b style="color:${color}">${t.alive}/${t.total} (${pct}%)</b>
<br><small style="color:var(--dim)">⚑ ${t.solves} flag · 🏆 ${t.points} pts</small>
</span>
</div>${bar}`;
}).join('');
} catch (e) {
box.innerHTML = '<div class="lb-empty">Gagal memuat SLA.</div>';
}
}
// auto-refresh SLA while its tab is open (every 30s)
setInterval(async () => {
const s = document.getElementById('view-sla');
if (s && s.classList.contains('active')) loadSla();
}, 30000);
// ---------- leaderboard (public endpoint) ----------
async function loadLeaderboard() {
const box = document.getElementById('lbList');
try {
const d = await api('/api/leaderboard');
const teams = d.teams || [];
if (!teams.length) {
box.innerHTML = '<div class="lb-empty">Belum ada flag yang berhasil dicuri. Jadilah yang pertama! 🚀</div>';
return;
}
const medal = ['🥇','🥈','🥉'];
box.innerHTML = teams.map((t, i) => {
const isMe = t.team === TEAM_ID;
const rank = i < 3 ? medal[i] : `<span class="lb-rank" style="width:28px">${i+1}</span>`;
return `<div class="lb-row ${isMe ? 'me' : ''}">
<span class="lb-rank">${rank}</span>
<span class="lb-name">${esc(t.name)} ${isMe ? '<small>(kamu)</small>' : ''}</span>
<span class="lb-solves">⚑ ${t.solves} solve${t.solves > 1 ? 's' : ''} · 🏆 ${t.points ?? 0} pts</span>
</div>`;
}).join('');
const found = teams.some(t => t.team === TEAM_ID);
if (!found) {
box.innerHTML += `<div class="lb-row me"><span class="lb-rank">—</span><span class="lb-name">Tim kamu <small>(kamu)</small></span><span class="lb-solves">⚑ 0 solve</span></div>`;
}
} catch (e) {
box.innerHTML = '<div class="lb-empty">Gagal memuat leaderboard.</div>';
}
}
// auto-refresh activity while its tab is open (every 15s)
setInterval(async () => {
const a = document.getElementById('view-activity');
if (a && a.classList.contains('active')) loadActivity();
}, 15000);
loadTargetDropdown();
checkSession();
</script>
</body>
</html>
+366 -42
View File
@@ -25,6 +25,7 @@ import re
import secrets
import shutil
import subprocess
import sys
import time
import uuid
from datetime import datetime
@@ -45,6 +46,88 @@ CHALLENGES = [
("warmup", 5, 27),
]
# Points system: base points earned by stealing a flag from another team's
# challenge. The SLA bonus is earned by keeping your OWN services alive.
POINTS_PER_FLAG = 100
SLA_BONUS_POINTS = 50
SLA_BONUS_MIN_ALIVE = 6 # bonus only when ALL 6 services are UP
def _load_points() -> dict:
p = TEAMS_DIR / "points.json"
if p.exists():
try:
return json.loads(p.read_text())
except Exception:
pass
return {"teams": {}}
def _save_points(data: dict):
(TEAMS_DIR / "points.json").write_text(json.dumps(data, indent=2))
def get_team_points(team_idx: int) -> int:
"""Total attack points a team has earned (from flag steals)."""
data = _load_points()
return int(data.get("teams", {}).get(str(team_idx), {}).get("points", 0))
def add_attack_points(team_idx: int, points: int, chall: str = "", target: int = 0,
ts: float = None) -> dict:
"""Award points to a team for stealing a flag. Returns updated tally."""
data = _load_points()
tid = str(team_idx)
me = data["teams"].setdefault(tid, {"points": 0, "events": []})
me["points"] = int(me.get("points", 0)) + points
me["events"].append({
"type": "attack",
"challenge": chall,
"target": target,
"points": points,
"ts": ts if ts is not None else time.time(),
"ts_human": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
})
me["events"] = me["events"][-200:]
_save_points(data)
return {"team": team_idx, "points": me["points"], "awarded": points}
def add_sla_bonus(team_idx: int, alive: int, total: int = 6) -> dict:
"""Award SLA bonus when all services are UP. Applies bonus at most once
per 5-minute window so online checks don't spam the ledger."""
data = _load_points()
tid = str(team_idx)
me = data["teams"].setdefault(tid, {"points": 0, "events": []})
now = time.time()
last = me.get("last_sla_bonus", 0)
if alive >= SLA_BONUS_MIN_ALIVE and total >= SLA_BONUS_MIN_ALIVE:
if now - last > 300: # 5 min window
me["points"] = int(me.get("points", 0)) + SLA_BONUS_POINTS
me["last_sla_bonus"] = now
me["events"].append({
"type": "sla_bonus",
"alive": alive,
"total": total,
"points": SLA_BONUS_POINTS,
"ts": now,
"ts_human": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
})
me["events"] = me["events"][-200:]
_save_points(data)
return {"team": team_idx, "points": me["points"], "awarded": SLA_BONUS_POINTS, "bonus": True}
return {"team": team_idx, "points": me["points"], "awarded": 0, "bonus": False}
def team_rank(idx: int) -> int:
"""1-based rank of team by total points."""
data = _load_points()["teams"]
rows = [(int(t), int(v.get("points", 0))) for t, v in data.items() if int(t) > 0]
rows.sort(key=lambda x: -x[1])
for i, (t, _) in enumerate(rows, 1):
if t == idx:
return i
return len(rows) + 1 # teams with 0 points rank after all scorers
def team_badge(idx: int) -> str:
"""Emoji badge for podium teams."""
r = team_rank(idx)
return {1: "👑 Juara 1", 2: "🥈 Runner-up", 3: "🥉 Peringkat 3"}.get(r, "")
PORT_BASE = 30000
STEP = 1000
@@ -67,17 +150,29 @@ def slugify(s: str) -> str:
s = re.sub(r"[\s_]+", "-", s)
return s or "team"
def create_team(idx: int, label: str = None):
"""Build a full team stack dir with unique ports/passwords."""
def create_team(idx: int, label: str = None, domain: str = None):
"""Build a full team stack dir with unique ports/passwords.
label -> team display name (leaderboard/topology)
domain -> custom subdomain host, e.g. "cyber-warriors" or
"cyber-warriors.gemastik.imrnes.team" (full host accepted).
Defaults to slugify(label).gemastik.imrnes.team.
"""
ports = team_ports(idx)
team_dir = TEAMS_DIR / f"team{idx}"
label = label or f"Tim {idx}"
slug = slugify(label)
# normalize custom domain -> host under *.gemastik.imrnes.team
host = (domain or f"{slug}.gemastik.imrnes.team").strip().lower()
host = host.replace("https://", "").replace("http://", "").rstrip("/")
if not host.endswith(".gemastik.imrnes.team"):
host = f"{host}.gemastik.imrnes.team"
slug = host.split(".")[0]
state = {
"index": idx,
"label": label,
"slug": slug,
"domain": f"{slug}.gemastik.imrnes.team",
"domain": host,
"ports": ports,
"admin_user": f"admin_team{idx}",
"admin_pass": gen_password(20),
@@ -184,6 +279,30 @@ def create_team(idx: int, label: str = None):
(team_dir / "state.json").write_text(json.dumps(state, indent=2))
return state
def update_team(idx: int, label: str = None, domain: str = None) -> dict:
"""Update a team's display name and/or custom domain (live re-route).
- label updates state.json['label'] (leaderboard/topology use this).
- domain updates slug + domain and rewrites the Traefik team route.
Returns updated state.
"""
team_dir = TEAMS_DIR / f"team{idx}"
if not (team_dir / "state.json").exists():
raise FileNotFoundError(f"Team {idx} not created")
st = json.loads((team_dir / "state.json").read_text())
if label:
st["label"] = str(label).strip()[:48] or st["label"]
if domain:
host = domain.strip().lower().replace("https://", "").replace("http://", "").rstrip("/")
if not host.endswith(".gemastik.imrnes.team"):
host = f"{host}.gemastik.imrnes.team"
st["domain"] = host
st["slug"] = host.split(".")[0]
(team_dir / "state.json").write_text(json.dumps(st, indent=2))
# re-route domain in Traefik immediately
ensure_team_domains()
return st
def start_team(idx: int):
team_dir = TEAMS_DIR / f"team{idx}"
if not (team_dir / "state.json").exists():
@@ -209,10 +328,19 @@ def set_ssh_passwords(idx: int):
cont = f"{name}_container_team{idx}"
pw = st["chall_passwords"][name]
cmd = f"echo 'ctfuser:{pw}' | chpasswd"
r = subprocess.run(["docker", "exec", cont, "sh", "-c", cmd],
capture_output=True, text=True, timeout=30)
if r.returncode != 0:
print(f"[set_ssh_passwords] {cont}: FAILED ({r.stderr.strip()[:100]})")
# retry a few times — right after `compose up`, container may still be booting
ok = False
for attempt in range(5):
r = subprocess.run(["docker", "exec", cont, "sh", "-c", cmd],
capture_output=True, text=True, timeout=30)
if r.returncode == 0:
ok = True
break
time.sleep(3)
if not ok:
print(f"[set_ssh_passwords] {cont}: FAILED after retries ({r.stderr.strip()[:100]})")
else:
print(f"[set_ssh_passwords] {cont}: OK")
def stop_team(idx: int):
team_dir = TEAMS_DIR / f"team{idx}"
@@ -226,40 +354,32 @@ def stop_team(idx: int):
return st
def _start_receiver(idx: int):
"""Launch team's receiver as a detached uvicorn process with team env."""
"""Start team's receiver via systemd service (independent of panel cgroup)."""
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))
# ensure the per-team systemd unit exists with current env
subprocess.run([sys.executable, str(BASE / "panel" / "gen_receiver_services.py"), "start"],
check=False, capture_output=True)
subprocess.run(["systemctl", "restart", f"gemastik-receiver-team{idx}.service"],
check=False, capture_output=True)
# best-effort pid bookkeeping for ps
try:
out = subprocess.run(["systemctl", "show", "-p", "MainPID", f"gemastik-receiver-team{idx}.service"],
capture_output=True, text=True).stdout
pid = out.strip().split("=")[1]
pidfile.write_text(pid)
except Exception:
pass
def _stop_receiver(idx: int):
"""Stop team's receiver via systemd service."""
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)
subprocess.run(["systemctl", "stop", f"gemastik-receiver-team{idx}.service"],
check=False, capture_output=True)
pidfile.unlink(missing_ok=True)
def team_logs(idx: int, service: str = None, tail: int = 100):
team_dir = TEAMS_DIR / f"team{idx}"
@@ -372,9 +492,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"
@@ -382,23 +508,221 @@ 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}
# NEW: award attack points (first solve only)
pts = add_attack_points(attacker_idx, POINTS_PER_FLAG, chall=chall, target=target_idx)
else:
pts = {"team": attacker_idx, "points": get_team_points(attacker_idx), "awarded": 0}
return {"success": True, "team": attacker_idx, "target": target_idx,
"challenge": chall, "points": pts}
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) and the points ledger."""
lb_path = TEAMS_DIR / "leaderboard.json"
lb_path.write_text(json.dumps({"solves": []}, indent=2))
(TEAMS_DIR / "points.json").write_text(json.dumps({"teams": {}}, indent=2))
return {"ok": True, "cleared": True}
def reset_environment() -> dict:
"""Full env reset: stop all teams, remove containers + receiver services,
delete team dirs (fresh for re-create). Keeps panel + images."""
results = []
for d in sorted(TEAMS_DIR.glob("team*")):
sf = d / "state.json"
if not sf.exists():
continue
st = json.loads(sf.read_text())
idx = st["index"]
try:
stop_team(idx) # compose down + stop receiver service + set status
except Exception as e:
results.append({"team": idx, "ok": False, "err": str(e)})
continue
# remove the per-team systemd receiver unit
subprocess.run(["systemctl", "disable", f"gemastik-receiver-team{idx}.service"],
check=False, capture_output=True)
subprocess.run(["systemctl", "stop", f"gemastik-receiver-team{idx}.service"],
check=False, capture_output=True)
unit = Path("/etc/systemd/system") / f"gemastik-receiver-team{idx}.service"
if unit.exists():
unit.unlink()
results.append({"team": idx, "ok": True})
subprocess.run(["systemctl", "daemon-reload"], check=False)
# remove team dirs entirely (fresh for re-create)
for d in sorted(TEAMS_DIR.glob("team*")):
shutil.rmtree(d, ignore_errors=True)
# wipe leaderboard too
reset_scores()
# drop team domains from Traefik (regenerate — no teams left)
try:
ensure_team_domains()
except Exception:
pass
return {"ok": True, "stopped": results, "teams_dir": str(TEAMS_DIR)}
# ---- SLA + scoring helpers ----
_SLA_CACHE = {"ts": 0, "data": None}
def _probe_one(recv_port: int, au: str, ap: str, name: str, st: dict) -> dict:
import urllib.request, urllib.error, base64
url = f"http://127.0.0.1:{recv_port}/check/{name}"
ok = False
try:
req = urllib.request.Request(url)
token = base64.b64encode(f"{au}:{ap}".encode()).decode()
req.add_header("Authorization", f"Basic {token}")
with urllib.request.urlopen(req, timeout=25) as resp:
body = resp.read().decode()
import json as _j
ok = bool(_j.loads(body).get("success")) if resp.status == 200 else False
except Exception:
ok = False
return {"name": name,
"port": st["ports"][name]["chall"],
"ssh": st["ports"][name]["ssh"],
"alive": ok}
def probe_team_sla_fast(idx: int) -> dict:
"""Probe one team's challenges. Sequential per challenge (receivers are
sync Flask; parallel probes overload them and cause false timeouts).
~30-60s worst case for 6 challs; results are cached by the refresher."""
td = TEAMS_DIR / f"team{idx}"
sf = td / "state.json"
if not sf.exists():
return {"team": idx, "alive": 0, "total": 0, "per_challenge": [], "error": "no team"}
st = json.loads(sf.read_text())
recv_port = st["ports"]["receiver"]
au, ap = st.get("admin_user", ""), st.get("admin_pass", "")
results = []
for name, _, _ in CHALLENGES:
try:
results.append(_probe_one(recv_port, au, ap, name, st))
except Exception:
results.append({"name": name, "port": 0, "ssh": 0, "alive": False})
alive = sum(1 for r in results if r["alive"])
return {"team": idx, "alive": alive, "total": len(results),
"per_challenge": results}
def _scoreboard_row(st: dict) -> dict:
"""Build one scoreboard row for a team state (runs in a worker thread)."""
idx = st["index"]
sla = probe_team_sla_fast(idx)
pts = get_team_points(idx)
solves = 0
lb_path = TEAMS_DIR / "leaderboard.json"
if lb_path.exists():
try:
lb = json.loads(lb_path.read_text())
solves = sum(1 for e in lb["solves"] if e["team"] == idx)
except Exception:
pass
return {
"team": idx,
"label": st.get("label") or f"Team {idx}",
"domain": st.get("domain") or "",
"alive": sla["alive"],
"total": sla["total"],
"sla_pct": round(100 * sla["alive"] / sla["total"], 1) if sla["total"] else 0,
"points": pts,
"solves": solves,
}
def sla_status_all() -> dict:
"""Per-team SLA (own team view) + aggregate scoreboard with points.
Reads a cache that a background thread keeps fresh (~every 30s), so the
HTTP endpoint is instant. First call (cold cache) blocks up to ~60s."""
import time as _t
if _SLA_CACHE["data"] is not None and _t.time() - _SLA_CACHE["ts"] < 60:
return _SLA_CACHE["data"]
_build_scoreboard()
return _SLA_CACHE["data"]
def _build_scoreboard() -> dict:
"""Build the scoreboard: probes teams 2-at-a-time (6 chall parallel per
team) to avoid overwhelming the receivers, then caches the result."""
import time as _t
import concurrent.futures
states = []
for d in sorted(TEAMS_DIR.glob("team*")):
sf = d / "state.json"
if sf.exists():
states.append(json.loads(sf.read_text()))
teams = []
# probe one team at a time (each team = 6 sequential chall checks)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
for row in ex.map(_scoreboard_row, states):
teams.append(row)
teams.sort(key=lambda x: (-x["points"], -x["solves"], x["team"]))
for i, t in enumerate(teams, 1):
t["rank"] = i
t["badge"] = {1: "👑 Juara 1", 2: "🥈 Runner-up", 3: "🥉 Peringkat 3"}.get(i, "")
_SLA_CACHE["ts"] = _t.time()
_SLA_CACHE["data"] = {"teams": teams, "ts": _t.time()}
return _SLA_CACHE["data"]
def start_sla_refresher():
"""Background daemon thread: keeps the SLA scoreboard cache warm."""
import threading
def _loop():
import time as _t
while True:
try:
_build_scoreboard()
except Exception:
pass
_t.sleep(30)
t = threading.Thread(target=_loop, daemon=True, name="sla-refresher")
t.start()
return t
if __name__ == "__main__":
import sys
+5
View File
@@ -0,0 +1,5 @@
# Allow apt to work on hosts whose clock is past GPG key expiry (2026+)
Acquire::AllowInsecureRepositories "true";
Acquire::AllowDowngradeToInsecureRepositories "true";
Apt::Get::AllowUnauthenticated "true";
Apt::Get::force-yes "true";
+11 -2
View File
@@ -12,15 +12,24 @@ WORKDIR /app
ENV DEBIAN_FRONTEND=noninteractive
ENV DB_PATH=/data/app.db
# Allow apt on hosts whose clock is past GPG key expiry (2026+)
COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure
# Install packages we need (exiftool, sqlite3, sshd, build tools, editor)
RUN apt-get update && \
apt-get install -y --no-install-recommends \
RUN apt-get -o Acquire::AllowInsecureRepositories=true update && \
apt-get -y --allow-unauthenticated install --no-install-recommends \
libimage-exiftool-perl \
sqlite3 \
openssh-server \
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
+5
View File
@@ -0,0 +1,5 @@
# Allow apt to work on hosts whose clock is past GPG key expiry (2026+)
Acquire::AllowInsecureRepositories "true";
Acquire::AllowDowngradeToInsecureRepositories "true";
Apt::Get::AllowUnauthenticated "true";
Apt::Get::force-yes "true";
+9 -2
View File
@@ -6,8 +6,11 @@ ARG PASSWORD=root
ENV DEBIAN_FRONTEND=noninteractive
WORKDIR /home/ctfuser/chall
RUN apt-get update && \
apt-get install -y --no-install-recommends \
# Allow apt on hosts whose clock is past GPG key expiry (2026+)
COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure
RUN apt-get -o Acquire::AllowInsecureRepositories=true update && \
apt-get -y --allow-unauthenticated install --no-install-recommends \
make \
g++ \
socat \
@@ -16,6 +19,10 @@ RUN apt-get update && \
bash \
nano \
vim \
curl \
wget \
netcat-openbsd \
git \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -m -d /home/ctfuser -s /bin/bash ctfuser && echo "ctfuser:${PASSWORD}" | chpasswd
+5
View File
@@ -0,0 +1,5 @@
# Allow apt to work on hosts whose clock is past GPG key expiry (2026+)
Acquire::AllowInsecureRepositories "true";
Acquire::AllowDowngradeToInsecureRepositories "true";
Apt::Get::AllowUnauthenticated "true";
Apt::Get::force-yes "true";
+11 -2
View File
@@ -13,15 +13,24 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
# Allow apt on hosts whose clock is past GPG key expiry (2026+)
COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure
# Install packages we need (exiftool, sqlite3, sshd, build tools, editor)
RUN apt-get update && \
apt-get install -y --no-install-recommends \
RUN apt-get -o Acquire::AllowInsecureRepositories=true update && \
apt-get -y --allow-unauthenticated install --no-install-recommends \
libimage-exiftool-perl \
sqlite3 \
openssh-server \
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
+5
View File
@@ -0,0 +1,5 @@
# Allow apt to work on hosts whose clock is past GPG key expiry (2026+)
Acquire::AllowInsecureRepositories "true";
Acquire::AllowDowngradeToInsecureRepositories "true";
Apt::Get::AllowUnauthenticated "true";
Apt::Get::force-yes "true";
+9 -1
View File
@@ -5,13 +5,21 @@ ENV DEBIAN_FRONTEND=noninteractive
ENV HOME=/home/ctfuser
WORKDIR /home/ctfuser/chall
RUN apt-get update && apt-get install -y --no-install-recommends \
# Allow apt on hosts whose clock is past GPG key expiry (2026+)
COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure
RUN apt-get -o Acquire::AllowInsecureRepositories=true update && apt-get -y --allow-unauthenticated install --no-install-recommends \
openssh-server \
build-essential \
libffi-dev \
libssl-dev \
python3-dev \
socat \
vim \
curl \
wget \
netcat-openbsd \
git \
bash \
&& rm -rf /var/lib/apt/lists/*
+5
View File
@@ -0,0 +1,5 @@
# Allow apt to work on hosts whose clock is past GPG key expiry (2026+)
Acquire::AllowInsecureRepositories "true";
Acquire::AllowDowngradeToInsecureRepositories "true";
Apt::Get::AllowUnauthenticated "true";
Apt::Get::force-yes "true";
+9 -1
View File
@@ -5,13 +5,21 @@ ENV DEBIAN_FRONTEND=noninteractive
ENV HOME=/home/ctfuser
WORKDIR /home/ctfuser/chall
RUN apt-get update && apt-get install -y --no-install-recommends \
# Allow apt on hosts whose clock is past GPG key expiry (2026+)
COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure
RUN apt-get -o Acquire::AllowInsecureRepositories=true update && apt-get -y --allow-unauthenticated install --no-install-recommends \
openssh-server \
build-essential \
libffi-dev \
libssl-dev \
python3-dev \
socat \
vim \
curl \
wget \
netcat-openbsd \
git \
bash \
&& rm -rf /var/lib/apt/lists/*
+5
View File
@@ -0,0 +1,5 @@
# Allow apt to work on hosts whose clock is past GPG key expiry (2026+)
Acquire::AllowInsecureRepositories "true";
Acquire::AllowDowngradeToInsecureRepositories "true";
Apt::Get::AllowUnauthenticated "true";
Apt::Get::force-yes "true";
+5 -2
View File
@@ -6,14 +6,17 @@ 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
# Allow apt on hosts whose clock is past GPG key expiry (2026+)
COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure
# 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 -o Acquire::AllowInsecureRepositories=true update && apt-get -y --allow-unauthenticated install nano vim git openssh-server python3 python3-pip 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
+5
View File
@@ -0,0 +1,5 @@
# Allow apt to work on hosts whose clock is past GPG key expiry (2026+)
Acquire::AllowInsecureRepositories "true";
Acquire::AllowDowngradeToInsecureRepositories "true";
Apt::Get::AllowUnauthenticated "true";
Apt::Get::force-yes "true";