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.
This commit is contained in:
@@ -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-<name>` 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
|
||||||
@@ -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:<pw>' | 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:-<none>}"
|
||||||
|
done
|
||||||
@@ -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> [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 ==="
|
||||||
@@ -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<q>["'])http://localhost:\{self\.port\}(?P<path>/[^"']*)?(?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())
|
||||||
@@ -14,6 +14,14 @@ from pathlib import Path
|
|||||||
TEAMS_DIR = Path("/opt/gemastik18-final/teams")
|
TEAMS_DIR = Path("/opt/gemastik18-final/teams")
|
||||||
RECEIVER_VENV = "/opt/gemastik18-final/receiver/.venv/bin/python"
|
RECEIVER_VENV = "/opt/gemastik18-final/receiver/.venv/bin/python"
|
||||||
UNIT_DIR = Path("/etc/systemd/system")
|
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):
|
def write_unit(idx: int, st: dict):
|
||||||
port = st["ports"]["receiver"]
|
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
|
# 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
|
# 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.
|
# normalize the name to underscores here. main.py looks up the same key.
|
||||||
|
# Same normalization applies to CHALLENGE_SCHEME_<NAME>.
|
||||||
|
schemes = {c["name"]: c.get("scheme") for c in load_registry().get("challenges", [])}
|
||||||
for ch in st["ports"]:
|
for ch in st["ports"]:
|
||||||
if ch in ("receiver", "panel"):
|
if ch in ("receiver", "panel"):
|
||||||
continue
|
continue
|
||||||
key = ch.upper().replace("-", "_")
|
key = ch.upper().replace("-", "_")
|
||||||
env[f"CHALLENGE_PORT_{key}"] = str(st["ports"][ch]["chall"])
|
env[f"CHALLENGE_PORT_{key}"] = str(st["ports"][ch]["chall"])
|
||||||
env[f"CHALLENGE_CONTAINER_{key}"] = f"{ch}_container_team{idx}"
|
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_<self.port>
|
# SSH passwords: checker Challenge.credentials() reads PASSWORD_<self.port>
|
||||||
# where self.port is the team challenge port.
|
# where self.port is the team challenge port.
|
||||||
pwd = st.get("chall_passwords", {}).get(ch)
|
pwd = st.get("chall_passwords", {}).get(ch)
|
||||||
|
|||||||
@@ -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
|
||||||
+57
-1
@@ -9,6 +9,8 @@ import json
|
|||||||
import time
|
import time
|
||||||
import asyncio
|
import asyncio
|
||||||
import threading
|
import threading
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
import httpx
|
import httpx
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect
|
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", ... }
|
labels = data.get("labels") or {} # { "1": "Tim Satu", ... }
|
||||||
domains = data.get("domains") or {} # { "1": "mycustom", ... }
|
domains = data.get("domains") or {} # { "1": "mycustom", ... }
|
||||||
created = []
|
created = []
|
||||||
|
ufw = []
|
||||||
for i in range(1, n + 1):
|
for i in range(1, n + 1):
|
||||||
td = orch.TEAMS_DIR / f"team{i}"
|
td = orch.TEAMS_DIR / f"team{i}"
|
||||||
if not td.exists():
|
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
|
dom = domains.get(str(i)) or domains.get(i) or None
|
||||||
st = orch.create_team(i, label, domain=dom)
|
st = orch.create_team(i, label, domain=dom)
|
||||||
created.append(st["index"])
|
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:
|
else:
|
||||||
# team exists: apply any label/domain overrides
|
# team exists: apply any label/domain overrides
|
||||||
label = labels.get(str(i)) or labels.get(i)
|
label = labels.get(str(i)) or labels.get(i)
|
||||||
@@ -544,7 +550,7 @@ async def api_teams_set(req: Request):
|
|||||||
if label or dom:
|
if label or dom:
|
||||||
orch.update_team(i, label=label, domain=dom)
|
orch.update_team(i, label=label, domain=dom)
|
||||||
orch.ensure_team_domains()
|
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}")
|
@app.put("/api/teams/{idx}")
|
||||||
async def api_team_update(idx: int, req: Request):
|
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:
|
except FileNotFoundError as e:
|
||||||
raise HTTPException(404, str(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")
|
@app.post("/api/teams/start")
|
||||||
async def api_teams_start(req: Request):
|
async def api_teams_start(req: Request):
|
||||||
require_login(req)
|
require_login(req)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Probe each team receiver's /check/<challenge> 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()
|
||||||
@@ -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 <teamIdx>
|
||||||
|
set -uo pipefail
|
||||||
|
IDX="${1:?usage: start_team_bg.sh <teamIdx>}"
|
||||||
|
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}"
|
||||||
@@ -318,6 +318,12 @@ function esc(s) {
|
|||||||
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
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) {
|
function showView(v) {
|
||||||
document.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.view === 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));
|
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(); }
|
function topoReset() { topoScale = 1; topoPanX = 0; topoPanY = 0; applyTopoView(); }
|
||||||
|
|
||||||
// ---------- Teams ----------
|
// ---------- Teams ----------
|
||||||
|
let TEAM_LABELS = {}; // idx -> label, filled by loadTeams (for confirm dialogs)
|
||||||
|
|
||||||
async function loadTeams() {
|
async function loadTeams() {
|
||||||
try {
|
try {
|
||||||
const d = await api('/api/teams');
|
const d = await api('/api/teams');
|
||||||
document.getElementById('teamCount').value = d.teams.length;
|
document.getElementById('teamCount').value = d.teams.length;
|
||||||
loadLeaderboard();
|
loadLeaderboard();
|
||||||
|
TEAM_LABELS = {};
|
||||||
|
for (const t of d.teams) TEAM_LABELS[t.index] = t.label || ('Team ' + t.index);
|
||||||
const grid = document.getElementById('teamsGrid');
|
const grid = document.getElementById('teamsGrid');
|
||||||
if (!d.teams.length) { grid.innerHTML = '<div class="card"><span style="color:#718096">Belum ada team. Set jumlah team untuk auto-create.</span></div>'; return; }
|
if (!d.teams.length) { grid.innerHTML = '<div class="card"><span style="color:#718096">Belum ada team. Set jumlah team untuk auto-create.</span></div>'; return; }
|
||||||
let html = '';
|
let html = '';
|
||||||
@@ -743,6 +753,7 @@ async function loadTeams() {
|
|||||||
<button onclick="editTeam(${t.index})">✏️ Edit Nama/Domain</button>
|
<button onclick="editTeam(${t.index})">✏️ Edit Nama/Domain</button>
|
||||||
<button onclick="openTeamLogs(${t.index})">📜 Logs</button>
|
<button onclick="openTeamLogs(${t.index})">📜 Logs</button>
|
||||||
<button onclick="randomizeTeam(${t.index})">🎲 Randomize Flag</button>
|
<button onclick="randomizeTeam(${t.index})">🎲 Randomize Flag</button>
|
||||||
|
<button class="danger" onclick="deleteTeam(${t.index})">🗑️ Hapus Team</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -859,6 +870,38 @@ async function randomizeTeam(idx) {
|
|||||||
} catch (e) { toast(e.message, true); }
|
} 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})…<br>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() {
|
async function loadLeaderboard() {
|
||||||
try {
|
try {
|
||||||
const d = await api('/api/leaderboard');
|
const d = await api('/api/leaderboard');
|
||||||
|
|||||||
@@ -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()
|
||||||
+331
-9
@@ -443,8 +443,15 @@ def create_team(idx: int, label: str = None, domain: str = None):
|
|||||||
for j, line in enumerate(recv_env):
|
for j, line in enumerate(recv_env):
|
||||||
if line.startswith(f"PASSWORD_{10000+coff*1000}="):
|
if line.startswith(f"PASSWORD_{10000+coff*1000}="):
|
||||||
recv_env[j] = f"PASSWORD_{10000+coff*1000}={state['chall_passwords'][name]}"
|
recv_env[j] = f"PASSWORD_{10000+coff*1000}={state['chall_passwords'][name]}"
|
||||||
recv_env.append(f"CHALLENGE_PORT_{name.upper()}={ports[name]['chall']}")
|
# systemd EnvironmentFile keys must match [A-Za-z_][A-Za-z0-9_]* — a
|
||||||
recv_env.append(f"CHALLENGE_CONTAINER_{name.upper()}={name}_container_team{idx}")
|
# 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")
|
(recv_dir / ".env").write_text("\n".join(recv_env) + "\n")
|
||||||
|
|
||||||
# --- flags (randomized per team so each team has unique flags) ---
|
# --- 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()
|
ensure_team_domains()
|
||||||
return st
|
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):
|
def start_team(idx: int):
|
||||||
team_dir = TEAMS_DIR / f"team{idx}"
|
team_dir = TEAMS_DIR / f"team{idx}"
|
||||||
if not (team_dir / "state.json").exists():
|
if not (team_dir / "state.json").exists():
|
||||||
@@ -491,6 +539,13 @@ def start_team(idx: int):
|
|||||||
svc_dir = team_dir / "services"
|
svc_dir = team_dir / "services"
|
||||||
subprocess.run(["docker", "compose", "-p", f"team{idx}", "-f", svc_dir / "docker-compose.yml", "up", "-d", "--build"],
|
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)
|
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)
|
_start_receiver(idx)
|
||||||
st = json.loads((team_dir / "state.json").read_text())
|
st = json.loads((team_dir / "state.json").read_text())
|
||||||
st["status"] = "running"
|
st["status"] = "running"
|
||||||
@@ -498,19 +553,53 @@ def start_team(idx: int):
|
|||||||
# Set per-team SSH passwords at runtime (images are shared across teams,
|
# 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).
|
# so chpasswd ensures each team's containers have the team's own password).
|
||||||
set_ssh_passwords(idx)
|
set_ssh_passwords(idx)
|
||||||
|
if gone:
|
||||||
|
print(f"[start_team] team{idx}: started missing sidecar(s): {', '.join(gone)}")
|
||||||
return st
|
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):
|
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}"
|
team_dir = TEAMS_DIR / f"team{idx}"
|
||||||
st = json.loads((team_dir / "state.json").read_text())
|
st = json.loads((team_dir / "state.json").read_text())
|
||||||
|
users = challenge_ssh_users()
|
||||||
|
failures = []
|
||||||
for name, coff, soff in CHALLENGES:
|
for name, coff, soff in CHALLENGES:
|
||||||
cont = f"{name}_container_team{idx}"
|
cont = f"{name}_container_team{idx}"
|
||||||
pw = st["chall_passwords"][name]
|
user = users.get(name, "root")
|
||||||
cmd = f"echo 'ctfuser:{pw}' | chpasswd"
|
pw = st["chall_passwords"].get(name)
|
||||||
# retry a few times — right after `compose up`, container may still be booting
|
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
|
ok = False
|
||||||
|
r = None
|
||||||
for attempt in range(5):
|
for attempt in range(5):
|
||||||
r = subprocess.run(["docker", "exec", cont, "sh", "-c", cmd],
|
r = subprocess.run(["docker", "exec", cont, "sh", "-c", cmd],
|
||||||
capture_output=True, text=True, timeout=30)
|
capture_output=True, text=True, timeout=30)
|
||||||
@@ -519,9 +608,13 @@ def set_ssh_passwords(idx: int):
|
|||||||
break
|
break
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
if not ok:
|
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:
|
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):
|
def stop_team(idx: int):
|
||||||
team_dir = TEAMS_DIR / f"team{idx}"
|
team_dir = TEAMS_DIR / f"team{idx}"
|
||||||
@@ -622,12 +715,241 @@ def ensure_team_domains() -> str:
|
|||||||
- main: {host}
|
- main: {host}
|
||||||
""")
|
""")
|
||||||
if not out:
|
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
|
# 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))
|
(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"
|
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 <chall>_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:
|
def list_teams() -> list:
|
||||||
out = []
|
out = []
|
||||||
if not TEAMS_DIR.exists():
|
if not TEAMS_DIR.exists():
|
||||||
|
|||||||
@@ -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 <challenge>_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()
|
||||||
Executable
+35
@@ -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 - <<PY
|
||||||
|
import sys; sys.path.insert(0,'.')
|
||||||
|
import json, subprocess, teams
|
||||||
|
want={c['name'] for c in teams.enabled_challenges()}
|
||||||
|
have={n.split('_container_team${i}')[0] for n in subprocess.run(['docker','ps','--format','{{.Names}}'],capture_output=True,text=True).stdout.split() if n.endswith('_container_team${i}')}
|
||||||
|
print(' '.join(sorted(want-have)) or '-')
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
df -h / | tail -1
|
||||||
+111
-23
@@ -1,17 +1,53 @@
|
|||||||
from .Challenge import Challenge
|
from .Challenge import Challenge
|
||||||
|
|
||||||
|
import select
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
import re
|
import re
|
||||||
import os
|
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):
|
class Phew(Challenge):
|
||||||
flag_location = 'flags/phew.txt'
|
flag_location = 'flags/phew.txt'
|
||||||
history_location = 'history/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")
|
_CONTAINER = os.environ.get("CHALLENGE_CONTAINER_PHEW", "phew_container")
|
||||||
_SERVICE_CMD = ["docker", "exec", "-i", _CONTAINER, "python3", "/home/ctfuser/chall/src/chall.py"]
|
# 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]+$')
|
_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:
|
def _read_container_flag(self) -> str:
|
||||||
# NB: a timeout is mandatory here. `docker exec` against a container
|
# NB: a timeout is mandatory here. `docker exec` against a container
|
||||||
@@ -27,7 +63,7 @@ class Phew(Challenge):
|
|||||||
return out.stdout.strip()
|
return out.stdout.strip()
|
||||||
|
|
||||||
def _spawn(self):
|
def _spawn(self):
|
||||||
return subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
self._SERVICE_CMD,
|
self._SERVICE_CMD,
|
||||||
stdin=subprocess.PIPE,
|
stdin=subprocess.PIPE,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
@@ -35,18 +71,66 @@ class Phew(Challenge):
|
|||||||
text=True,
|
text=True,
|
||||||
bufsize=0,
|
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):
|
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()
|
start = time.time()
|
||||||
buf = []
|
buf = []
|
||||||
r = proc.stdout.read
|
deadline = start + timeout
|
||||||
while True:
|
while True:
|
||||||
if time.time() - start > timeout:
|
if time.time() > deadline:
|
||||||
tail = ''.join(buf)[-500:]
|
tail = ''.join(buf)[-500:]
|
||||||
raise TimeoutError(f"Timeout waiting for '{token}'. Got so far:\n{tail}")
|
raise TimeoutError(f"Timeout waiting for '{token}'. Got so far:\n{tail}")
|
||||||
ch = r(1)
|
remaining = deadline - time.time()
|
||||||
if ch == "" and proc.poll() is not None:
|
ready, _, _ = select.select([proc.stdout], [], [], min(remaining, 1.0))
|
||||||
raise RuntimeError(f"Process ended while waiting for '{token}'. Output:\n{''.join(buf)}")
|
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)
|
buf.append(ch)
|
||||||
if len(buf) > max_bytes:
|
if len(buf) > max_bytes:
|
||||||
raise RuntimeError("Exceeded max read size")
|
raise RuntimeError("Exceeded max read size")
|
||||||
@@ -92,12 +176,12 @@ class Phew(Challenge):
|
|||||||
def run_encrypt_once(pt_hex: str) -> str:
|
def run_encrypt_once(pt_hex: str) -> str:
|
||||||
proc = self._spawn()
|
proc = self._spawn()
|
||||||
try:
|
try:
|
||||||
self._read_until(proc, "> ", timeout=10.0)
|
self._read_until(proc, "> ", timeout=self._BOOT_TIMEOUT)
|
||||||
self._send_line(proc, "1")
|
self._send_line(proc, "1")
|
||||||
self._read_until(proc, "pt (hex)", timeout=3.0)
|
self._read_until(proc, "pt (hex)", timeout=3.0)
|
||||||
self._read_until(proc, "> ", timeout=3.0)
|
self._read_until(proc, "> ", timeout=3.0)
|
||||||
self._send_line(proc, pt_hex)
|
self._send_line(proc, pt_hex)
|
||||||
out = self._read_until(proc, "> ", timeout=5.0)
|
out = self._read_until(proc, "> ", timeout=self._CRYPTO_TIMEOUT)
|
||||||
ct_hex = self._expect_hex_field(out, "ct")
|
ct_hex = self._expect_hex_field(out, "ct")
|
||||||
self._send_line(proc, "9")
|
self._send_line(proc, "9")
|
||||||
try:
|
try:
|
||||||
@@ -107,18 +191,17 @@ class Phew(Challenge):
|
|||||||
raise AssertionError("Program did not exit after exit command (encrypt)")
|
raise AssertionError("Program did not exit after exit command (encrypt)")
|
||||||
return ct_hex
|
return ct_hex
|
||||||
finally:
|
finally:
|
||||||
if proc.poll() is None:
|
self._reap(proc)
|
||||||
proc.kill()
|
|
||||||
|
|
||||||
def run_decrypt_once(ct_hex: str) -> str:
|
def run_decrypt_once(ct_hex: str) -> str:
|
||||||
proc = self._spawn()
|
proc = self._spawn()
|
||||||
try:
|
try:
|
||||||
self._read_until(proc, "> ", timeout=10.0)
|
self._read_until(proc, "> ", timeout=self._BOOT_TIMEOUT)
|
||||||
self._send_line(proc, "3")
|
self._send_line(proc, "3")
|
||||||
self._read_until(proc, "ct (hex)", timeout=3.0)
|
self._read_until(proc, "ct (hex)", timeout=3.0)
|
||||||
self._read_until(proc, "> ", timeout=3.0)
|
self._read_until(proc, "> ", timeout=3.0)
|
||||||
self._send_line(proc, ct_hex)
|
self._send_line(proc, ct_hex)
|
||||||
out = self._read_until(proc, "> ", timeout=5.0)
|
out = self._read_until(proc, "> ", timeout=self._CRYPTO_TIMEOUT)
|
||||||
pt_hex = self._expect_hex_field(out, "pt")
|
pt_hex = self._expect_hex_field(out, "pt")
|
||||||
self._send_line(proc, "9")
|
self._send_line(proc, "9")
|
||||||
try:
|
try:
|
||||||
@@ -128,15 +211,19 @@ class Phew(Challenge):
|
|||||||
raise AssertionError("Program did not exit after exit command (decrypt)")
|
raise AssertionError("Program did not exit after exit command (decrypt)")
|
||||||
return pt_hex
|
return pt_hex
|
||||||
finally:
|
finally:
|
||||||
if proc.poll() is None:
|
self._reap(proc)
|
||||||
proc.kill()
|
|
||||||
|
|
||||||
def run_keyct_once() -> str:
|
def run_keyct_once() -> str:
|
||||||
proc = self._spawn()
|
proc = self._spawn()
|
||||||
try:
|
try:
|
||||||
self._read_until(proc, "> ", timeout=10.0)
|
self._read_until(proc, "> ", timeout=self._BOOT_TIMEOUT)
|
||||||
self._send_line(proc, "4")
|
self._send_line(proc, "4")
|
||||||
out = self._read_until(proc, "> ", timeout=5.0)
|
# 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")
|
ct_hex = self._expect_hex_field(out, "ct")
|
||||||
self._send_line(proc, "9")
|
self._send_line(proc, "9")
|
||||||
try:
|
try:
|
||||||
@@ -146,19 +233,18 @@ class Phew(Challenge):
|
|||||||
raise AssertionError("Program did not exit after exit command (keyct)")
|
raise AssertionError("Program did not exit after exit command (keyct)")
|
||||||
return ct_hex
|
return ct_hex
|
||||||
finally:
|
finally:
|
||||||
if proc.poll() is None:
|
self._reap(proc)
|
||||||
proc.kill()
|
|
||||||
|
|
||||||
def run_bingo_reject_wrong_key():
|
def run_bingo_reject_wrong_key():
|
||||||
wrong_key_hex = "00" * 66
|
wrong_key_hex = "00" * 66
|
||||||
proc = self._spawn()
|
proc = self._spawn()
|
||||||
try:
|
try:
|
||||||
self._read_until(proc, "> ", timeout=10.0)
|
self._read_until(proc, "> ", timeout=self._BOOT_TIMEOUT)
|
||||||
self._send_line(proc, "2")
|
self._send_line(proc, "2")
|
||||||
self._read_until(proc, "key (hex)", timeout=3.0)
|
self._read_until(proc, "key (hex)", timeout=3.0)
|
||||||
self._read_until(proc, "> ", timeout=3.0)
|
self._read_until(proc, "> ", timeout=3.0)
|
||||||
self._send_line(proc, wrong_key_hex)
|
self._send_line(proc, wrong_key_hex)
|
||||||
out = self._read_until(proc, "> ", timeout=5.0)
|
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:]}"
|
assert "nope" in out.lower(), f"bingo did not reject wrong key; got:\n{out[-300:]}"
|
||||||
self._send_line(proc, "9")
|
self._send_line(proc, "9")
|
||||||
try:
|
try:
|
||||||
@@ -167,8 +253,7 @@ class Phew(Challenge):
|
|||||||
proc.kill()
|
proc.kill()
|
||||||
raise AssertionError("Program did not exit after exit command (bingo)")
|
raise AssertionError("Program did not exit after exit command (bingo)")
|
||||||
finally:
|
finally:
|
||||||
if proc.poll() is None:
|
self._reap(proc)
|
||||||
proc.kill()
|
|
||||||
|
|
||||||
ct1 = run_encrypt_once("414243444546")
|
ct1 = run_encrypt_once("414243444546")
|
||||||
assert ct1 and self._HEX_RE.match(ct1), "encrypt(1) did not return hex"
|
assert ct1 and self._HEX_RE.match(ct1), "encrypt(1) did not return hex"
|
||||||
@@ -198,3 +283,6 @@ class Phew(Challenge):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f'Could not check phew: {e}')
|
self.logger.error(f'Could not check phew: {e}')
|
||||||
return False
|
return False
|
||||||
|
finally:
|
||||||
|
# never leave a spawned chall.py behind, whatever happened above
|
||||||
|
self._reap_all()
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class Art(Challenge):
|
|||||||
def check(self):
|
def check(self):
|
||||||
try:
|
try:
|
||||||
word = self.random_string(8)
|
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)
|
r = requests.get(url, timeout=5)
|
||||||
assert r.text == f'<iframe height="100%" width="100%" frameborder="0" src=https://asciified.thelicato.io/api/v2/ascii?text={word}></iframe>', 'Unexpected response'
|
assert r.text == f'<iframe height="100%" width="100%" frameborder="0" src=https://asciified.thelicato.io/api/v2/ascii?text={word}></iframe>', 'Unexpected response'
|
||||||
self.logger.info('Check passed for art')
|
self.logger.info('Check passed for art')
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class Burvesigner(Challenge):
|
|||||||
|
|
||||||
def check(self):
|
def check(self):
|
||||||
try:
|
try:
|
||||||
url = f'http://localhost:{self.port}'
|
url = self.url()
|
||||||
flag = open(self.flag_location).read()
|
flag = open(self.flag_location).read()
|
||||||
|
|
||||||
# C1: login guest success
|
# C1: login guest success
|
||||||
|
|||||||
@@ -10,11 +10,39 @@ class Challenge(object):
|
|||||||
name = __name__
|
name = __name__
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
port = 0
|
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_<NAME> 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
|
self.port = port
|
||||||
|
if host:
|
||||||
|
self.host = host
|
||||||
self.add_logger()
|
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 "'<Class>' 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_<NAME>=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):
|
def add_logger(self):
|
||||||
self.logger = logging.getLogger()
|
self.logger = logging.getLogger()
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class Crawlback(Challenge):
|
|||||||
|
|
||||||
def check(self):
|
def check(self):
|
||||||
try:
|
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
|
assert r.text.split('\n').pop(0) == MOCK_DATA
|
||||||
|
|
||||||
|
|||||||
@@ -35,29 +35,29 @@ class GemasFetcher(Challenge):
|
|||||||
## register
|
## register
|
||||||
username = self.random_string(5)
|
username = self.random_string(5)
|
||||||
password = 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"
|
assert r.headers.get("location") == "/auth/login", "Register Failed"
|
||||||
|
|
||||||
## login
|
## 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"
|
assert r.headers.get("location") == "/dashboard", "Login Failed"
|
||||||
|
|
||||||
## wget
|
## wget
|
||||||
content = {"provider": "wget","url":MOCK_URL}
|
content = {"provider": "wget","url":MOCK_URL}
|
||||||
files = {"file": ("visit", b"\x00\x00"+gzip.compress(zlib.compress(json.dumps(content).encode())))}
|
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"
|
assert MOCK_DATA_WGET in r.text, "wget Failed"
|
||||||
|
|
||||||
## curl
|
## curl
|
||||||
content = {"provider": "curl","url":MOCK_URL}
|
content = {"provider": "curl","url":MOCK_URL}
|
||||||
files = {"file": ("visit", b"\x00\x01"+gzip.compress(zlib.compress(json.dumps(content).encode())))}
|
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"
|
assert r.text.split('\n').pop(0) == MOCK_DATA_CURL, "curl Failed"
|
||||||
|
|
||||||
## python
|
## python
|
||||||
content = {"provider": "python","url":MOCK_URL}
|
content = {"provider": "python","url":MOCK_URL}
|
||||||
files = {"file": ("visit", b"\x00\x02"+gzip.compress(zlib.compress(json.dumps(content).encode())))}
|
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"
|
assert r.text.startswith('"PCFkb2N0eXBlIGh0bWw'), "python Failed"
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ import requests
|
|||||||
|
|
||||||
class GemasNotes(Challenge):
|
class GemasNotes(Challenge):
|
||||||
history_location = 'history/gemas-notes.txt'
|
history_location = 'history/gemas-notes.txt'
|
||||||
host = "http://localhost:12000"
|
|
||||||
|
|
||||||
def distribute(self, flag):
|
def distribute(self, flag):
|
||||||
try:
|
try:
|
||||||
username = "gemasflagreceiver"
|
username = "gemasflagreceiver"
|
||||||
password = "AuTeEbn%.Q5$pC_ge6"
|
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"):
|
if not result.get("success"):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -26,7 +24,7 @@ class GemasNotes(Challenge):
|
|||||||
|
|
||||||
def check(self):
|
def check(self):
|
||||||
try:
|
try:
|
||||||
url = f'http://localhost:{self.port}'
|
url = self.url()
|
||||||
|
|
||||||
# login
|
# login
|
||||||
token = requests.post(f"{url}/api/login",json={"email":"checker@gemasnotes.id", "password":"uRIqCvJ<IGb;VDT14"}).json()["token"]
|
token = requests.post(f"{url}/api/login",json={"email":"checker@gemasnotes.id", "password":"uRIqCvJ<IGb;VDT14"}).json()["token"]
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class Hirnfick(Challenge):
|
|||||||
def check(self):
|
def check(self):
|
||||||
try:
|
try:
|
||||||
res = requests.post(
|
res = requests.post(
|
||||||
f"http://localhost:{self.port}/api/run",
|
self.url(f'/api/run'),
|
||||||
timeout=5,
|
timeout=5,
|
||||||
json={
|
json={
|
||||||
"code":
|
"code":
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import requests
|
|||||||
class Pasta(Challenge):
|
class Pasta(Challenge):
|
||||||
flag_location = 'flags/pasta.txt'
|
flag_location = 'flags/pasta.txt'
|
||||||
history_location = 'history/pasta.txt'
|
history_location = 'history/pasta.txt'
|
||||||
host = "http://localhost:13000"
|
|
||||||
|
|
||||||
def distribute(self, flag):
|
def distribute(self, flag):
|
||||||
try:
|
try:
|
||||||
with open(self.flag_location, 'w') as f:
|
with open(self.flag_location, 'w') as f:
|
||||||
@@ -25,7 +23,7 @@ class Pasta(Challenge):
|
|||||||
|
|
||||||
def check(self):
|
def check(self):
|
||||||
try:
|
try:
|
||||||
url = f'http://localhost:{self.port}'
|
url = self.url()
|
||||||
username = f"checker-{self.random_string(8)}"
|
username = f"checker-{self.random_string(8)}"
|
||||||
pwd = self.random_string(12)
|
pwd = self.random_string(12)
|
||||||
flag = open(self.flag_location).read()
|
flag = open(self.flag_location).read()
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import os
|
|||||||
class S3(Challenge):
|
class S3(Challenge):
|
||||||
flag_location = 'flags/s3.txt'
|
flag_location = 'flags/s3.txt'
|
||||||
history_location = 'history/s3.txt'
|
history_location = 'history/s3.txt'
|
||||||
host = 'http://localhost:20000'
|
|
||||||
|
|
||||||
def distribute(self, flag):
|
def distribute(self, flag):
|
||||||
try:
|
try:
|
||||||
with open(self.flag_location, 'w') as f:
|
with open(self.flag_location, 'w') as f:
|
||||||
@@ -29,11 +27,11 @@ class S3(Challenge):
|
|||||||
filename = self.random_string(8) + ".txt"
|
filename = self.random_string(8) + ".txt"
|
||||||
content = self.random_string(64)
|
content = self.random_string(64)
|
||||||
|
|
||||||
r = requests.post(f"http://localhost:{self.port}/upload", files={'file': (filename, content)})
|
r = requests.post(self.url(f'/upload'), files={'file': (filename, content)})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.text == f'Download <a href="/download?filename={filename}">here</a>'
|
assert r.text == f'Download <a href="/download?filename={filename}">here</a>'
|
||||||
|
|
||||||
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.status_code == 200
|
||||||
assert r.text == content
|
assert r.text == content
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class XL(Challenge):
|
|||||||
|
|
||||||
def check(self):
|
def check(self):
|
||||||
try:
|
try:
|
||||||
url = f'http://localhost:{self.port}'
|
url = self.url()
|
||||||
files = {'file': self.generate_mock_file()}
|
files = {'file': self.generate_mock_file()}
|
||||||
r = requests.post(url, files=files, timeout=5)
|
r = requests.post(url, files=files, timeout=5)
|
||||||
assert r.json() == MOCK_RESULT, 'Unexpected response'
|
assert r.json() == MOCK_RESULT, 'Unexpected response'
|
||||||
|
|||||||
@@ -10,11 +10,21 @@ class Challenge(object):
|
|||||||
name = __name__
|
name = __name__
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
port = 0
|
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
|
self.port = port
|
||||||
|
if host:
|
||||||
|
self.host = host
|
||||||
self.add_logger()
|
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):
|
def add_logger(self):
|
||||||
self.logger = logging.getLogger()
|
self.logger = logging.getLogger()
|
||||||
|
|
||||||
|
|||||||
@@ -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 ""))
|
return bool(r and "FLAG_OK" in (r.stdout or ""))
|
||||||
|
|
||||||
|
|
||||||
def _web_alive(port: int, timeout: float = 5.0) -> bool:
|
def _web_alive(port: int, timeout: float = 5.0, scheme: str = None) -> bool:
|
||||||
"""Any HTTP response (even 4xx/5xx) proves the listener is up."""
|
"""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)
|
Some challenges serve TLS (gleam-drive's bandit runs
|
||||||
return r.status_code < 600
|
`Listening on https://localhost:8000`). A plain http:// GET against a TLS
|
||||||
except requests.exceptions.RequestException:
|
port returns an SSLError/wrong-version-number, which reads as "service
|
||||||
return False
|
down" even though it is perfectly healthy. The scheme is per-challenge and
|
||||||
except Exception:
|
comes from CHALLENGE_SCHEME_<NAME>; when unset, try http first then fall
|
||||||
return False
|
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_<NAME> (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:
|
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"
|
history_location = "history/anti-alchemy.txt"
|
||||||
|
|
||||||
def check(self):
|
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):
|
class Asmr(Challenge):
|
||||||
@@ -112,7 +135,7 @@ class Fjb(Challenge):
|
|||||||
history_location = "history/fjb.txt"
|
history_location = "history/fjb.txt"
|
||||||
|
|
||||||
def check(self):
|
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):
|
class GiftCard(Challenge):
|
||||||
@@ -140,7 +163,7 @@ class GleamDrive(Challenge):
|
|||||||
history_location = "history/gleam-drive.txt"
|
history_location = "history/gleam-drive.txt"
|
||||||
|
|
||||||
def check(self):
|
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):
|
class GoGreen(Challenge):
|
||||||
@@ -156,7 +179,7 @@ class KodeViewer(Challenge):
|
|||||||
history_location = "history/kode-viewer.txt"
|
history_location = "history/kode-viewer.txt"
|
||||||
|
|
||||||
def check(self):
|
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):
|
class MoreLess(Challenge):
|
||||||
@@ -164,7 +187,7 @@ class MoreLess(Challenge):
|
|||||||
history_location = "history/more-less.txt"
|
history_location = "history/more-less.txt"
|
||||||
|
|
||||||
def check(self):
|
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):
|
class TempestPoc(Challenge):
|
||||||
@@ -172,7 +195,7 @@ class TempestPoc(Challenge):
|
|||||||
history_location = "history/tempest-poc.txt"
|
history_location = "history/tempest-poc.txt"
|
||||||
|
|
||||||
def check(self):
|
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):
|
class Ticketer(Challenge):
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ ARG PASSWORD
|
|||||||
|
|
||||||
RUN echo root:${PASSWORD} | chpasswd
|
RUN echo root:${PASSWORD} | chpasswd
|
||||||
COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure
|
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 "PasswordAuthentication yes" >> /etc/ssh/sshd_config
|
||||||
RUN echo "PermitRootLogin yes" >> /etc/ssh/sshd_config
|
RUN echo "PermitRootLogin yes" >> /etc/ssh/sshd_config
|
||||||
RUN service ssh start
|
RUN service ssh start
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
source "http://rubygems.org"
|
source "https://rubygems.org"
|
||||||
|
|
||||||
gem "sinatra"
|
gem "sinatra"
|
||||||
gem "slim"
|
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"
|
||||||
|
|||||||
@@ -26,7 +26,8 @@
|
|||||||
"ssh_offset": 22,
|
"ssh_offset": 22,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"service_dir": "blogpost",
|
"service_dir": "blogpost",
|
||||||
"org_port": 10000
|
"org_port": 10000,
|
||||||
|
"ssh_user": "ctfuser"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "carbeat",
|
"name": "carbeat",
|
||||||
@@ -37,7 +38,8 @@
|
|||||||
"ssh_offset": 23,
|
"ssh_offset": 23,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"service_dir": "carbeat",
|
"service_dir": "carbeat",
|
||||||
"org_port": 11000
|
"org_port": 11000,
|
||||||
|
"ssh_user": "ctfuser"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "cdn",
|
"name": "cdn",
|
||||||
@@ -48,7 +50,8 @@
|
|||||||
"ssh_offset": 24,
|
"ssh_offset": 24,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"service_dir": "cdn",
|
"service_dir": "cdn",
|
||||||
"org_port": 12000
|
"org_port": 12000,
|
||||||
|
"ssh_user": "ctfuser"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phew",
|
"name": "phew",
|
||||||
@@ -59,7 +62,8 @@
|
|||||||
"ssh_offset": 25,
|
"ssh_offset": 25,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"service_dir": "phew",
|
"service_dir": "phew",
|
||||||
"org_port": 13000
|
"org_port": 13000,
|
||||||
|
"ssh_user": "ctfuser"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sheesh",
|
"name": "sheesh",
|
||||||
@@ -70,7 +74,8 @@
|
|||||||
"ssh_offset": 26,
|
"ssh_offset": 26,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"service_dir": "sheesh",
|
"service_dir": "sheesh",
|
||||||
"org_port": 14000
|
"org_port": 14000,
|
||||||
|
"ssh_user": "ctfuser"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "warmup",
|
"name": "warmup",
|
||||||
@@ -81,7 +86,8 @@
|
|||||||
"ssh_offset": 27,
|
"ssh_offset": 27,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"service_dir": "warmup",
|
"service_dir": "warmup",
|
||||||
"org_port": 15000
|
"org_port": 15000,
|
||||||
|
"ssh_user": "ctfuser"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "art",
|
"name": "art",
|
||||||
@@ -90,9 +96,10 @@
|
|||||||
"desc": "Ruby ASCII art renderer",
|
"desc": "Ruby ASCII art renderer",
|
||||||
"chall_offset": 10,
|
"chall_offset": 10,
|
||||||
"ssh_offset": 110,
|
"ssh_offset": 110,
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"service_dir": "art",
|
"service_dir": "art",
|
||||||
"org_port": 10000
|
"org_port": 10000,
|
||||||
|
"ssh_user": "root"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "xl",
|
"name": "xl",
|
||||||
@@ -101,9 +108,10 @@
|
|||||||
"desc": "Node XL spreadsheets app",
|
"desc": "Node XL spreadsheets app",
|
||||||
"chall_offset": 11,
|
"chall_offset": 11,
|
||||||
"ssh_offset": 111,
|
"ssh_offset": 111,
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"service_dir": "xl",
|
"service_dir": "xl",
|
||||||
"org_port": 11000
|
"org_port": 11000,
|
||||||
|
"ssh_user": "root"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "gemas-notes",
|
"name": "gemas-notes",
|
||||||
@@ -114,7 +122,8 @@
|
|||||||
"ssh_offset": 112,
|
"ssh_offset": 112,
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"service_dir": "gemas-notes",
|
"service_dir": "gemas-notes",
|
||||||
"org_port": 12000
|
"org_port": 12000,
|
||||||
|
"ssh_user": "root"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "pasta",
|
"name": "pasta",
|
||||||
@@ -125,7 +134,8 @@
|
|||||||
"ssh_offset": 113,
|
"ssh_offset": 113,
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"service_dir": "pasta",
|
"service_dir": "pasta",
|
||||||
"org_port": 13000
|
"org_port": 13000,
|
||||||
|
"ssh_user": "root"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "burvesigner",
|
"name": "burvesigner",
|
||||||
@@ -136,7 +146,8 @@
|
|||||||
"ssh_offset": 114,
|
"ssh_offset": 114,
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"service_dir": "burvesigner",
|
"service_dir": "burvesigner",
|
||||||
"org_port": 14000
|
"org_port": 14000,
|
||||||
|
"ssh_user": "root"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "hirnfick",
|
"name": "hirnfick",
|
||||||
@@ -147,7 +158,8 @@
|
|||||||
"ssh_offset": 115,
|
"ssh_offset": 115,
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"service_dir": "hirnfick",
|
"service_dir": "hirnfick",
|
||||||
"org_port": 15000
|
"org_port": 15000,
|
||||||
|
"ssh_user": "root"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "gemas-fetcher",
|
"name": "gemas-fetcher",
|
||||||
@@ -158,7 +170,8 @@
|
|||||||
"ssh_offset": 116,
|
"ssh_offset": 116,
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"service_dir": "gemas-fetcher",
|
"service_dir": "gemas-fetcher",
|
||||||
"org_port": 16000
|
"org_port": 16000,
|
||||||
|
"ssh_user": "root"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "s3",
|
"name": "s3",
|
||||||
@@ -167,9 +180,10 @@
|
|||||||
"desc": "S3-ish service",
|
"desc": "S3-ish service",
|
||||||
"chall_offset": 20,
|
"chall_offset": 20,
|
||||||
"ssh_offset": 120,
|
"ssh_offset": 120,
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"service_dir": "s3",
|
"service_dir": "s3",
|
||||||
"org_port": 20000
|
"org_port": 20000,
|
||||||
|
"ssh_user": "root"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "crawlback",
|
"name": "crawlback",
|
||||||
@@ -180,7 +194,8 @@
|
|||||||
"ssh_offset": 121,
|
"ssh_offset": 121,
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"service_dir": "crawlback",
|
"service_dir": "crawlback",
|
||||||
"org_port": 21000
|
"org_port": 21000,
|
||||||
|
"ssh_user": "root"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "back-to-basic",
|
"name": "back-to-basic",
|
||||||
@@ -191,7 +206,8 @@
|
|||||||
"ssh_offset": 122,
|
"ssh_offset": 122,
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"service_dir": "back-to-basic",
|
"service_dir": "back-to-basic",
|
||||||
"org_port": 22000
|
"org_port": 22000,
|
||||||
|
"ssh_user": "root"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "anti-alchemy",
|
"name": "anti-alchemy",
|
||||||
@@ -200,9 +216,10 @@
|
|||||||
"desc": "Alchemy flask app (web)",
|
"desc": "Alchemy flask app (web)",
|
||||||
"chall_offset": 30,
|
"chall_offset": 30,
|
||||||
"ssh_offset": 130,
|
"ssh_offset": 130,
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"service_dir": "anti-alchemy",
|
"service_dir": "anti-alchemy",
|
||||||
"org_port": 11000
|
"org_port": 11000,
|
||||||
|
"ssh_user": "root"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "asmr",
|
"name": "asmr",
|
||||||
@@ -213,7 +230,8 @@
|
|||||||
"ssh_offset": 131,
|
"ssh_offset": 131,
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"service_dir": "asmr",
|
"service_dir": "asmr",
|
||||||
"org_port": 15000
|
"org_port": 15000,
|
||||||
|
"ssh_user": "root"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "bit-canvas",
|
"name": "bit-canvas",
|
||||||
@@ -222,9 +240,10 @@
|
|||||||
"desc": "C xinetd pwn",
|
"desc": "C xinetd pwn",
|
||||||
"chall_offset": 32,
|
"chall_offset": 32,
|
||||||
"ssh_offset": 132,
|
"ssh_offset": 132,
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"service_dir": "bit-canvas",
|
"service_dir": "bit-canvas",
|
||||||
"org_port": 20000
|
"org_port": 20000,
|
||||||
|
"ssh_user": "root"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "fjb",
|
"name": "fjb",
|
||||||
@@ -235,7 +254,8 @@
|
|||||||
"ssh_offset": 133,
|
"ssh_offset": 133,
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"service_dir": "fjb",
|
"service_dir": "fjb",
|
||||||
"org_port": 17000
|
"org_port": 17000,
|
||||||
|
"ssh_user": "root"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "gift-card",
|
"name": "gift-card",
|
||||||
@@ -244,9 +264,10 @@
|
|||||||
"desc": "Crypto gift card",
|
"desc": "Crypto gift card",
|
||||||
"chall_offset": 34,
|
"chall_offset": 34,
|
||||||
"ssh_offset": 134,
|
"ssh_offset": 134,
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"service_dir": "gift-card",
|
"service_dir": "gift-card",
|
||||||
"org_port": 21000
|
"org_port": 21000,
|
||||||
|
"ssh_user": "root"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "gift-voucher",
|
"name": "gift-voucher",
|
||||||
@@ -255,9 +276,10 @@
|
|||||||
"desc": "Crypto gift voucher",
|
"desc": "Crypto gift voucher",
|
||||||
"chall_offset": 35,
|
"chall_offset": 35,
|
||||||
"ssh_offset": 135,
|
"ssh_offset": 135,
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"service_dir": "gift-voucher",
|
"service_dir": "gift-voucher",
|
||||||
"org_port": 16000
|
"org_port": 16000,
|
||||||
|
"ssh_user": "root"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "gleam-drive",
|
"name": "gleam-drive",
|
||||||
@@ -266,9 +288,11 @@
|
|||||||
"desc": "Gleam drive web-crypto",
|
"desc": "Gleam drive web-crypto",
|
||||||
"chall_offset": 36,
|
"chall_offset": 36,
|
||||||
"ssh_offset": 136,
|
"ssh_offset": 136,
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"service_dir": "gleam-drive",
|
"service_dir": "gleam-drive",
|
||||||
"org_port": 12000
|
"org_port": 12000,
|
||||||
|
"scheme": "https",
|
||||||
|
"ssh_user": "root"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "go-green",
|
"name": "go-green",
|
||||||
@@ -279,7 +303,8 @@
|
|||||||
"ssh_offset": 137,
|
"ssh_offset": 137,
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"service_dir": "go-green",
|
"service_dir": "go-green",
|
||||||
"org_port": 20000
|
"org_port": 20000,
|
||||||
|
"ssh_user": "root"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "kode-viewer",
|
"name": "kode-viewer",
|
||||||
@@ -290,7 +315,8 @@
|
|||||||
"ssh_offset": 138,
|
"ssh_offset": 138,
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"service_dir": "kode-viewer",
|
"service_dir": "kode-viewer",
|
||||||
"org_port": 10000
|
"org_port": 10000,
|
||||||
|
"ssh_user": "root"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "more-less",
|
"name": "more-less",
|
||||||
@@ -299,9 +325,10 @@
|
|||||||
"desc": "Python more/less web",
|
"desc": "Python more/less web",
|
||||||
"chall_offset": 39,
|
"chall_offset": 39,
|
||||||
"ssh_offset": 139,
|
"ssh_offset": 139,
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"service_dir": "more-less",
|
"service_dir": "more-less",
|
||||||
"org_port": 22000
|
"org_port": 22000,
|
||||||
|
"ssh_user": "root"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "tempest-poc",
|
"name": "tempest-poc",
|
||||||
@@ -312,7 +339,8 @@
|
|||||||
"ssh_offset": 140,
|
"ssh_offset": 140,
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"service_dir": "tempest-poc",
|
"service_dir": "tempest-poc",
|
||||||
"org_port": 14080
|
"org_port": 14080,
|
||||||
|
"ssh_user": "root"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "ticketer",
|
"name": "ticketer",
|
||||||
@@ -321,9 +349,10 @@
|
|||||||
"desc": "Ticketer crypto oracle (socat)",
|
"desc": "Ticketer crypto oracle (socat)",
|
||||||
"chall_offset": 41,
|
"chall_offset": 41,
|
||||||
"ssh_offset": 141,
|
"ssh_offset": 141,
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"service_dir": "ticketer",
|
"service_dir": "ticketer",
|
||||||
"org_port": 14000
|
"org_port": 14000,
|
||||||
|
"ssh_user": "root"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user