The web SSH terminal and the credential API reported `ctfuser` for all 16
challenges, but only the 6 native GEMASTIK XVIII images provision ctfuser.
Every imported XVI/XVII image does `RUN echo root:${PASSWORD} | chpasswd`,
so 10 of 16 participant logins were refused with "Permission denied".
Root causes (all the same class of bug - login hardcoded in the wrong layer):
- main.py websocket ssh handler read st["ssh_user"], a single team-wide value
defaulting to ctfuser, instead of the per-challenge registry field
- /api/credential proxied the global receiver on :18080, which only knows the
6 native challenges, so the other 10 returned "Invalid challenge"
- team.html hardcoded the challenge picker to those same 6 challenges, making
the other 10 unreachable from the terminal entirely
- index.html rendered `<b>ctfuser</b>` and a stale hardcoded SSH port table
Fixes:
- orch.challenge_credential()/all_teams() read the TEAM's state.json, which
holds the same per-challenge password the panel chpasswds
- gen_receiver_services.py injects SSH_USER_<port> from the registry so the
receiver's /credential endpoint agrees with the panel
- receiver Challenge.credentials() honours SSH_USER_<port> (ctfuser fallback)
- new /api/team/{idx}/own-challenges feeds the picker; targets now carry
challenge + ssh_user
- UI takes user and port from the server instead of hardcoding them
Verified: 32/32 credential payloads correct across teams 1-2, and 32/32 real
paramiko SSH logins succeed with whoami confirming the expected account.
Also adds bulk team delete: POST /api/teams/bulk-delete runs one background
thread and is polled via GET /api/teams/bulk-delete/{job_id}, plus per-team
checkboxes with select-all/clear in the UI. Deletion must stay sequential
because delete_team() regenerates shared artifacts at the end.
77 lines
3.1 KiB
Python
77 lines
3.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Inject SSH_USER_<port> env into the GLOBAL receiver unit (gemastik-receiver).
|
|
|
|
Why: the panel's /api/credential proxy targets the global receiver on :18080,
|
|
not the per-team receivers. That unit had no Environment= lines at all, so
|
|
Challenge.credentials() fell back to the hardcoded 'ctfuser' literal and every
|
|
imported XVI/XVII challenge reported a login that could never work.
|
|
|
|
The registry's `ssh_user` per challenge is the single source of truth (the
|
|
panel already chpasswds that same account). Read the port each challenge runs
|
|
on from the global receiver's own config so the keys line up with self.port.
|
|
"""
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
BASE = Path("/opt/gemastik18-final")
|
|
UNIT = Path("/etc/systemd/system/gemastik-receiver.service")
|
|
REGISTRY = BASE / "teams/challenge_registry.json"
|
|
RECV_CONFIG = BASE / "receiver/config.py"
|
|
|
|
reg = json.loads(REGISTRY.read_text())
|
|
ssh_users = {c["name"]: c.get("ssh_user", "ctfuser") for c in reg.get("challenges", [])}
|
|
|
|
# Challenge -> port used by the GLOBAL receiver. main.py builds the challenge
|
|
# objects from CHALLENGE_PORT_* / PASSWORD_* env; with none set it falls back to
|
|
# the pydantic settings PASSWORD_<port> in config.py, so mirror those ports.
|
|
text = RECV_CONFIG.read_text()
|
|
ports = {}
|
|
for m in re.finditer(r"PASSWORD_(\d+)\s*[:=]", text):
|
|
ports.setdefault(m.group(1), int(m.group(1)))
|
|
# name -> port needs the CHALLENGE_PORT mapping; take it from registry order +
|
|
# the receiver main.py template, else fall back to PASSWORD_<port> keys.
|
|
name_to_port = {}
|
|
for m in re.finditer(r'"PASSWORD_(\d+)"', text):
|
|
name_to_port.setdefault(m.group(1), m.group(1))
|
|
print(f"registry challenges: {len(ssh_users)}")
|
|
|
|
# Build Environment lines for every challenge whose port we can resolve.
|
|
env_lines = []
|
|
resolved = 0
|
|
for name, user in sorted(ssh_users.items()):
|
|
# The global receiver uses the node-range ports from config.py; find the
|
|
# port by matching the challenge name against the receiver's own mapping.
|
|
port = None
|
|
m = re.search(rf"{re.escape(name)}.*?(\d{{4,5}})", text)
|
|
if m:
|
|
port = m.group(1)
|
|
if not port:
|
|
continue
|
|
env_lines.append(f'Environment="SSH_USER_{port}={user}"')
|
|
resolved += 1
|
|
|
|
if not env_lines:
|
|
print("ERROR: could not resolve any challenge port from config.py", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
body = UNIT.read_text()
|
|
# Drop any SSH_USER_ lines we previously injected (idempotent re-runs).
|
|
kept = [ln for ln in body.splitlines() if "SSH_USER_" not in ln]
|
|
# Insert right after the existing Environment=PYTHONUNBUFFERED line.
|
|
out = []
|
|
for ln in kept:
|
|
out.append(ln)
|
|
if ln.startswith("Environment=PYTHONUNBUFFERED"):
|
|
out.extend(env_lines)
|
|
if not any(ln.startswith("Environment=PYTHONUNBUFFERED") for ln in out):
|
|
out.extend(env_lines)
|
|
UNIT.write_text("\n".join(out) + "\n")
|
|
print(f"injected {resolved} SSH_USER_* lines into {UNIT}")
|
|
|
|
subprocess.run(["systemctl", "daemon-reload"], check=True)
|
|
subprocess.run(["systemctl", "restart", "gemastik-receiver"], check=True)
|
|
print("reloaded + restarted gemastik-receiver")
|