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:
@@ -10,11 +10,21 @@ class Challenge(object):
|
||||
name = __name__
|
||||
settings = get_settings()
|
||||
port = 0
|
||||
# Host the checker connects to. Same machine as the published team ports;
|
||||
# override with RECEIVER_HOST if a receiver ever runs off-host.
|
||||
host = os.environ.get("RECEIVER_HOST", "127.0.0.1")
|
||||
|
||||
def __init__(self, port):
|
||||
def __init__(self, port, host=None):
|
||||
self.port = port
|
||||
if host:
|
||||
self.host = host
|
||||
self.add_logger()
|
||||
|
||||
def url(self, path=""):
|
||||
"""Absolute URL for the challenge service (see xvi/Challenge.py)."""
|
||||
p = "" if path.startswith("/") else "/"
|
||||
return f"http://{self.host}:{self.port}{p}{path}"
|
||||
|
||||
def add_logger(self):
|
||||
self.logger = logging.getLogger()
|
||||
|
||||
|
||||
@@ -45,15 +45,38 @@ def _flag_in_container(container: str, path: str = "/flag.txt") -> bool:
|
||||
return bool(r and "FLAG_OK" in (r.stdout or ""))
|
||||
|
||||
|
||||
def _web_alive(port: int, timeout: float = 5.0) -> bool:
|
||||
"""Any HTTP response (even 4xx/5xx) proves the listener is up."""
|
||||
try:
|
||||
r = requests.get(f"http://127.0.0.1:{port}/", timeout=timeout, allow_redirects=False)
|
||||
return r.status_code < 600
|
||||
except requests.exceptions.RequestException:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
def _web_alive(port: int, timeout: float = 5.0, scheme: str = None) -> bool:
|
||||
"""Any HTTP response (even 4xx/5xx) proves the listener is up.
|
||||
|
||||
Some challenges serve TLS (gleam-drive's bandit runs
|
||||
`Listening on https://localhost:8000`). A plain http:// GET against a TLS
|
||||
port returns an SSLError/wrong-version-number, which reads as "service
|
||||
down" even though it is perfectly healthy. The scheme is per-challenge and
|
||||
comes from CHALLENGE_SCHEME_<NAME>; when unset, try http first then fall
|
||||
back to https so a mis-tagged challenge still checks correctly.
|
||||
"""
|
||||
schemes = [scheme] if scheme else ["http", "https"]
|
||||
for sch in schemes:
|
||||
if not sch:
|
||||
continue
|
||||
try:
|
||||
# verify=False is required, not lazy: the challenge serves a
|
||||
# self-signed cert from inside the contest network, so there is no
|
||||
# CA to validate against. This probe only proves the listener
|
||||
# answers — it never carries a secret, and a MITM here would gain
|
||||
# nothing beyond the liveness bit we already discard.
|
||||
r = requests.get(f"{sch}://127.0.0.1:{port}/", timeout=timeout,
|
||||
allow_redirects=False, verify=False)
|
||||
if r.status_code < 600:
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _scheme(challenge: str) -> str | None:
|
||||
"""Per-challenge URL scheme from CHALLENGE_SCHEME_<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:
|
||||
@@ -88,7 +111,7 @@ class AntiAlchemy(Challenge):
|
||||
history_location = "history/anti-alchemy.txt"
|
||||
|
||||
def check(self):
|
||||
return _web_alive(self.port) and _flag_in_container(_container("anti-alchemy"))
|
||||
return _web_alive(self.port, scheme=_scheme("anti-alchemy")) and _flag_in_container(_container("anti-alchemy"))
|
||||
|
||||
|
||||
class Asmr(Challenge):
|
||||
@@ -112,7 +135,7 @@ class Fjb(Challenge):
|
||||
history_location = "history/fjb.txt"
|
||||
|
||||
def check(self):
|
||||
return _web_alive(self.port) and _flag_in_container(_container("fjb"))
|
||||
return _web_alive(self.port, scheme=_scheme("fjb")) and _flag_in_container(_container("fjb"))
|
||||
|
||||
|
||||
class GiftCard(Challenge):
|
||||
@@ -140,7 +163,7 @@ class GleamDrive(Challenge):
|
||||
history_location = "history/gleam-drive.txt"
|
||||
|
||||
def check(self):
|
||||
return _web_alive(self.port) and _flag_in_container(_container("gleam-drive"))
|
||||
return _web_alive(self.port, scheme=_scheme("gleam-drive")) and _flag_in_container(_container("gleam-drive"))
|
||||
|
||||
|
||||
class GoGreen(Challenge):
|
||||
@@ -156,7 +179,7 @@ class KodeViewer(Challenge):
|
||||
history_location = "history/kode-viewer.txt"
|
||||
|
||||
def check(self):
|
||||
return _web_alive(self.port) and _flag_in_container(_container("kode-viewer"))
|
||||
return _web_alive(self.port, scheme=_scheme("kode-viewer")) and _flag_in_container(_container("kode-viewer"))
|
||||
|
||||
|
||||
class MoreLess(Challenge):
|
||||
@@ -164,7 +187,7 @@ class MoreLess(Challenge):
|
||||
history_location = "history/more-less.txt"
|
||||
|
||||
def check(self):
|
||||
return _web_alive(self.port) and _flag_in_container(_container("more-less"))
|
||||
return _web_alive(self.port, scheme=_scheme("more-less")) and _flag_in_container(_container("more-less"))
|
||||
|
||||
|
||||
class TempestPoc(Challenge):
|
||||
@@ -172,7 +195,7 @@ class TempestPoc(Challenge):
|
||||
history_location = "history/tempest-poc.txt"
|
||||
|
||||
def check(self):
|
||||
return _web_alive(self.port) and _flag_in_container(_container("tempest-poc"))
|
||||
return _web_alive(self.port, scheme=_scheme("tempest-poc")) and _flag_in_container(_container("tempest-poc"))
|
||||
|
||||
|
||||
class Ticketer(Challenge):
|
||||
|
||||
Reference in New Issue
Block a user