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)
This commit is contained in:
root
2026-09-23 16:37:58 +08:00
parent 4a65f24af3
commit 43ed3df557
5 changed files with 566 additions and 68 deletions
+198 -7
View File
@@ -7,9 +7,10 @@ admin credentials stay out of the browser.
import os import os
import json import json
import time import time
import asyncio
import httpx import httpx
from pathlib import Path 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.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from typing import Optional from typing import Optional
@@ -79,6 +80,16 @@ async def _proxy(method: str, path: str, body: dict = None):
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def index(req: Request): 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"]}"')
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): if not _authorized(req):
return RedirectResponse("/login") return RedirectResponse("/login")
html = (BASE_DIR / "static" / "index.html").read_text() 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) @app.get("/team/{idx}", response_class=HTMLResponse)
async def team_portal(idx: int, req: Request): async def team_portal(idx: int, req: Request):
"""Public portal page for a team (served at <slug>.gemastik.imrnes.team/team/<idx>).""" """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 <slug>.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 = (BASE_DIR / "static" / "team.html").read_text()
html = html.replace('name="team-id" content="0"', f'name="team-id" content="{idx}"') html = html.replace('name="team-id" content="0"', f'name="team-id" content="{idx}"')
return HTMLResponse(html) 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") @app.get("/api/team/{idx}/info")
async def api_team_info(idx: int): async def api_team_info(idx: int, req: Request):
"""Public: basic team info for the portal (no secrets besides per-team SSH creds).""" """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}" td = orch.TEAMS_DIR / f"team{idx}"
if not (td / "state.json").exists(): if not (td / "state.json").exists():
raise HTTPException(404, "Team not found") raise HTTPException(404, "Team not found")
st = json.loads((td / "state.json").read_text()) st = json.loads((td / "state.json").read_text())
# expose only what a team needs: label, domain, ports, ssh creds, status
return {"team": { return {"team": {
"index": st.get("index"), "index": st.get("index"),
"label": st.get("label"), "label": st.get("label"),
@@ -122,7 +222,7 @@ async def api_team_info(idx: int):
"status": st.get("status"), "status": st.get("status"),
"ports": st.get("ports"), "ports": st.get("ports"),
"ssh_user": st.get("ssh_user"), "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") @app.get("/api/public/teams")
async def api_public_teams(): async def api_public_teams():
"""Public list of team names (for the submit dropdown).""" """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()]}
# ============ 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
+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" id="portalLink">← Kembali ke portal tim</a></p>
</div>
</div>
<script>
const TEAM_ID = parseInt(document.querySelector('meta[name="team-id"]').content || '0', 10);
document.getElementById('portalLink').href = `/team/${TEAM_ID}`;
</script>
</body>
</html>
+1 -1
View File
@@ -238,7 +238,7 @@ function toast(msg, err=false) {
} }
function esc(s) { 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) { function showView(v) {
+246 -56
View File
@@ -3,17 +3,21 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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> <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; } * { 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; } 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; } 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; } .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; } h1 { font-size:22px; }
.sub { color:var(--dim); font-size:13px; } .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 { 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 { 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); } .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; } .kv { display:grid; grid-template-columns:150px 1fr; gap:6px 12px; font-size:13px; margin-bottom:4px; }
@@ -25,13 +29,37 @@
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; } .dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; }
.dot.up { background:var(--green); } .dot.down { background:var(--red); } .dot.up { background:var(--green); } .dot.down { background:var(--red); }
label { display:block; font-size:12px; color:var(--dim); margin:12px 0 5px; } 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; } 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 { 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; } 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: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 { 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.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); } #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; } .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); }
</style> </style>
</head> </head>
<body> <body>
@@ -43,70 +71,233 @@
<div class="sub" id="teamDomain"></div> <div class="sub" id="teamDomain"></div>
</div> </div>
<span class="badge" id="teamStatus">…</span> <span class="badge" id="teamStatus">…</span>
<button class="ghost" id="logoutBtn" style="display:none" onclick="logout()">Keluar</button>
</header> </header>
<div class="card"> <!-- LOGIN -->
<h2>🌐 Akses Server Tim Kamu</h2> <div id="loginScreen">
<div id="accessBox" class="sshbox">Memuat…</div> <div class="card">
<div style="margin-top:12px;font-size:12px;color:var(--dim)"> <h2>🔐 Login Portal Tim</h2>
Tiap challenge punya port sendiri. SSH login: <b>ctfuser</b> + password dari panel panitia. <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" id="guideLink">baca panduan</a></div>
</div> </div>
</div> </div>
<div class="card"> <!-- PORTAL -->
<h2>📊 Status Challenge</h2> <div id="portalScreen" style="display:none">
<table> <div class="tabs">
<thead><tr><th>Challenge</th><th>Port</th><th>SSH</th><th>Status</th></tr></thead> <button class="active" data-view="term" onclick="showView('term')">🖥️ Terminal SSH</button>
<tbody id="statusBody"></tbody> <button data-view="targets" onclick="showView('targets')">🎯 Target Musuh</button>
</table> <button data-view="submit" onclick="showView('submit')">🚩 Submit Flag</button>
</div> <button data-view="guide" onclick="showView('guide')">📖 Panduan SSH</button>
</div>
<div class="card"> <div class="view active" id="view-term">
<h2>🚩 Submit Flag</h2> <div class="card">
<label>Challenge</label> <h2>🖥️ SSH Terminal — pilih challenge</h2>
<select id="challenge"> <div class="term-toolbar">
<option value="blogpost">blogpost (web)</option> <select id="termChall" style="width:200px" onchange="connectTerm()">
<option value="carbeat">carbeat (pwn)</option> <option value="blogpost">blogpost (web)</option>
<option value="cdn">cdn (web)</option> <option value="carbeat">carbeat (pwn)</option>
<option value="phew">phew (crypto)</option> <option value="cdn">cdn (web)</option>
<option value="sheesh">sheesh (crypto)</option> <option value="phew">phew (crypto)</option>
<option value="warmup">warmup</option> <option value="sheesh">sheesh (crypto)</option>
</select> <option value="warmup">warmup</option>
<label>Flag</label> </select>
<input id="flag" placeholder="GEMASTIK18{...}" autocomplete="off"> <button class="ghost" onclick="connectTerm()">🔄 Sambung</button>
<button onclick="submitFlag()">Submit Flag</button> <span id="termStatus">Belum tersambung</span>
<div id="result"></div> </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>Flag</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-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>
</div> </div>
<script> <script>
const TEAM_ID = parseInt(document.querySelector('meta[name="team-id"]').content || '0', 10); const TEAM_ID = parseInt(document.querySelector('meta[name="team-id"]').content || '0', 10);
let term = null, ws = null;
async function api(url, opts) { const r = await fetch(url, opts); return r.json(); } 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();
}
async function load() { // ---------- auth ----------
try { async function checkSession() {
const t = await api(`/api/team/${TEAM_ID}/info`); const s = await api(`/api/team/${TEAM_ID}/session`);
document.getElementById('teamTitle').textContent = t.team.label || ('Team ' + TEAM_ID); if (s.authed) {
document.getElementById('teamDomain').textContent = t.team.domain || ''; document.getElementById('loginScreen').style.display = 'none';
document.getElementById('teamStatus').textContent = t.team.status === 'running' ? '● RUNNING' : '○ STOPPED'; document.getElementById('portalScreen').style.display = 'block';
const access = []; document.getElementById('logoutBtn').style.display = '';
for (const [name, p] of Object.entries(t.team.ports)) { loadInfo();
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;
} }
} }
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();
} 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.team)}] — ${esc(t.challenge)}\n`;
if (t.domain) html += ` domain : https://${esc(t.domain)}\n`;
html += ` service: ${esc(window.location.hostname)}:${t.port}\n`;
html += ` ssh : ${esc(window.location.hostname)}:${t.ssh}\n\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() { async function submitFlag() {
const res = document.getElementById('result'); const res = document.getElementById('result');
res.className = ''; res.className = '';
@@ -118,12 +309,11 @@ async function submitFlag() {
flag: document.getElementById('flag').value.trim(), flag: document.getElementById('flag').value.trim(),
}), }),
}); });
if (d.success) { res.className = 'ok'; res.innerHTML = '✅ Flag benar!'; } if (d.success) { res.className = 'ok'; res.textContent = '✅ Flag benar! Poin masuk.'; }
else { res.className = 'err'; res.innerHTML = '❌ ' + esc(d.error || 'Gagal'); } else { res.className = 'err'; res.textContent = '❌ ' + (d.error || 'Gagal'); }
} }
load(); checkSession();
setInterval(load, 15000);
</script> </script>
</body> </body>
</html> </html>
+13 -4
View File
@@ -209,10 +209,19 @@ def set_ssh_passwords(idx: int):
cont = f"{name}_container_team{idx}" cont = f"{name}_container_team{idx}"
pw = st["chall_passwords"][name] pw = st["chall_passwords"][name]
cmd = f"echo 'ctfuser:{pw}' | chpasswd" cmd = f"echo 'ctfuser:{pw}' | chpasswd"
r = subprocess.run(["docker", "exec", cont, "sh", "-c", cmd], # retry a few times — right after `compose up`, container may still be booting
capture_output=True, text=True, timeout=30) ok = False
if r.returncode != 0: for attempt in range(5):
print(f"[set_ssh_passwords] {cont}: FAILED ({r.stderr.strip()[:100]})") 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): def stop_team(idx: int):
team_dir = TEAMS_DIR / f"team{idx}" team_dir = TEAMS_DIR / f"team{idx}"