193 lines
7.0 KiB
Python
193 lines
7.0 KiB
Python
#!/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
|
||
|
|
import httpx
|
||
|
|
from pathlib import Path
|
||
|
|
from fastapi import FastAPI, Request, HTTPException
|
||
|
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||
|
|
from fastapi.staticfiles import StaticFiles
|
||
|
|
from typing import Optional
|
||
|
|
|
||
|
|
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):
|
||
|
|
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())
|
||
|
|
|
||
|
|
@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 = []
|
||
|
|
return {"lines": lines[-200:]}
|