diff --git a/panel/main.py b/panel/main.py index dcaedcf..5a69c37 100644 --- a/panel/main.py +++ b/panel/main.py @@ -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 @@ -79,6 +80,16 @@ 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: .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"]}"') + return HTMLResponse(html) + return HTMLResponse("

Team tidak ditemukan: " + slug + "

", status_code=404) if not _authorized(req): return RedirectResponse("/login") html = (BASE_DIR / "static" / "index.html").read_text() @@ -100,20 +111,109 @@ 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 .gemastik.imrnes.team/team/).""" + """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()) + host = (req.headers.get("host") or "").split(":")[0] + # host check: allow panel domain (uses explicit /team/N link for admin preview) + # and the team's own .domain; block cross-team access. + if not (host == f"panel.gemastik.imrnes.team" or host == f"gemastik.imrnes.team" + or host == st.get("domain") or host.startswith("127.0.0.1") or host.startswith("localhost")): + raise HTTPException(403, "Akses team lain tidak diizinkan") 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("/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}" + st = json.loads((td / "state.json").read_text()) if (td / "state.json").exists() else {} + host = (req.headers.get("host") or "").split(":")[0] + if not (host == f"panel.gemastik.imrnes.team" or host == f"gemastik.imrnes.team" + or host == st.get("domain") or host.startswith("127.0.0.1") or host.startswith("localhost")): + 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()) + 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 + +@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): + """Public: list of enemy teams' services (attack targets) for this team's portal.""" + 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": st.get("label", f"Team {st.get('index')}"), + "team_idx": st.get("index"), + "domain": st.get("domain"), + "challenge": name, + "port": p["chall"], + "ssh": p["ssh"], + }) + return {"targets": out} + @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).""" +async def api_team_info(idx: int, req: Request): + """Team portal info β€” requires team login; no admin secrets.""" + if not _team_authorized(req, idx): + raise HTTPException(401, "Login portal team dulu") 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 return {"team": { "index": st.get("index"), "label": st.get("label"), @@ -122,7 +222,7 @@ 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 }} @@ -416,4 +516,95 @@ async def api_leaderboard(req: Request): @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()]} \ No newline at end of file + return {"teams": [{"index": t["index"], "label": t.get("label", f"Team {t['index']}")} for t in orch.list_teams()]} + + +# ============ 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()) + 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 \ No newline at end of file diff --git a/panel/static/guide.html b/panel/static/guide.html new file mode 100644 index 0000000..86123b7 --- /dev/null +++ b/panel/static/guide.html @@ -0,0 +1,108 @@ + + + + + + +Panduan SSH β€” Gemastik A/D + + + +
+
+ +
+

πŸ“– Panduan SSH & Attack

+
Gemastik 18 Final β€” Attack & Defense
+
+
+ +
+

🎯 Konsep Attack & Defense

+

Setiap tim punya 6 challenge container yang harus dijaga (defense) dan bisa menyerang container tim lain (attack). Setiap container menyimpan flag yang nilainya dicek panitia secara berkala (SLA). Kalau service mati atau flag berubah, tim kamu kehilangan poin SLA.

+

Kamu menang dengan: (1) menjaga service tetap hidup, (2) mencuri flag dari tim lawan dan submit, (3) mencegah tim lawan mencuri flag kamu.

+
+ +
+

πŸ” Login SSH

+

Cara 1 β€” Web Terminal di Portal Tim

+
    +
  1. Buka portal tim kamu (domain dari panitia).
  2. +
  3. Login dengan password SSH tim.
  4. +
  5. Tab πŸ–₯️ Terminal SSH β†’ pilih challenge β†’ Sambung.
  6. +
  7. Langsung masuk sebagai ctfuser β€” tanpa perlu aplikasi SSH.
  8. +
+

Cara 2 β€” SSH Client (opsional)

+
ssh ctfuser@43.134.105.109 -p <PORT_SSH>
+

Contoh: untuk challenge blogpost tim 1, port SSH = 31022.

+
+ +
+

🧭 Command Penting

+ + + + + + + + +
CommandFungsi
cat /flag.txtLihat flag tim kamu sendiri
ls /app /srv /opt /homeCari file source/service
ps auxLihat proses yang berjalan
ss -tlnpLihat port yang dibuka service
systemctl statusCek service (kalau ada)
cat /etc/passwdDaftar user lokal
+
+ +
+

βš”οΈ Alur Attack

+
    +
  1. Buka tab 🎯 Target Musuh di portal β€” lihat daftar domain/port tim lain.
  2. +
  3. Analisa service lawan dari source code di container kamu sendiri (biasanya sama).
  4. +
  5. Cari vuln (SSTI, path traversal, RCE, crypto oracle, dsb).
  6. +
  7. Exploit service lawan β†’ baca /flag.txt mereka.
  8. +
  9. Submit flag di tab 🚩 Submit Flag β†’ poin masuk ke leaderboard.
  10. +
+
⚠️ Jangan sampai flag tim kamu bocor! Ganti password SSH rutin (via panitia), dan jangan tinggalkan flag di tempat umum.
+
+ +
+

πŸ›‘οΈ Alur Defense

+
    +
  1. Pastikan service selalu hidup β€” SLA dicek panitia berkala.
  2. +
  3. Patch vuln yang bisa dipakai lawan (jika tahu).
  4. +
  5. Jangan ubah /flag.txt β€” mount read-only, tidak bisa diubah dari dalam container.
  6. +
  7. Pantau ps aux / log β€” kalau ada aktivitas mencurigakan, restart via panitia.
  8. +
+
+ +
+

πŸ“ž Butuh Bantuan?

+

Hubungi panitia untuk: reset password SSH, restart container, atau lapor service down. Portal ini untuk peserta β€” panel admin terpisah.

+

← Kembali ke portal tim

+
+
+ + + + \ No newline at end of file diff --git a/panel/static/index.html b/panel/static/index.html index 3124cd5..0762f34 100644 --- a/panel/static/index.html +++ b/panel/static/index.html @@ -238,7 +238,7 @@ function toast(msg, err=false) { } function esc(s) { - return (s||'').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } function showView(v) { diff --git a/panel/static/team.html b/panel/static/team.html index dd0d751..ce3113d 100644 --- a/panel/static/team.html +++ b/panel/static/team.html @@ -3,17 +3,21 @@ -Portal Team β€” Gemastik A/D + +Portal Tim β€” Gemastik A/D + + @@ -43,70 +71,233 @@
… + -
-

🌐 Akses Server Tim Kamu

-
Memuat…
-
- Tiap challenge punya port sendiri. SSH login: ctfuser + password dari panel panitia. + +
+
+

πŸ” Login Portal Tim

+
Masukkan password SSH tim kamu (dari panitia)
+ + + + +
-
-

πŸ“Š Status Challenge

- - - -
ChallengePortSSHStatus
-
+ + \ No newline at end of file diff --git a/panel/teams.py b/panel/teams.py index 9836f1a..710e472 100644 --- a/panel/teams.py +++ b/panel/teams.py @@ -209,10 +209,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}"