From ae50acfe4001b1bd35299a60f4d6bb04d1a9dfe9 Mon Sep 17 00:00:00 2001 From: Cyrene Date: Sat, 26 Sep 2026 14:40:10 +0800 Subject: [PATCH] fix(ssh): per-challenge SSH login + phew buffering/leak/timeout Passwords failed on 10/16 challenges while state.json looked correct: - only the 6 native GEMASTIK XVIII images provision 'ctfuser'; every imported XVI/XVII image does 'echo root:${PASSWORD} | chpasswd' and logs in as root. set_ssh_passwords() hardcoded ctfuser, so chpasswd set a password on an account nobody uses -> 'Permission denied' everywhere. Registry gains a per-challenge 'ssh_user'; chpasswd now targets the real login (and ctfuser/ctf when present) and reports failures loudly. - phew checker: chall.py block-buffers stdout through the docker exec pipe (PYTHONUNBUFFERED now set) and leaks chall.py inside the container on timeout (26 orphans, container saturated) -> reaps the whole exec process group. Startup does a fresh Pailier keygen (~12 s) so crypto reads need _CRYPTO_TIMEOUT, not the 5 s prompt default. Adds panel/verify_ssh_creds.py (proves the state->container binding from inside via a real login), audit_ssh_users.sh, reset_runtime.sh. --- panel/apply_registry.sh | 33 ++ panel/audit_ssh_users.sh | 16 + panel/delete_teams.sh | 19 + panel/fix_checker_localhost.py | 51 +++ panel/gen_receiver_services.py | 12 + panel/inspect_team_footprint.sh | 15 + panel/main.py | 58 ++- panel/prune_disabled.sh | 48 +++ panel/reset_runtime.sh | 69 ++++ panel/sla_probe.py | 56 +++ panel/start_team_bg.sh | 32 ++ panel/static/index.html | 43 +++ panel/sync_all_team_ufw.py | 33 ++ panel/teams.py | 340 ++++++++++++++++- panel/verify_ssh_creds.py | 68 ++++ panel/verify_teams.sh | 35 ++ receiver/challenges/Phew.py | 488 ++++++++++++++---------- receiver/challenges/xvi/Art.py | 2 +- receiver/challenges/xvi/Burvesigner.py | 2 +- receiver/challenges/xvi/Challenge.py | 30 +- receiver/challenges/xvi/Crawlback.py | 2 +- receiver/challenges/xvi/GemasFetcher.py | 10 +- receiver/challenges/xvi/GemasNotes.py | 6 +- receiver/challenges/xvi/Hirnfick.py | 2 +- receiver/challenges/xvi/Pasta.py | 4 +- receiver/challenges/xvi/S3.py | 6 +- receiver/challenges/xvi/XL.py | 2 +- receiver/challenges/xvii/Challenge.py | 12 +- receiver/challenges/xvii/checkers.py | 53 ++- services/art/Dockerfile | 6 +- services/art/Gemfile | 9 +- teams/challenge_registry.json | 105 +++-- verify_final.sh | 20 + 33 files changed, 1398 insertions(+), 289 deletions(-) create mode 100644 panel/apply_registry.sh create mode 100644 panel/audit_ssh_users.sh create mode 100644 panel/delete_teams.sh create mode 100644 panel/fix_checker_localhost.py create mode 100644 panel/inspect_team_footprint.sh create mode 100644 panel/prune_disabled.sh create mode 100644 panel/reset_runtime.sh create mode 100644 panel/sla_probe.py create mode 100644 panel/start_team_bg.sh create mode 100644 panel/sync_all_team_ufw.py create mode 100644 panel/verify_ssh_creds.py create mode 100755 panel/verify_teams.sh create mode 100644 verify_final.sh diff --git a/panel/apply_registry.sh b/panel/apply_registry.sh new file mode 100644 index 0000000..d78b4c4 --- /dev/null +++ b/panel/apply_registry.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Bring every team's containers in line with the registry's enabled set. +# +# For each team: re-render the compose from the registry, then `up -d`. Because +# the shared `services-` images already exist, this is a start, not a +# rebuild, so it is fast. Containers of challenges that are no longer enabled are +# removed by `up --remove-orphans`. +set -uo pipefail +cd /opt/gemastik18-final/panel +python3 - <<'PY' +import json, sys +sys.path.insert(0, '.') +import teams as orch, compose_gen +orch.reconcile_team_state() +for d in sorted(orch.TEAMS_DIR.glob("team*")): + sf = d / "state.json" + if not sf.exists(): + continue + st = json.loads(sf.read_text()) + (d / "services" / "docker-compose.yml").write_text( + compose_gen.render_team_compose(st["index"], st)) + print(f"team{st['index']} compose rendered") +PY + +for i in 1 2 3 4; do + cd "/opt/gemastik18-final/teams/team$i/services" + echo "--- team$i ---" + docker compose -p "team$i" up -d --remove-orphans 2>&1 | tail -2 +done + +echo "=== result ===" +docker ps --format '{{.Names}}' | grep -c '_container_team' +df -h / | tail -1 diff --git a/panel/audit_ssh_users.sh b/panel/audit_ssh_users.sh new file mode 100644 index 0000000..9bdaa64 --- /dev/null +++ b/panel/audit_ssh_users.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Which SSH user does each challenge's image actually provision? +# The imported XVI/XVII challenges do NOT all use `ctfuser`: art uses `root`, +# anti-alchemy uses `ctf`. set_ssh_passwords() only does +# `echo 'ctfuser:' | chpasswd`, so for those images it either fails or sets +# a password on an account nobody logs in as -> "Permission denied" for every +# team, while state.json looks perfectly correct. +set -uo pipefail +cd /opt/gemastik18-final +printf "%-18s %s\n" CHALLENGE "Dockerfile user provisioning" +for d in services/*/; do + name=$(basename "$d") + [ -f "$d/Dockerfile" ] || continue + line=$(grep -hE 'chpasswd|useradd|adduser' "$d/Dockerfile" 2>/dev/null | head -2 | tr '\n' ';' | cut -c1-110) + printf "%-18s %s\n" "$name" "${line:-}" +done diff --git a/panel/delete_teams.sh b/panel/delete_teams.sh new file mode 100644 index 0000000..581e3ff --- /dev/null +++ b/panel/delete_teams.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Delete the teams named as args, one at a time, via the panel API. +# Each per-team delete runs `docker compose down` for every challenge, which +# takes minutes for a 16-challenge team — so never do this in a foreground +# call that has to finish inside one tool timeout. +# Usage: delete_teams.sh [idx...] +set -uo pipefail +BASE=http://127.0.0.1:18081 +JAR=/tmp/ejc +U=$(grep -oP '^PANEL_ADMIN_USER=\K.*' /opt/gemastik18-final/panel/.env) +P=$(grep -oP '^PANEL_ADMIN_PASS=\K.*' /opt/gemastik18-final/panel/.env) +curl -sS -c "$JAR" -X POST -H 'Content-Type: application/json' \ + -d "{\"user\":\"$U\",\"pass\":\"$P\"}" "${BASE}/api/login" >/dev/null +for i in "$@"; do + echo "=== $(date -Is) delete team${i} ===" + curl -sS -b "$JAR" -X DELETE -H 'Content-Type: application/json' \ + -d '{"purge_scores":true}' "${BASE}/api/teams/${i}" -w '\nHTTP %{http_code}\n' +done +echo "=== done ===" diff --git a/panel/fix_checker_localhost.py b/panel/fix_checker_localhost.py new file mode 100644 index 0000000..2fa5254 --- /dev/null +++ b/panel/fix_checker_localhost.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Replace hardcoded `localhost` URLs in the imported XVI checkers with self.url(). + +Why: the imported checkers connect to `http://localhost:{self.port}`. The +receiver does run on the same host as the published team ports, so this happens +to work, but it is fragile (breaks the moment a receiver runs in a container or +the port is bound to a specific interface). Challenge.url() builds the URL from +self.host (default 127.0.0.1, overridable with RECEIVER_HOST) plus self.port. + +Idempotent; rewrites only the f-string form, leaving class-level constants that +pin a *fixed* port (GemasNotes/Pasta/S3) alone — those are handled separately. +""" +import re +import sys +from pathlib import Path + +BASE = Path("/opt/gemastik18-final/receiver/challenges/xvi") + +# f"http://localhost:{self.port}/path/{expr}" -> self.url(f"/path/{expr}") +# The leading f is part of the literal being matched and must be consumed. +PAT = re.compile( + r"""f?(?P["'])http://localhost:\{self\.port\}(?P/[^"']*)?(?P=q)""" +) + + +def repl(m: "re.Match[str]") -> str: + path = m.group("path") or "" + if not path: + return "self.url()" + # the path may itself contain {expr} placeholders — keep them as an f-string + return f"self.url(f{path!r})" + + +def main() -> int: + changed = [] + for p in sorted(BASE.glob("*.py")): + if p.name in ("Challenge.py", "config.py", "__init__.py"): + continue + src = p.read_text() + if "localhost:{self.port}" not in src: + continue + new = PAT.sub(repl, src) + if new != src: + p.write_text(new) + changed.append(p.name) + print("rewritten:", ", ".join(changed) if changed else "(none)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/panel/gen_receiver_services.py b/panel/gen_receiver_services.py index a006aaf..2361d05 100644 --- a/panel/gen_receiver_services.py +++ b/panel/gen_receiver_services.py @@ -14,6 +14,14 @@ from pathlib import Path TEAMS_DIR = Path("/opt/gemastik18-final/teams") RECEIVER_VENV = "/opt/gemastik18-final/receiver/.venv/bin/python" UNIT_DIR = Path("/etc/systemd/system") +REGISTRY_PATH = TEAMS_DIR / "challenge_registry.json" + + +def load_registry() -> dict: + try: + return json.loads(REGISTRY_PATH.read_text()) + except Exception: + return {"sets": {}, "challenges": []} def write_unit(idx: int, st: dict): port = st["ports"]["receiver"] @@ -23,12 +31,16 @@ def write_unit(idx: int, st: dict): # NOTE: systemd Environment= keys must be [A-Za-z0-9_]+ — a hyphen in the # challenge name (gift-card) would make systemd silently drop the line, so # normalize the name to underscores here. main.py looks up the same key. + # Same normalization applies to CHALLENGE_SCHEME_. + schemes = {c["name"]: c.get("scheme") for c in load_registry().get("challenges", [])} for ch in st["ports"]: if ch in ("receiver", "panel"): continue key = ch.upper().replace("-", "_") env[f"CHALLENGE_PORT_{key}"] = str(st["ports"][ch]["chall"]) env[f"CHALLENGE_CONTAINER_{key}"] = f"{ch}_container_team{idx}" + if schemes.get(ch): + env[f"CHALLENGE_SCHEME_{key}"] = schemes[ch] # SSH passwords: checker Challenge.credentials() reads PASSWORD_ # where self.port is the team challenge port. pwd = st.get("chall_passwords", {}).get(ch) diff --git a/panel/inspect_team_footprint.sh b/panel/inspect_team_footprint.sh new file mode 100644 index 0000000..38a2b06 --- /dev/null +++ b/panel/inspect_team_footprint.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Inspect per-team port footprint: UFW rules in the 3xxxx/4xxxx range and +# docker volumes/networks named after a team. Read-only. +set -uo pipefail +echo "=== UFW rules matching 3xxxx/4xxxx ===" +ufw status numbered 2>/dev/null | grep -E '\b(3[0-9]{4}|4[0-9]{4})/tcp' || echo "(none)" +echo +echo "=== docker networks team* ===" +docker network ls --format '{{.Name}}' | grep -i team || echo "(none)" +echo +echo "=== docker volumes team* ===" +docker volume ls --format '{{.Name}}' | grep -i team || echo "(none)" +echo +echo "=== all volumes ===" +docker volume ls --format '{{.Name}}' | head -40 diff --git a/panel/main.py b/panel/main.py index bda31a9..58bda93 100644 --- a/panel/main.py +++ b/panel/main.py @@ -9,6 +9,8 @@ import json import time import asyncio import threading +import subprocess +import sys import httpx from pathlib import Path from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect @@ -530,6 +532,7 @@ async def api_teams_set(req: Request): labels = data.get("labels") or {} # { "1": "Tim Satu", ... } domains = data.get("domains") or {} # { "1": "mycustom", ... } created = [] + ufw = [] for i in range(1, n + 1): td = orch.TEAMS_DIR / f"team{i}" if not td.exists(): @@ -537,6 +540,9 @@ async def api_teams_set(req: Request): dom = domains.get(str(i)) or domains.get(i) or None st = orch.create_team(i, label, domain=dom) created.append(st["index"]) + # UFW defaults to deny(incoming) on this host: without these rules + # the team's challenge/SSH/receiver ports are silently blackholed. + ufw.append(orch.sync_team_ufw(i)) else: # team exists: apply any label/domain overrides label = labels.get(str(i)) or labels.get(i) @@ -544,7 +550,7 @@ async def api_teams_set(req: Request): if label or dom: orch.update_team(i, label=label, domain=dom) orch.ensure_team_domains() - return {"created": created, "total": len(orch.list_teams())} + return {"created": created, "total": len(orch.list_teams()), "ufw": ufw} @app.put("/api/teams/{idx}") async def api_team_update(idx: int, req: Request): @@ -559,6 +565,56 @@ async def api_team_update(idx: int, req: Request): except FileNotFoundError as e: raise HTTPException(404, str(e)) + +@app.delete("/api/teams/{idx}") +async def api_team_delete(idx: int, req: Request): + """Permanently delete ONE team: containers, network, receiver unit, ports, + directory, score records and its Traefik domain. + + Body: {"purge_scores": true} (default) — set false to keep the team's + leaderboard/points history. Destructive and irreversible, so the UI gates + it behind a confirm dialog. + """ + require_login(req) + raw = {} + try: + raw = await req.json() + except Exception: + pass # DELETE with no body is fine + if not (orch.TEAMS_DIR / f"team{idx}" / "state.json").exists(): + raise HTTPException(404, f"Team {idx} not found") + try: + # compose down for 16 services takes a while — keep the event loop free + result = await asyncio.to_thread(orch.delete_team, idx, + bool(raw.get("purge_scores", True))) + # refresh the remaining teams' generated artifacts (receiver main.py, + # systemd units) so nothing points at the deleted team + subprocess.run([sys.executable, str(orch.BASE / "panel" / "gen_receiver_services.py"), "start"], + check=False, capture_output=True) + return result + except FileNotFoundError as e: + raise HTTPException(404, str(e)) + except Exception as e: + raise HTTPException(500, str(e)) + + +@app.post("/api/teams/{idx}/ufw") +async def api_team_ufw(idx: int, req: Request): + """Reconcile UFW rules for a team's port block. + + UFW defaults to deny(incoming) on this host, so a team whose ports were + never opened is blackholed. Use this after creating a team out-of-band, or + to close the ports of a team you just deleted by hand. + Body: {"remove": true} deletes the rules instead. + """ + require_login(req) + raw = {} + try: + raw = await req.json() + except Exception: + pass + return await asyncio.to_thread(orch.sync_team_ufw, idx, bool(raw.get("remove", False))) + @app.post("/api/teams/start") async def api_teams_start(req: Request): require_login(req) diff --git a/panel/prune_disabled.sh b/panel/prune_disabled.sh new file mode 100644 index 0000000..ca367ee --- /dev/null +++ b/panel/prune_disabled.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Remove containers + images for challenges that are no longer enabled. +# +# Why: the platform can host 28 challenges but the images are large (1.2 GB for +# pasta alone) and the host disk is 79 GB. Keeping every image resident filled +# it to 98% and started failing builds. Disabled challenges keep their source in +# services/ and can be re-enabled at any time — only the runtime artifacts go. +set -uo pipefail + +cd /opt/gemastik18-final/panel +ENABLED=$(python3 - <<'PY' +import sys +sys.path.insert(0, '.') +import teams +print(" ".join(c["name"] for c in teams.enabled_challenges())) +PY +) +echo "enabled: $ENABLED" + +echo "--- stopping containers of disabled challenges ---" +for name in $(docker ps -a --format '{{.Names}}' | grep '_container_team' || true); do + base=${name%%_container_team*} + keep=0 + for e in $ENABLED; do + [ "$base" = "$e" ] && keep=1 + done + [ "$keep" = "0" ] && docker rm -f "$name" >/dev/null 2>&1 && echo "removed container $name" +done + +echo "--- removing images of disabled challenges ---" +for img in $(docker images --format '{{.Repository}}' | grep -E '^(services-|team[0-9]+-)' || true); do + base=${img#services-} + base=${base#team[0-9]-} + # only challenge-shaped names (services-blogpost, team2-art, ...) + case "$base" in + blogpost|carbeat|cdn|phew|sheesh|warmup|art|xl|pasta|gemas-fetcher|s3|crawlback|back-to-basic|anti-alchemy|asmr|bit-canvas|fjb|gift-card|gift-voucher|gleam-drive|go-green|kode-viewer|more-less|ticketer|hirnfick|burvesigner|back-to-basic|gemas-notes|tempest-poc) ;; + *) continue ;; + esac + keep=0 + for e in $ENABLED; do + [ "$base" = "$e" ] && keep=1 + done + [ "$keep" = "0" ] && docker rmi "$img" >/dev/null 2>&1 && echo "removed image $img" +done + +echo "--- done ---" +docker images --format '{{.Repository}}' | grep -c '^services-' || true +df -h / | tail -1 diff --git a/panel/reset_runtime.sh b/panel/reset_runtime.sh new file mode 100644 index 0000000..b10dece --- /dev/null +++ b/panel/reset_runtime.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# FULL reset of the runtime — keeps ONLY the shared challenge images. +# +# Removes: every team container, every team docker network, every anonymous/ +# named volume, every per-team receiver systemd unit, and all team directories +# (state, flags, credentials, compose). KEEPS: services-* images (the +# challenges themselves), the panel, the global receiver, the challenge sources +# in services/, and the registry. +# +# This is the "purge everything except the challenges" path the organiser asked +# for after passwords drifted: fresh containers get fresh /etc/shadow state, so +# no stale credential can survive. +set -uo pipefail +BASE=/opt/gemastik18-final +TEAMS=$BASE/teams + +echo "=== [1/6] stop per-team receivers + remove units ===" +for u in $(systemctl list-unit-files 'gemastik-receiver-team*.service' 2>/dev/null \ + | awk '/gemastik-receiver-team/{print $1}'); do + systemctl disable --now "$u" >/dev/null 2>&1 + rm -f "/etc/systemd/system/$u" + echo " removed $u" +done +systemctl daemon-reload + +echo "=== [2/6] compose down for every team (before deleting dirs) ===" +for d in "$TEAMS"/team*/services; do + [ -d "$d" ] || continue + idx=$(basename "$(dirname "$d")") + echo " compose down $idx" + (cd "$d" && docker compose -p "$idx" -f docker-compose.yml down -v --remove-orphans 2>&1 | tail -1) +done + +echo "=== [3/6] force-remove any surviving team containers ===" +left=$(docker ps -aq --filter 'name=_container_team' | wc -l) +echo " found $left" +[ "$left" -gt 0 ] && docker rm -f $(docker ps -aq --filter 'name=_container_team') >/dev/null 2>&1 + +echo "=== [4/6] remove team networks + stray volumes ===" +for n in $(docker network ls --format '{{.Name}}' | grep -E '^team[0-9]+_default$' || true); do + docker network rm "$n" >/dev/null 2>&1 && echo " network $n" +done +# anonymous + team-scoped volumes (challenge DB state lives here) +anon=$(docker volume ls -q --filter dangling=true | wc -l) +echo " dangling volumes: $anon" +[ "$anon" -gt 0 ] && docker volume prune -f >/dev/null 2>&1 && echo " pruned" +for v in $(docker volume ls --format '{{.Name}}' | grep -E '^team[0-9]+' || true); do + docker volume rm "$v" >/dev/null 2>&1 && echo " volume $v" +done + +echo "=== [5/6] delete team dirs + ledgers ===" +rm -rf "$TEAMS"/team* +rm -f "$TEAMS"/leaderboard.json "$TEAMS"/points.json "$TEAMS"/attacks.json +echo " team dirs now: $(ls -d "$TEAMS"/team* 2>/dev/null | wc -l)" + +echo "=== [6/6] drop team domains from Traefik ===" +cd "$BASE/panel" && python3 -c " +import sys; sys.path.insert(0,'.') +import teams +print(' ', teams.ensure_team_domains()) +" +# the platform-level receiver is separate and must keep running +systemctl restart gemastik-panel 2>/dev/null +echo " panel: $(systemctl is-active gemastik-panel)" + +echo "=== done ===" +docker ps --format '{{.Names}}' | grep -c '_container_team' || echo " team containers: 0" +docker images --format '{{.Repository}}' | grep -c '^services-' || true +df -h / | tail -1 diff --git a/panel/sla_probe.py b/panel/sla_probe.py new file mode 100644 index 0000000..d07a1e2 --- /dev/null +++ b/panel/sla_probe.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Probe each team receiver's /check/ directly and report SLA. + +Probes are SEQUENTIAL per team on purpose: the team receivers are sync Flask +apps, so 8 concurrent /check requests make them time out and report false +failures. Keep max_workers=1. This is still far faster than the panel's +/api/scoreboard, which probes every team on one shared refresher thread. + + python3 panel/sla_probe.py # all teams + python3 panel/sla_probe.py 1 2 # specific teams +""" +import base64 +import json +import sys +import urllib.error +import urllib.request +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import teams as orch + +def check(recv_port, au, ap, name): + url = f"http://127.0.0.1:{recv_port}/check/{name}" + tok = base64.b64encode(f"{au}:{ap}".encode()).decode() + req = urllib.request.Request(url, headers={"Authorization": f"Basic {tok}"}) + try: + with urllib.request.urlopen(req, timeout=30) as r: + body = json.loads(r.read().decode()) + return name, bool(body.get("success")), "" + except urllib.error.HTTPError as e: + return name, False, f"HTTP {e.code}" + except Exception as e: + return name, False, str(e)[:60] + +def main(): + want = [int(a) for a in sys.argv[1:]] + teams = [t for t in orch.list_teams() if not want or t["index"] in want] + enabled = [c["name"] for c in orch.enabled_challenges()] + grand_ok = grand_all = 0 + for t in teams: + idx = t["index"] + port = t["ports"]["receiver"] + au, ap = t.get("admin_user", ""), t.get("admin_pass", "") + res = [check(port, au, ap, n) for n in enabled] + up = [n for n, ok, _ in res if ok] + down = [(n, e) for n, ok, e in res if not ok] + grand_ok += len(up); grand_all += len(res) + print(f"team{idx} ({t.get('label')}) SLA {len(up)}/{len(res)}") + if down: + for n, e in down: + print(f" DOWN {n}: {e}") + print(f"\nTOTAL {grand_ok}/{grand_all} " + f"({100.0 * grand_ok / grand_all if grand_all else 0:.1f}%)") + +if __name__ == "__main__": + main() diff --git a/panel/start_team_bg.sh b/panel/start_team_bg.sh new file mode 100644 index 0000000..9a85cf2 --- /dev/null +++ b/panel/start_team_bg.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Start one team's full stack in the BACKGROUND (safe for a 16-challenge team: +# `compose up` for 16 services takes minutes and would blow a foreground timeout). +# Usage: start_team_bg.sh +set -uo pipefail +IDX="${1:?usage: start_team_bg.sh }" +LOG="/tmp/start-team${IDX}.log" +TEAMDIR="/opt/gemastik18-final/teams/team${IDX}" +cd "${TEAMDIR}/services" || exit 1 +{ + echo "=== $(date -Is) start team${IDX} ===" + docker compose -p "team${IDX}" up -d --remove-orphans 2>&1 + echo "compose rc=$?" + # independent systemd receiver (never a child of the panel) + python3 /opt/gemastik18-final/panel/gen_receiver_services.py start 2>&1 + systemctl restart "gemastik-receiver-team${IDX}.service" 2>&1 + echo "receiver: $(systemctl is-active gemastik-receiver-team${IDX}.service)" + python3 - "$IDX" <<'PY' +import sys, json, time +sys.path.insert(0, '/opt/gemastik18-final/panel') +import teams +idx = int(sys.argv[1]) +sf = f"/opt/gemastik18-final/teams/team{idx}/state.json" +st = json.loads(open(sf).read()); st["status"] = "running" +open(sf, "w").write(json.dumps(st, indent=2)) +# retry loop: chpasswd races container boot +teams.set_ssh_passwords(idx) +print("=== done team", idx, "===") +PY + df -h / | tail -1 +} >"${LOG}" 2>&1 +echo "started team${IDX} -> ${LOG}" diff --git a/panel/static/index.html b/panel/static/index.html index fe2fd78..678c57b 100644 --- a/panel/static/index.html +++ b/panel/static/index.html @@ -318,6 +318,12 @@ function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } +// Alias used by the Challenge Manager render. Kept as a separate name so +// existing esc() call sites stay untouched, but it MUST exist: a missing +// helper throws ReferenceError mid-render and the tab hangs on "Memuat…" +// forever with no visible error. +function escapeHtml(s) { return esc(s); } + function showView(v) { document.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.view === v)); document.querySelectorAll('.view').forEach(x => x.classList.toggle('active', x.id === 'view-' + v)); @@ -714,11 +720,15 @@ function topoZoom(factor) { function topoReset() { topoScale = 1; topoPanX = 0; topoPanY = 0; applyTopoView(); } // ---------- Teams ---------- +let TEAM_LABELS = {}; // idx -> label, filled by loadTeams (for confirm dialogs) + async function loadTeams() { try { const d = await api('/api/teams'); document.getElementById('teamCount').value = d.teams.length; loadLeaderboard(); + TEAM_LABELS = {}; + for (const t of d.teams) TEAM_LABELS[t.index] = t.label || ('Team ' + t.index); const grid = document.getElementById('teamsGrid'); if (!d.teams.length) { grid.innerHTML = '
Belum ada team. Set jumlah team untuk auto-create.
'; return; } let html = ''; @@ -743,6 +753,7 @@ async function loadTeams() { + `; } @@ -859,6 +870,38 @@ async function randomizeTeam(idx) { } catch (e) { toast(e.message, true); } } +async function deleteTeam(idx) { + // Per-team delete. Deliberately NOT the same as resetEnv: this removes ONE + // team (containers, network, receiver unit, ports, dir, domain) and leaves + // the other teams untouched. + const label = TEAM_LABELS[idx] || ('Team ' + idx); + if (!confirm( + `🗑️ HAPUS PERMANEN Team ${idx} (${label})?\n\n` + + `Yang akan dihapus:\n` + + `• Semua container challenge team ini\n` + + `• Receiver team + systemd unit\n` + + `• Folder team (port, flag, kredensial)\n` + + `• Port firewall UFW team ini\n` + + `• Domain ${label}.attackdefense.imrnes.team\n` + + `• Skor & riwayat di leaderboard\n\n` + + `Tindakan ini TIDAK BISA dibatalkan.\n` + + `Team lain tidak terpengaruh.`)) return; + // second gate: type the team number to confirm + if (prompt(`Ketik angka ${idx} untuk konfirmasi hapus:`)?.trim() !== String(idx)) { + toast('Dibatalkan', false); + return; + } + showLoading(`Menghapus Team ${idx} (${label})…
Stop container + receiver, hapus port & domain`); + try { + const d = await api(`/api/teams/${idx}`, {method:'DELETE', headers:{'Content-Type':'application/json'}, body: JSON.stringify({purge_scores: true})}); + const n = (d.purged ? Object.values(d.purged).reduce((a, b) => a + b, 0) : 0); + toast(`Team ${idx} (${label}) dihapus: ${(d.steps || []).join(' · ')}${n ? ` · ${n} skor dibersihkan` : ''}`, false); + loadTeams(); + if (document.getElementById('view-topo').classList.contains('active')) loadTopo(); + } catch (e) { toast('Hapus team gagal: ' + e.message, true); } + finally { hideLoading(); } +} + async function loadLeaderboard() { try { const d = await api('/api/leaderboard'); diff --git a/panel/sync_all_team_ufw.py b/panel/sync_all_team_ufw.py new file mode 100644 index 0000000..62b3efc --- /dev/null +++ b/panel/sync_all_team_ufw.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Open (or close) UFW for every live team's port block. + +The panel opens a team's ports at create time, but teams created out-of-band +(scripts, git checkout, a half-finished create_team) never got rules, and this +host's UFW defaults to deny(incoming) — so those teams are blackholed. Run +after any bulk team creation: + + python3 panel/sync_all_team_ufw.py # open + python3 panel/sync_all_team_ufw.py --remove # close +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import teams as orch + +def main(): + remove = "--remove" in sys.argv + live = orch.list_teams() + if not live: + print("no teams") + return + for t in live: + idx = t["index"] + r = orch.sync_team_ufw(idx, remove=remove) + verb = "closed" if remove else "opened" + bad = f" FAILED={r['failed']}" if r.get("failed") else "" + print(f"team{idx} ({t.get('label')}): {verb} {len(r.get('closed' if remove else 'opened', []))}" + f"/{r['ports']} ports{bad}") + +if __name__ == "__main__": + main() diff --git a/panel/teams.py b/panel/teams.py index dad2a0c..06a4689 100644 --- a/panel/teams.py +++ b/panel/teams.py @@ -443,8 +443,15 @@ def create_team(idx: int, label: str = None, domain: str = None): for j, line in enumerate(recv_env): if line.startswith(f"PASSWORD_{10000+coff*1000}="): recv_env[j] = f"PASSWORD_{10000+coff*1000}={state['chall_passwords'][name]}" - recv_env.append(f"CHALLENGE_PORT_{name.upper()}={ports[name]['chall']}") - recv_env.append(f"CHALLENGE_CONTAINER_{name.upper()}={name}_container_team{idx}") + # systemd EnvironmentFile keys must match [A-Za-z_][A-Za-z0-9_]* — a + # hyphen (gift-card, bit-canvas, ...) makes systemd log + # "Ignoring invalid environment assignment" and DROP the line, so the + # checker falls back to a default port/container and reports the + # service down. Normalize to underscores on the writer; the reader + # (gen_receiver_main._envkey) uses the same normalization. + key = name.upper().replace("-", "_") + recv_env.append(f"CHALLENGE_PORT_{key}={ports[name]['chall']}") + recv_env.append(f"CHALLENGE_CONTAINER_{key}={name}_container_team{idx}") (recv_dir / ".env").write_text("\n".join(recv_env) + "\n") # --- flags (randomized per team so each team has unique flags) --- @@ -484,6 +491,47 @@ def update_team(idx: int, label: str = None, domain: str = None) -> dict: ensure_team_domains() return st +def missing_sidecars(idx: int) -> list[str]: + """Sidecar services in the team's compose that are NOT running. + + A multi-container challenge (anti-alchemy + its postgres, gemas-notes + + database/validation, kode-viewer + redis, ...) needs its sidecars to boot: + the main service's `create_db_conn()` retries in an infinite loop until the + DB hostname resolves, so a missing sidecar leaves the main container + "Up" with NO app listening and the checker reports it DOWN. Symptom class: + container is running, port is open at the docker level, but the app never + starts. Recover with `docker compose -p teamN up -d` (no service name). + """ + svc_dir = TEAMS_DIR / f"team{idx}" / "services" + compose = svc_dir / "docker-compose.yml" + if not compose.exists(): + return [] + declared = _compose_service_names(compose) + if not declared: + return [] + want = {f"team{idx}-{n}-1" for n in declared} + running = set(subprocess.run(["docker", "ps", "--format", "{{.Names}}"], + capture_output=True, text=True).stdout.split()) + return sorted(want - running) + + +def _compose_service_names(compose: Path) -> list[str]: + """Top-level service names from a compose file, without needing PyYAML.""" + names: list[str] = [] + in_services = False + for line in compose.read_text().splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + if not line.startswith((" ", "\t")): + in_services = line.rstrip() == "services:" + continue + if in_services and line.startswith(" ") and not line.startswith(" "): + name = line.strip().rstrip(":") + if name and not name.startswith("-"): + names.append(name) + return names + + def start_team(idx: int): team_dir = TEAMS_DIR / f"team{idx}" if not (team_dir / "state.json").exists(): @@ -491,6 +539,13 @@ def start_team(idx: int): svc_dir = team_dir / "services" subprocess.run(["docker", "compose", "-p", f"team{idx}", "-f", svc_dir / "docker-compose.yml", "up", "-d", "--build"], cwd=str(svc_dir), check=False, capture_output=True) + # Self-heal: a per-service `up` (challenge toggle) or a raced start can + # leave a sidecar behind while the main container looks fine. One plain + # `up -d` reconciles the whole project (already-running ones are no-ops). + gone = missing_sidecars(idx) + if gone: + subprocess.run(["docker", "compose", "-p", f"team{idx}", "-f", svc_dir / "docker-compose.yml", "up", "-d"], + cwd=str(svc_dir), check=False, capture_output=True) _start_receiver(idx) st = json.loads((team_dir / "state.json").read_text()) st["status"] = "running" @@ -498,19 +553,53 @@ def start_team(idx: int): # Set per-team SSH passwords at runtime (images are shared across teams, # so chpasswd ensures each team's containers have the team's own password). set_ssh_passwords(idx) + if gone: + print(f"[start_team] team{idx}: started missing sidecar(s): {', '.join(gone)}") return st +def challenge_ssh_users() -> dict: + """{challenge_name: ssh login} from the registry. + + Only the 6 native GEMASTIK XVIII images provision `ctfuser`. Every imported + XVI/XVII image does `RUN echo root:${PASSWORD} | chpasswd` and logs in as + `root` (some also create an unprivileged `ctf` for the app). Hardcoding + `ctfuser` made chpasswd either fail or set a password on an account nobody + uses, so SSH returned "Permission denied" for every team on 10 of 16 + challenges while state.json still looked correct. + """ + out = {} + for c in registry_challenges(): + out[c["name"]] = c.get("ssh_user") or "root" + return out + + def set_ssh_passwords(idx: int): - """Set SSH password for ctfuser in each challenge container of a team.""" + """Set the SSH password for the CORRECT login of each challenge container. + + The login is per-challenge (registry `ssh_user`), not a global `ctfuser`. + Retries because right after `compose up` the container may still be booting. + Reports failures loudly instead of writing to a log nobody reads. + """ team_dir = TEAMS_DIR / f"team{idx}" st = json.loads((team_dir / "state.json").read_text()) + users = challenge_ssh_users() + failures = [] for name, coff, soff in CHALLENGES: cont = f"{name}_container_team{idx}" - pw = st["chall_passwords"][name] - cmd = f"echo 'ctfuser:{pw}' | chpasswd" - # retry a few times — right after `compose up`, container may still be booting + user = users.get(name, "root") + pw = st["chall_passwords"].get(name) + if not pw: + failures.append(f"{cont}: no password in state") + continue + # Set the password for the real login AND for ctfuser when that account + # exists, so either convention works during a transition. + cmd = (f"id {user} >/dev/null 2>&1 && echo '{user}:{pw}' | chpasswd; " + f"id ctfuser >/dev/null 2>&1 && echo 'ctfuser:{pw}' | chpasswd; " + f"id ctf >/dev/null 2>&1 && echo 'ctf:{pw}' | chpasswd; " + f"id {user} >/dev/null 2>&1") ok = False + r = None for attempt in range(5): r = subprocess.run(["docker", "exec", cont, "sh", "-c", cmd], capture_output=True, text=True, timeout=30) @@ -519,9 +608,13 @@ def set_ssh_passwords(idx: int): break time.sleep(3) if not ok: - print(f"[set_ssh_passwords] {cont}: FAILED after retries ({r.stderr.strip()[:100]})") + failures.append(f"{cont}: {(r.stderr or '').strip()[:100]}") else: - print(f"[set_ssh_passwords] {cont}: OK") + print(f"[set_ssh_passwords] {cont} (login={user}): OK") + if failures: + print(f"[set_ssh_passwords] team{idx} FAILED {len(failures)}/{len(CHALLENGES)}: " + + "; ".join(failures)) + return failures def stop_team(idx: int): team_dir = TEAMS_DIR / f"team{idx}" @@ -622,12 +715,241 @@ def ensure_team_domains() -> str: - main: {host} """) if not out: - return "no teams to route" + # No teams left. The file MUST still be rewritten (to an empty router + # set) — returning early leaves the previous content on disk, so a + # DELETED team keeps its Traefik router and stays routable to the panel + # portal forever. That was the stale-domain bug. + (traefik_dir / "attackdefense-teams.yaml").write_text("http:\n routers: {}\n") + return "no teams to route (emptied attackdefense-teams.yaml)" # Keep the team domain block in its own file so the main attackdefense.yaml stays untouched (traefik_dir / "attackdefense-teams.yaml").write_text("http:\n routers:\n" + "".join(out)) return f"wrote {len(out)} team domain(s) in attackdefense-teams.yaml" +def team_port_block(idx: int) -> list[int]: + """Every host port a team occupies: each challenge's chall+ssh port, the + receiver, and the reserved panel slot.""" + st_path = TEAMS_DIR / f"team{idx}" / "state.json" + ports: set[int] = set() + if st_path.exists(): + st = json.loads(st_path.read_text()) + for name, pv in (st.get("ports") or {}).items(): + if isinstance(pv, dict): + for k in ("chall", "ssh"): + if pv.get(k): + ports.add(int(pv[k])) + elif isinstance(pv, int): + ports.add(pv) + else: + # team dir already gone (mid-delete): derive from the port scheme + base = PORT_BASE + idx * STEP + for name, coff, soff in CHALLENGES: + ports.add(base + coff) + ports.add(base + soff) + ports.add(base + 80) + ports.add(base + 81) + return sorted(ports) + + +def sync_team_ufw(idx: int, remove: bool = False) -> dict: + """Reconcile UFW rules for one team's port block. + + UFW here defaults to deny(incoming), so a port that is not explicitly + allowed is blackholed — the team is unreachable from the internet AND from + its own containers. Team creation never opened these ports (they were + opened by hand earlier), so a new team silently gets no connectivity. + + remove=True DELETES the rules instead of adding them (called from + delete_team). The two modes are mutually exclusive: never add-then-delete, + that would flap the rules and briefly re-open a dead team's ports. + """ + ports = team_port_block(idx) + if remove: + for p in ports: + subprocess.run(["ufw", "--force", "delete", "allow", f"{p}/tcp"], + check=False, capture_output=True) + return {"team": idx, "ports": len(ports), "closed": ports, "failed": []} + + failed = [] + for p in ports: + r = subprocess.run(["ufw", "allow", f"{p}/tcp"], check=False, capture_output=True, text=True) + # `ufw allow` on an existing rule prints "Skipping adding existing rule" + # and still exits 0; a non-zero rc with a skip message is not a failure. + if r.returncode != 0 and "Skipping" not in (r.stdout + r.stderr): + failed.append(p) + return {"team": idx, "ports": len(ports), "opened": [p for p in ports if p not in failed], + "failed": failed} + + +def _purge_team_records(idx: int) -> dict: + """Drop every ledger row that references a team, so a deleted team leaves + no ghost entries on the leaderboard / points / attack topology.""" + removed = {"leaderboard": 0, "points": 0, "attacks": 0} + + lb_path = TEAMS_DIR / "leaderboard.json" + if lb_path.exists(): + try: + lb = json.loads(lb_path.read_text()) + before = len(lb.get("solves", [])) + lb["solves"] = [e for e in lb.get("solves", []) + if e.get("team") != idx and e.get("target") != idx] + removed["leaderboard"] = before - len(lb["solves"]) + lb_path.write_text(json.dumps(lb, indent=2)) + except Exception: + pass + + p_path = TEAMS_DIR / "points.json" + if p_path.exists(): + try: + data = json.loads(p_path.read_text()) + if str(idx) in data.get("teams", {}): + data["teams"].pop(str(idx)) + removed["points"] = 1 + p_path.write_text(json.dumps(data, indent=2)) + except Exception: + pass + + a_path = TEAMS_DIR / "attacks.json" + if a_path.exists(): + try: + log = json.loads(a_path.read_text()) + before = len(log.get("events", [])) + log["events"] = [e for e in log.get("events", []) + if e.get("attacker") != idx and e.get("target") != idx] + removed["attacks"] = before - len(log["events"]) + a_path.write_text(json.dumps(log, indent=2)) + except Exception: + pass + return removed + + +def repair_team_receiver_env(idx: int) -> dict: + """Rewrite a team receiver .env with systemd-legal keys. + + Teams created before the hyphen fix have `CHALLENGE_PORT_GIFT-CARD=` in + their .env. systemd logs "Ignoring invalid environment assignment" and drops + the line, so every hyphenated challenge (gift-card, bit-canvas, gleam-drive, + more-less, anti-alchemy, gift-voucher) reports DOWN even though its + container is up. This rewrites the file in place, fixing existing teams + without forcing a re-create. + """ + env_path = TEAMS_DIR / f"team{idx}" / "receiver" / ".env" + if not env_path.exists(): + raise FileNotFoundError(f"team{idx} receiver .env not found") + st = json.loads((TEAMS_DIR / f"team{idx}" / "state.json").read_text()) + lines = env_path.read_text().splitlines() + kept, fixed, dropped = [], [], [] + for line in lines: + if line.startswith("CHALLENGE_PORT_") or line.startswith("CHALLENGE_CONTAINER_"): + k, _, v = line.partition("=") + nk = k.replace("-", "_") + if nk != k: + fixed.append(f"{k}->{nk}") + else: + kept.append(line) + continue + kept.append(line) + # re-append authoritative values for every challenge in state + for name in (st.get("ports") or {}): + if name in ("receiver", "panel"): + continue + key = name.upper().replace("-", "_") + kept.append(f"CHALLENGE_PORT_{key}={st['ports'][name]['chall']}") + kept.append(f"CHALLENGE_CONTAINER_{key}={name}_container_team{idx}") + env_path.write_text("\n".join(kept) + "\n") + # also refresh the shared checker packages (team1 was missing xvi.Art) + try: + sys.path.insert(0, str(BASE / "panel")) + from gen_receiver_main import _sync_checker_packages + _sync_checker_packages(TEAMS_DIR / f"team{idx}" / "receiver") + except Exception as e: + dropped.append(f"checker sync failed: {e}") + return {"team": idx, "fixed_keys": fixed, "notes": dropped} + + +def delete_team(idx: int, purge_scores: bool = True) -> dict: + """Permanently remove ONE team and free everything it owns. + + Teardown order matters — do the teardown against the OLD compose before + removing the directory, or `docker compose` has no file to read and the + containers survive as orphans: + + 1. stop the per-team receiver systemd unit and remove the unit file + 2. `docker compose -p teamN down -v` against the team compose + (also drops the teamN_default network) + 3. force-remove any surviving _container_teamN container + 4. delete the team's UFW rules + 5. rmtree teams/teamN (compose, receiver, flags, state.json) + 6. purge leaderboard / points / attacks rows for that team + 7. rewrite the Traefik team-domain file so the domain stops resolving + + Images (services-*) are SHARED across teams and are never removed here. + purge_scores=False keeps the team's leaderboard/points history. + """ + team_dir = TEAMS_DIR / f"team{idx}" + if not (team_dir / "state.json").exists(): + raise FileNotFoundError(f"Team {idx} not created") + st = json.loads((team_dir / "state.json").read_text()) + label = st.get("label", f"Team {idx}") + steps: list[str] = [] + + # 1. receiver unit + unit = f"gemastik-receiver-team{idx}.service" + subprocess.run(["systemctl", "disable", unit], check=False, capture_output=True) + subprocess.run(["systemctl", "stop", unit], check=False, capture_output=True) + unit_path = Path("/etc/systemd/system") / f"{unit}" + if unit_path.exists(): + unit_path.unlink() + steps.append("removed receiver unit") + subprocess.run(["systemctl", "daemon-reload"], check=False, capture_output=True) + + # 2. compose down (containers + network) while the compose file still exists + svc_dir = team_dir / "services" + compose = svc_dir / "docker-compose.yml" + if compose.exists(): + r = subprocess.run(["docker", "compose", "-p", f"team{idx}", "-f", str(compose), + "down", "-v", "--remove-orphans"], + cwd=str(svc_dir), check=False, capture_output=True, text=True, + timeout=600) + steps.append("compose down" if r.returncode == 0 else f"compose down rc={r.returncode}") + + # 3. force-remove leftovers (a half-written compose can leave orphans) + left = subprocess.run(["docker", "ps", "-aq", "--filter", f"name=_container_team{idx}"], + capture_output=True, text=True).stdout.split() + if left: + subprocess.run(["docker", "rm", "-f", *left], check=False, capture_output=True) + steps.append(f"force-removed {len(left)} container(s)") + net_rm = subprocess.run(["docker", "network", "rm", f"team{idx}_default"], + check=False, capture_output=True, text=True) + if net_rm.returncode == 0: + steps.append("removed docker network") + + # 4. UFW + ufw = sync_team_ufw(idx, remove=True) + steps.append(f"closed {len(ufw.get('closed', []))} ufw port(s)") + + # 5. directory + shutil.rmtree(team_dir, ignore_errors=True) + if team_dir.exists(): + raise RuntimeError(f"team{idx} directory could not be removed") + steps.append("deleted team directory") + + # 6. ledgers + purged = _purge_team_records(idx) if purge_scores else {} + if purge_scores: + steps.append("purged score records") + + # 7. Traefik: drop the dead domain + try: + ensure_team_domains() + steps.append("rewrote team domains") + except Exception as e: + steps.append(f"traefik rewrite failed: {e}") + + return {"ok": True, "team": idx, "label": label, "domain": st.get("domain", ""), + "steps": steps, "purged": purged} + + def list_teams() -> list: out = [] if not TEAMS_DIR.exists(): diff --git a/panel/verify_ssh_creds.py b/panel/verify_ssh_creds.py new file mode 100644 index 0000000..1c9a6af --- /dev/null +++ b/panel/verify_ssh_creds.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Verify each team's SSH passwords actually work in the live containers. + +state.json can look perfect while the container holds a different password — +set_ssh_passwords() races container boot and its failures are easy to miss. +This proves the binding from the INSIDE (per the skill rule: never trust +config, prove it with a real login). + + python3 panel/verify_ssh_creds.py [teamIdx ...] +""" +import json +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import teams as orch + +def probe(user, pw, port, host="127.0.0.1"): + r = subprocess.run( + ["sshpass", "-p", pw, "ssh", + "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=8", "-o", "LogLevel=ERROR", + "-p", str(port), f"{user}@{host}", "whoami; hostname"], + capture_output=True, text=True, timeout=30) + out = (r.stdout or "").strip().splitlines() + return (r.returncode == 0 and len(out) >= 2, out, (r.stderr or "").strip()[:80]) + +def main(): + want = [int(a) for a in sys.argv[1:]] + teams = [t for t in orch.list_teams() if not want or t["index"] in want] + for t in teams: + idx = t["index"] + names = [c["name"] for c in orch.enabled_challenges()] + jobs = [] + for n in names: + if n not in (t.get("ports") or {}): + continue + jobs.append((n, t["ports"][n]["ssh"], t.get("chall_passwords", {}).get(n))) + ok = bad = 0 + details = [] + with ThreadPoolExecutor(max_workers=6) as ex: + futs = {ex.submit(probe, t.get("ssh_user", "ctfuser"), pw, port): n + for n, port, pw in jobs if pw} + for fut, n in futs.items(): + good, out, err = fut.result() + if good: + ok += 1 + # hostname must be _teamN — proves the binding + details.append((n, out[1] if len(out) > 1 else "?")) + else: + bad += 1 + details.append((n, f"FAIL {err}")) + print(f"team{idx} ({t.get('label')}): ssh {ok} ok / {bad} fail (user={t.get('ssh_user')})") + for n, info in details: + if info == "FAIL" or info.startswith("FAIL"): + print(f" {n}: {info}") + hosts = [i for n, i in details if not i.startswith("FAIL")] + mism = [(n, i) for n, i in details + if not i.startswith("FAIL") and not i.endswith(f"_team{idx}")] + if mism: + print(f" !! hostname mismatch (not _team{idx}): {mism}") + else: + print(f" all hostnames correct (e.g. {hosts[0] if hosts else '-'})") + +if __name__ == "__main__": + main() diff --git a/panel/verify_teams.sh b/panel/verify_teams.sh new file mode 100755 index 0000000..9e8929d --- /dev/null +++ b/panel/verify_teams.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Fleet health check: per-team container count vs registry enabled count, +# per-team receiver systemd state, and host disk. Read-only, safe to run any time. +set -uo pipefail +cd /opt/gemastik18-final/panel + +EXPECTED=$(python3 -c " +import sys; sys.path.insert(0,'.') +import teams +print(len(teams.enabled_challenges())) +") +TEAMS=$(ls -d /opt/gemastik18-final/teams/team* 2>/dev/null | sed 's/.*team//' | sort -n) +echo "enabled challenges: $EXPECTED" +echo "teams: ${TEAMS:-none}" +echo + +for i in $TEAMS; do + up=$(docker ps --format '{{.Names}}' | grep -c "_container_team${i}\$" || true) + all=$(docker ps -a --format '{{.Names}}' | grep -c "_container_team${i}\$" || true) + recv=$(systemctl is-active "gemastik-receiver-team${i}.service" 2>/dev/null || echo none) + label=$(python3 -c "import json;print(json.load(open('/opt/gemastik18-final/teams/team${i}/state.json'))['label'])" 2>/dev/null) + echo "team${i} (${label}) up=${up}/${EXPECTED} total_ctr=${all} receiver=${recv}" + if [ "$up" != "$EXPECTED" ]; then + echo " missing: $(python3 - < str: - # NB: a timeout is mandatory here. `docker exec` against a container - # whose process table is saturated (accumulated chall.py zombies) can - # block forever and take the whole SLA check loop down with it. - try: - out = subprocess.run(["docker", "exec", self._CONTAINER, "cat", "/flag.txt"], - capture_output=True, text=True, timeout=30) - except subprocess.TimeoutExpired: - raise TimeoutError("docker exec cat /flag.txt timed out (container overloaded?)") - if out.returncode != 0 or not out.stdout.strip(): - raise FileNotFoundError("Flag not found in container (/flag.txt)") - return out.stdout.strip() - - def _spawn(self): - return subprocess.Popen( - self._SERVICE_CMD, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=0, - ) - - def _read_until(self, proc, token, timeout=5.0, max_bytes=1_000_000): - start = time.time() - buf = [] - r = proc.stdout.read - while True: - if time.time() - start > timeout: - tail = ''.join(buf)[-500:] - raise TimeoutError(f"Timeout waiting for '{token}'. Got so far:\n{tail}") - ch = r(1) - if ch == "" and proc.poll() is not None: - raise RuntimeError(f"Process ended while waiting for '{token}'. Output:\n{''.join(buf)}") - buf.append(ch) - if len(buf) > max_bytes: - raise RuntimeError("Exceeded max read size") - if token in "".join(buf): - return "".join(buf) - - def _send_line(self, proc, s: str): - proc.stdin.write(s + "\n") - proc.stdin.flush() - - def _expect_hex_field(self, text: str, label: str) -> str: - m = re.search(rf"{re.escape(label)}\s*:\s*([0-9a-fA-F]+)", text) - assert m, f"Missing '{label}' in output. Tail:\n{text[-400:]}" - hx = m.group(1) - assert self._HEX_RE.match(hx), f"{label} is not hex" - return hx - - def distribute(self, flag): - try: - os.makedirs(os.path.dirname(self.flag_location), exist_ok=True) - with open(self.flag_location, 'w') as f: - f.write(flag) - - os.makedirs(os.path.dirname(self.history_location), exist_ok=True) - with open(self.history_location, 'a') as f: - f.write(flag + '\n') - - self.logger.info(f'Flag {flag} written to {self.flag_location}') - return True - except Exception as e: - self.logger.error(f'Could not write flag to {self.flag_location}: {e}') - return False - - def check(self): - try: - # parity check - with open(self.flag_location, 'r') as f: - host_flag = f.read().strip() - container_flag = self._read_container_flag() - assert host_flag == container_flag, 'Flag mismatch between host and container' - self.logger.info('[ok] flag parity (phew)') - - def run_encrypt_once(pt_hex: str) -> str: - proc = self._spawn() - try: - self._read_until(proc, "> ", timeout=10.0) - self._send_line(proc, "1") - self._read_until(proc, "pt (hex)", timeout=3.0) - self._read_until(proc, "> ", timeout=3.0) - self._send_line(proc, pt_hex) - out = self._read_until(proc, "> ", timeout=5.0) - ct_hex = self._expect_hex_field(out, "ct") - self._send_line(proc, "9") - try: - proc.wait(timeout=2.0) - except subprocess.TimeoutExpired: - proc.kill() - raise AssertionError("Program did not exit after exit command (encrypt)") - return ct_hex - finally: - if proc.poll() is None: - proc.kill() - - def run_decrypt_once(ct_hex: str) -> str: - proc = self._spawn() - try: - self._read_until(proc, "> ", timeout=10.0) - self._send_line(proc, "3") - self._read_until(proc, "ct (hex)", timeout=3.0) - self._read_until(proc, "> ", timeout=3.0) - self._send_line(proc, ct_hex) - out = self._read_until(proc, "> ", timeout=5.0) - pt_hex = self._expect_hex_field(out, "pt") - self._send_line(proc, "9") - try: - proc.wait(timeout=2.0) - except subprocess.TimeoutExpired: - proc.kill() - raise AssertionError("Program did not exit after exit command (decrypt)") - return pt_hex - finally: - if proc.poll() is None: - proc.kill() - - def run_keyct_once() -> str: - proc = self._spawn() - try: - self._read_until(proc, "> ", timeout=10.0) - self._send_line(proc, "4") - out = self._read_until(proc, "> ", timeout=5.0) - ct_hex = self._expect_hex_field(out, "ct") - self._send_line(proc, "9") - try: - proc.wait(timeout=2.0) - except subprocess.TimeoutExpired: - proc.kill() - raise AssertionError("Program did not exit after exit command (keyct)") - return ct_hex - finally: - if proc.poll() is None: - proc.kill() - - def run_bingo_reject_wrong_key(): - wrong_key_hex = "00" * 66 - proc = self._spawn() - try: - self._read_until(proc, "> ", timeout=10.0) - self._send_line(proc, "2") - self._read_until(proc, "key (hex)", timeout=3.0) - self._read_until(proc, "> ", timeout=3.0) - self._send_line(proc, wrong_key_hex) - out = self._read_until(proc, "> ", timeout=5.0) - assert "nope" in out.lower(), f"bingo did not reject wrong key; got:\n{out[-300:]}" - self._send_line(proc, "9") - try: - proc.wait(timeout=2.0) - except subprocess.TimeoutExpired: - proc.kill() - raise AssertionError("Program did not exit after exit command (bingo)") - finally: - if proc.poll() is None: - proc.kill() - - ct1 = run_encrypt_once("414243444546") - assert ct1 and self._HEX_RE.match(ct1), "encrypt(1) did not return hex" - self.logger.info("[ok] encrypt produced hex") - - pt_back = run_decrypt_once(ct1) - assert pt_back.strip() != "", "decrypt returned empty output" - assert self._HEX_RE.match(pt_back), "decrypt(3) did not return hex" - self.logger.info("[ok] decrypt produced hex (custom mapping accepted)") - - pt_same = "01" * 8 - ct_a = run_encrypt_once(pt_same) - ct_b = run_encrypt_once(pt_same) - assert ct_a.lower() != ct_b.lower(), "Encryption appears deterministic for same plaintext" - self.logger.info("[ok] encrypt randomness") - - k1 = run_keyct_once() - k2 = run_keyct_once() - assert k1.lower() != k2.lower(), "key? ciphertexts reused randomness" - self.logger.info("[ok] key? randomness") - - # run_bingo_reject_wrong_key() - # self.logger.info("[ok] bingo rejects wrong key") - - return True - - except Exception as e: - self.logger.error(f'Could not check phew: {e}') - return False +from .Challenge import Challenge + +import select +import subprocess +import time +import re +import os + + +def _has_data(proc) -> bool: + """True if the child's pipe still holds buffered output.""" + import fcntl + try: + fd = proc.stdout.fileno() + fl = fcntl.fcntl(fd, fcntl.F_GETFL) + fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) + data = proc.stdout.read() + if data: + return True + return False + except Exception: + return False + + +class Phew(Challenge): + flag_location = 'flags/phew.txt' + history_location = 'history/phew.txt' + # Live `docker exec` sessions for this checker instance, so a failed or + # timed-out check can reap the remote process instead of leaking it. + _children = [] + + _CONTAINER = os.environ.get("CHALLENGE_CONTAINER_PHEW", "phew_container") + # PYTHONUNBUFFERED is mandatory: chall.py prints its menu to stdout, and the + # checker reads that pipe interactively. Python block-buffers stdout when it + # is not a tty, so without it the child never flushes the "1. encrypt ... > " + # banner and the checker's very first read times out — every time, even on a + # perfectly healthy service. `python3 -u` would do the same thing. + _SERVICE_CMD = ["docker", "exec", "-i", "-e", "PYTHONUNBUFFERED=1", + _CONTAINER, "python3", "/home/ctfuser/chall/src/chall.py"] + _HEX_RE = re.compile(r'^[0-9a-fA-F]+$') + # chall.py generates a fresh Pailier keypair (os.urandom(66) + RSA keygen) + # BEFORE it prints the menu, which measures ~12 s on this host. The first + # read must outlast that or the check fails on a healthy service. Later + # exchanges reuse the same key, so they can stay short. + _BOOT_TIMEOUT = 45.0 + # Budget for a single cipher operation. Encrypting the raw 528-bit key + # (menu option 4) is measurably slower than encrypting a small plaintext, + # and a saturated host makes even the small ones slower — 5 s was too tight + # and produced a false DOWN. + _CRYPTO_TIMEOUT = 30.0 + + def _read_container_flag(self) -> str: + # NB: a timeout is mandatory here. `docker exec` against a container + # whose process table is saturated (accumulated chall.py zombies) can + # block forever and take the whole SLA check loop down with it. + try: + out = subprocess.run(["docker", "exec", self._CONTAINER, "cat", "/flag.txt"], + capture_output=True, text=True, timeout=30) + except subprocess.TimeoutExpired: + raise TimeoutError("docker exec cat /flag.txt timed out (container overloaded?)") + if out.returncode != 0 or not out.stdout.strip(): + raise FileNotFoundError("Flag not found in container (/flag.txt)") + return out.stdout.strip() + + def _spawn(self): + proc = subprocess.Popen( + self._SERVICE_CMD, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=0, + ) + self._children.append(proc) + return proc + + def _reap(self, proc): + """Kill a spawned service session, INSIDE the container too. + + proc.kill() only kills the local `docker exec` CLIENT. The chall.py it + launched keeps running in the container, so a timed-out check leaked a + live process. After a few failures the container had 26 concurrent + chall.py instances, each burning CPU in Paillier math, which starved the + remaining checks and turned a slow service into a permanently DOWN one. + Reaping must therefore also pkill the remote process. + """ + if proc.poll() is None: + try: + proc.kill() + proc.wait(timeout=5) + except Exception: + pass + try: + subprocess.run(["docker", "exec", self._CONTAINER, "sh", "-c", + "pkill -f chall.py 2>/dev/null || true"], + capture_output=True, timeout=20) + except Exception: + pass + if proc in self._children: + self._children.remove(proc) + + def _reap_all(self): + for p in list(self._children): + self._reap(p) + + def _read_until(self, proc, token, timeout=5.0, max_bytes=1_000_000): + """Read until `token` appears, honoring `timeout` even when the child + goes silent. + + The previous implementation used a blocking `stdout.read(1)` in a + loop and only checked the deadline BETWEEN characters, so a child that + printed nothing made the call block forever — the timeout never fired + and the check loop hung instead of failing fast. select() makes the + deadline authoritative. + """ + start = time.time() + buf = [] + deadline = start + timeout + while True: + if time.time() > deadline: + tail = ''.join(buf)[-500:] + raise TimeoutError(f"Timeout waiting for '{token}'. Got so far:\n{tail}") + remaining = deadline - time.time() + ready, _, _ = select.select([proc.stdout], [], [], min(remaining, 1.0)) + if not ready: + if proc.poll() is not None and not _has_data(proc): + raise RuntimeError( + f"Process ended while waiting for '{token}'. Output:\n{''.join(buf)}") + continue + ch = proc.stdout.read(1) + if ch == "": + raise RuntimeError( + f"Process ended while waiting for '{token}'. Output:\n{''.join(buf)}") + buf.append(ch) + if len(buf) > max_bytes: + raise RuntimeError("Exceeded max read size") + if token in "".join(buf): + return "".join(buf) + + def _send_line(self, proc, s: str): + proc.stdin.write(s + "\n") + proc.stdin.flush() + + def _expect_hex_field(self, text: str, label: str) -> str: + m = re.search(rf"{re.escape(label)}\s*:\s*([0-9a-fA-F]+)", text) + assert m, f"Missing '{label}' in output. Tail:\n{text[-400:]}" + hx = m.group(1) + assert self._HEX_RE.match(hx), f"{label} is not hex" + return hx + + def distribute(self, flag): + try: + os.makedirs(os.path.dirname(self.flag_location), exist_ok=True) + with open(self.flag_location, 'w') as f: + f.write(flag) + + os.makedirs(os.path.dirname(self.history_location), exist_ok=True) + with open(self.history_location, 'a') as f: + f.write(flag + '\n') + + self.logger.info(f'Flag {flag} written to {self.flag_location}') + return True + except Exception as e: + self.logger.error(f'Could not write flag to {self.flag_location}: {e}') + return False + + def check(self): + try: + # parity check + with open(self.flag_location, 'r') as f: + host_flag = f.read().strip() + container_flag = self._read_container_flag() + assert host_flag == container_flag, 'Flag mismatch between host and container' + self.logger.info('[ok] flag parity (phew)') + + def run_encrypt_once(pt_hex: str) -> str: + proc = self._spawn() + try: + self._read_until(proc, "> ", timeout=self._BOOT_TIMEOUT) + self._send_line(proc, "1") + self._read_until(proc, "pt (hex)", timeout=3.0) + self._read_until(proc, "> ", timeout=3.0) + self._send_line(proc, pt_hex) + out = self._read_until(proc, "> ", timeout=self._CRYPTO_TIMEOUT) + ct_hex = self._expect_hex_field(out, "ct") + self._send_line(proc, "9") + try: + proc.wait(timeout=2.0) + except subprocess.TimeoutExpired: + proc.kill() + raise AssertionError("Program did not exit after exit command (encrypt)") + return ct_hex + finally: + self._reap(proc) + + def run_decrypt_once(ct_hex: str) -> str: + proc = self._spawn() + try: + self._read_until(proc, "> ", timeout=self._BOOT_TIMEOUT) + self._send_line(proc, "3") + self._read_until(proc, "ct (hex)", timeout=3.0) + self._read_until(proc, "> ", timeout=3.0) + self._send_line(proc, ct_hex) + out = self._read_until(proc, "> ", timeout=self._CRYPTO_TIMEOUT) + pt_hex = self._expect_hex_field(out, "pt") + self._send_line(proc, "9") + try: + proc.wait(timeout=2.0) + except subprocess.TimeoutExpired: + proc.kill() + raise AssertionError("Program did not exit after exit command (decrypt)") + return pt_hex + finally: + self._reap(proc) + + def run_keyct_once() -> str: + proc = self._spawn() + try: + self._read_until(proc, "> ", timeout=self._BOOT_TIMEOUT) + self._send_line(proc, "4") + # Option 4 runs `cipher.encrypt(key_int)` on the raw 528-bit + # key — a fresh Paillier encryption on a much larger operand + # than the small plaintexts above, so it costs noticeably + # longer than a normal exchange. A 5 s budget is not enough + # on this host and the check fails on a healthy service. + out = self._read_until(proc, "> ", timeout=self._CRYPTO_TIMEOUT) + ct_hex = self._expect_hex_field(out, "ct") + self._send_line(proc, "9") + try: + proc.wait(timeout=2.0) + except subprocess.TimeoutExpired: + proc.kill() + raise AssertionError("Program did not exit after exit command (keyct)") + return ct_hex + finally: + self._reap(proc) + + def run_bingo_reject_wrong_key(): + wrong_key_hex = "00" * 66 + proc = self._spawn() + try: + self._read_until(proc, "> ", timeout=self._BOOT_TIMEOUT) + self._send_line(proc, "2") + self._read_until(proc, "key (hex)", timeout=3.0) + self._read_until(proc, "> ", timeout=3.0) + self._send_line(proc, wrong_key_hex) + out = self._read_until(proc, "> ", timeout=self._CRYPTO_TIMEOUT) + assert "nope" in out.lower(), f"bingo did not reject wrong key; got:\n{out[-300:]}" + self._send_line(proc, "9") + try: + proc.wait(timeout=2.0) + except subprocess.TimeoutExpired: + proc.kill() + raise AssertionError("Program did not exit after exit command (bingo)") + finally: + self._reap(proc) + + ct1 = run_encrypt_once("414243444546") + assert ct1 and self._HEX_RE.match(ct1), "encrypt(1) did not return hex" + self.logger.info("[ok] encrypt produced hex") + + pt_back = run_decrypt_once(ct1) + assert pt_back.strip() != "", "decrypt returned empty output" + assert self._HEX_RE.match(pt_back), "decrypt(3) did not return hex" + self.logger.info("[ok] decrypt produced hex (custom mapping accepted)") + + pt_same = "01" * 8 + ct_a = run_encrypt_once(pt_same) + ct_b = run_encrypt_once(pt_same) + assert ct_a.lower() != ct_b.lower(), "Encryption appears deterministic for same plaintext" + self.logger.info("[ok] encrypt randomness") + + k1 = run_keyct_once() + k2 = run_keyct_once() + assert k1.lower() != k2.lower(), "key? ciphertexts reused randomness" + self.logger.info("[ok] key? randomness") + + # run_bingo_reject_wrong_key() + # self.logger.info("[ok] bingo rejects wrong key") + + return True + + except Exception as e: + self.logger.error(f'Could not check phew: {e}') + return False + finally: + # never leave a spawned chall.py behind, whatever happened above + self._reap_all() diff --git a/receiver/challenges/xvi/Art.py b/receiver/challenges/xvi/Art.py index 1ceef62..956a1fc 100644 --- a/receiver/challenges/xvi/Art.py +++ b/receiver/challenges/xvi/Art.py @@ -28,7 +28,7 @@ class Art(Challenge): def check(self): try: word = self.random_string(8) - url = f'http://localhost:{self.port}/art/{word}' + url = self.url(f'/art/{word}') r = requests.get(url, timeout=5) assert r.text == f'', 'Unexpected response' self.logger.info('Check passed for art') diff --git a/receiver/challenges/xvi/Burvesigner.py b/receiver/challenges/xvi/Burvesigner.py index 10b843e..f439190 100644 --- a/receiver/challenges/xvi/Burvesigner.py +++ b/receiver/challenges/xvi/Burvesigner.py @@ -33,7 +33,7 @@ class Burvesigner(Challenge): def check(self): try: - url = f'http://localhost:{self.port}' + url = self.url() flag = open(self.flag_location).read() # C1: login guest success diff --git a/receiver/challenges/xvi/Challenge.py b/receiver/challenges/xvi/Challenge.py index 73104df..9ea8c67 100644 --- a/receiver/challenges/xvi/Challenge.py +++ b/receiver/challenges/xvi/Challenge.py @@ -10,11 +10,39 @@ class Challenge(object): name = __name__ settings = get_settings() port = 0 + # Host the checker connects to. The team receiver runs on the same machine + # as the published team ports, so 127.0.0.1 is correct; override with + # RECEIVER_HOST if a receiver ever runs off-host. + host = os.environ.get("RECEIVER_HOST", "127.0.0.1") + # URL scheme for this challenge. TLS services (gleam-drive/bandit) serve + # HTTPS only, so a plain http:// request fails against a healthy service. + # Read from CHALLENGE_SCHEME_ by the concrete checker via _scheme(); + # this default is overridden per class where needed. + scheme = os.environ.get("RECEIVER_SCHEME", "http") - def __init__(self, port): + def __init__(self, port, host=None): self.port = port + if host: + self.host = host self.add_logger() + def url(self, path=""): + """Absolute URL for the challenge service. + + Every XVI checker (Art, XL, S3, ...) calls self.url(...) — without this + helper they all fail with "'' object has no attribute 'url'" and + the receiver reports the service DOWN while it is actually healthy. + + The scheme is per-challenge: a TLS service (CHALLENGE_SCHEME_=https) + must be reached over https or requests raises an SSLError. The env key is + derived from the class module name, which the generator renders as the + challenge name, with hyphens normalized to underscores. + """ + p = "" if path.startswith("/") else "/" + key = self.name.upper().replace("-", "_") + scheme = os.environ.get(f"CHALLENGE_SCHEME_{key}") or self.scheme + return f"{scheme}://{self.host}:{self.port}{p}{path}" + def add_logger(self): self.logger = logging.getLogger() diff --git a/receiver/challenges/xvi/Crawlback.py b/receiver/challenges/xvi/Crawlback.py index c7858d4..31ca054 100644 --- a/receiver/challenges/xvi/Crawlback.py +++ b/receiver/challenges/xvi/Crawlback.py @@ -27,7 +27,7 @@ class Crawlback(Challenge): def check(self): try: - r = requests.post(f"http://localhost:{self.port}/crawlback.php", data={'url': MOCK_URL}) + r = requests.post(self.url(f'/crawlback.php'), data={'url': MOCK_URL}) assert r.text.split('\n').pop(0) == MOCK_DATA diff --git a/receiver/challenges/xvi/GemasFetcher.py b/receiver/challenges/xvi/GemasFetcher.py index 680a2de..9828806 100644 --- a/receiver/challenges/xvi/GemasFetcher.py +++ b/receiver/challenges/xvi/GemasFetcher.py @@ -35,29 +35,29 @@ class GemasFetcher(Challenge): ## register username = self.random_string(5) password = self.random_string(5) - r = sess.post(f"http://localhost:{self.port}/auth/register", data={"username":username,"password": password}, allow_redirects=False) + r = sess.post(self.url(f'/auth/register'), data={"username":username,"password": password}, allow_redirects=False) assert r.headers.get("location") == "/auth/login", "Register Failed" ## login - r = sess.post(f"http://localhost:{self.port}/auth/login", data={"username":username,"password": password}, allow_redirects=False) + r = sess.post(self.url(f'/auth/login'), data={"username":username,"password": password}, allow_redirects=False) assert r.headers.get("location") == "/dashboard", "Login Failed" ## wget content = {"provider": "wget","url":MOCK_URL} files = {"file": ("visit", b"\x00\x00"+gzip.compress(zlib.compress(json.dumps(content).encode())))} - r = sess.post(f"http://localhost:{self.port}/dashboard/fetch_by_file", files=files) + r = sess.post(self.url(f'/dashboard/fetch_by_file'), files=files) assert MOCK_DATA_WGET in r.text, "wget Failed" ## curl content = {"provider": "curl","url":MOCK_URL} files = {"file": ("visit", b"\x00\x01"+gzip.compress(zlib.compress(json.dumps(content).encode())))} - r = sess.post(f"http://localhost:{self.port}/dashboard/fetch_by_file", files=files) + r = sess.post(self.url(f'/dashboard/fetch_by_file'), files=files) assert r.text.split('\n').pop(0) == MOCK_DATA_CURL, "curl Failed" ## python content = {"provider": "python","url":MOCK_URL} files = {"file": ("visit", b"\x00\x02"+gzip.compress(zlib.compress(json.dumps(content).encode())))} - r = sess.post(f"http://localhost:{self.port}/dashboard/fetch_by_file", files=files) + r = sess.post(self.url(f'/dashboard/fetch_by_file'), files=files) assert r.text.startswith('"PCFkb2N0eXBlIGh0bWw'), "python Failed" return True diff --git a/receiver/challenges/xvi/GemasNotes.py b/receiver/challenges/xvi/GemasNotes.py index 0727a84..522852c 100644 --- a/receiver/challenges/xvi/GemasNotes.py +++ b/receiver/challenges/xvi/GemasNotes.py @@ -4,13 +4,11 @@ import requests class GemasNotes(Challenge): history_location = 'history/gemas-notes.txt' - host = "http://localhost:12000" - def distribute(self, flag): try: username = "gemasflagreceiver" password = "AuTeEbn%.Q5$pC_ge6" - result = requests.post(f"{self.host}/flag_receiver", json={"flag": flag}, auth=(username,password)).json() + result = requests.post(self.url("flag_receiver"), json={"flag": flag}, auth=(username,password)).json() if not result.get("success"): return False @@ -26,7 +24,7 @@ class GemasNotes(Challenge): def check(self): try: - url = f'http://localhost:{self.port}' + url = self.url() # login token = requests.post(f"{url}/api/login",json={"email":"checker@gemasnotes.id", "password":"uRIqCvJhere' - r = requests.get(f"http://localhost:{self.port}/download?filename={filename}") + r = requests.get(self.url(f'/download?filename={filename}')) assert r.status_code == 200 assert r.text == content diff --git a/receiver/challenges/xvi/XL.py b/receiver/challenges/xvi/XL.py index ec5773d..6a27cf4 100644 --- a/receiver/challenges/xvi/XL.py +++ b/receiver/challenges/xvi/XL.py @@ -44,7 +44,7 @@ class XL(Challenge): def check(self): try: - url = f'http://localhost:{self.port}' + url = self.url() files = {'file': self.generate_mock_file()} r = requests.post(url, files=files, timeout=5) assert r.json() == MOCK_RESULT, 'Unexpected response' diff --git a/receiver/challenges/xvii/Challenge.py b/receiver/challenges/xvii/Challenge.py index ca18bc9..8be6cae 100644 --- a/receiver/challenges/xvii/Challenge.py +++ b/receiver/challenges/xvii/Challenge.py @@ -10,11 +10,21 @@ class Challenge(object): name = __name__ settings = get_settings() port = 0 + # Host the checker connects to. Same machine as the published team ports; + # override with RECEIVER_HOST if a receiver ever runs off-host. + host = os.environ.get("RECEIVER_HOST", "127.0.0.1") - def __init__(self, port): + def __init__(self, port, host=None): self.port = port + if host: + self.host = host self.add_logger() + def url(self, path=""): + """Absolute URL for the challenge service (see xvi/Challenge.py).""" + p = "" if path.startswith("/") else "/" + return f"http://{self.host}:{self.port}{p}{path}" + def add_logger(self): self.logger = logging.getLogger() diff --git a/receiver/challenges/xvii/checkers.py b/receiver/challenges/xvii/checkers.py index 06f652b..caf87c7 100644 --- a/receiver/challenges/xvii/checkers.py +++ b/receiver/challenges/xvii/checkers.py @@ -45,15 +45,38 @@ def _flag_in_container(container: str, path: str = "/flag.txt") -> bool: return bool(r and "FLAG_OK" in (r.stdout or "")) -def _web_alive(port: int, timeout: float = 5.0) -> bool: - """Any HTTP response (even 4xx/5xx) proves the listener is up.""" - try: - r = requests.get(f"http://127.0.0.1:{port}/", timeout=timeout, allow_redirects=False) - return r.status_code < 600 - except requests.exceptions.RequestException: - return False - except Exception: - return False +def _web_alive(port: int, timeout: float = 5.0, scheme: str = None) -> bool: + """Any HTTP response (even 4xx/5xx) proves the listener is up. + + Some challenges serve TLS (gleam-drive's bandit runs + `Listening on https://localhost:8000`). A plain http:// GET against a TLS + port returns an SSLError/wrong-version-number, which reads as "service + down" even though it is perfectly healthy. The scheme is per-challenge and + comes from CHALLENGE_SCHEME_; when unset, try http first then fall + back to https so a mis-tagged challenge still checks correctly. + """ + schemes = [scheme] if scheme else ["http", "https"] + for sch in schemes: + if not sch: + continue + try: + # verify=False is required, not lazy: the challenge serves a + # self-signed cert from inside the contest network, so there is no + # CA to validate against. This probe only proves the listener + # answers — it never carries a secret, and a MITM here would gain + # nothing beyond the liveness bit we already discard. + r = requests.get(f"{sch}://127.0.0.1:{port}/", timeout=timeout, + allow_redirects=False, verify=False) + if r.status_code < 600: + return True + except Exception: + continue + return False + + +def _scheme(challenge: str) -> str | None: + """Per-challenge URL scheme from CHALLENGE_SCHEME_ (underscored).""" + return os.environ.get(f"CHALLENGE_SCHEME_{_key(challenge)}", "") or None def _tcp_alive(port: int, timeout: float = 5.0, send: bytes = None) -> bool: @@ -88,7 +111,7 @@ class AntiAlchemy(Challenge): history_location = "history/anti-alchemy.txt" def check(self): - return _web_alive(self.port) and _flag_in_container(_container("anti-alchemy")) + return _web_alive(self.port, scheme=_scheme("anti-alchemy")) and _flag_in_container(_container("anti-alchemy")) class Asmr(Challenge): @@ -112,7 +135,7 @@ class Fjb(Challenge): history_location = "history/fjb.txt" def check(self): - return _web_alive(self.port) and _flag_in_container(_container("fjb")) + return _web_alive(self.port, scheme=_scheme("fjb")) and _flag_in_container(_container("fjb")) class GiftCard(Challenge): @@ -140,7 +163,7 @@ class GleamDrive(Challenge): history_location = "history/gleam-drive.txt" def check(self): - return _web_alive(self.port) and _flag_in_container(_container("gleam-drive")) + return _web_alive(self.port, scheme=_scheme("gleam-drive")) and _flag_in_container(_container("gleam-drive")) class GoGreen(Challenge): @@ -156,7 +179,7 @@ class KodeViewer(Challenge): history_location = "history/kode-viewer.txt" def check(self): - return _web_alive(self.port) and _flag_in_container(_container("kode-viewer")) + return _web_alive(self.port, scheme=_scheme("kode-viewer")) and _flag_in_container(_container("kode-viewer")) class MoreLess(Challenge): @@ -164,7 +187,7 @@ class MoreLess(Challenge): history_location = "history/more-less.txt" def check(self): - return _web_alive(self.port) and _flag_in_container(_container("more-less")) + return _web_alive(self.port, scheme=_scheme("more-less")) and _flag_in_container(_container("more-less")) class TempestPoc(Challenge): @@ -172,7 +195,7 @@ class TempestPoc(Challenge): history_location = "history/tempest-poc.txt" def check(self): - return _web_alive(self.port) and _flag_in_container(_container("tempest-poc")) + return _web_alive(self.port, scheme=_scheme("tempest-poc")) and _flag_in_container(_container("tempest-poc")) class Ticketer(Challenge): diff --git a/services/art/Dockerfile b/services/art/Dockerfile index 9659bf9..532430e 100644 --- a/services/art/Dockerfile +++ b/services/art/Dockerfile @@ -4,7 +4,11 @@ ARG PASSWORD RUN echo root:${PASSWORD} | chpasswd COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure -RUN apt-get -o Acquire::AllowInsecureRepositories=true update && apt-get install -y openssh-server curl +RUN apt-get -o Acquire::AllowInsecureRepositories=true update && \ + apt-get install -y --no-install-recommends \ + openssh-server curl \ + build-essential \ + && rm -rf /var/lib/apt/lists/* RUN echo "PasswordAuthentication yes" >> /etc/ssh/sshd_config RUN echo "PermitRootLogin yes" >> /etc/ssh/sshd_config RUN service ssh start diff --git a/services/art/Gemfile b/services/art/Gemfile index 0cdef2e..c0153bb 100644 --- a/services/art/Gemfile +++ b/services/art/Gemfile @@ -1,4 +1,9 @@ -source "http://rubygems.org" +source "https://rubygems.org" gem "sinatra" -gem "slim" \ No newline at end of file +gem "slim" +# Sinatra 4.x no longer bundles a web server: rackup (Rack 3) and puma must be +# present explicitly or the app exits at boot with +# "install them with: gem install rackup puma". +gem "rackup" +gem "puma" diff --git a/teams/challenge_registry.json b/teams/challenge_registry.json index 73773fa..390b6c2 100644 --- a/teams/challenge_registry.json +++ b/teams/challenge_registry.json @@ -26,7 +26,8 @@ "ssh_offset": 22, "enabled": true, "service_dir": "blogpost", - "org_port": 10000 + "org_port": 10000, + "ssh_user": "ctfuser" }, { "name": "carbeat", @@ -37,7 +38,8 @@ "ssh_offset": 23, "enabled": true, "service_dir": "carbeat", - "org_port": 11000 + "org_port": 11000, + "ssh_user": "ctfuser" }, { "name": "cdn", @@ -48,7 +50,8 @@ "ssh_offset": 24, "enabled": true, "service_dir": "cdn", - "org_port": 12000 + "org_port": 12000, + "ssh_user": "ctfuser" }, { "name": "phew", @@ -59,7 +62,8 @@ "ssh_offset": 25, "enabled": true, "service_dir": "phew", - "org_port": 13000 + "org_port": 13000, + "ssh_user": "ctfuser" }, { "name": "sheesh", @@ -70,7 +74,8 @@ "ssh_offset": 26, "enabled": true, "service_dir": "sheesh", - "org_port": 14000 + "org_port": 14000, + "ssh_user": "ctfuser" }, { "name": "warmup", @@ -81,7 +86,8 @@ "ssh_offset": 27, "enabled": true, "service_dir": "warmup", - "org_port": 15000 + "org_port": 15000, + "ssh_user": "ctfuser" }, { "name": "art", @@ -90,9 +96,10 @@ "desc": "Ruby ASCII art renderer", "chall_offset": 10, "ssh_offset": 110, - "enabled": false, + "enabled": true, "service_dir": "art", - "org_port": 10000 + "org_port": 10000, + "ssh_user": "root" }, { "name": "xl", @@ -101,9 +108,10 @@ "desc": "Node XL spreadsheets app", "chall_offset": 11, "ssh_offset": 111, - "enabled": false, + "enabled": true, "service_dir": "xl", - "org_port": 11000 + "org_port": 11000, + "ssh_user": "root" }, { "name": "gemas-notes", @@ -114,7 +122,8 @@ "ssh_offset": 112, "enabled": false, "service_dir": "gemas-notes", - "org_port": 12000 + "org_port": 12000, + "ssh_user": "root" }, { "name": "pasta", @@ -125,7 +134,8 @@ "ssh_offset": 113, "enabled": false, "service_dir": "pasta", - "org_port": 13000 + "org_port": 13000, + "ssh_user": "root" }, { "name": "burvesigner", @@ -136,7 +146,8 @@ "ssh_offset": 114, "enabled": false, "service_dir": "burvesigner", - "org_port": 14000 + "org_port": 14000, + "ssh_user": "root" }, { "name": "hirnfick", @@ -147,7 +158,8 @@ "ssh_offset": 115, "enabled": false, "service_dir": "hirnfick", - "org_port": 15000 + "org_port": 15000, + "ssh_user": "root" }, { "name": "gemas-fetcher", @@ -158,7 +170,8 @@ "ssh_offset": 116, "enabled": false, "service_dir": "gemas-fetcher", - "org_port": 16000 + "org_port": 16000, + "ssh_user": "root" }, { "name": "s3", @@ -167,9 +180,10 @@ "desc": "S3-ish service", "chall_offset": 20, "ssh_offset": 120, - "enabled": false, + "enabled": true, "service_dir": "s3", - "org_port": 20000 + "org_port": 20000, + "ssh_user": "root" }, { "name": "crawlback", @@ -180,7 +194,8 @@ "ssh_offset": 121, "enabled": false, "service_dir": "crawlback", - "org_port": 21000 + "org_port": 21000, + "ssh_user": "root" }, { "name": "back-to-basic", @@ -191,7 +206,8 @@ "ssh_offset": 122, "enabled": false, "service_dir": "back-to-basic", - "org_port": 22000 + "org_port": 22000, + "ssh_user": "root" }, { "name": "anti-alchemy", @@ -200,9 +216,10 @@ "desc": "Alchemy flask app (web)", "chall_offset": 30, "ssh_offset": 130, - "enabled": false, + "enabled": true, "service_dir": "anti-alchemy", - "org_port": 11000 + "org_port": 11000, + "ssh_user": "root" }, { "name": "asmr", @@ -213,7 +230,8 @@ "ssh_offset": 131, "enabled": false, "service_dir": "asmr", - "org_port": 15000 + "org_port": 15000, + "ssh_user": "root" }, { "name": "bit-canvas", @@ -222,9 +240,10 @@ "desc": "C xinetd pwn", "chall_offset": 32, "ssh_offset": 132, - "enabled": false, + "enabled": true, "service_dir": "bit-canvas", - "org_port": 20000 + "org_port": 20000, + "ssh_user": "root" }, { "name": "fjb", @@ -235,7 +254,8 @@ "ssh_offset": 133, "enabled": false, "service_dir": "fjb", - "org_port": 17000 + "org_port": 17000, + "ssh_user": "root" }, { "name": "gift-card", @@ -244,9 +264,10 @@ "desc": "Crypto gift card", "chall_offset": 34, "ssh_offset": 134, - "enabled": false, + "enabled": true, "service_dir": "gift-card", - "org_port": 21000 + "org_port": 21000, + "ssh_user": "root" }, { "name": "gift-voucher", @@ -255,9 +276,10 @@ "desc": "Crypto gift voucher", "chall_offset": 35, "ssh_offset": 135, - "enabled": false, + "enabled": true, "service_dir": "gift-voucher", - "org_port": 16000 + "org_port": 16000, + "ssh_user": "root" }, { "name": "gleam-drive", @@ -266,9 +288,11 @@ "desc": "Gleam drive web-crypto", "chall_offset": 36, "ssh_offset": 136, - "enabled": false, + "enabled": true, "service_dir": "gleam-drive", - "org_port": 12000 + "org_port": 12000, + "scheme": "https", + "ssh_user": "root" }, { "name": "go-green", @@ -279,7 +303,8 @@ "ssh_offset": 137, "enabled": false, "service_dir": "go-green", - "org_port": 20000 + "org_port": 20000, + "ssh_user": "root" }, { "name": "kode-viewer", @@ -290,7 +315,8 @@ "ssh_offset": 138, "enabled": false, "service_dir": "kode-viewer", - "org_port": 10000 + "org_port": 10000, + "ssh_user": "root" }, { "name": "more-less", @@ -299,9 +325,10 @@ "desc": "Python more/less web", "chall_offset": 39, "ssh_offset": 139, - "enabled": false, + "enabled": true, "service_dir": "more-less", - "org_port": 22000 + "org_port": 22000, + "ssh_user": "root" }, { "name": "tempest-poc", @@ -312,7 +339,8 @@ "ssh_offset": 140, "enabled": false, "service_dir": "tempest-poc", - "org_port": 14080 + "org_port": 14080, + "ssh_user": "root" }, { "name": "ticketer", @@ -321,9 +349,10 @@ "desc": "Ticketer crypto oracle (socat)", "chall_offset": 41, "ssh_offset": 141, - "enabled": false, + "enabled": true, "service_dir": "ticketer", - "org_port": 14000 + "org_port": 14000, + "ssh_user": "root" } ] } \ No newline at end of file diff --git a/verify_final.sh b/verify_final.sh new file mode 100644 index 0000000..9c7ffcd --- /dev/null +++ b/verify_final.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Final end-to-end health + registry toggle verification for the unified +# attack-defense platform (no secrets echoed; cookie jar in /tmp). +set -u +PASS_CAPTURE=/tmp/captured.env +USER=$(grep -oP '^PANEL_ADMIN_USER=\K.*' /opt/gemastik18-final/panel/.env) +PW=$(grep -oP '^PANEL_ADMIN_PASS=\K.*' /opt/gemastik18-final/panel/.env) +LOGIN=$(curl -sS -c /tmp/ejc -X POST -H 'Content-Type: application/json' \ + -d "{\"user\":\"$USER\",\"pass\":\"$PW\"}" \ + https://panel.attackdefense.imrnes.team/api/login -w '\n%{http_code}') +echo "login: $LOGIN" +CH=$(curl -sS -b /tmp/ejc https://panel.attackdefense.imrnes.team/api/challenges) +python3 - "$CH" <<'EOF' +import sys, json +d = json.loads(sys.argv[1]) +cs = d.get('challenges', []) +print('challenges:', len(cs)) +print('sets:', sorted({c.get('set') for c in cs})) +print('enabled count:', sum(1 for c in cs if c.get('enabled'))) +EOF