2026-09-23 14:26:26 +08:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""
|
|
|
|
|
Gemastik A/D Panel — web UI for the gemastik18-final receiver.
|
|
|
|
|
Serves a dashboard at / and proxies receiver API calls server-side so the
|
|
|
|
|
admin credentials stay out of the browser.
|
|
|
|
|
"""
|
|
|
|
|
import os
|
|
|
|
|
import json
|
|
|
|
|
import time
|
2026-09-23 16:37:58 +08:00
|
|
|
import asyncio
|
2026-09-23 14:26:26 +08:00
|
|
|
import httpx
|
|
|
|
|
from pathlib import Path
|
2026-09-23 16:37:58 +08:00
|
|
|
from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect
|
2026-09-23 14:26:26 +08:00
|
|
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
|
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
2026-09-23 15:14:52 +08:00
|
|
|
import teams as orch
|
|
|
|
|
|
2026-09-23 14:26:26 +08:00
|
|
|
RECEIVER_URL = os.environ.get("RECEIVER_URL", "http://127.0.0.1:18080")
|
|
|
|
|
ADMIN_USER = os.environ.get("PANEL_ADMIN_USER", "admin")
|
|
|
|
|
ADMIN_PASS = os.environ.get("PANEL_ADMIN_PASS", "admin")
|
|
|
|
|
|
|
|
|
|
BASE_DIR = Path(__file__).parent
|
|
|
|
|
|
|
|
|
|
app = FastAPI(title="Gemastik A/D Panel")
|
|
|
|
|
|
|
|
|
|
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"},
|
|
|
|
|
{"name": "cdn", "port": 12000, "ssh": 12022, "category": "web", "desc": "CDN/image proxy SSTI"},
|
|
|
|
|
{"name": "phew", "port": 13000, "ssh": 13022, "category": "crypto","desc": "Paillier crypto oracle"},
|
|
|
|
|
{"name": "sheesh", "port": 14000, "ssh": 14022, "category": "crypto","desc": "AES padding oracle"},
|
|
|
|
|
{"name": "warmup", "port": 15000, "ssh": 15022, "category": "warmup","desc": "Go file viewer (path traversal)"},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
# Simple in-memory session tokens (good enough for a CTF ops panel)
|
|
|
|
|
_sessions = {}
|
|
|
|
|
|
|
|
|
|
def _check_basic(req: Request):
|
|
|
|
|
auth = req.headers.get("authorization", "")
|
|
|
|
|
if not auth.startswith("Basic "):
|
|
|
|
|
return None
|
|
|
|
|
import base64
|
|
|
|
|
try:
|
|
|
|
|
decoded = base64.b64decode(auth.split(" ", 1)[1]).decode()
|
|
|
|
|
user, _, pw = decoded.partition(":")
|
|
|
|
|
return (user, pw)
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def _authorized(req: Request) -> bool:
|
|
|
|
|
creds = _check_basic(req)
|
|
|
|
|
if creds and creds[0] == ADMIN_USER and creds[1] == ADMIN_PASS:
|
|
|
|
|
return True
|
|
|
|
|
# session token via cookie
|
|
|
|
|
token = req.cookies.get("panel_token")
|
|
|
|
|
return token in _sessions and _sessions[token] > time.time()
|
|
|
|
|
|
|
|
|
|
def _receiver_auth() -> tuple:
|
|
|
|
|
# Load receiver admin creds from its .env (single source of truth)
|
|
|
|
|
env_path = Path("/opt/gemastik18-final/receiver/.env")
|
|
|
|
|
u = p = ""
|
|
|
|
|
try:
|
|
|
|
|
for line in env_path.read_text().splitlines():
|
|
|
|
|
if line.startswith("ADMIN_USERNAME="):
|
|
|
|
|
u = line.split("=", 1)[1]
|
|
|
|
|
elif line.startswith("ADMIN_PASSWORD="):
|
|
|
|
|
p = line.split("=", 1)[1]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return (u, p)
|
|
|
|
|
|
|
|
|
|
async def _proxy(method: str, path: str, body: dict = None):
|
|
|
|
|
u, p = _receiver_auth()
|
|
|
|
|
async with httpx.AsyncClient(timeout=20) as client:
|
|
|
|
|
resp = await client.request(method, f"{RECEIVER_URL}{path}",
|
|
|
|
|
auth=(u, p), json=body if body is not None else None)
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
|
|
|
async def index(req: Request):
|
2026-09-23 16:37:58 +08:00
|
|
|
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"]}"')
|
2026-09-23 17:02:52 +08:00
|
|
|
html = html.replace('href="/team/0/guide"', f'href="/team/{t["index"]}/guide"')
|
2026-09-23 16:37:58 +08:00
|
|
|
return HTMLResponse(html)
|
|
|
|
|
return HTMLResponse("<h2 style='font-family:sans-serif;color:#888;padding:40px'>Team tidak ditemukan: " + slug + "</h2>", status_code=404)
|
2026-09-23 14:26:26 +08:00
|
|
|
if not _authorized(req):
|
|
|
|
|
return RedirectResponse("/login")
|
|
|
|
|
html = (BASE_DIR / "static" / "index.html").read_text()
|
|
|
|
|
return HTMLResponse(html)
|
|
|
|
|
|
|
|
|
|
@app.get("/login", response_class=HTMLResponse)
|
|
|
|
|
async def login_page(req: Request):
|
|
|
|
|
if _authorized(req):
|
|
|
|
|
return RedirectResponse("/")
|
|
|
|
|
return HTMLResponse((BASE_DIR / "static" / "login.html").read_text())
|
|
|
|
|
|
2026-09-23 15:14:52 +08:00
|
|
|
@app.get("/submit", response_class=HTMLResponse)
|
|
|
|
|
async def submit_page(req: Request):
|
|
|
|
|
"""Public flag submission page for teams (no login)."""
|
|
|
|
|
return HTMLResponse((BASE_DIR / "static" / "submit.html").read_text())
|
|
|
|
|
|
2026-09-23 16:18:11 +08:00
|
|
|
|
|
|
|
|
# ============ Per-team portal (public via <slug>.gemastik.imrnes.team) ============
|
|
|
|
|
|
|
|
|
|
@app.get("/team/{idx}", response_class=HTMLResponse)
|
|
|
|
|
async def team_portal(idx: int, req: Request):
|
2026-09-23 16:37:58 +08:00
|
|
|
"""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())
|
2026-09-23 17:02:52 +08:00
|
|
|
if not _check_team_host(req, st):
|
2026-09-23 16:37:58 +08:00
|
|
|
raise HTTPException(403, "Akses team lain tidak diizinkan")
|
2026-09-23 17:02:52 +08:00
|
|
|
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)
|
2026-09-23 16:18:11 +08:00
|
|
|
html = (BASE_DIR / "static" / "team.html").read_text()
|
|
|
|
|
html = html.replace('name="team-id" content="0"', f'name="team-id" content="{idx}"')
|
2026-09-23 17:02:52 +08:00
|
|
|
html = html.replace('href="/team/0/guide"', f'href="/team/{idx}/guide"')
|
2026-09-23 16:18:11 +08:00
|
|
|
return HTMLResponse(html)
|
|
|
|
|
|
|
|
|
|
|
2026-09-23 16:37:58 +08:00
|
|
|
@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}"
|
2026-09-23 17:02:52 +08:00
|
|
|
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):
|
2026-09-23 16:37:58 +08:00
|
|
|
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())
|
2026-09-23 17:02:52 +08:00
|
|
|
if not _check_team_host(req, st):
|
|
|
|
|
raise HTTPException(403, "Akses team lain tidak diizinkan")
|
2026-09-23 16:37:58 +08:00
|
|
|
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
|
|
|
|
|
|
2026-09-23 17:02:52 +08:00
|
|
|
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
|
|
|
|
|
|
2026-09-23 16:37:58 +08:00
|
|
|
@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):
|
2026-09-23 17:14:42 +08:00
|
|
|
"""Team targets — requires team login + own host. Only domain + port."""
|
2026-09-23 17:02:52 +08:00
|
|
|
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")
|
2026-09-23 16:37:58 +08:00
|
|
|
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({
|
2026-09-23 18:05:44 +08:00
|
|
|
"team_idx": st.get("index"),
|
|
|
|
|
"team_label": st.get("label", f"Team {st.get('index')}"),
|
2026-09-23 17:14:42 +08:00
|
|
|
"domain": st.get("domain") or (st.get("slug", f"team{st.get('index')}") + ".gemastik.imrnes.team"),
|
2026-09-23 16:37:58 +08:00
|
|
|
"port": p["chall"],
|
|
|
|
|
})
|
|
|
|
|
return {"targets": out}
|
|
|
|
|
|
2026-09-23 16:18:11 +08:00
|
|
|
@app.get("/api/team/{idx}/info")
|
2026-09-23 16:37:58 +08:00
|
|
|
async def api_team_info(idx: int, req: Request):
|
2026-09-23 17:02:52 +08:00
|
|
|
"""Team portal info — requires team login + own host; no admin secrets."""
|
2026-09-23 16:18:11 +08:00
|
|
|
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())
|
2026-09-23 17:02:52 +08:00
|
|
|
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")
|
2026-09-23 16:18:11 +08:00
|
|
|
return {"team": {
|
|
|
|
|
"index": st.get("index"),
|
|
|
|
|
"label": st.get("label"),
|
|
|
|
|
"slug": st.get("slug"),
|
|
|
|
|
"domain": st.get("domain"),
|
|
|
|
|
"status": st.get("status"),
|
|
|
|
|
"ports": st.get("ports"),
|
|
|
|
|
"ssh_user": st.get("ssh_user"),
|
2026-09-23 16:37:58 +08:00
|
|
|
"ssh_pass": st.get("ssh_pass"), # same password used to login portal + SSH
|
2026-09-23 16:18:11 +08:00
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/team/{idx}/status")
|
2026-09-23 17:02:52 +08:00
|
|
|
async def api_team_status(idx: int, req: Request):
|
|
|
|
|
"""SLA status for a team's challenges (read-only health, own-host only)."""
|
2026-09-23 16:18:11 +08:00
|
|
|
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())
|
2026-09-23 17:02:52 +08:00
|
|
|
if not _check_team_host(req, st):
|
|
|
|
|
raise HTTPException(403, "Akses team lain tidak diizinkan")
|
2026-09-23 16:18:11 +08:00
|
|
|
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", "")
|
|
|
|
|
results = []
|
|
|
|
|
for name, coff, soff in orch.CHALLENGES:
|
|
|
|
|
try:
|
|
|
|
|
async with httpx.AsyncClient(timeout=8) as client:
|
|
|
|
|
resp = await client.get(f"http://127.0.0.1:{recv_port}/check/{name}",
|
|
|
|
|
auth=(au, ap))
|
|
|
|
|
ok = bool(resp.json().get("success")) if resp.status_code == 200 else False
|
|
|
|
|
except Exception:
|
|
|
|
|
ok = False
|
|
|
|
|
results.append({"name": name, "port": st["ports"][name]["chall"],
|
|
|
|
|
"ssh": st["ports"][name]["ssh"], "alive": ok})
|
|
|
|
|
return {"results": results}
|
|
|
|
|
|
2026-09-23 22:56:07 +08:00
|
|
|
@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}"}
|
|
|
|
|
|
2026-09-23 14:26:26 +08:00
|
|
|
@app.post("/api/login")
|
|
|
|
|
async def api_login(req: Request):
|
|
|
|
|
data = await req.json()
|
|
|
|
|
if data.get("user") == ADMIN_USER and data.get("pass") == ADMIN_PASS:
|
|
|
|
|
token = os.urandom(16).hex()
|
|
|
|
|
_sessions[token] = time.time() + 8 * 3600
|
|
|
|
|
resp = JSONResponse({"ok": True})
|
|
|
|
|
resp.set_cookie("panel_token", token, httponly=True, samesite="lax", max_age=8 * 3600)
|
|
|
|
|
return resp
|
|
|
|
|
raise HTTPException(401, "Invalid credentials")
|
|
|
|
|
|
|
|
|
|
@app.post("/api/logout")
|
|
|
|
|
async def api_logout(req: Request):
|
|
|
|
|
token = req.cookies.get("panel_token")
|
|
|
|
|
if token:
|
|
|
|
|
_sessions.pop(token, None)
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
def require_login(req: Request):
|
|
|
|
|
if not _authorized(req):
|
|
|
|
|
raise HTTPException(401, "Not authorized")
|
|
|
|
|
|
|
|
|
|
# ---- receiver proxy endpoints (server-side, keeps admin creds secret) ----
|
|
|
|
|
|
|
|
|
|
@app.get("/api/challenges")
|
|
|
|
|
async def api_challenges(req: Request):
|
|
|
|
|
require_login(req)
|
|
|
|
|
return {"challenges": CHALLENGES}
|
|
|
|
|
|
|
|
|
|
@app.get("/api/status")
|
|
|
|
|
async def api_status(req: Request):
|
|
|
|
|
require_login(req)
|
|
|
|
|
results = []
|
|
|
|
|
for ch in CHALLENGES:
|
|
|
|
|
try:
|
|
|
|
|
resp = await _proxy("GET", f"/check/{ch['name']}")
|
|
|
|
|
ok = bool(resp.json().get("success")) if resp.status_code == 200 else False
|
|
|
|
|
except Exception as e:
|
|
|
|
|
ok = False
|
|
|
|
|
# read host flag file
|
|
|
|
|
flag = ""
|
|
|
|
|
try:
|
|
|
|
|
fp = Path(f"/opt/gemastik18-final/receiver/flags/{ch['name']}.txt")
|
|
|
|
|
if fp.exists():
|
|
|
|
|
flag = fp.read_text().strip()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
results.append({**ch, "alive": ok, "flag": flag})
|
|
|
|
|
return {"results": results, "ts": int(time.time())}
|
|
|
|
|
|
|
|
|
|
@app.post("/api/flag")
|
|
|
|
|
async def api_flag(req: Request):
|
|
|
|
|
require_login(req)
|
|
|
|
|
data = await req.json()
|
|
|
|
|
challenge = data.get("challenge", "")
|
|
|
|
|
flag = data.get("flag", "")
|
|
|
|
|
if challenge not in [c["name"] for c in CHALLENGES]:
|
|
|
|
|
raise HTTPException(400, "Unknown challenge")
|
|
|
|
|
if not flag:
|
|
|
|
|
raise HTTPException(400, "Flag is empty")
|
|
|
|
|
resp = await _proxy("POST", "/flag", {"challenge": challenge, "flag": flag})
|
|
|
|
|
return {"receiver_status": resp.status_code, "receiver_body": resp.text}
|
|
|
|
|
|
|
|
|
|
@app.post("/api/restart/{challenge}")
|
|
|
|
|
async def api_restart(challenge: str, req: Request):
|
|
|
|
|
require_login(req)
|
|
|
|
|
resp = await _proxy("GET", f"/restart/{challenge}")
|
|
|
|
|
return {"receiver_status": resp.status_code, "receiver_body": resp.text}
|
|
|
|
|
|
|
|
|
|
@app.post("/api/rollback/{challenge}")
|
|
|
|
|
async def api_rollback(challenge: str, req: Request):
|
|
|
|
|
require_login(req)
|
|
|
|
|
resp = await _proxy("GET", f"/rollback/{challenge}")
|
|
|
|
|
return {"receiver_status": resp.status_code, "receiver_body": resp.text}
|
|
|
|
|
|
|
|
|
|
@app.post("/api/activate/{challenge}")
|
|
|
|
|
async def api_activate(challenge: str, req: Request):
|
|
|
|
|
require_login(req)
|
|
|
|
|
resp = await _proxy("GET", f"/activate/{challenge}")
|
|
|
|
|
return {"receiver_status": resp.status_code, "receiver_body": resp.text}
|
|
|
|
|
|
|
|
|
|
@app.post("/api/deactivate/{challenge}")
|
|
|
|
|
async def api_deactivate(challenge: str, req: Request):
|
|
|
|
|
require_login(req)
|
|
|
|
|
resp = await _proxy("GET", f"/deactivate/{challenge}")
|
|
|
|
|
return {"receiver_status": resp.status_code, "receiver_body": resp.text}
|
|
|
|
|
|
|
|
|
|
@app.get("/api/credential/{challenge}")
|
|
|
|
|
async def api_credential(challenge: str, req: Request):
|
|
|
|
|
require_login(req)
|
|
|
|
|
resp = await _proxy("GET", f"/credential/{challenge}")
|
|
|
|
|
if resp.status_code == 200:
|
|
|
|
|
return resp.json()
|
|
|
|
|
return {"error": resp.text}
|
|
|
|
|
|
|
|
|
|
@app.get("/api/history")
|
|
|
|
|
async def api_history(req: Request):
|
|
|
|
|
require_login(req)
|
|
|
|
|
try:
|
|
|
|
|
lines = (BASE_DIR.parent / "history" / "command.txt").read_text().splitlines()
|
|
|
|
|
except Exception:
|
|
|
|
|
lines = []
|
2026-09-23 15:14:52 +08:00
|
|
|
return {"lines": lines[-200:]}
|
|
|
|
|
|
|
|
|
|
# ============ Multi-team orchestrator endpoints ============
|
|
|
|
|
|
|
|
|
|
@app.get("/api/teams")
|
|
|
|
|
async def api_teams(req: Request):
|
|
|
|
|
require_login(req)
|
|
|
|
|
return {"teams": orch.list_teams()}
|
|
|
|
|
|
|
|
|
|
@app.post("/api/teams/set")
|
|
|
|
|
async def api_teams_set(req: Request):
|
2026-09-23 21:50:16 +08:00
|
|
|
"""Set/create N teams (idempotent: creates missing, keeps existing).
|
|
|
|
|
|
|
|
|
|
body: {count, labels: {"1": "Tim Satu"}, domains: {"1": "tim-satu"}}"""
|
2026-09-23 15:14:52 +08:00
|
|
|
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")
|
2026-09-23 16:18:11 +08:00
|
|
|
labels = data.get("labels") or {} # { "1": "Tim Satu", ... }
|
2026-09-23 21:50:16 +08:00
|
|
|
domains = data.get("domains") or {} # { "1": "mycustom", ... }
|
2026-09-23 15:14:52 +08:00
|
|
|
created = []
|
|
|
|
|
for i in range(1, n + 1):
|
|
|
|
|
td = orch.TEAMS_DIR / f"team{i}"
|
|
|
|
|
if not td.exists():
|
2026-09-23 16:18:11 +08:00
|
|
|
label = labels.get(str(i)) or labels.get(i) or f"Tim {i}"
|
2026-09-23 21:50:16 +08:00
|
|
|
dom = domains.get(str(i)) or domains.get(i) or None
|
|
|
|
|
st = orch.create_team(i, label, domain=dom)
|
2026-09-23 15:14:52 +08:00
|
|
|
created.append(st["index"])
|
2026-09-23 21:50:16 +08:00
|
|
|
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)
|
2026-09-23 16:18:11 +08:00
|
|
|
orch.ensure_team_domains()
|
2026-09-23 15:14:52 +08:00
|
|
|
return {"created": created, "total": len(orch.list_teams())}
|
|
|
|
|
|
2026-09-23 21:50:16 +08:00
|
|
|
@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))
|
|
|
|
|
|
2026-09-23 15:14:52 +08:00
|
|
|
@app.post("/api/teams/start")
|
|
|
|
|
async def api_teams_start(req: Request):
|
|
|
|
|
require_login(req)
|
|
|
|
|
data = await req.json()
|
|
|
|
|
idx = data.get("index")
|
|
|
|
|
if idx is None:
|
|
|
|
|
# start all
|
|
|
|
|
results = []
|
|
|
|
|
for t in orch.list_teams():
|
|
|
|
|
try:
|
|
|
|
|
results.append({"team": t["index"], "ok": True})
|
|
|
|
|
orch.start_team(t["index"])
|
|
|
|
|
except Exception as e:
|
|
|
|
|
results.append({"team": t["index"], "ok": False, "err": str(e)})
|
|
|
|
|
return {"results": results}
|
|
|
|
|
try:
|
|
|
|
|
st = orch.start_team(int(idx))
|
|
|
|
|
return {"ok": True, "team": st}
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
|
|
|
|
@app.post("/api/teams/stop")
|
|
|
|
|
async def api_teams_stop(req: Request):
|
|
|
|
|
require_login(req)
|
|
|
|
|
data = await req.json()
|
|
|
|
|
idx = data.get("index")
|
|
|
|
|
if idx is None:
|
|
|
|
|
results = []
|
|
|
|
|
for t in orch.list_teams():
|
|
|
|
|
try:
|
|
|
|
|
orch.stop_team(t["index"])
|
|
|
|
|
results.append({"team": t["index"], "ok": True})
|
|
|
|
|
except Exception as e:
|
|
|
|
|
results.append({"team": t["index"], "ok": False, "err": str(e)})
|
|
|
|
|
return {"results": results}
|
|
|
|
|
try:
|
|
|
|
|
st = orch.stop_team(int(idx))
|
|
|
|
|
return {"ok": True, "team": st}
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
|
|
|
|
@app.get("/api/teams/{idx}/logs")
|
|
|
|
|
async def api_team_logs(idx: int, req: Request, service: Optional[str] = None, tail: int = 100):
|
|
|
|
|
require_login(req)
|
|
|
|
|
try:
|
|
|
|
|
logs = orch.team_logs(idx, service, tail)
|
|
|
|
|
return {"team": idx, "logs": logs}
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
|
|
|
|
@app.get("/api/teams/{idx}/creds")
|
|
|
|
|
async def api_team_creds(idx: int, req: Request):
|
|
|
|
|
require_login(req)
|
|
|
|
|
try:
|
|
|
|
|
td = orch.TEAMS_DIR / f"team{idx}"
|
|
|
|
|
st = json.loads((td / "state.json").read_text())
|
|
|
|
|
return {"team": st}
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise HTTPException(404, str(e))
|
|
|
|
|
|
|
|
|
|
@app.get("/api/topology")
|
|
|
|
|
async def api_topology(req: Request):
|
|
|
|
|
"""Return topology graph data (nodes + edges) for the UI."""
|
|
|
|
|
require_login(req)
|
|
|
|
|
teams = orch.list_teams()
|
|
|
|
|
nodes = [
|
|
|
|
|
{"id": "panel", "label": "Panel A/D", "type": "panel", "url": "https://panel.gemastik.imrnes.team"},
|
|
|
|
|
{"id": "traefik", "label": "Traefik / Coolify", "type": "infra"},
|
|
|
|
|
{"id": "dns", "label": "*.imrnes.team → 43.134.105.109", "type": "infra"},
|
|
|
|
|
]
|
|
|
|
|
edges = [{"from": "dns", "to": "traefik"}, {"from": "traefik", "to": "panel"}]
|
|
|
|
|
for t in teams:
|
|
|
|
|
nid = f"team{t['index']}"
|
|
|
|
|
nodes.append({
|
|
|
|
|
"id": nid, "label": t.get("label", f"Team {t['index']}"),
|
|
|
|
|
"type": "team", "index": t["index"], "status": t.get("status", "unknown"),
|
|
|
|
|
"receiver_port": t["ports"]["receiver"], "ssh_pass": t.get("ssh_pass", ""),
|
|
|
|
|
})
|
|
|
|
|
edges.append({"from": "traefik", "to": nid, "label": f":{t['ports']['receiver']}"})
|
|
|
|
|
for name, coff, soff in orch.CHALLENGES:
|
|
|
|
|
cn = f"team{t['index']}-{name}"
|
|
|
|
|
nodes.append({"id": cn, "label": f"{name}", "type": "challenge",
|
|
|
|
|
"port": t["ports"][name]["chall"], "ssh": t["ports"][name]["ssh"]})
|
|
|
|
|
edges.append({"from": nid, "to": cn, "label": f":{t['ports'][name]['chall']}"})
|
|
|
|
|
return {"nodes": nodes, "edges": edges}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============ Flag randomization + team submission ============
|
|
|
|
|
|
|
|
|
|
@app.post("/api/teams/{idx}/randomize")
|
|
|
|
|
async def api_randomize(idx: int, req: Request):
|
|
|
|
|
"""Randomize all flags for a team (recreates containers to pick up new flags)."""
|
|
|
|
|
require_login(req)
|
|
|
|
|
try:
|
|
|
|
|
mapping = orch.randomize_flags(idx)
|
|
|
|
|
return {"ok": True, "team": idx, "flags": mapping}
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
|
|
|
|
|
2026-09-23 17:14:42 +08:00
|
|
|
@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()
|
|
|
|
|
|
|
|
|
|
|
2026-09-23 15:14:52 +08:00
|
|
|
@app.post("/api/flag/submit")
|
|
|
|
|
async def api_flag_submit(req: Request):
|
2026-09-23 18:05:44 +08:00
|
|
|
"""Public endpoint: teams submit flags. No login needed.
|
|
|
|
|
body: {team: attacker, target?: victim, challenge, flag}"""
|
2026-09-23 15:14:52 +08:00
|
|
|
data = await req.json()
|
2026-09-23 18:05:44 +08:00
|
|
|
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)
|
2026-09-23 15:14:52 +08:00
|
|
|
chall = data.get("challenge", "")
|
|
|
|
|
flag = data.get("flag", "").strip()
|
|
|
|
|
team_name = data.get("team_name", "").strip()[:64] or f"Team {team}"
|
|
|
|
|
if team <= 0:
|
|
|
|
|
return {"success": False, "error": "Select your team"}
|
|
|
|
|
if chall not in [c[0] for c in orch.CHALLENGES]:
|
|
|
|
|
return {"success": False, "error": "Challenge not found"}
|
|
|
|
|
if not flag:
|
|
|
|
|
return {"success": False, "error": "Flag is required"}
|
2026-09-23 18:05:44 +08:00
|
|
|
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", [])}
|
2026-09-23 15:14:52 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/leaderboard")
|
|
|
|
|
async def api_leaderboard(req: Request):
|
2026-09-23 21:50:16 +08:00
|
|
|
"""Leaderboard of solves so far. Team names resolved LIVE from state.json
|
|
|
|
|
so renaming a team updates leaderboard + topology everywhere."""
|
2026-09-23 15:14:52 +08:00
|
|
|
lb_path = orch.TEAMS_DIR / "leaderboard.json"
|
|
|
|
|
if lb_path.exists():
|
|
|
|
|
lb = json.loads(lb_path.read_text())
|
|
|
|
|
else:
|
|
|
|
|
lb = {"solves": []}
|
2026-09-23 21:50:16 +08:00
|
|
|
# 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}"
|
2026-09-23 15:14:52 +08:00
|
|
|
# aggregate per team
|
|
|
|
|
teams = {}
|
|
|
|
|
for e in lb["solves"]:
|
2026-09-23 21:50:16 +08:00
|
|
|
name = team_name(e["team"])
|
|
|
|
|
t = teams.setdefault(e["team"], {"team": e["team"], "name": name, "solves": 0, "challs": []})
|
|
|
|
|
t["name"] = name
|
2026-09-23 15:14:52 +08:00
|
|
|
t["solves"] += 1
|
|
|
|
|
t["challs"].append(e["challenge"])
|
|
|
|
|
return {"solves": lb["solves"], "teams": sorted(teams.values(), key=lambda x: -x["solves"])}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/public/teams")
|
|
|
|
|
async def api_public_teams():
|
|
|
|
|
"""Public list of team names (for the submit dropdown)."""
|
2026-09-23 16:37:58 +08:00
|
|
|
return {"teams": [{"index": t["index"], "label": t.get("label", f"Team {t['index']}")} for t in orch.list_teams()]}
|
|
|
|
|
|
2026-09-23 17:14:42 +08:00
|
|
|
@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}
|
|
|
|
|
|
2026-09-23 16:37:58 +08:00
|
|
|
|
|
|
|
|
# ============ 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())
|
2026-09-23 17:02:52 +08:00
|
|
|
# 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
|
2026-09-23 16:37:58 +08:00
|
|
|
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
|