feat: challenge registry-driven platform + XVI/XVII imports + admin toggle + domain rename
- Rename repo/domain: attack-defense-platform / attackdefense.imrnes.team (all refs replaced)
- challenge_registry.json: single source of truth (28 challs across gemastik18/xvi/xvii)
- teams.py: registry-driven CHALLENGES, set_challenge_enabled, sync_challenge_runtime
(apply enable/disable to live teams: build/up or stop/remove + receiver restart)
- compose_gen.py: render per-team compose from canonical per-challenge templates
(image reuse, per-team ports 30xxx, flag mounts, passwords)
- gen_canonical_composes.py: canonical docker-compose.yml for all services
- import_new_challenges.py: import XVI/XVII services + EOL base image fixes
(debian:buster→bookworm, node:14→20, python:3.7-slim→3.11)
- receiver: xvi package (10 checkers) + xvii package (12 generic checkers),
Challenge base reads PASSWORD_<team_port> from env; gen_receiver_main.py
generates per-team main.py from registry
- main.py: /api/challenges returns full registry; PATCH /api/challenges/<name>
toggles enabled + applies to live teams
- index.html: 🏗️ Challenge Manager tab (toggle per challenge, grouped by set)
- SLA bonus now dynamic (all enabled challenges, not hardcoded 6)
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
compose_gen — render a team's docker-compose.yml from the challenge registry.
|
||||
|
||||
For every ENABLED challenge, a canonical per-challenge compose template lives
|
||||
at services/<name>/docker-compose.yml (see gen_canonical_composes.py). The
|
||||
renderer:
|
||||
|
||||
- replaces `build:` blocks with `image: services-<name>` for the MAIN
|
||||
service (sidecars keep their images/builds),
|
||||
- rewrites container_name / hostname to the per-team suffix,
|
||||
- rewrites host ports (<ORG>:<INT>, <ORG+22>:22) to the team's ports,
|
||||
- replaces PASSWORD_<ORG> placeholders with the team's challenge password,
|
||||
- normalizes flag volume to ../receiver/flags/<name>.txt.
|
||||
|
||||
Multi-container challenges (gemas-notes, gemas-fetcher, kode-viewer,
|
||||
anti-alchemy, tempest-poc) keep their sidecar services.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
BASE = Path("/opt/gemastik18-final")
|
||||
TEAMS_DIR = BASE / "teams"
|
||||
SERVICES_SRC = BASE / "services"
|
||||
|
||||
# Challenges whose compose has multiple top-level services; the FIRST service
|
||||
# listed is the MAIN challenge service (gets image: reuse + challenge ports),
|
||||
# the rest are sidecars.
|
||||
SIDECAR_NAMES = {
|
||||
"anti-alchemy": ["anti-alchemy-db"],
|
||||
"gemas-fetcher": ["mongodb"],
|
||||
"gemas-notes": ["database", "validation-service"],
|
||||
"kode-viewer": ["redis"],
|
||||
"tempest-poc": ["backend"],
|
||||
}
|
||||
|
||||
def _load_registry() -> dict:
|
||||
try:
|
||||
return json.loads((TEAMS_DIR / "challenge_registry.json").read_text())
|
||||
except Exception:
|
||||
return {"sets": {}, "challenges": []}
|
||||
|
||||
def read_template(name: str) -> str:
|
||||
p = SERVICES_SRC / name / "docker-compose.yml"
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(f"Tidak ada template compose untuk {name} di {p}")
|
||||
return p.read_text()
|
||||
|
||||
def _parse_services(text: str):
|
||||
names = []
|
||||
for line in text.splitlines():
|
||||
m = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line)
|
||||
if m and not line.startswith(" "):
|
||||
names.append(m.group(1))
|
||||
return names
|
||||
|
||||
def render_team_compose(idx: int, state: dict) -> str:
|
||||
ports = state["ports"]
|
||||
passwords = state["chall_passwords"]
|
||||
blocks = []
|
||||
for ch in enabled_challenges():
|
||||
name = ch["name"]
|
||||
text = read_template(name)
|
||||
main = _parse_services(text)
|
||||
main = main[0] if main else name
|
||||
sidecars = set(SIDECAR_NAMES.get(name, []))
|
||||
org = int(ch.get("org_port", 10000))
|
||||
tc = ports[name]["chall"]
|
||||
ts = ports[name]["ssh"]
|
||||
|
||||
# --- rewrite container_name / hostname per team ---
|
||||
out_lines = []
|
||||
for line in text.splitlines():
|
||||
s = line.strip()
|
||||
if s.startswith("container_name:"):
|
||||
cname = s.split(":", 1)[1].strip()
|
||||
line = f" container_name: {cname}_team{idx}"
|
||||
elif s.startswith("hostname:"):
|
||||
hname = s.split(":", 1)[1].strip()
|
||||
if hname == name:
|
||||
line = f" hostname: {name}_team{idx}"
|
||||
else:
|
||||
# sidecar hostname also suffixed to keep per-team network unique
|
||||
line = f" hostname: {hname}_team{idx}"
|
||||
out_lines.append(line)
|
||||
text = "\n".join(out_lines)
|
||||
|
||||
# --- ports: rewrite ONLY the main challenge service's ports ---
|
||||
# The main service is the one whose ports map to org/org+22.
|
||||
# (sidecar "ports_chall" cases: gemas-notes validation-service exposes
|
||||
# 12000:80 — handled by rewriting ANY "<org>:" / "<org+22>:" occurrence.)
|
||||
text = re.sub(rf'"({org}):', f'"{tc}:', text)
|
||||
text = re.sub(rf'"({org + 22}):', f'"{ts}:', text)
|
||||
|
||||
# --- build: -> image for MAIN only ---
|
||||
# Replace the main service's build block with image: services-<name>.
|
||||
# NB we process by service names, not generic removal, so sidecar
|
||||
# builds survive.
|
||||
lines = text.splitlines()
|
||||
i = 0
|
||||
in_main = False
|
||||
cur = None
|
||||
out = []
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
m = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line)
|
||||
if m and not line.startswith(" "):
|
||||
cur = m.group(1)
|
||||
in_main = (cur == main)
|
||||
out.append(line)
|
||||
i += 1
|
||||
continue
|
||||
# inside a service block
|
||||
if in_main and line.strip() == "build:":
|
||||
# skip build block (context/args/dockerfile...) until next key at same indent
|
||||
out.append(f" image: services-{name}")
|
||||
i += 1
|
||||
while i < len(lines) and (lines[i].startswith(" ") or lines[i].strip() == ""):
|
||||
i += 1
|
||||
continue
|
||||
out.append(line)
|
||||
i += 1
|
||||
text = "\n".join(out)
|
||||
|
||||
# --- PASSWORD placeholder -> team password ---
|
||||
text = re.sub(r"\$PASSWORD_" + str(org) + r"\b", passwords[name], text)
|
||||
text = re.sub(r"PASSWORD_" + str(org) + r"\b", passwords[name], text)
|
||||
|
||||
# --- flag volume normalization ---
|
||||
# Replace any ./flag.txt / ../receiver/flags/<name>.txt with the per-team flag mount
|
||||
text = re.sub(r"\./flag\.txt(:\w+)?", f"../receiver/flags/{name}.txt", text)
|
||||
blocks.append(text)
|
||||
header = "version: '3.8'\nservices:\n"
|
||||
body = []
|
||||
for b in blocks:
|
||||
if not b.strip():
|
||||
continue
|
||||
# strip a leading "services:" header from each block (they are fragments)
|
||||
b = re.sub(r"^services:\n", "", b)
|
||||
body.append(b)
|
||||
return header + "\n".join(body) + "\n"
|
||||
|
||||
def enabled_challenges() -> list:
|
||||
return [c for c in _load_registry().get("challenges", []) if c.get("enabled")]
|
||||
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate canonical docker-compose.yml templates under services/<name>/ for
|
||||
every challenge in the registry (gemastik18 + imported XVI/XVII).
|
||||
|
||||
The generated per-challenge file is the SOURCE for compose_gen — i.e. the
|
||||
per-team compose is rendered from these templates. Uses:
|
||||
|
||||
services/<name>/docker-compose.yml (canonical, uniform)
|
||||
|
||||
If a template already exists for gemastik18 challenges (from the shared
|
||||
compose), keep it. For imported challenges, build one from the registry.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
BASE = Path("/opt/gemastik18-final")
|
||||
SVC = BASE / "services"
|
||||
TEAMS_DIR = BASE / "teams"
|
||||
|
||||
def load_registry():
|
||||
return json.loads((TEAMS_DIR / "challenge_registry.json").read_text())
|
||||
|
||||
def internal_port_for(ch: dict) -> int:
|
||||
"""Best-effort: the container's internal listening port."""
|
||||
name = ch["name"]
|
||||
known = {
|
||||
# web-ish default 8000, others from upstream compose
|
||||
"art": 8080, "xl": 3000, "gemas-notes": 80, "pasta": 8000,
|
||||
"burvesigner": 80, "hirnfick": 8000, "gemas-fetcher": 8000,
|
||||
"s3": 80, "crawlback": 80, "back-to-basic": 8000,
|
||||
"anti-alchemy": 5000, "asmr": 8000, "bit-canvas": 8000,
|
||||
"fjb": 80, "gift-card": 5000, "gift-voucher": 5000,
|
||||
"gleam-drive": 8000, "go-green": 8000, "kode-viewer": 3000,
|
||||
"more-less": 8000, "tempest-poc": 80, "ticketer": 5000,
|
||||
}
|
||||
return known.get(name, 8000)
|
||||
|
||||
def sidecar_for(ch: dict) -> dict:
|
||||
"""Return {sidecar_name: {image, env, volumes, cmd...}} or {}."""
|
||||
name = ch["name"]
|
||||
if name == "gemas-notes":
|
||||
return {
|
||||
"database": {
|
||||
"image": "mysql:8",
|
||||
"container_name": None, # per-team
|
||||
"environment": ["MYSQL_ROOT_PASSWORD=why_my_random_string_password_doesnot_working",
|
||||
"MYSQL_DATABASE=gemasnotes"],
|
||||
"volumes": ["./gemas-notes/src/db/init.sql:/docker-entrypoint-initdb.d/init.sql"],
|
||||
},
|
||||
"validation-service": {
|
||||
"build": "./gemas-notes/src/validation-service",
|
||||
"container_name": None,
|
||||
"depends_on": ["database"],
|
||||
"ports_chall": 80, # challenge port exposed here (REST API)
|
||||
},
|
||||
}
|
||||
if name == "gemas-fetcher":
|
||||
return {
|
||||
"mongodb": {
|
||||
"image": "mongo:4.4",
|
||||
"container_name": None,
|
||||
"environment": ["MONGO_INITDB_ROOT_USERNAME=ctf",
|
||||
"MONGO_INITDB_ROOT_PASSWORD=asjdkjk23j1k3dsdn2h233j3jj",
|
||||
"MONGO_INITDB_DATABASE=web_fetcher"],
|
||||
}
|
||||
}
|
||||
if name == "kode-viewer":
|
||||
return {
|
||||
"redis": {
|
||||
"image": "redis:alpine",
|
||||
"container_name": None,
|
||||
"volumes": ["./redis-data:/data"],
|
||||
}
|
||||
}
|
||||
if name == "anti-alchemy":
|
||||
return {
|
||||
"anti-alchemy-db": {
|
||||
"image": "postgres:16.3-alpine",
|
||||
"container_name": None,
|
||||
"environment": ["POSTGRES_USER=postgres", "POSTGRES_PASSWORD=password"],
|
||||
"volumes": ["./db/dump.sql:/docker-entrypoint-initdb.d/init.sql"],
|
||||
}
|
||||
}
|
||||
if name == "tempest-poc":
|
||||
return {
|
||||
"backend": {
|
||||
"build": "./tempest-poc/backend",
|
||||
"container_name": None,
|
||||
"ports_ssh": 22,
|
||||
"extra_hosts": True,
|
||||
},
|
||||
"frontend": {
|
||||
"build": "./tempest-poc/frontend",
|
||||
"container_name": None,
|
||||
"ports_chall": 80,
|
||||
"extra_hosts": True,
|
||||
}
|
||||
}
|
||||
return {}
|
||||
|
||||
def render(ch: dict) -> str:
|
||||
name = ch["name"]
|
||||
org = int(ch.get("org_port", 10000))
|
||||
internal = internal_port_for(ch)
|
||||
sidecars = sidecar_for(ch)
|
||||
# For tempest-poc the main (first) service is frontend; compose_gen's
|
||||
# SIDECAR_NAMES marks backend as sidecar. Order matters: put frontend
|
||||
# before backend in the generated file so compose_gen picks frontend as main.
|
||||
if name == "tempest-poc":
|
||||
sidecars = {"frontend": sidecars.pop("frontend"), **sidecars}
|
||||
lines = ["services:"]
|
||||
# main service
|
||||
cont = f"{name}_container"
|
||||
lines += [
|
||||
f" {name}:",
|
||||
f" container_name: {cont}",
|
||||
f" hostname: {name}",
|
||||
" restart: always",
|
||||
" build:",
|
||||
" context: .",
|
||||
" args:",
|
||||
f" - PASSWORD=$PASSWORD_{org}",
|
||||
]
|
||||
# volumes: flag + bashrc + preexec (uniform unless challenge differs)
|
||||
flag_path = f"../receiver/flags/{name}.txt:/flag.txt:ro"
|
||||
# gift-card/gift-voucher mount to /ctf/<name>/flag.txt
|
||||
if name in ("gift-card", "gift-voucher"):
|
||||
flag_path = f"../receiver/flags/{name}.txt:/ctf/{name}/flag.txt:ro"
|
||||
if name == "pasta":
|
||||
flag_path = f"../receiver/flags/{name}.txt:/ctf/pasta/flag.txt:ro"
|
||||
lines += [
|
||||
" volumes:",
|
||||
f" - {flag_path}",
|
||||
" - ../utils/bashrc:/root/.bashrc:ro",
|
||||
" - ../utils/preexec.sh:/root/.preexec.sh:ro",
|
||||
]
|
||||
# ports: external (org / org+22) but rewritten per team by composer
|
||||
if name == "gemas-notes":
|
||||
# main service = note-service (Go, ssh only). The challenge port is
|
||||
# exposed by validation-service (the REST API the checker hits).
|
||||
pass
|
||||
elif name == "tempest-poc":
|
||||
# main (frontend) exposes chall port; backend (sidecar) has ssh.
|
||||
pass
|
||||
else:
|
||||
lines += [
|
||||
" ports:",
|
||||
f" - \"{org}:{internal}\"",
|
||||
f" - \"{org + 22}:22\"",
|
||||
]
|
||||
lines.append(" extra_hosts:")
|
||||
lines.append(' - "host.docker.internal:host-gateway"')
|
||||
# env (challenge-specific)
|
||||
if name == "carbeat":
|
||||
lines.append(" environment:")
|
||||
lines.append(" - FLAG=GEMASTIK18{local_flag}")
|
||||
if name == "phew":
|
||||
lines.append(" environment:")
|
||||
lines.append(" - FLAG=GEMASTIK18{local_flag}")
|
||||
if name == "sheesh":
|
||||
lines.append(" environment:")
|
||||
lines.append(" - FLAG=GEMASTIK18{local_flag}")
|
||||
if name == "anti-alchemy":
|
||||
lines.append(" environment:")
|
||||
lines.append(" - DB_NAME=postgres")
|
||||
lines.append(" - DB_USER=postgres")
|
||||
lines.append(" - DB_PASS=password")
|
||||
lines.append(" - DB_HOST=anti-alchemy-db")
|
||||
lines.append(" - DB_PORT=5432")
|
||||
lines.append(f" - SECRET_KEY=$PASSWORD_{org}")
|
||||
lines.append(" depends_on:")
|
||||
lines.append(" - anti-alchemy-db")
|
||||
if name == "gemas-fetcher":
|
||||
lines.append(" environment:")
|
||||
lines.append(" - MONGO_URI=mongodb://ctf:asjdkjk23j1k3dsdn2h233j3jj@mongodb:27017/web_fetcher?authSource=admin")
|
||||
lines.append(f" - APP_URI=http://0.0.0.0:{org}")
|
||||
lines.append(" depends_on:")
|
||||
lines.append(" - mongodb")
|
||||
if name == "kode-viewer":
|
||||
lines.append(" environment:")
|
||||
lines.append(" - REDIS_HOST=redis")
|
||||
lines.append(" - REDIS_PORT=6379")
|
||||
lines.append(" depends_on:")
|
||||
lines.append(" - redis")
|
||||
if name == "gemas-notes":
|
||||
lines.append(" ports:")
|
||||
lines.append(f" - \"{org + 22}:22\"")
|
||||
lines.append(" depends_on:")
|
||||
lines.append(" - database")
|
||||
lines.append(" - validation-service")
|
||||
# note: gemas-notes internal port differs (validation-service is the 8000 listener?)
|
||||
# upstream maps validation-service:12000:80 and gemas-notes has no port except ssh.
|
||||
# Our single main service is gemas-notes which exposes ssh on 22.
|
||||
if name == "burvesigner":
|
||||
lines.append(" volumes:")
|
||||
lines.append(" - ../receiver/files/burvesigner.priv:/priv.data:ro")
|
||||
# sidecars
|
||||
for sname, sc in sidecars.items():
|
||||
cont_s = f"{sname}_container"
|
||||
lines.append(f" {sname}:")
|
||||
if sc.get("build"):
|
||||
lines += [" build:",
|
||||
f" context: {sc['build']}",
|
||||
f" dockerfile: Dockerfile"]
|
||||
elif sc.get("image"):
|
||||
lines.append(f" image: {sc['image']}")
|
||||
if sc.get("container_name") is not None:
|
||||
lines.append(f" container_name: {cont_s}")
|
||||
if sc.get("environment"):
|
||||
lines.append(" environment:")
|
||||
for e in sc["environment"]:
|
||||
lines.append(f" - {e}")
|
||||
if sc.get("volumes"):
|
||||
lines.append(" volumes:")
|
||||
for v in sc["volumes"]:
|
||||
lines.append(f" - {v}")
|
||||
if sc.get("depends_on"):
|
||||
lines.append(" depends_on:")
|
||||
for dep in sc["depends_on"]:
|
||||
lines.append(f" - {dep}")
|
||||
if sc.get("ports_chall"):
|
||||
lines.append(" ports:")
|
||||
lines.append(f" - \"{org}:{sc['ports_chall']}\"")
|
||||
if sc.get("ports_ssh"):
|
||||
lines.append(" ports:")
|
||||
lines.append(f" - \"{org + 22}:{sc['ports_ssh']}\"")
|
||||
if sc.get("extra_hosts"):
|
||||
lines.append(" extra_hosts:")
|
||||
lines.append(' - "host.docker.internal:host-gateway"')
|
||||
if sname == "frontend" and name == "tempest-poc":
|
||||
# tempest frontend already handled via ports_chall
|
||||
pass
|
||||
elif sname == "frontend":
|
||||
lines += [" ports:", f" - \"{org}:80\""]
|
||||
lines.append(" extra_hosts:")
|
||||
lines.append(' - "host.docker.internal:host-gateway"')
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
def main():
|
||||
reg = load_registry()
|
||||
for ch in reg["challenges"]:
|
||||
name = ch["name"]
|
||||
dst = SVC / name / "docker-compose.yml"
|
||||
if name in ("blogpost", "carbeat", "cdn", "phew", "sheesh", "warmup"):
|
||||
# keep existing split template (from shared compose)
|
||||
tpl = SVC / name / "compose.template.yml"
|
||||
if tpl.exists() and not dst.exists():
|
||||
text = tpl.read_text()
|
||||
lines = [l for l in text.splitlines() if l.strip() and not l.strip().startswith("#")]
|
||||
out = []
|
||||
for l in lines:
|
||||
if l.strip().startswith("# ---") and out:
|
||||
break
|
||||
out.append(l)
|
||||
dst.write_text("\n".join(out).rstrip() + "\n")
|
||||
print(f"kept template: {name}")
|
||||
continue
|
||||
if name == "tempest-poc":
|
||||
dst.write_text(render_tempest(ch))
|
||||
print(f"wrote canonical compose (tempest): {name}")
|
||||
continue
|
||||
dst.write_text(render(ch))
|
||||
print(f"wrote canonical compose: {name}")
|
||||
|
||||
|
||||
def render_tempest(ch: dict) -> str:
|
||||
"""tempest-poc: frontend (nginx) is the MAIN challenge service — it gets
|
||||
the challenge container name + SSH + challenge port; backend is a sidecar
|
||||
service that frontend proxies to (no host port needed)."""
|
||||
name = ch["name"]
|
||||
org = int(ch.get("org_port", 10000))
|
||||
return f"""services:
|
||||
{name}:
|
||||
container_name: {name}_container
|
||||
hostname: {name}
|
||||
restart: always
|
||||
build:
|
||||
context: ./tempest-poc/frontend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "{org}:80"
|
||||
- "{org + 22}:22"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
backend:
|
||||
build:
|
||||
context: ./tempest-poc/backend
|
||||
dockerfile: Dockerfile
|
||||
"""
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the per-team receiver main.py from the challenge registry.
|
||||
|
||||
The receiver's `challenges` dict is generated from ENABLED registry entries so
|
||||
the SLA checker pool exactly matches the distributed challenges. Imports come
|
||||
from three packages:
|
||||
- challenges.<Name> (gemastik18 native checkers)
|
||||
- challenges.xvi.<Name> (GEMASTIK XVI checkers)
|
||||
- challenges.xvii.checkers (GEMASTIK XVII generic checkers)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from teams import TEAMS_DIR, load_registry
|
||||
|
||||
|
||||
def checker_class_for(ch: dict) -> str:
|
||||
"""Return Python import path + class name for a registry challenge."""
|
||||
name = ch["name"]
|
||||
s = ch["set"]
|
||||
# gemastik18 native challenges have their own checker class (capitalized)
|
||||
if s == "gemastik18":
|
||||
cls = {
|
||||
"blogpost": "Blogpost",
|
||||
"carbeat": "Carbeat",
|
||||
"cdn": "CDN",
|
||||
"phew": "Phew",
|
||||
"sheesh": "Sheesh",
|
||||
"warmup": "Warmup",
|
||||
}.get(name)
|
||||
if cls:
|
||||
return f"from challenges.{cls} import {cls}", cls
|
||||
raise KeyError(f"no native checker for {name}")
|
||||
if s == "xvi":
|
||||
cls = {
|
||||
"art": "Art",
|
||||
"xl": "XL",
|
||||
"gemas-notes": "GemasNotes",
|
||||
"pasta": "Pasta",
|
||||
"burvesigner": "Burvesigner",
|
||||
"hirnfick": "Hirnfick",
|
||||
"gemas-fetcher": "GemasFetcher",
|
||||
"s3": "S3",
|
||||
"crawlback": "Crawlback",
|
||||
"back-to-basic": "BackToBasic",
|
||||
}.get(name)
|
||||
if cls:
|
||||
return f"from challenges.xvi.{cls} import {cls}", cls
|
||||
raise KeyError(f"no xvi checker for {name}")
|
||||
if s == "xvii":
|
||||
cls = {
|
||||
"anti-alchemy": "AntiAlchemy",
|
||||
"asmr": "Asmr",
|
||||
"bit-canvas": "BitCanvas",
|
||||
"fjb": "Fjb",
|
||||
"gift-card": "GiftCard",
|
||||
"gift-voucher": "GiftVoucher",
|
||||
"gleam-drive": "GleamDrive",
|
||||
"go-green": "GoGreen",
|
||||
"kode-viewer": "KodeViewer",
|
||||
"more-less": "MoreLess",
|
||||
"tempest-poc": "TempestPoc",
|
||||
"ticketer": "Ticketer",
|
||||
}.get(name)
|
||||
if cls:
|
||||
return f"from challenges.xvii.checkers import {cls}", cls
|
||||
raise KeyError(f"no xvii checker for {name}")
|
||||
raise KeyError(f"unknown set {s}")
|
||||
|
||||
|
||||
def render_receiver_main(enabled: list[dict], port_defaults: dict) -> str:
|
||||
imports = []
|
||||
entries = []
|
||||
for ch in enabled:
|
||||
name = ch["name"]
|
||||
imp, cls = checker_class_for(ch)
|
||||
imports.append(imp)
|
||||
default = port_defaults.get(name, 10000)
|
||||
entries.append(f' "{name}": {cls}(_ch_port("{name}", {default})),')
|
||||
return f"""from fastapi import Depends, FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from config import get_settings
|
||||
|
||||
{chr(10).join(imports)}
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI()
|
||||
security = HTTPBasic()
|
||||
settings = get_settings()
|
||||
|
||||
def _ch_port(name: str, default: int) -> int:
|
||||
# read from .env manually (pydantic settings has fixed fields)
|
||||
val = os.environ.get(f"CHALLENGE_PORT_{{name.upper()}}")
|
||||
if not val:
|
||||
try:
|
||||
with open(os.path.join(os.path.dirname(__file__), ".env")) as f:
|
||||
for line in f:
|
||||
if line.startswith(f"CHALLENGE_PORT_{{name.upper()}}="):
|
||||
val = line.strip().split("=", 1)[1]
|
||||
except Exception:
|
||||
pass
|
||||
return int(val) if val else default
|
||||
|
||||
def _ch_container(name: str, default: str) -> str:
|
||||
val = os.environ.get(f"CHALLENGE_CONTAINER_{{name.upper()}}")
|
||||
if not val:
|
||||
try:
|
||||
with open(os.path.join(os.path.dirname(__file__), ".env")) as f:
|
||||
for line in f:
|
||||
if line.startswith(f"CHALLENGE_CONTAINER_{{name.upper()}}="):
|
||||
val = line.strip().split("=", 1)[1]
|
||||
except Exception:
|
||||
pass
|
||||
return val or default
|
||||
|
||||
challenges = {{
|
||||
{chr(10).join(entries)}
|
||||
}}
|
||||
|
||||
async def run_challenge_checks():
|
||||
\"\"\"Run check function on all challenges at startup\"\"\"
|
||||
logger.info("\\n" + "="*60)
|
||||
logger.info("Running challenge checks...")
|
||||
logger.info("="*60 + "\\n")
|
||||
|
||||
results = {{}}
|
||||
|
||||
for name, challenge in challenges.items():
|
||||
logger.info(f"\\n[{{name}}] Starting check...")
|
||||
try:
|
||||
# Give service time between checks
|
||||
await asyncio.sleep(2)
|
||||
|
||||
result = challenge.check()
|
||||
results[name] = result
|
||||
|
||||
if result:
|
||||
logger.info(f"[{{name}}] ✓ Check PASSED")
|
||||
else:
|
||||
logger.warning(f"[{{name}}] ✗ Check FAILED")
|
||||
except Exception as e:
|
||||
logger.error(f"[{{name}}] ✗ Check ERROR: {{e}}")
|
||||
results[name] = False
|
||||
|
||||
# Print summary
|
||||
logger.info("\\n" + "="*60)
|
||||
logger.info("Challenge Check Summary:")
|
||||
logger.info("="*60)
|
||||
passed = sum(1 for r in results.values() if r)
|
||||
total = len(results)
|
||||
for name, result in results.items():
|
||||
status = "✓ PASS" if result else "✗ FAIL"
|
||||
logger.info(f" {{name:20}} {{status}}")
|
||||
logger.info(f"\\nTotal: {{passed}}/{{total}} passed")
|
||||
logger.info("="*60 + "\\n")
|
||||
|
||||
return results
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
\"\"\"Run challenge checks on application startup\"\"\"
|
||||
asyncio.create_task(run_challenge_checks())
|
||||
|
||||
class Flag(BaseModel):
|
||||
flag: str
|
||||
challenge: str
|
||||
|
||||
class History(BaseModel):
|
||||
log: str
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
return {{"service": "receiver-service"}}
|
||||
|
||||
|
||||
@app.get("/restart/{{challenge}}")
|
||||
def restart(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
|
||||
validate(credentials, challenge)
|
||||
os.system(f"docker compose -f {{settings.COMPOSE_LOCATION}} restart {{challenge}}")
|
||||
return {{"message": "Challenge restarted"}}
|
||||
|
||||
|
||||
@app.get("/rollback/{{challenge}}")
|
||||
def rollback(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
|
||||
validate(credentials, challenge)
|
||||
os.system(f"docker compose -f {{settings.COMPOSE_LOCATION}} up -d --force-recreate {{challenge}}")
|
||||
return {{"message": "Challenge restarted"}}
|
||||
|
||||
|
||||
@app.get("/activate/{{challenge}}")
|
||||
def activate(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
|
||||
validate(credentials, challenge)
|
||||
os.system(f"docker compose -f {{settings.COMPOSE_LOCATION}} up -d {{challenge}}")
|
||||
return {{"message": "Challenge activated"}}
|
||||
|
||||
|
||||
@app.get("/deactivate/{{challenge}}")
|
||||
def deactive(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
|
||||
validate(credentials, challenge)
|
||||
os.system(f"docker compose -f {{settings.COMPOSE_LOCATION}} down {{challenge}}")
|
||||
return {{"message": "Challenge deactivated"}}
|
||||
|
||||
|
||||
@app.get("/credential/{{challenge}}")
|
||||
def credential(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
|
||||
validate(credentials, challenge)
|
||||
return challenges[challenge].credentials()
|
||||
|
||||
|
||||
@app.post("/flag")
|
||||
def receive(data: Flag, credentials: HTTPBasicCredentials = Depends(security)):
|
||||
validate(credentials, data.challenge)
|
||||
challenge = challenges[data.challenge]
|
||||
if challenge.distribute(data.flag):
|
||||
return {{"message": "Flag received"}}
|
||||
|
||||
raise HTTPException(status_code=500, detail="Error receiving flag")
|
||||
|
||||
|
||||
@app.get("/check/{{challenge}}")
|
||||
def check(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
|
||||
validate(credentials, challenge)
|
||||
return {{"success": challenges[challenge].check()}}
|
||||
|
||||
@app.post("/history")
|
||||
def history(data: History):
|
||||
with open('history/command.txt', 'a') as f:
|
||||
f.write(data.log + '\\n')
|
||||
return {{"message": "Command received"}}
|
||||
|
||||
def is_admin(credentials):
|
||||
if credentials.username != settings.ADMIN_USERNAME or credentials.password != settings.ADMIN_PASSWORD:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def validate(credentials, challenge):
|
||||
if not is_admin(credentials):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
if challenge not in challenges:
|
||||
raise HTTPException(status_code=400, detail="Invalid challenge")
|
||||
"""
|
||||
|
||||
|
||||
def sync_team_receivers() -> None:
|
||||
"""Regenerate main.py for every existing team from its state + registry."""
|
||||
for d in sorted(TEAMS_DIR.glob("team*")):
|
||||
sf = d / "state.json"
|
||||
if not sf.exists():
|
||||
continue
|
||||
st = json.loads(sf.read_text())
|
||||
idx = st["index"]
|
||||
enabled = [c for c in load_registry()["challenges"] if c.get("enabled")]
|
||||
text = render_receiver_main(enabled, {c["name"]: st["ports"][c["name"]]["chall"] for c in enabled})
|
||||
(d / "receiver" / "main.py").write_text(text)
|
||||
print(f"team{idx}: receiver main.py regenerated ({len(enabled)} challenges)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sync_team_receivers()
|
||||
@@ -25,6 +25,11 @@ def write_unit(idx: int, st: dict):
|
||||
continue
|
||||
env[f"CHALLENGE_PORT_{ch.upper()}"] = str(st["ports"][ch]["chall"])
|
||||
env[f"CHALLENGE_CONTAINER_{ch.upper()}"] = f"{ch}_container_team{idx}"
|
||||
# SSH passwords: checker Challenge.credentials() reads PASSWORD_<self.port>
|
||||
# where self.port is the team challenge port.
|
||||
pwd = st.get("chall_passwords", {}).get(ch)
|
||||
if pwd:
|
||||
env[f"PASSWORD_{st['ports'][ch]['chall']}"] = pwd
|
||||
env["COMPOSE_LOCATION"] = str(TEAMS_DIR / f"team{idx}" / "services" / "docker-compose.yml")
|
||||
env_lines = "\n".join(f'Environment="{k}={v}"' for k, v in env.items())
|
||||
unit = f"""[Unit]
|
||||
@@ -48,6 +53,12 @@ WantedBy=multi-user.target
|
||||
|
||||
def main():
|
||||
action = sys.argv[1] if len(sys.argv) > 1 else "start"
|
||||
# Regenerate receiver main.py for existing teams from the registry
|
||||
try:
|
||||
from gen_receiver_main import sync_team_receivers
|
||||
sync_team_receivers()
|
||||
except Exception as e:
|
||||
print(f"WARN: receiver main.py regen failed: {e}")
|
||||
for d in sorted(TEAMS_DIR.glob("team*")):
|
||||
sf = d / "state.json"
|
||||
if not sf.exists():
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One-time importer: copy XVI/XVII challenge sources into the platform
|
||||
services/ tree, with EOL base-image fixes and a canonical docker-compose.yml
|
||||
(per-challenge template) that the compose generator can render per team.
|
||||
|
||||
Run after cloning the upstream repos:
|
||||
python3 import_new_challenges.py <xvi_dir> <xvii_dir>
|
||||
|
||||
For each imported challenge it writes services/<name>/:
|
||||
- source files (Dockerfile, src, start.sh, requirements, db dumps...)
|
||||
- docker-compose.yml (canonical template: single main service + sidecars)
|
||||
- apt-insecure.conf (2026-clock GPG fix)
|
||||
"""
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BASE = Path("/opt/gemastik18-final")
|
||||
SVC = BASE / "services"
|
||||
|
||||
# ---------------------------------------------------------------- helpers
|
||||
def copy_tree(src: Path, dst: Path, ignore=None):
|
||||
if dst.exists():
|
||||
shutil.rmtree(dst)
|
||||
shutil.copytree(src, dst, ignore=ignore)
|
||||
|
||||
def add_apt_insecure(d: Path):
|
||||
conf = "Acquire::AllowInsecureRepositories \"true\";\nAcquire::AllowDowngradeToInsecureRepositories \"true\";\nApt::Get::AllowUnauthenticated \"true\";\n"
|
||||
(d / "apt-insecure.conf").write_text(conf)
|
||||
|
||||
def fix_base_image(d: Path, old: str, new: str):
|
||||
"""Swap the FROM line in a Dockerfile (EOL base -> supported)."""
|
||||
df = d / "Dockerfile"
|
||||
if not df.exists():
|
||||
return False
|
||||
t = df.read_text()
|
||||
if old in t:
|
||||
df.write_text(t.replace(old, new, 1))
|
||||
print(f" [fix base] {d.name}: {old} -> {new}")
|
||||
return True
|
||||
return False
|
||||
|
||||
DROP_FROM = [
|
||||
(r"public\.ecr\.aws/docker/library/(python:3\.11-slim-buster|python:3\.11-slim-bullseye)\b", "python:3.11-slim-bookworm"),
|
||||
(r"public\.ecr\.aws/docker/library/ruby:2\.7\.2\b", "ruby:3.2-slim-bookworm"),
|
||||
(r"public\.ecr\.aws/docker/library/php:8\.0-apache\b", "php:8.2-apache-bookworm"),
|
||||
(r"public\.ecr\.aws/docker/library/golang:bullseye\b", "golang:1.22-bookworm"),
|
||||
(r"public\.ecr\.aws/docker/library/ubuntu:20\.04\b", "ubuntu:24.04"),
|
||||
(r"public\.ecr\.aws/docker/library/ubuntu:22\.04\b", "ubuntu:24.04"),
|
||||
(r"public\.ecr\.aws/docker/library/node(:[0-9]+)?\b", "node:20-slim-bookworm"),
|
||||
(r"public\.ecr\.aws/docker/library/python:3\.10\.6\b", "python:3.10-slim-bookworm"),
|
||||
]
|
||||
|
||||
def fix_dockerfile(d: Path):
|
||||
df = d / "Dockerfile"
|
||||
if not df.exists():
|
||||
return
|
||||
t = df.read_text()
|
||||
for pat, new in DROP_FROM:
|
||||
t2 = re.sub(pat, new, t)
|
||||
if t2 != t:
|
||||
print(f" [fix base] {d.name}: {pat} -> {new}")
|
||||
t = t2
|
||||
# apt-insecure for every apt-get run
|
||||
if "apt-get" in t and "99gemastik-insecure" not in t:
|
||||
t = t.replace(
|
||||
"RUN apt-get update",
|
||||
"COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure\nRUN apt-get -o Acquire::AllowInsecureRepositories=true update",
|
||||
1)
|
||||
t = t.replace(
|
||||
"RUN apt-get install",
|
||||
"RUN apt-get -y --allow-unauthenticated install",
|
||||
1)
|
||||
# ensure openssh + ctfuser-ish (the standard template)
|
||||
df.write_text(t)
|
||||
|
||||
# ---------------------------------------------------------------- import
|
||||
def import_xvi(src: Path):
|
||||
print(f"=== Importing XVI from {src} ===")
|
||||
services = src / "services"
|
||||
for d in sorted(services.iterdir()):
|
||||
if not d.is_dir() or d.name == ".git":
|
||||
continue
|
||||
name = d.name
|
||||
dst = SVC / name
|
||||
print(f" - {name}")
|
||||
copy_tree(d, dst, ignore=shutil.ignore_patterns("__pycache__", ".git"))
|
||||
add_apt_insecure(dst)
|
||||
fix_dockerfile(dst)
|
||||
(dst / "docker-compose.yml").unlink(missing_ok=True)
|
||||
# special: gemas-notes, gemas-fetcher need their src subdirs — already copied whole dir.
|
||||
|
||||
def import_xvii(src: Path):
|
||||
print(f"=== Importing XVII from {src} ===")
|
||||
for d in sorted(src.iterdir()):
|
||||
if not d.is_dir() or d.name.startswith("."):
|
||||
continue
|
||||
name = d.name
|
||||
dst = SVC / name
|
||||
print(f" - {name}")
|
||||
copy_tree(d, dst, ignore=shutil.ignore_patterns("__pycache__", ".git", "docker-compose.yml", "README.md", "test"))
|
||||
add_apt_insecure(dst)
|
||||
fix_dockerfile(dst)
|
||||
(dst / "docker-compose.yml").unlink(missing_ok=True)
|
||||
if name == "tempest-poc":
|
||||
# compose is frontend+backend split; keep both Dockerfiles
|
||||
for sub in ("backend", "frontend"):
|
||||
subd = dst / sub
|
||||
if subd.exists():
|
||||
if (subd / "Dockerfile").exists():
|
||||
fix_dockerfile(subd)
|
||||
add_apt_insecure(subd)
|
||||
|
||||
if __name__ == "__main__":
|
||||
xvi = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/opt/gemastik-xvi-final")
|
||||
xvii = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("/opt/gemastik-xvii-final")
|
||||
import_xvi(xvi)
|
||||
import_xvii(xvii)
|
||||
print("Done. Next: write canonical docker-compose.yml templates per challenge.")
|
||||
+45
-9
@@ -90,8 +90,8 @@ async def _proxy(method: str, path: str, body: dict = None):
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(req: Request):
|
||||
host = (req.headers.get("host") or "").split(":")[0]
|
||||
# Team portal domains: <slug>.gemastik.imrnes.team -> their team portal (no admin login)
|
||||
if host.endswith(".gemastik.imrnes.team") and host != "gemastik.imrnes.team" and host != "panel.gemastik.imrnes.team":
|
||||
# Team portal domains: <slug>.attackdefense.imrnes.team -> their team portal (no admin login)
|
||||
if host.endswith(".attackdefense.imrnes.team") and host != "attackdefense.imrnes.team" and host != "panel.attackdefense.imrnes.team":
|
||||
slug = host.split(".")[0]
|
||||
for t in orch.list_teams():
|
||||
if t.get("slug") == slug:
|
||||
@@ -117,7 +117,7 @@ async def submit_page(req: Request):
|
||||
return HTMLResponse((BASE_DIR / "static" / "submit.html").read_text())
|
||||
|
||||
|
||||
# ============ Per-team portal (public via <slug>.gemastik.imrnes.team) ============
|
||||
# ============ Per-team portal (public via <slug>.attackdefense.imrnes.team) ============
|
||||
|
||||
@app.get("/team/{idx}", response_class=HTMLResponse)
|
||||
async def team_portal(idx: int, req: Request):
|
||||
@@ -194,13 +194,13 @@ def _team_authorized(req: Request, idx: int) -> bool:
|
||||
|
||||
def _check_team_host(req: Request, st: dict) -> bool:
|
||||
"""IDOR guard: host must be this team's own domain (or localhost).
|
||||
panel.gemastik / gemastik.imrnes.team only allowed with a valid ADMIN session."""
|
||||
panel.gemastik / attackdefense.imrnes.team only allowed with a valid ADMIN session."""
|
||||
host = (req.headers.get("host") or "").split(":")[0]
|
||||
if host == st.get("domain"):
|
||||
return True
|
||||
if host.startswith("127.0.0.1") or host.startswith("localhost"):
|
||||
return True
|
||||
if host in ("panel.gemastik.imrnes.team", "gemastik.imrnes.team"):
|
||||
if host in ("panel.attackdefense.imrnes.team", "attackdefense.imrnes.team"):
|
||||
return _authorized(req) # admin preview only
|
||||
return False
|
||||
|
||||
@@ -234,7 +234,7 @@ async def api_team_targets(idx: int, req: Request):
|
||||
out.append({
|
||||
"team_idx": st.get("index"),
|
||||
"team_label": st.get("label", f"Team {st.get('index')}"),
|
||||
"domain": st.get("domain") or (st.get("slug", f"team{st.get('index')}") + ".gemastik.imrnes.team"),
|
||||
"domain": st.get("domain") or (st.get("slug", f"team{st.get('index')}") + ".attackdefense.imrnes.team"),
|
||||
"port": p["chall"],
|
||||
})
|
||||
return {"targets": out}
|
||||
@@ -357,7 +357,43 @@ def require_login(req: Request):
|
||||
@app.get("/api/challenges")
|
||||
async def api_challenges(req: Request):
|
||||
require_login(req)
|
||||
return {"challenges": CHALLENGES}
|
||||
# Full registry (all sets) with enabled status, plus count of live teams
|
||||
reg = orch.load_registry()
|
||||
challs = []
|
||||
for c in reg.get("challenges", []):
|
||||
challs.append({
|
||||
"name": c["name"],
|
||||
"set": c.get("set"),
|
||||
"category": c.get("category"),
|
||||
"desc": c.get("desc"),
|
||||
"enabled": bool(c.get("enabled")),
|
||||
"chall_offset": c.get("chall_offset"),
|
||||
"ssh_offset": c.get("ssh_offset"),
|
||||
"org_port": c.get("org_port"),
|
||||
"service_dir": c.get("service_dir"),
|
||||
})
|
||||
return {"challenges": challs, "hint": "PATCH /api/challenges/<name> with {\"enabled\": bool} to toggle"}
|
||||
|
||||
|
||||
@app.patch("/api/challenges/{challenge}")
|
||||
async def api_challenge_toggle(challenge: str, req: Request):
|
||||
require_login(req)
|
||||
data = await req.json()
|
||||
enabled = bool(data.get("enabled"))
|
||||
reg = orch.load_registry()
|
||||
found = any(c.get("name") == challenge for c in reg.get("challenges", []))
|
||||
if not found:
|
||||
raise HTTPException(404, "Unknown challenge")
|
||||
changed = orch.set_challenge_enabled(challenge, enabled)
|
||||
# apply to live teams (build/up or stop/remove + receiver restart);
|
||||
# skip rebuild when the flag didn't actually change
|
||||
if "unchanged" in changed:
|
||||
return {"ok": True, "name": challenge, "enabled": enabled, "applied": [], "unchanged": True}
|
||||
try:
|
||||
report = await asyncio.to_thread(orch.sync_challenge_runtime, challenge, enabled)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Registry updated tapi runtime gagal: {e}")
|
||||
return {"ok": True, "name": challenge, "enabled": enabled, "applied": report.get("teams", [])}
|
||||
|
||||
@app.get("/api/status")
|
||||
async def api_status(req: Request):
|
||||
@@ -549,7 +585,7 @@ async def api_topology(req: Request):
|
||||
require_login(req)
|
||||
teams = orch.list_teams()
|
||||
nodes = [
|
||||
{"id": "panel", "label": "Panel A/D", "type": "panel", "url": "https://panel.gemastik.imrnes.team"},
|
||||
{"id": "panel", "label": "Panel A/D", "type": "panel", "url": "https://panel.attackdefense.imrnes.team"},
|
||||
{"id": "traefik", "label": "Traefik / Coolify", "type": "infra"},
|
||||
{"id": "dns", "label": "*.imrnes.team → 43.134.105.109", "type": "infra"},
|
||||
]
|
||||
@@ -687,7 +723,7 @@ async def api_admin_targets(req: Request):
|
||||
if not (d / "state.json").exists():
|
||||
continue
|
||||
st = json.loads((d / "state.json").read_text())
|
||||
dom = st.get("domain") or (st.get("slug", f"team{st.get('index')}") + ".gemastik.imrnes.team")
|
||||
dom = st.get("domain") or (st.get("slug", f"team{st.get('index')}") + ".attackdefense.imrnes.team")
|
||||
for name, coff, soff in orch.CHALLENGES:
|
||||
p = st["ports"].get(name)
|
||||
if not p:
|
||||
|
||||
+68
-6
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Gemastik A/D Panel</title>
|
||||
<title>Attack Defense Platform</title>
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body {
|
||||
@@ -117,7 +117,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>⚔️ GEMASTIK A/D — NODE CONTROL</h1>
|
||||
<h1>⚔️ ATTACK DEFENSE PLATFORM — NODE CONTROL</h1>
|
||||
<div class="actions">
|
||||
<span class="pill" id="clock">--:--:--</span>
|
||||
<button onclick="refresh()">🔄 Refresh</button>
|
||||
@@ -135,6 +135,7 @@
|
||||
<button class="tab" data-view="creds" onclick="showView('creds'); loadCreds()">🔑 Creds</button>
|
||||
<button class="tab" data-view="targets" onclick="showView('targets'); loadTargetMatrix()">🎯 Target Matrix</button>
|
||||
<button class="tab" data-view="sla" onclick="showView('sla'); loadSla()">📊 SLA & Skor</button>
|
||||
<button class="tab" data-view="chmgr" onclick="showView('chmgr'); loadChMgr()">🏗️ Challenge Manager</button>
|
||||
</div>
|
||||
|
||||
<!-- Challenges view -->
|
||||
@@ -229,7 +230,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-note">Receiver: https://gemastik.imrnes.team · Panel: https://panel.gemastik.imrnes.team · Auto-refresh tiap 15 detik</div>
|
||||
<!-- Challenge Manager view -->
|
||||
<div class="view" id="view-chmgr">
|
||||
<div class="card">
|
||||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<b style="color:#5ad1ff">🏗️ Challenge Manager</b>
|
||||
<span style="font-size:11px;color:#718096">Semua challenge dari registry (GEMASTIK 18, XVI, XVII). Toggle untuk aktif/nonaktif global di semua tim — diterapkan langsung (build/up atau stop/remove + restart receiver).</span>
|
||||
<button class="primary" onclick="loadChMgr()" style="padding:4px 10px;font-size:12px">🔄 Muat ulang</button>
|
||||
</div>
|
||||
<div id="chMgrList" style="margin-top:14px;font-family:ui-monospace,monospace;font-size:12px;line-height:1.9;color:#dbe6f4;background:#0a101f;border:1px solid #1e3a5f;border-radius:10px;padding:14px;overflow-x:auto">Memuat…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-note">Receiver: https://attackdefense.imrnes.team · Panel: https://panel.attackdefense.imrnes.team · Auto-refresh tiap 15 detik</div>
|
||||
|
||||
<!-- modal flag -->
|
||||
<div class="modal-back" id="modalFlag">
|
||||
@@ -310,6 +323,7 @@ function showView(v) {
|
||||
document.querySelectorAll('.view').forEach(x => x.classList.toggle('active', x.id === 'view-' + v));
|
||||
if (v === 'topo') loadTopo(); // refresh attacks immediately on tab switch + every 10s
|
||||
if (v === 'sla') loadSla();
|
||||
if (v === 'chmgr') loadChMgr();
|
||||
}
|
||||
let topoTimer = null;
|
||||
function startTopoTimer() {
|
||||
@@ -347,6 +361,54 @@ async function loadSla() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Challenge Manager ----------
|
||||
async function loadChMgr() {
|
||||
const box = document.getElementById('chMgrList');
|
||||
if (!box) return;
|
||||
box.innerHTML = 'Memuat…';
|
||||
try {
|
||||
const d = await api('/api/challenges');
|
||||
const list = d.challenges || [];
|
||||
const setLabels = { 'gemastik18': 'GEMASTIK 18', 'xvi': 'GEMASTIK XVI', 'xvii': 'GEMASTIK XVII' };
|
||||
const bySet = {};
|
||||
for (const c of list) (bySet[c.set] = bySet[c.set] || []).push(c);
|
||||
let html = '';
|
||||
for (const [set, chs] of Object.entries(bySet)) {
|
||||
html += `<div style="margin:8px 0 4px;color:#5ad1ff;font-weight:700">📦 ${escapeHtml(setLabels[set] || set)} (${chs.length})</div>`;
|
||||
for (const c of chs) {
|
||||
const on = c.enabled ? 'checked' : '';
|
||||
html += `<div style="display:flex;align-items:center;gap:10px;padding:6px 8px;border-bottom:1px solid #13233d;border-radius:6px">
|
||||
<input type="checkbox" id="chmgr-${escapeHtml(c.name)}" ${on} onchange="toggleChallenge('${escapeHtml(c.name)}', this.checked)" style="width:16px;height:16px;accent-color:#22c55e">
|
||||
<b style="min-width:150px">${escapeHtml(c.name)}</b>
|
||||
<span style="color:#718096;font-size:11px">${escapeHtml(c.set)} · ${escapeHtml(c.category || '-')}</span>
|
||||
<span style="color:#3d5a80;font-size:11px">chall+${c.chall_offset}/ssh+${c.ssh_offset}</span>
|
||||
<span style="margin-left:auto;font-size:11px;color:${c.enabled ? '#22c55e' : '#e74c3c'}">${c.enabled ? '● AKTIF' : '○ NONAKTIF'}</span>
|
||||
<span id="chmgr-msg-${escapeHtml(c.name)}" style="font-size:11px;color:#718096"></span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
box.innerHTML = html || '<div style="color:#718096">Tidak ada challenge di registry.</div>';
|
||||
} catch (e) {
|
||||
box.innerHTML = `<div style="color:#e74c3c">Gagal memuat: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleChallenge(name, enabled) {
|
||||
const msg = document.getElementById('chmgr-msg-' + name);
|
||||
if (!msg) return;
|
||||
msg.textContent = '⏳ menerapkan…';
|
||||
msg.style.color = '#f5c542';
|
||||
try {
|
||||
const d = await api('/api/challenges/' + encodeURIComponent(name), { method: 'PATCH', body: JSON.stringify({ enabled }) });
|
||||
msg.textContent = `✓ ${d.enabled ? 'diaktifkan' : 'dinonaktifkan'} (${(d.applied || []).length} tim)`;
|
||||
msg.style.color = d.enabled ? '#22c55e' : '#e74c3c';
|
||||
setTimeout(() => { msg.textContent = ''; loadChMgr(); }, 4000);
|
||||
} catch (e) {
|
||||
msg.textContent = '✗ ' + e.message;
|
||||
msg.style.color = '#e74c3c';
|
||||
loadChMgr();
|
||||
}
|
||||
}
|
||||
// ---------- Challenges ----------
|
||||
async function refresh() {
|
||||
const grid = document.getElementById('grid');
|
||||
@@ -421,7 +483,7 @@ async function viewCred(ch) {
|
||||
const c = await api(`/api/credential/${ch}`);
|
||||
document.getElementById('mcTitle').textContent = 'SSH Credentials — ' + ch;
|
||||
const sshPort = {blogpost:10022, carbeat:11022, cdn:12022, phew:13022, sheesh:14022, warmup:15022}[ch] || 10022;
|
||||
const cmd = `ssh ctfuser@${ch}.gemastik.imrnes.team -p ${sshPort}`;
|
||||
const cmd = `ssh ctfuser@${ch}.attackdefense.imrnes.team -p ${sshPort}`;
|
||||
document.getElementById('mcBody').innerHTML =
|
||||
`<div>User: <b>ctfuser</b></div>
|
||||
<div>Pass: <b>${esc(c.password)}</b></div>
|
||||
@@ -686,7 +748,7 @@ function teamCountChanged() {
|
||||
<span style="color:#718096;font-size:12px;width:60px">Team ${i}</span>
|
||||
<input data-idx="${i}" data-field="name" placeholder="Nama team (contoh: Cyber Warriors)" style="flex:1;min-width:120px">
|
||||
<input data-idx="${i}" data-field="domain" placeholder="domain custom (contoh: team-cyber)" style="flex:1;min-width:120px">
|
||||
<span style="color:#3b4a63;font-size:11px">.gemastik.imrnes.team</span>
|
||||
<span style="color:#3b4a63;font-size:11px">.attackdefense.imrnes.team</span>
|
||||
</div>`;
|
||||
}
|
||||
box.innerHTML = html;
|
||||
@@ -795,7 +857,7 @@ async function viewTeamCred(idx) {
|
||||
for (const [name, p] of Object.entries(t.team.ports)) {
|
||||
if (name === 'receiver' || name === 'panel') continue;
|
||||
html += `<div class="kv" style="margin-top:6px">
|
||||
<b>${name}</b><span>ssh ctfuser@${name}.gemastik.imrnes.team -p ${p.ssh} · pass: ${esc(t.team.chall_passwords[name])}</span>
|
||||
<b>${name}</b><span>ssh ctfuser@${name}.attackdefense.imrnes.team -p ${p.ssh} · pass: ${esc(t.team.chall_passwords[name])}</span>
|
||||
</div>`;
|
||||
}
|
||||
document.getElementById('mtcTitle').textContent = `Kredensial Team ${idx}`;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Gemastik A/D Panel — Login</title>
|
||||
<title>Attack Defense Platform — Login</title>
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body {
|
||||
@@ -34,7 +34,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>⚔️ GEMASTIK A/D</h1>
|
||||
<h1>⚔️ ATTACK DEFENSE</h1>
|
||||
<div class="sub">Node Control Panel — imrnes</div>
|
||||
<form id="f">
|
||||
<label>Username</label>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<div class="logo">G</div>
|
||||
<div>
|
||||
<h1>Gemastik XVIII — Attack & Defense</h1>
|
||||
<div class="sub">Submit flag untuk tim kamu. Server: gemastik.imrnes.team</div>
|
||||
<div class="sub">Submit flag untuk tim kamu. Server: attackdefense.imrnes.team</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="team-id" content="0">
|
||||
<title>Portal Tim — Gemastik A/D</title>
|
||||
<title>Portal Tim — Attack Defense Platform</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
|
||||
<style>
|
||||
|
||||
+151
-58
@@ -36,15 +36,133 @@ TEAMS_DIR = BASE / "teams"
|
||||
SERVICES_SRC = BASE / "services"
|
||||
RECEIVER_SRC = BASE / "receiver"
|
||||
|
||||
# Challenge definitions: (service name, chall port offset 0-5, ssh offset 22-27)
|
||||
CHALLENGES = [
|
||||
("blogpost", 0, 22),
|
||||
("carbeat", 1, 23),
|
||||
("cdn", 2, 24),
|
||||
("phew", 3, 25),
|
||||
("sheesh", 4, 26),
|
||||
("warmup", 5, 27),
|
||||
]
|
||||
# ---------------------------------------------------------------------------
|
||||
# Challenge registry — single source of truth across all distributed sets.
|
||||
# Loaded from teams/challenge_registry.json (admin can toggle enabled).
|
||||
#
|
||||
# CHALLENGES below is a DERIVED list: (name, chall_offset, ssh_offset) for
|
||||
# every ENABLED challenge, in registry order. All team logic uses this.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_REGISTRY_PATH = TEAMS_DIR / "challenge_registry.json"
|
||||
|
||||
def _default_registry() -> dict:
|
||||
return {"sets": {}, "challenges": []}
|
||||
|
||||
def load_registry() -> dict:
|
||||
try:
|
||||
return json.loads(_REGISTRY_PATH.read_text())
|
||||
except Exception:
|
||||
return _default_registry()
|
||||
|
||||
def save_registry(reg: dict):
|
||||
_REGISTRY_PATH.write_text(json.dumps(reg, indent=2))
|
||||
|
||||
def registry_challenges() -> list:
|
||||
"""All challenge dicts from the registry (enabled or not), in order."""
|
||||
return load_registry().get("challenges", [])
|
||||
|
||||
def enabled_challenges() -> list:
|
||||
return [c for c in registry_challenges() if c.get("enabled")]
|
||||
|
||||
def set_challenge_enabled(name: str, enabled: bool) -> dict:
|
||||
"""Flip a challenge's enabled flag in the registry (global toggle)."""
|
||||
reg = load_registry()
|
||||
for c in reg.get("challenges", []):
|
||||
if c["name"] == name:
|
||||
if bool(c.get("enabled")) == bool(enabled):
|
||||
return {"unchanged": True, **c}
|
||||
c["enabled"] = bool(enabled)
|
||||
save_registry(reg)
|
||||
return c
|
||||
raise KeyError(f"Challenge {name} tidak ada di registry")
|
||||
|
||||
|
||||
def sync_challenge_runtime(name: str, enabled: bool) -> dict:
|
||||
"""Apply an enable/disable toggle to every RUNNING team:
|
||||
- regenerate that team's compose from the registry (compose_gen)
|
||||
- for ENABLE: docker compose up -d <name> (builds image first if needed)
|
||||
- for DISABLE: docker compose rm -sf <name> (stop+remove the container)
|
||||
- restart the team receiver so its challenge dict matches (main.py)
|
||||
Returns a per-team report.
|
||||
"""
|
||||
import compose_gen
|
||||
reg = load_registry()
|
||||
ch = next((c for c in reg.get("challenges", []) if c["name"] == name), None)
|
||||
if not ch:
|
||||
raise KeyError(f"Challenge {name} tidak ada di registry")
|
||||
# rebuild the derived CHALLENGES for fresh creates
|
||||
global CHALLENGES
|
||||
CHALLENGES = [(c["name"], c["chall_offset"], c["ssh_offset"]) for c in enabled_challenges()]
|
||||
report = {"challenge": name, "enabled": bool(enabled), "teams": []}
|
||||
for d in sorted(TEAMS_DIR.glob("team*")):
|
||||
sf = d / "state.json"
|
||||
if not sf.exists():
|
||||
continue
|
||||
st = json.loads(sf.read_text())
|
||||
idx = st["index"]
|
||||
svc_dir = d / "services"
|
||||
try:
|
||||
if enabled:
|
||||
# fresh flag file for this challenge
|
||||
flags_dir = d / "receiver" / "flags"
|
||||
flags_dir.mkdir(parents=True, exist_ok=True)
|
||||
flag = f"GEMASTIK18{{TEAM{idx}_{name.upper()}_{secrets.token_hex(6)}}}"
|
||||
(flags_dir / f"{name}.txt").write_text(flag)
|
||||
st.setdefault("flags", {})[name] = flag
|
||||
st["ports"][name] = {"chall": 30000 + idx*1000 + ch["chall_offset"],
|
||||
"ssh": 30000 + idx*1000 + ch["ssh_offset"]}
|
||||
st.setdefault("chall_passwords", {})[name] = st["chall_passwords"].get(
|
||||
name) or f"chall{idx}_{name}_{secrets.token_hex(4)}"
|
||||
# write state BEFORE rendering (render needs ports[name])
|
||||
(sf).write_text(json.dumps(st, indent=2))
|
||||
# regenerate whole compose (so enabled challenge included),
|
||||
# then bring up just this service
|
||||
new_text = compose_gen.render_team_compose(idx, st)
|
||||
(svc_dir / "docker-compose.yml").write_text(new_text)
|
||||
# inject the team-specific password env if compose uses ${PASSWORD_*}
|
||||
envp = svc_dir / ".env"
|
||||
if not envp.exists():
|
||||
env_lines = [f"ADMIN_USERNAME={st['admin_user']}", f"ADMIN_PASSWORD={st['admin_pass']}",
|
||||
f"COMPOSE_LOCATION={svc_dir}/docker-compose.yml"]
|
||||
for i in range(20):
|
||||
env_lines.append(f"PASSWORD_{(i*1000)+10000}=placeholder")
|
||||
(envp).write_text("\n".join(env_lines) + "\n")
|
||||
r = subprocess.run(["docker", "compose", "-p", f"team{idx}", "-f", svc_dir / "docker-compose.yml",
|
||||
"up", "-d", "--build", name],
|
||||
cwd=str(svc_dir), capture_output=True, text=True, timeout=1800)
|
||||
ok = r.returncode == 0
|
||||
report["teams"].append({"team": idx, "ok": ok, "detail": (r.stdout or r.stderr)[-300:]})
|
||||
if ok:
|
||||
# set the per-team SSH password after container boot
|
||||
try:
|
||||
pw = st["chall_passwords"][name]
|
||||
subprocess.run(["docker", "exec", f"{name}_container_team{idx}", "sh", "-c",
|
||||
f"echo 'ctfuser:{pw}' | chpasswd"], capture_output=True, timeout=60)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# stop + remove the container FIRST (old compose), then regenerate
|
||||
r = subprocess.run(["docker", "compose", "-p", f"team{idx}", "-f", svc_dir / "docker-compose.yml",
|
||||
"rm", "-sf", name],
|
||||
cwd=str(svc_dir), capture_output=True, text=True, timeout=120)
|
||||
# remove from state ports/flags
|
||||
st["ports"].pop(name, None)
|
||||
st.setdefault("flags", {}).pop(name, None)
|
||||
(sf).write_text(json.dumps(st, indent=2))
|
||||
# regenerate compose WITHOUT this challenge
|
||||
new_text = compose_gen.render_team_compose(idx, st)
|
||||
(svc_dir / "docker-compose.yml").write_text(new_text)
|
||||
report["teams"].append({"team": idx, "ok": True,
|
||||
"detail": (r.stdout or r.stderr)[-300:] or "removed"})
|
||||
# restart receiver so its challenge set matches
|
||||
_start_receiver(idx)
|
||||
except Exception as e:
|
||||
report["teams"].append({"team": idx, "ok": False, "detail": str(e)[-300:]})
|
||||
return report
|
||||
|
||||
# Derived list used everywhere (keeps old API: tuples of name, coff, soff)
|
||||
CHALLENGES = [(c["name"], c["chall_offset"], c["ssh_offset"]) for c in enabled_challenges()]
|
||||
|
||||
# Points system: base points earned by stealing a flag from another team's
|
||||
# challenge. The SLA bonus is earned by keeping your OWN services alive.
|
||||
@@ -96,7 +214,8 @@ def add_sla_bonus(team_idx: int, alive: int, total: int = 6) -> dict:
|
||||
me = data["teams"].setdefault(tid, {"points": 0, "events": []})
|
||||
now = time.time()
|
||||
last = me.get("last_sla_bonus", 0)
|
||||
if alive >= SLA_BONUS_MIN_ALIVE and total >= SLA_BONUS_MIN_ALIVE:
|
||||
min_alive = max(1, len(enabled_challenges()))
|
||||
if alive >= min_alive and total >= min_alive:
|
||||
if now - last > 300: # 5 min window
|
||||
me["points"] = int(me.get("points", 0)) + SLA_BONUS_POINTS
|
||||
me["last_sla_bonus"] = now
|
||||
@@ -155,18 +274,18 @@ def create_team(idx: int, label: str = None, domain: str = None):
|
||||
|
||||
label -> team display name (leaderboard/topology)
|
||||
domain -> custom subdomain host, e.g. "cyber-warriors" or
|
||||
"cyber-warriors.gemastik.imrnes.team" (full host accepted).
|
||||
Defaults to slugify(label).gemastik.imrnes.team.
|
||||
"cyber-warriors.attackdefense.imrnes.team" (full host accepted).
|
||||
Defaults to slugify(label).attackdefense.imrnes.team.
|
||||
"""
|
||||
ports = team_ports(idx)
|
||||
team_dir = TEAMS_DIR / f"team{idx}"
|
||||
label = label or f"Tim {idx}"
|
||||
slug = slugify(label)
|
||||
# normalize custom domain -> host under *.gemastik.imrnes.team
|
||||
host = (domain or f"{slug}.gemastik.imrnes.team").strip().lower()
|
||||
# normalize custom domain -> host under *.attackdefense.imrnes.team
|
||||
host = (domain or f"{slug}.attackdefense.imrnes.team").strip().lower()
|
||||
host = host.replace("https://", "").replace("http://", "").rstrip("/")
|
||||
if not host.endswith(".gemastik.imrnes.team"):
|
||||
host = f"{host}.gemastik.imrnes.team"
|
||||
if not host.endswith(".attackdefense.imrnes.team"):
|
||||
host = f"{host}.attackdefense.imrnes.team"
|
||||
slug = host.split(".")[0]
|
||||
state = {
|
||||
"index": idx,
|
||||
@@ -188,8 +307,8 @@ def create_team(idx: int, label: str = None, domain: str = None):
|
||||
svc_dir = team_dir / "services"
|
||||
if svc_dir.exists():
|
||||
shutil.rmtree(svc_dir)
|
||||
shutil.copytree(SERVICES_SRC, svc_dir, ignore=shutil.ignore_patterns("__pycache__", ".git", "exploits", "exploit"))
|
||||
# compose references ../utils/bashrc etc — copy utils next to services
|
||||
svc_dir.mkdir(parents=True, exist_ok=True)
|
||||
# copy utils next to services (compose references ../utils/bashrc)
|
||||
utils_src = BASE / "utils"
|
||||
utils_dst = team_dir / "utils"
|
||||
if utils_src.exists():
|
||||
@@ -202,45 +321,19 @@ def create_team(idx: int, label: str = None, domain: str = None):
|
||||
if bashrc.exists():
|
||||
bashrc.write_text(bashrc.read_text().replace(
|
||||
"host.docker.internal:18080", f"host.docker.internal:{recv_port}"))
|
||||
# generate compose from the challenge registry (compose_gen renders the
|
||||
# per-team file: image: services-<name>, team ports, team passwords)
|
||||
import compose_gen
|
||||
compose_text = compose_gen.render_team_compose(idx, state)
|
||||
compose = svc_dir / "docker-compose.yml"
|
||||
text = compose.read_text()
|
||||
# --- replace build: blocks with image: so teams reuse the base images (no rebuild) ---
|
||||
# Each service's build block looks like:
|
||||
# build:
|
||||
# context: <name>
|
||||
# args:
|
||||
# - PASSWORD=$PASSWORD_XXXXX
|
||||
# Replace the whole block with " image: services-<name>".
|
||||
for name, coff, soff in CHALLENGES:
|
||||
text = re.sub(
|
||||
rf" build:\n context: {name}\n args:\n - PASSWORD=\$PASSWORD_[0-9]+\n",
|
||||
f" image: services-{name}\n",
|
||||
text)
|
||||
compose.write_text(text)
|
||||
# rewrite container names + ports per challenge
|
||||
for name, coff, soff in CHALLENGES:
|
||||
cont_old = f"{name}_container"
|
||||
cont_new = f"{name}_container_team{idx}"
|
||||
text = text.replace(f"container_name: {cont_old}", f"container_name: {cont_new}")
|
||||
text = text.replace(f"hostname: {name}", f"hostname: {name}_team{idx}")
|
||||
# ports mapping: "10000:8000" -> "<team_chall>:8000"
|
||||
old_chall = str(10000 + coff * 1000) # 10000,11000,12000,13000,14000,15000
|
||||
old_ssh = str(10022 + coff * 1000) # 10022,11022,...
|
||||
text = re.sub(rf'"({old_chall}):', f'"{ports[name]["chall"]}:', text)
|
||||
text = re.sub(rf'"({old_ssh}):', f'"{ports[name]["ssh"]}:', text)
|
||||
# PASSWORD_* args -> team passwords
|
||||
for name, coff, soff in CHALLENGES:
|
||||
old_env = f"PASSWORD_{10000 + coff * 1000}"
|
||||
text = re.sub(rf"\${{{old_env}}}", state["chall_passwords"][name], text)
|
||||
# compose file references services/.env — we'll create it below
|
||||
compose.write_text(text)
|
||||
compose.write_text(compose_text)
|
||||
|
||||
# team services/.env (PASSWORD_* in same shape as starter.py)
|
||||
env_lines = [f"ADMIN_USERNAME={state['admin_user']}", f"ADMIN_PASSWORD={state['admin_pass']}"]
|
||||
env_lines.append(f"COMPOSE_LOCATION={svc_dir}/docker-compose.yml")
|
||||
for i in range(20):
|
||||
env_lines.append(f"PASSWORD_{(i*1000)+10000}={gen_password(20)}")
|
||||
# force the six used passwords to team ones
|
||||
# force the used passwords to team ones
|
||||
for name, coff, soff in CHALLENGES:
|
||||
for j, line in enumerate(env_lines):
|
||||
if line.startswith(f"PASSWORD_{10000+coff*1000}="):
|
||||
@@ -294,8 +387,8 @@ def update_team(idx: int, label: str = None, domain: str = None) -> dict:
|
||||
st["label"] = str(label).strip()[:48] or st["label"]
|
||||
if domain:
|
||||
host = domain.strip().lower().replace("https://", "").replace("http://", "").rstrip("/")
|
||||
if not host.endswith(".gemastik.imrnes.team"):
|
||||
host = f"{host}.gemastik.imrnes.team"
|
||||
if not host.endswith(".attackdefense.imrnes.team"):
|
||||
host = f"{host}.attackdefense.imrnes.team"
|
||||
st["domain"] = host
|
||||
st["slug"] = host.split(".")[0]
|
||||
(team_dir / "state.json").write_text(json.dumps(st, indent=2))
|
||||
@@ -399,7 +492,7 @@ def team_logs(idx: int, service: str = None, tail: int = 100):
|
||||
def ensure_team_domains() -> str:
|
||||
"""Write Traefik dynamic config for each team domain -> panel (:18081).
|
||||
|
||||
Each team gets <slug>.gemastik.imrnes.team. The panel routes
|
||||
Each team gets <slug>.attackdefense.imrnes.team. The panel routes
|
||||
/team/<idx> to that team's portal page (public, no panitia login),
|
||||
and /api/team/<idx>/* serves team-scoped API. Writing this into the
|
||||
same dynamic dir Traefik watches means domains appear automatically.
|
||||
@@ -419,10 +512,10 @@ def ensure_team_domains() -> str:
|
||||
td = TEAMS_DIR / f"team{t['index']}"
|
||||
st = json.loads((td / "state.json").read_text())
|
||||
st["slug"] = slug
|
||||
st["domain"] = f"{slug}.gemastik.imrnes.team"
|
||||
st["domain"] = f"{slug}.attackdefense.imrnes.team"
|
||||
(td / "state.json").write_text(json.dumps(st, indent=2))
|
||||
changed = True
|
||||
host = f"{slug}.gemastik.imrnes.team"
|
||||
host = f"{slug}.attackdefense.imrnes.team"
|
||||
out.append(f""" team-{slug}-http:
|
||||
rule: Host(`{host}`)
|
||||
entryPoints:
|
||||
@@ -442,9 +535,9 @@ def ensure_team_domains() -> str:
|
||||
""")
|
||||
if not out:
|
||||
return "no teams to route"
|
||||
# Keep the team domain block in its own file so the main gemastik.yaml stays untouched
|
||||
(traefik_dir / "gemastik-teams.yaml").write_text("http:\n routers:\n" + "".join(out))
|
||||
return f"wrote {len(out)} team domain(s) in gemastik-teams.yaml"
|
||||
# Keep the team domain block in its own file so the main attackdefense.yaml stays untouched
|
||||
(traefik_dir / "attackdefense-teams.yaml").write_text("http:\n routers:\n" + "".join(out))
|
||||
return f"wrote {len(out)} team domain(s) in attackdefense-teams.yaml"
|
||||
|
||||
|
||||
def list_teams() -> list:
|
||||
|
||||
@@ -27,7 +27,10 @@ class Challenge(object):
|
||||
raise NotImplementedError
|
||||
|
||||
def credentials(self):
|
||||
pwd = os.environ.get(f'PASSWORD_{self.port}')
|
||||
if not pwd:
|
||||
pwd = getattr(self.settings, f'PASSWORD_{self.port}', '')
|
||||
return {
|
||||
'username': 'ctfuser',
|
||||
'password': getattr(self.settings, f'PASSWORD_{self.port}'),
|
||||
'password': pwd,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
from .Challenge import Challenge
|
||||
|
||||
import io
|
||||
import pandas as pd
|
||||
import requests
|
||||
import re
|
||||
|
||||
class Art(Challenge):
|
||||
flag_location = 'flags/art.txt'
|
||||
history_location = 'history/art.txt'
|
||||
|
||||
|
||||
def distribute(self, flag):
|
||||
try:
|
||||
with open(self.flag_location, 'w') as f:
|
||||
f.write(flag)
|
||||
|
||||
with open(self.history_location, 'a') as f:
|
||||
f.write(flag + '\n')
|
||||
|
||||
self.logger.info(f'Flag {flag} written to {self.flag_location}')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'Could not write flag to {self.flag_location}: {e}')
|
||||
return False
|
||||
|
||||
def check(self):
|
||||
try:
|
||||
word = self.random_string(8)
|
||||
url = f'http://localhost:{self.port}/art/{word}'
|
||||
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'
|
||||
self.logger.info('Check passed for art')
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'Could not check art: {e}')
|
||||
return False
|
||||
@@ -0,0 +1,35 @@
|
||||
from .Challenge import Challenge
|
||||
from pwn import *
|
||||
|
||||
class BackToBasic(Challenge):
|
||||
flag_location = 'flags/back-to-basic.txt'
|
||||
history_location = 'history/back-to-basic.txt'
|
||||
|
||||
def distribute(self, flag):
|
||||
try:
|
||||
with open(self.flag_location, 'w') as f:
|
||||
f.write(flag)
|
||||
|
||||
with open(self.history_location, 'a') as f:
|
||||
f.write(flag + '\n')
|
||||
|
||||
self.logger.info(f'Flag {flag} written to {self.flag_location}')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'Could not write flag to {self.flag_location}: {e}')
|
||||
return False
|
||||
|
||||
def check(self):
|
||||
try:
|
||||
r = remote("localhost",self.port)
|
||||
assert b"idea?" in r.recvline(), "Failed First"
|
||||
|
||||
r.sendline(b"testt")
|
||||
|
||||
assert b"thing" in r.recvline(), "Failed Last"
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f'Could not check back-to-basic: {e}')
|
||||
return False
|
||||
@@ -0,0 +1,124 @@
|
||||
from .Challenge import Challenge
|
||||
|
||||
from fastecdsa.curve import Curve
|
||||
from fastecdsa.point import Point
|
||||
import requests
|
||||
import time
|
||||
import os
|
||||
import json
|
||||
|
||||
|
||||
class Burvesigner(Challenge):
|
||||
flag_location = 'flags/burvesigner.txt'
|
||||
history_location = 'history/burvesigner.txt'
|
||||
priv_location = 'files/burvesigner.priv'
|
||||
|
||||
def distribute(self, flag):
|
||||
try:
|
||||
with open(self.flag_location, 'w') as f:
|
||||
f.write(flag)
|
||||
|
||||
with open(self.priv_location, 'wb') as f:
|
||||
f.write(os.urandom(256))
|
||||
|
||||
with open(self.history_location, 'a') as f:
|
||||
f.write(flag + '\n')
|
||||
|
||||
self.logger.info(f'Flag {flag} written to {self.flag_location}')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'Could not write flag to {self.flag_location}: {e}')
|
||||
return False
|
||||
|
||||
def check(self):
|
||||
try:
|
||||
url = f'http://localhost:{self.port}'
|
||||
flag = open(self.flag_location).read()
|
||||
|
||||
# C1: login guest success
|
||||
guest_data = {
|
||||
'username': 'guest',
|
||||
'password': 'guest',
|
||||
}
|
||||
response = requests.post(url, data=guest_data, timeout=5)
|
||||
guest_token = response.cookies["token"]
|
||||
assert "Welcome, guest!" in response.text, "Guest cannot login"
|
||||
assert "flashes" not in response.text, "Guest cannot login"
|
||||
self.logger.info(f'C1 success for burvesigner')
|
||||
|
||||
# C2: login admin success
|
||||
admin_data = {
|
||||
'username': 'merricx_number_1_fans',
|
||||
'password': getattr(self.settings, f'PASSWORD_{self.port}'),
|
||||
}
|
||||
response = requests.post(url, data=admin_data, timeout=5)
|
||||
admin_token = response.cookies["token"]
|
||||
assert "Welcome, merricx_number_1_fans!" in response.text, "Admin cannot login"
|
||||
assert flag in response.text, "Flag is missing in admin page"
|
||||
assert "flashes" not in response.text, "Admin cannot login"
|
||||
self.logger.info(f'C2 success for burvesigner')
|
||||
|
||||
# C3: login guest fail
|
||||
guest_data = {
|
||||
'username': 'guest',
|
||||
'password': 'Guest',
|
||||
}
|
||||
response = requests.post(url, data=guest_data, timeout=5).text
|
||||
assert "Welcome, guest!" not in response, "Guest with wrong credential can login"
|
||||
assert "flashes" in response, "Guest with wrong credential can login"
|
||||
self.logger.info(f'C3 success for burvesigner')
|
||||
|
||||
# C4: login admin fail
|
||||
admin_data = {
|
||||
'username': 'merricx_number_1_fans',
|
||||
'password': 'password',
|
||||
}
|
||||
response = requests.post(url, data=admin_data, timeout=5).text
|
||||
assert "Welcome, merricx_number_1_fans!" not in response, "Admin with wrong credential can login"
|
||||
assert flag not in response, "Flag is showing in admin page with wrong credential"
|
||||
assert "flashes" in response, "Admin with wrong credential can login"
|
||||
self.logger.info(f'C4 success for burvesigner')
|
||||
|
||||
self.logger.info('calling sleep(5)')
|
||||
time.sleep(5)
|
||||
|
||||
# C5: cek apakah token guest di C1 sudah expired atau belum
|
||||
response = requests.get(url, cookies={"token": guest_token}, timeout=5)
|
||||
assert "Welcome, guest!" in response.text, "Guest token expires early"
|
||||
assert "flashes" not in response.text, "Guest token expires early"
|
||||
self.logger.info(f'C5 success for burvesigner')
|
||||
|
||||
# C6: cek apakah token admin di C2 sudah expired atau belum
|
||||
response = requests.get(url, cookies={"token": admin_token}, timeout=5)
|
||||
assert "Welcome, merricx_number_1_fans!" in response.text, "Admin token expires early"
|
||||
assert flag in response.text, "Admin token expires early"
|
||||
assert "flashes" not in response.text, "Admin token expires early"
|
||||
self.logger.info(f'C6 success for burvesigner')
|
||||
|
||||
# C7: cek endpoint /params
|
||||
response = requests.get(url + "/params", timeout=5).text
|
||||
response = response.replace("<pre>", "").replace("</pre>", "")
|
||||
params = json.loads(response)
|
||||
assert params["p"] and params["a"] and params["b"] and params["n"], "Missing p, a, b and/or n parameter(s)"
|
||||
assert params["G"][0] and params["G"][1] and params["Y"][0] and params["Y"][1], "Missing G and/or Y point(s)"
|
||||
self.logger.info(f'C7 success for burvesigner')
|
||||
|
||||
# C8: cek apakah curve C valid dan point G di C
|
||||
C = Curve("burvesigner", params["p"], params["a"], params["b"], params["n"], params["G"][0], params["G"][1])
|
||||
assert C.G == Point(params["G"][0], params["G"][1], C), "Point G is not valid"
|
||||
self.logger.info(f'C8 success for burvesigner')
|
||||
|
||||
# C9: cek apakah point G * priv = Y
|
||||
t = params["p"].bit_length() // 8
|
||||
priv = open(self.priv_location, "rb").read()[:t]
|
||||
x = int.from_bytes(priv, "little")
|
||||
Y = Point(params["Y"][0], params["Y"][1], C)
|
||||
assert C.G * x == Y, "Point Y is not valid"
|
||||
self.logger.info(f'C9 success for burvesigner')
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'Could not check burvesigner: {e}')
|
||||
return False
|
||||
@@ -0,0 +1,38 @@
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
|
||||
from config import get_settings
|
||||
|
||||
|
||||
class Challenge(object):
|
||||
name = __name__
|
||||
settings = get_settings()
|
||||
port = 0
|
||||
|
||||
def __init__(self, port):
|
||||
self.port = port
|
||||
self.add_logger()
|
||||
|
||||
def add_logger(self):
|
||||
self.logger = logging.getLogger()
|
||||
|
||||
def random_string(self, length):
|
||||
charset = string.ascii_uppercase + string.ascii_lowercase + string.digits
|
||||
return ''.join(random.choice(charset) for i in range(length))
|
||||
|
||||
def distribute(self, flag):
|
||||
raise NotImplementedError
|
||||
|
||||
def check(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def credentials(self):
|
||||
pwd = os.environ.get(f'PASSWORD_{self.port}')
|
||||
if not pwd:
|
||||
pwd = getattr(self.settings, f'PASSWORD_{self.port}', '')
|
||||
return {
|
||||
'username': 'root',
|
||||
'password': pwd,
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
from .Challenge import Challenge
|
||||
|
||||
import requests
|
||||
import os
|
||||
|
||||
MOCK_URL = 'http://google.com'
|
||||
MOCK_DATA = '<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">'
|
||||
|
||||
class Crawlback(Challenge):
|
||||
flag_location = 'flags/crawlback.txt'
|
||||
history_location = 'history/crawlback.txt'
|
||||
|
||||
def distribute(self, flag):
|
||||
try:
|
||||
with open(self.flag_location, 'w') as f:
|
||||
f.write(flag)
|
||||
|
||||
with open(self.history_location, 'a') as f:
|
||||
f.write(flag + '\n')
|
||||
|
||||
self.logger.info(f'Flag {flag} written to {self.flag_location}')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'Could not write flag to {self.flag_location}: {e}')
|
||||
return False
|
||||
|
||||
def check(self):
|
||||
try:
|
||||
r = requests.post(f"http://localhost:{self.port}/crawlback.php", data={'url': MOCK_URL})
|
||||
|
||||
assert r.text.split('\n').pop(0) == MOCK_DATA
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'Could not check crawlback: {e}')
|
||||
return False
|
||||
@@ -0,0 +1,67 @@
|
||||
from .Challenge import Challenge
|
||||
|
||||
import requests
|
||||
import zlib
|
||||
import gzip
|
||||
import json
|
||||
|
||||
MOCK_URL = 'http://google.com'
|
||||
MOCK_DATA_WGET = 'Google</title>'
|
||||
MOCK_DATA_CURL = '<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">'
|
||||
|
||||
class GemasFetcher(Challenge):
|
||||
flag_location = 'flags/gemas-fetcher.txt'
|
||||
history_location = 'history/gemas-fetcher.txt'
|
||||
|
||||
def distribute(self, flag):
|
||||
try:
|
||||
with open(self.flag_location, 'w') as f:
|
||||
f.write(flag)
|
||||
|
||||
with open(self.history_location, 'a') as f:
|
||||
f.write(flag + '\n')
|
||||
|
||||
self.logger.info(f'Flag {flag} written to {self.flag_location}')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'Could not write flag to {self.flag_location}: {e}')
|
||||
return False
|
||||
|
||||
def check(self):
|
||||
try:
|
||||
sess = requests.Session()
|
||||
|
||||
## register
|
||||
username = self.random_string(5)
|
||||
password = self.random_string(5)
|
||||
r = sess.post(f"http://localhost:{self.port}/auth/register", data={"username":username,"password": password}, allow_redirects=False)
|
||||
assert r.headers.get("location") == "/auth/login", "Register Failed"
|
||||
|
||||
## login
|
||||
r = sess.post(f"http://localhost:{self.port}/auth/login", data={"username":username,"password": password}, allow_redirects=False)
|
||||
assert r.headers.get("location") == "/dashboard", "Login Failed"
|
||||
|
||||
## wget
|
||||
content = {"provider": "wget","url":MOCK_URL}
|
||||
files = {"file": ("visit", b"\x00\x00"+gzip.compress(zlib.compress(json.dumps(content).encode())))}
|
||||
r = sess.post(f"http://localhost:{self.port}/dashboard/fetch_by_file", files=files)
|
||||
assert MOCK_DATA_WGET in r.text, "wget Failed"
|
||||
|
||||
## curl
|
||||
content = {"provider": "curl","url":MOCK_URL}
|
||||
files = {"file": ("visit", b"\x00\x01"+gzip.compress(zlib.compress(json.dumps(content).encode())))}
|
||||
r = sess.post(f"http://localhost:{self.port}/dashboard/fetch_by_file", files=files)
|
||||
assert r.text.split('\n').pop(0) == MOCK_DATA_CURL, "curl Failed"
|
||||
|
||||
## python
|
||||
content = {"provider": "python","url":MOCK_URL}
|
||||
files = {"file": ("visit", b"\x00\x02"+gzip.compress(zlib.compress(json.dumps(content).encode())))}
|
||||
r = sess.post(f"http://localhost:{self.port}/dashboard/fetch_by_file", files=files)
|
||||
assert r.text.startswith('"PCFkb2N0eXBlIGh0bWw'), "python Failed"
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'Could not check gemas-fetcher: {e}')
|
||||
return False
|
||||
@@ -0,0 +1,67 @@
|
||||
from .Challenge import Challenge
|
||||
|
||||
import requests
|
||||
|
||||
class GemasNotes(Challenge):
|
||||
history_location = 'history/gemas-notes.txt'
|
||||
host = "http://localhost:12000"
|
||||
|
||||
def distribute(self, flag):
|
||||
try:
|
||||
username = "gemasflagreceiver"
|
||||
password = "AuTeEbn%.Q5$pC_ge6"
|
||||
result = requests.post(f"{self.host}/flag_receiver", json={"flag": flag}, auth=(username,password)).json()
|
||||
if not result.get("success"):
|
||||
return False
|
||||
|
||||
with open(self.history_location, 'a') as f:
|
||||
f.write(flag + '\n')
|
||||
|
||||
self.logger.info(f'Flag {flag} updated in gemas-notes database')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'Could send flag to gemas-notes challenge: {e}')
|
||||
return False
|
||||
|
||||
def check(self):
|
||||
try:
|
||||
url = f'http://localhost:{self.port}'
|
||||
|
||||
# login
|
||||
token = requests.post(f"{url}/api/login",json={"email":"checker@gemasnotes.id", "password":"uRIqCvJ<IGb;VDT14"}).json()["token"]
|
||||
header = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# get count
|
||||
old_count = requests.post(f"{url}/api/notes/count", headers=header, json={"count_by":"title", "keyword":""}).json()["count"]
|
||||
|
||||
# create notes
|
||||
notes = {"title":self.random_string(10), "content":self.random_string(20), "tags":self.random_string(10)}
|
||||
status_code = requests.put(f"{url}/api/notes", headers=header, json=notes).status_code
|
||||
assert status_code in [200, 201], "Cannot Create Note"
|
||||
|
||||
# get notes
|
||||
all_notes = requests.get(f"{url}/api/notes").json()
|
||||
note = list(filter(lambda x: x["title"] == notes["title"], all_notes))
|
||||
assert len(note) != 0, "Note was not created"
|
||||
|
||||
# get new count
|
||||
new_count = requests.post(f"{url}/api/notes/count", headers=header, json={"count_by":"title", "keyword":""}).json()["count"]
|
||||
assert old_count != new_count, "Invalid count"
|
||||
|
||||
# update notes
|
||||
new_content = self.random_string(20)
|
||||
notes["id"] = note[0]["id"]
|
||||
notes["content"] = new_content
|
||||
status_code = requests.patch(f"{url}/api/notes", headers=header, json=notes).status_code
|
||||
assert status_code in [200, 204], "Cannot Update Note"
|
||||
|
||||
# delete notes
|
||||
status_code = requests.delete(f"{url}/api/notes/{notes['id']}", headers=header, json=notes).status_code
|
||||
assert status_code == 200, "Cannot Delete Note"
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'Could not check gemas-notes: {e}')
|
||||
return False
|
||||
@@ -0,0 +1,42 @@
|
||||
import requests
|
||||
from base64 import b64decode
|
||||
|
||||
from .Challenge import Challenge
|
||||
|
||||
|
||||
class Hirnfick(Challenge):
|
||||
flag_location = 'flags/hirnfick.txt'
|
||||
history_location = 'history/hirnfick.txt'
|
||||
|
||||
def distribute(self, flag):
|
||||
try:
|
||||
with open(self.flag_location, 'w') as f:
|
||||
f.write(flag)
|
||||
|
||||
with open(self.history_location, 'a') as f:
|
||||
f.write(flag + '\n')
|
||||
|
||||
self.logger.info(f'Flag {flag} written to {self.flag_location}')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f'Could not write flag to {self.flag_location}: {e}')
|
||||
return False
|
||||
|
||||
def check(self):
|
||||
try:
|
||||
res = requests.post(
|
||||
f"http://localhost:{self.port}/api/run",
|
||||
timeout=5,
|
||||
json={
|
||||
"code":
|
||||
"+[-->-[>>+>-----<<]<--<---]>-.>>>+.>>..+++[.>]<<<<.+++.------.<<-.>>>>+."
|
||||
})
|
||||
|
||||
assert b64decode(res.json()["output"]) == b"HirnFick 1.0\nHello, World!"
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f'Could not check hirnfick: {e}')
|
||||
return False
|
||||
@@ -0,0 +1,109 @@
|
||||
from .Challenge import Challenge
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class Pasta(Challenge):
|
||||
flag_location = 'flags/pasta.txt'
|
||||
history_location = 'history/pasta.txt'
|
||||
host = "http://localhost:13000"
|
||||
|
||||
def distribute(self, flag):
|
||||
try:
|
||||
with open(self.flag_location, 'w') as f:
|
||||
f.write(flag)
|
||||
|
||||
with open(self.history_location, 'a') as f:
|
||||
f.write(flag + '\n')
|
||||
|
||||
self.logger.info(f'Flag {flag} written to {self.flag_location}')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'Could not write flag to {self.flag_location}: {e}')
|
||||
return False
|
||||
|
||||
def check(self):
|
||||
try:
|
||||
url = f'http://localhost:{self.port}'
|
||||
username = f"checker-{self.random_string(8)}"
|
||||
pwd = self.random_string(12)
|
||||
flag = open(self.flag_location).read()
|
||||
|
||||
admin_data = {
|
||||
'username': 'deomkicer_number_1_fans',
|
||||
'password': getattr(self.settings, f'PASSWORD_{self.port}'),
|
||||
}
|
||||
|
||||
# login admin and check flag
|
||||
response = requests.post(
|
||||
f"{url}/auth",
|
||||
json=admin_data).json()
|
||||
|
||||
token = response.get('token')
|
||||
assert token, "Token is missing in login admin"
|
||||
check_flag = requests.get(f"{url}/flag", headers={'Authorization': f"Bearer {token}"}).json()
|
||||
assert check_flag.get('flag') == flag, "Flag is missing/mismatch"
|
||||
|
||||
# register
|
||||
response = requests.post(
|
||||
f"{url}/register",
|
||||
json={
|
||||
"username": f"{username}",
|
||||
"password": f"{pwd}"}).json()
|
||||
|
||||
assert response.get('success') == "User registered succesfully", "Register failed"
|
||||
|
||||
# login with version 1
|
||||
response = requests.post(
|
||||
f"{url}/auth?version=1",
|
||||
json={
|
||||
"username": f"{username}",
|
||||
"password": f"{pwd}"}).json()
|
||||
|
||||
token = response.get('token')
|
||||
assert token, "Token is missing in login v1"
|
||||
check_home = requests.get(f"{url}/", headers={'Authorization': f"Bearer {token}"}).json()
|
||||
assert check_home.get('username') == username, "Different username found in login v1"
|
||||
|
||||
# login with version 2
|
||||
response = requests.post(
|
||||
f"{url}/auth?version=2",
|
||||
json={
|
||||
"username": f"{username}",
|
||||
"password": f"{pwd}"}).json()
|
||||
|
||||
token = response.get('token')
|
||||
assert token, "Token is missing in login v2"
|
||||
check_home = requests.get(f"{url}/", headers={'Authorization': f"Bearer {token}"}).json()
|
||||
assert check_home.get('username') == username, "Different username found in login v2"
|
||||
|
||||
# login with version 3
|
||||
response = requests.post(
|
||||
f"{url}/auth?version=3",
|
||||
json={
|
||||
"username": f"{username}",
|
||||
"password": f"{pwd}"}).json()
|
||||
|
||||
token = response.get('token')
|
||||
assert token, "Token is missing in login v3"
|
||||
check_home = requests.get(f"{url}/", headers={'Authorization': f"Bearer {token}"}).json()
|
||||
assert check_home.get('username') == username, "Different username found in login v3"
|
||||
|
||||
# login with version 4
|
||||
response = requests.post(
|
||||
f"{url}/auth?version=4",
|
||||
json={
|
||||
"username": f"{username}",
|
||||
"password": f"{pwd}"}).json()
|
||||
|
||||
token = response.get('token')
|
||||
assert token, "Token is missing in login v4"
|
||||
check_home = requests.get(f"{url}/", headers={'Authorization': f"Bearer {token}"}).json()
|
||||
assert check_home.get('username') == username, "Different username found in login v4"
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'Could not check pasta: {e}')
|
||||
return False
|
||||
@@ -0,0 +1,44 @@
|
||||
from .Challenge import Challenge
|
||||
|
||||
import requests
|
||||
import os
|
||||
|
||||
|
||||
class S3(Challenge):
|
||||
flag_location = 'flags/s3.txt'
|
||||
history_location = 'history/s3.txt'
|
||||
host = 'http://localhost:20000'
|
||||
|
||||
def distribute(self, flag):
|
||||
try:
|
||||
with open(self.flag_location, 'w') as f:
|
||||
f.write(flag)
|
||||
|
||||
with open(self.history_location, 'a') as f:
|
||||
f.write(flag + '\n')
|
||||
|
||||
self.logger.info(f'Flag {flag} written to {self.flag_location}')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'Could not write flag to {self.flag_location}: {e}')
|
||||
return False
|
||||
|
||||
def check(self):
|
||||
try:
|
||||
filename = self.random_string(8) + ".txt"
|
||||
content = self.random_string(64)
|
||||
|
||||
r = requests.post(f"http://localhost:{self.port}/upload", files={'file': (filename, content)})
|
||||
assert r.status_code == 200
|
||||
assert r.text == f'Download <a href="/download?filename={filename}">here</a>'
|
||||
|
||||
r = requests.get(f"http://localhost:{self.port}/download?filename={filename}")
|
||||
assert r.status_code == 200
|
||||
assert r.text == content
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'Could not check s3: {e}')
|
||||
return False
|
||||
@@ -0,0 +1,66 @@
|
||||
from .Challenge import Challenge
|
||||
|
||||
import io
|
||||
import pandas as pd
|
||||
import requests
|
||||
import re
|
||||
|
||||
MOCK_DATA = [
|
||||
{'name': 'John','age': 30, 'city': 'New York'},
|
||||
{'name': 'Mary', 'age': 25, 'city': 'San Francisco'},
|
||||
{'name': 'Peter', 'age': 45, 'city': 'Chicago'},
|
||||
]
|
||||
|
||||
MOCK_RESULT = {
|
||||
"Sheet1":{
|
||||
"!ref":"A1:C4",
|
||||
"A1":{"t":"s","v":"name","h":"name","w":"name"},"B1":{"t":"s","v":"age","h":"age","w":"age"},"C1":{"t":"s","v":"city","h":"city","w":"city"},
|
||||
"A2":{"t":"s","v":"John","h":"John","w":"John"},"B2":{"t":"n","v":30,"w":"30"},"C2":{"t":"s","v":"New York","h":"New York","w":"New York"},
|
||||
"A3":{"t":"s","v":"Mary","h":"Mary","w":"Mary"},"B3":{"t":"n","v":25,"w":"25"},"C3":{"t":"s","v":"San Francisco","h":"San Francisco","w":"San Francisco"},
|
||||
"A4":{"t":"s","v":"Peter","h":"Peter","w":"Peter"},"B4":{"t":"n","v":45,"w":"45"},"C4":{"t":"s","v":"Chicago","h":"Chicago","w":"Chicago"},
|
||||
"!margins":{"left":0.75,"right":0.75,"top":1,"bottom":1,"header":0.5,"footer":0.5}
|
||||
}
|
||||
}
|
||||
|
||||
class XL(Challenge):
|
||||
flag_location = 'flags/xl.txt'
|
||||
history_location = 'history/xl.txt'
|
||||
|
||||
|
||||
def distribute(self, flag):
|
||||
try:
|
||||
with open(self.flag_location, 'w') as f:
|
||||
f.write(flag)
|
||||
|
||||
with open(self.history_location, 'a') as f:
|
||||
f.write(flag + '\n')
|
||||
|
||||
self.logger.info(f'Flag {flag} written to {self.flag_location}')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'Could not write flag to {self.flag_location}: {e}')
|
||||
return False
|
||||
|
||||
def check(self):
|
||||
try:
|
||||
url = f'http://localhost:{self.port}'
|
||||
files = {'file': self.generate_mock_file()}
|
||||
r = requests.post(url, files=files, timeout=5)
|
||||
assert r.json() == MOCK_RESULT, 'Unexpected response'
|
||||
self.logger.info('Check passed for xl')
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'Could not check xl: {e}')
|
||||
return False
|
||||
|
||||
def generate_mock_file(self):
|
||||
memory_file = io.BytesIO()
|
||||
|
||||
df = pd.DataFrame(MOCK_DATA)
|
||||
df.to_excel(memory_file, index=False)
|
||||
|
||||
memory_file.seek(0)
|
||||
return memory_file
|
||||
@@ -0,0 +1,36 @@
|
||||
from pydantic import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
COMPOSE_LOCATION: str
|
||||
ADMIN_USERNAME: str
|
||||
ADMIN_PASSWORD: str
|
||||
PASSWORD_10000: str
|
||||
PASSWORD_11000: str
|
||||
PASSWORD_12000: str
|
||||
PASSWORD_13000: str
|
||||
PASSWORD_14000: str
|
||||
PASSWORD_15000: str
|
||||
PASSWORD_16000: str
|
||||
PASSWORD_17000: str
|
||||
PASSWORD_18000: str
|
||||
PASSWORD_19000: str
|
||||
PASSWORD_20000: str
|
||||
PASSWORD_21000: str
|
||||
PASSWORD_22000: str
|
||||
PASSWORD_23000: str
|
||||
PASSWORD_24000: str
|
||||
PASSWORD_25000: str
|
||||
PASSWORD_26000: str
|
||||
PASSWORD_27000: str
|
||||
PASSWORD_28000: str
|
||||
PASSWORD_29000: str
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings():
|
||||
return Settings()
|
||||
@@ -0,0 +1,38 @@
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
|
||||
from config import get_settings
|
||||
|
||||
|
||||
class Challenge(object):
|
||||
name = __name__
|
||||
settings = get_settings()
|
||||
port = 0
|
||||
|
||||
def __init__(self, port):
|
||||
self.port = port
|
||||
self.add_logger()
|
||||
|
||||
def add_logger(self):
|
||||
self.logger = logging.getLogger()
|
||||
|
||||
def random_string(self, length):
|
||||
charset = string.ascii_uppercase + string.ascii_lowercase + string.digits
|
||||
return ''.join(random.choice(charset) for i in range(length))
|
||||
|
||||
def distribute(self, flag):
|
||||
raise NotImplementedError
|
||||
|
||||
def check(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def credentials(self):
|
||||
pwd = os.environ.get(f'PASSWORD_{self.port}')
|
||||
if not pwd:
|
||||
pwd = getattr(self.settings, f'PASSWORD_{self.port}', '')
|
||||
return {
|
||||
'username': 'ctfuser',
|
||||
'password': pwd,
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
"""SLA checkers for GEMASTIK XVII challenges (imported from
|
||||
github.com/vidner/gemastik-xvii-final — no upstream receiver was provided).
|
||||
|
||||
Each checker validates liveness + flag presence in the container. Protocols:
|
||||
- TCP/netcat : asmr, bit-canvas, go-green (xinetd banner) & ticketer (socat)
|
||||
- HTTP GET : anti-alchemy, fjb, gift-card, gift-voucher, gleam-drive,
|
||||
kode-viewer, more-less, tempest-poc
|
||||
"""
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
|
||||
import requests
|
||||
|
||||
from .Challenge import Challenge
|
||||
|
||||
|
||||
def _docker_exec(container: str, *args, timeout: int = 10):
|
||||
try:
|
||||
return subprocess.run(["docker", "exec", container, *args],
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _flag_in_container(container: str, path: str = "/flag.txt") -> bool:
|
||||
r = _docker_exec(container, "sh", "-c", f"test -f {path} && cat {path} || echo MISSING")
|
||||
return bool(r and "MISSING" not in (r.stdout or "") and r.returncode == 0)
|
||||
|
||||
|
||||
def _tcp_banner(port: int, timeout: float = 4.0, expect: bytes = None) -> bool:
|
||||
try:
|
||||
s = socket.create_connection(("127.0.0.1", port), timeout=timeout)
|
||||
s.settimeout(timeout)
|
||||
data = s.recv(256)
|
||||
s.close()
|
||||
if expect:
|
||||
return expect.lower() in data.lower()
|
||||
return len(data) > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class AntiAlchemy(Challenge):
|
||||
flag_location = "flags/anti-alchemy.txt"
|
||||
history_location = "history/anti-alchemy.txt"
|
||||
|
||||
def check(self):
|
||||
try:
|
||||
url = f"http://localhost:{self.port}/"
|
||||
r = requests.get(url, timeout=6)
|
||||
assert r.status_code in (200, 302, 500) or len(r.text) > 0
|
||||
return _flag_in_container(os.environ.get("CHALLENGE_CONTAINER_ANTI_ALCHEMY", "anti-alchemy_container"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class Asmr(Challenge):
|
||||
flag_location = "flags/asmr.txt"
|
||||
history_location = "history/asmr.txt"
|
||||
|
||||
def check(self):
|
||||
try:
|
||||
return _tcp_banner(self.port, expect=None)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class BitCanvas(Challenge):
|
||||
flag_location = "flags/bit-canvas.txt"
|
||||
history_location = "history/bit-canvas.txt"
|
||||
|
||||
def check(self):
|
||||
try:
|
||||
return _tcp_banner(self.port, expect=None)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class Fjb(Challenge):
|
||||
flag_location = "flags/fjb.txt"
|
||||
history_location = "history/fjb.txt"
|
||||
|
||||
def check(self):
|
||||
try:
|
||||
url = f"http://localhost:{self.port}/"
|
||||
r = requests.get(url, timeout=6)
|
||||
assert r.status_code < 500
|
||||
return _flag_in_container(os.environ.get("CHALLENGE_CONTAINER_FJB", "fjb_container"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class GiftCard(Challenge):
|
||||
flag_location = "flags/gift-card.txt"
|
||||
history_location = "history/gift-card.txt"
|
||||
|
||||
def check(self):
|
||||
try:
|
||||
url = f"http://localhost:{self.port}/"
|
||||
r = requests.get(url, timeout=6)
|
||||
assert r.status_code < 500
|
||||
return _flag_in_container(os.environ.get("CHALLENGE_CONTAINER_GIFT_CARD", "gift-card_container"),
|
||||
"/ctf/gift-card/flag.txt")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class GiftVoucher(Challenge):
|
||||
flag_location = "flags/gift-voucher.txt"
|
||||
history_location = "history/gift-voucher.txt"
|
||||
|
||||
def check(self):
|
||||
try:
|
||||
url = f"http://localhost:{self.port}/"
|
||||
r = requests.get(url, timeout=6)
|
||||
assert r.status_code < 500
|
||||
return _flag_in_container(os.environ.get("CHALLENGE_CONTAINER_GIFT_VOUCHER", "gift-voucher_container"),
|
||||
"/ctf/gift-voucher/flag.txt")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class GleamDrive(Challenge):
|
||||
flag_location = "flags/gleam-drive.txt"
|
||||
history_location = "history/gleam-drive.txt"
|
||||
|
||||
def check(self):
|
||||
try:
|
||||
url = f"http://localhost:{self.port}/"
|
||||
r = requests.get(url, timeout=6)
|
||||
assert r.status_code < 500
|
||||
return _flag_in_container(os.environ.get("CHALLENGE_CONTAINER_GLEAM_DRIVE", "gleam-drive_container"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class GoGreen(Challenge):
|
||||
flag_location = "flags/go-green.txt"
|
||||
history_location = "history/go-green.txt"
|
||||
|
||||
def check(self):
|
||||
try:
|
||||
return _tcp_banner(self.port, expect=None)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class KodeViewer(Challenge):
|
||||
flag_location = "flags/kode-viewer.txt"
|
||||
history_location = "history/kode-viewer.txt"
|
||||
|
||||
def check(self):
|
||||
try:
|
||||
url = f"http://localhost:{self.port}/"
|
||||
r = requests.get(url, timeout=6)
|
||||
assert r.status_code < 500
|
||||
return _flag_in_container(os.environ.get("CHALLENGE_CONTAINER_KODE_VIEWER", "kode-viewer_container"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class MoreLess(Challenge):
|
||||
flag_location = "flags/more-less.txt"
|
||||
history_location = "history/more-less.txt"
|
||||
|
||||
def check(self):
|
||||
try:
|
||||
url = f"http://localhost:{self.port}/"
|
||||
r = requests.get(url, timeout=6)
|
||||
assert r.status_code < 500
|
||||
return _flag_in_container(os.environ.get("CHALLENGE_CONTAINER_MORE_LESS", "more-less_container"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class TempestPoc(Challenge):
|
||||
flag_location = "flags/tempest-poc.txt"
|
||||
history_location = "history/tempest-poc.txt"
|
||||
|
||||
def check(self):
|
||||
try:
|
||||
url = f"http://localhost:{self.port}/"
|
||||
r = requests.get(url, timeout=6)
|
||||
assert r.status_code < 500
|
||||
return _flag_in_container(os.environ.get("CHALLENGE_CONTAINER_TEMPEST_POC", "tempest-poc_container"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class Ticketer(Challenge):
|
||||
flag_location = "flags/ticketer.txt"
|
||||
history_location = "history/ticketer.txt"
|
||||
|
||||
def check(self):
|
||||
try:
|
||||
return _tcp_banner(self.port, expect=None)
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,25 @@
|
||||
FROM python:3.11-slim-bookworm
|
||||
|
||||
ARG PASSWORD
|
||||
|
||||
ENV DEBIAN_FRONTEND noninteractive
|
||||
|
||||
RUN echo root:${PASSWORD} | chpasswd
|
||||
COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure
|
||||
RUN apt-get -o Acquire::AllowInsecureRepositories=true update && apt-get install -y openssh-server curl nano
|
||||
RUN echo "PasswordAuthentication yes" >> /etc/ssh/sshd_config
|
||||
RUN echo "PermitRootLogin yes" >> /etc/ssh/sshd_config
|
||||
RUN service ssh start
|
||||
RUN useradd --user-group --system --create-home --no-log-init --shell /bin/bash ctf
|
||||
|
||||
WORKDIR /home/ctf/app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r ./requirements.txt && rm ./requirements.txt
|
||||
|
||||
COPY src/ .
|
||||
|
||||
COPY entrypoint.sh .
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
ENTRYPOINT [ "./entrypoint.sh" ]
|
||||
@@ -0,0 +1,3 @@
|
||||
Acquire::AllowInsecureRepositories "true";
|
||||
Acquire::AllowDowngradeToInsecureRepositories "true";
|
||||
Apt::Get::AllowUnauthenticated "true";
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,34 @@
|
||||
services:
|
||||
anti-alchemy:
|
||||
container_name: anti-alchemy_container
|
||||
hostname: anti-alchemy
|
||||
restart: always
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
- PASSWORD=$PASSWORD_11000
|
||||
volumes:
|
||||
- ../receiver/flags/anti-alchemy.txt:/flag.txt:ro
|
||||
- ../utils/bashrc:/root/.bashrc:ro
|
||||
- ../utils/preexec.sh:/root/.preexec.sh:ro
|
||||
ports:
|
||||
- "11000:5000"
|
||||
- "11022:22"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
- DB_NAME=postgres
|
||||
- DB_USER=postgres
|
||||
- DB_PASS=password
|
||||
- DB_HOST=anti-alchemy-db
|
||||
- DB_PORT=5432
|
||||
- SECRET_KEY=$PASSWORD_11000
|
||||
depends_on:
|
||||
- anti-alchemy-db
|
||||
anti-alchemy-db:
|
||||
image: postgres:16.3-alpine
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=password
|
||||
volumes:
|
||||
- ./db/dump.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
/usr/sbin/sshd -D &
|
||||
sleep 3
|
||||
python3 ./misc/init_users.py
|
||||
su ctf -c "gunicorn --bind 0.0.0.0:5000 --timeout 60 --workers 6 app:app"
|
||||
@@ -0,0 +1 @@
|
||||
PLACEHOLDER
|
||||
@@ -0,0 +1,3 @@
|
||||
Flask
|
||||
gunicorn
|
||||
psycopg2-binary
|
||||
@@ -0,0 +1,83 @@
|
||||
class ClauseBuilder:
|
||||
def __init__(self) -> None:
|
||||
self._queries = []
|
||||
self._chars_to_sanitize = ["'", '"']
|
||||
self._is_where_called = False
|
||||
|
||||
def _sanitize(self, q) -> str:
|
||||
for c in self._chars_to_sanitize:
|
||||
if c in q:
|
||||
q = q.replace(c, c * 2)
|
||||
return str(q).strip()
|
||||
|
||||
def _where(self, column, value, op) -> map:
|
||||
if not self._is_where_called:
|
||||
op = "WHERE"
|
||||
self._is_where_called = True
|
||||
return map(self._sanitize, [column, value, op])
|
||||
|
||||
def final(self) -> str:
|
||||
q = " ".join(self._queries)
|
||||
self.__init__()
|
||||
return str(q).strip()
|
||||
|
||||
def order_by(self, column, value) -> None:
|
||||
column, value = map(self._sanitize, [column, value])
|
||||
self._queries.append("ORDER BY")
|
||||
self._queries.append('"' + column + '"')
|
||||
self._queries.append(value)
|
||||
|
||||
def select(self, table) -> None:
|
||||
table = self._sanitize(table)
|
||||
self._queries.append("SELECT")
|
||||
self._queries.append("*")
|
||||
self._queries.append("FROM")
|
||||
self._queries.append('"' + table + '"')
|
||||
|
||||
def where(self, column, value, cmp="ILIKE", op="AND") -> None:
|
||||
column, value, op = self._where(column, value, op)
|
||||
self._queries.append(op)
|
||||
self._queries.append('"' + column + '"')
|
||||
if column == "id":
|
||||
self._queries.append("=")
|
||||
self._queries.append(str(int(value)))
|
||||
elif cmp == "EQ":
|
||||
self._queries.append("=")
|
||||
self._queries.append("'" + value + "'")
|
||||
else:
|
||||
self._queries.append("ILIKE")
|
||||
self._queries.append("'%" + value + "%'")
|
||||
|
||||
|
||||
class QueryBuilder:
|
||||
def __init__(self) -> None:
|
||||
self._cb = ClauseBuilder()
|
||||
self._obj = {}
|
||||
self._defined_keys = ["table", "columns"]
|
||||
self._sorting_values = ["asc", "desc"]
|
||||
self._login_keys = ["username"]
|
||||
|
||||
def _order_by(self) -> None:
|
||||
for value, column in self._obj.items():
|
||||
if value in self._sorting_values:
|
||||
self._cb.order_by(column, value)
|
||||
break
|
||||
|
||||
def _select(self) -> None:
|
||||
self._cb.select(self._obj["table"])
|
||||
|
||||
def _where(self) -> None:
|
||||
for column, value in self._obj.items():
|
||||
if column in self._defined_keys + self._sorting_values:
|
||||
continue
|
||||
elif column in self._login_keys:
|
||||
self._cb.where(column, value, "EQ")
|
||||
else:
|
||||
self._cb.where(column, value)
|
||||
|
||||
def generate(self, obj) -> str:
|
||||
self._obj = obj
|
||||
self._select()
|
||||
self._where()
|
||||
self._order_by()
|
||||
return self._cb.final()
|
||||
@@ -0,0 +1,119 @@
|
||||
from flask import Flask, render_template, session, redirect, url_for
|
||||
from os import environ
|
||||
|
||||
from antialchemy import *
|
||||
from helper import *
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["SECRET_KEY"] = environ.get("SECRET_KEY", "SECRET_KEY")
|
||||
app.config["PERMANENT_SESSION_LIFETIME"] = 3 * 60
|
||||
qb = QueryBuilder()
|
||||
|
||||
|
||||
@app.get("/api/flag")
|
||||
@check_login_status
|
||||
def flag():
|
||||
if session["user"] == "admin":
|
||||
try:
|
||||
return make_resp(200, open("/flag.txt").read())
|
||||
except:
|
||||
return make_resp(500, "Flag not found, please contact problem setter")
|
||||
|
||||
return make_resp(401, "You need to log in as admin to get the flag")
|
||||
|
||||
|
||||
@app.post("/api/login")
|
||||
@check_request_body
|
||||
def login(*args, **kwargs):
|
||||
payload = kwargs.copy()
|
||||
|
||||
try:
|
||||
conn = create_db_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
qb.generate(
|
||||
{
|
||||
"table": "users",
|
||||
"username": payload["username"],
|
||||
}
|
||||
)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return make_resp(401, "Invalid username/password")
|
||||
|
||||
_, username, password_hash = row
|
||||
password = payload.pop("password")
|
||||
|
||||
cur.execute(
|
||||
qb.generate(
|
||||
{
|
||||
"table": "salt",
|
||||
"username": username,
|
||||
}
|
||||
)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return make_resp(401, "Invalid username/password")
|
||||
|
||||
_, _, salt = row
|
||||
|
||||
if not check_password_hash(password, salt, password_hash):
|
||||
return make_resp(401, "Invalid username/password")
|
||||
|
||||
session.clear()
|
||||
session["user"] = username
|
||||
return redirect(url_for("index"))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Exception: {e}")
|
||||
return make_resp(400, "Bad Request")
|
||||
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@app.get("/api/logout")
|
||||
@check_login_status
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect(url_for("index"))
|
||||
|
||||
|
||||
@app.post("/api/view")
|
||||
@check_login_status
|
||||
@check_request_body
|
||||
def view(*args, **kwargs):
|
||||
payload = kwargs.copy()
|
||||
|
||||
try:
|
||||
conn = create_db_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(qb.generate(payload | {"table": "cwe"}))
|
||||
rows = cur.fetchall()
|
||||
return make_resp(200, "OK", {"rows": rows})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Exception: {e}")
|
||||
return make_resp(400, "Bad Request")
|
||||
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
try:
|
||||
if session["user"]:
|
||||
return render_template("dashboard.html")
|
||||
except:
|
||||
pass
|
||||
|
||||
return render_template("login.html")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True)
|
||||
@@ -0,0 +1,73 @@
|
||||
from flask import request, session
|
||||
from functools import wraps
|
||||
from hashlib import sha1
|
||||
from json import dumps
|
||||
from os import environ
|
||||
from psycopg2 import connect
|
||||
from string import printable
|
||||
from time import sleep
|
||||
|
||||
def create_db_conn():
|
||||
while True:
|
||||
try:
|
||||
return connect(
|
||||
database=environ.get("DB_NAME", "postgres"),
|
||||
user=environ.get("DB_USER", "postgres"),
|
||||
password=environ.get("DB_PASS", "password"),
|
||||
host=environ.get("DB_HOST", "localhost"),
|
||||
port=environ.get("DB_PORT", "5432"),
|
||||
)
|
||||
except:
|
||||
sleep(1)
|
||||
|
||||
|
||||
def make_resp(status, message, data=None):
|
||||
return {"status": status, "message": message, "data": data}
|
||||
|
||||
|
||||
def generate_password_hash(password, salt):
|
||||
return sha1((salt + password).encode()).hexdigest()
|
||||
|
||||
|
||||
def check_password_hash(password, salt, password_hash):
|
||||
return generate_password_hash(password, salt) == password_hash
|
||||
|
||||
|
||||
def check_login_status(f):
|
||||
@wraps(f)
|
||||
def inner(*args, **kwargs):
|
||||
try:
|
||||
session["user"]
|
||||
return f(*args, **kwargs)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Exception: {e}")
|
||||
return make_resp(401, "Unauthorized")
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
def check_request_body(f):
|
||||
check_blist = lambda s: all(x not in s.lower() for x in ["pg_"])
|
||||
check_wlist = lambda s: all(c in printable for c in s)
|
||||
|
||||
@wraps(f)
|
||||
def inner(*args, **kwargs):
|
||||
try:
|
||||
data = request.get_json()
|
||||
data = {k: v for k, v in data.items() if data.get(k)}
|
||||
|
||||
dd = dumps(data, separators=(",", ":"))
|
||||
assert len(dd) < 256 and check_blist(dd), "Bad payload"
|
||||
|
||||
for item in data.items():
|
||||
assert all(map(check_wlist, item)), "Bad payload"
|
||||
|
||||
kwargs.update(data)
|
||||
return f(*args, **kwargs)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Exception: {e}")
|
||||
return make_resp(400, "Bad Request")
|
||||
|
||||
return inner
|
||||
@@ -0,0 +1,28 @@
|
||||
import sys
|
||||
sys.path += [".", ".."]
|
||||
|
||||
from helper import create_db_conn
|
||||
from os import environ
|
||||
|
||||
print("Getting environment variables...")
|
||||
print(environ.get("SECRET_KEY", "SECRET_KEY"))
|
||||
print()
|
||||
|
||||
print("Getting rows of users and salt...")
|
||||
conn = create_db_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("SELECT * FROM users")
|
||||
rows = cur.fetchall()
|
||||
for row in rows:
|
||||
print(row)
|
||||
|
||||
cur.execute("SELECT * FROM salt")
|
||||
rows = cur.fetchall()
|
||||
for row in rows:
|
||||
print(row)
|
||||
print()
|
||||
|
||||
print("Done")
|
||||
cur.close()
|
||||
conn.close()
|
||||
@@ -0,0 +1,25 @@
|
||||
import sys
|
||||
sys.path += [".", ".."]
|
||||
|
||||
from helper import create_db_conn, generate_password_hash
|
||||
from os import environ, urandom
|
||||
|
||||
conn = create_db_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
users = [
|
||||
("admin", environ.get("SECRET_KEY", "SECRET_KEY"), urandom(8).hex()),
|
||||
("gemastik", "P@ssw0rd", urandom(8).hex()),
|
||||
]
|
||||
|
||||
for username, password, salt in users:
|
||||
cur.execute(
|
||||
"INSERT INTO users (username, password) VALUES (%s, %s)",
|
||||
(username, generate_password_hash(password, salt)),
|
||||
)
|
||||
cur.execute("INSERT INTO salt (username, salt) VALUES (%s, %s)", (username, salt))
|
||||
|
||||
conn.commit()
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.form-group button{width:100%}.container{max-width:640px;position:absolute;top:50%;left:50%;-ms-transform:translate(-50%,-50%);transform:translate(-50%,-50%)}h1{text-align:center}
|
||||
@@ -0,0 +1 @@
|
||||
document.getElementById("form-submit").addEventListener("click",async function(e){e.preventDefault();let t=document.getElementById("form-username").value,a=document.getElementById("form-password").value;if(!t||!a){alert("Username/password cannot be empty");return}try{let n=await fetch("/api/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:t,password:a})}),o=await n.json();alert(o.message)}catch(r){console.log(r),window.location.reload()}});
|
||||
@@ -0,0 +1,61 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>List of Top CWE - Common Weakness Enumeration</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='dashboard.min.css') }}" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="atas">
|
||||
<form action="javascript:search()" id="search">
|
||||
<label for="code">CWE ID<input type="number" name="CWE ID" id="cwe-id" min="0" autofocus /></label>
|
||||
<label for="title">Title<input type="text" name="Title" id="title" maxlength="255" /></label>
|
||||
<label for="description">Description<input type="text" name="Description" id="description"
|
||||
maxlength="255" /></label>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
<div style="margin: auto; text-align: center;">
|
||||
<p>
|
||||
Welcome to <span style="font-weight: bold">List of Top CWE</span>,
|
||||
{{ session["user"] }}!
|
||||
</p>
|
||||
<p>
|
||||
Click here to access admin <strong id="access-flag">flag</strong> or
|
||||
<strong id="logout">logout</strong>.
|
||||
</p>
|
||||
</div>
|
||||
<form id="pagination">
|
||||
<button type="submit" id="prev-page">Prev</button>
|
||||
<p id="page"></p>
|
||||
<button type="submit" id="next-page">Next</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="bawah">
|
||||
<table class="sortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th class="sortable-column" onclick="javascript:sort(0)">
|
||||
CWE ID
|
||||
</th>
|
||||
<th class="sortable-column" onclick="javascript:sort(1)">
|
||||
Title
|
||||
</th>
|
||||
<th class="sortable-column" onclick="javascript:sort(2)">
|
||||
Description
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="fillable-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
<script src="{{ url_for('static', filename='dashboard.min.js') }}"></script>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Login - Common Weakness Enumeration</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous" />
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='login.min.css') }}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Login</h1>
|
||||
<form>
|
||||
<div class="form-group pt-3">
|
||||
<input type="text" class="form-control" id="form-username" placeholder="Username" />
|
||||
</div>
|
||||
<div class="form-group pt-3">
|
||||
<input type="password" class="form-control" id="form-password" placeholder="Password" />
|
||||
</div>
|
||||
<div class="form-group pt-3">
|
||||
<button type="submit" class="btn btn-primary" id="form-submit">
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='login.min.js') }}"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
FROM ruby:3.2-slim-bookworm
|
||||
|
||||
ARG PASSWORD
|
||||
|
||||
RUN echo root:${PASSWORD} | chpasswd
|
||||
COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure
|
||||
RUN apt-get -o Acquire::AllowInsecureRepositories=true update && apt-get install -y openssh-server curl
|
||||
RUN echo "PasswordAuthentication yes" >> /etc/ssh/sshd_config
|
||||
RUN echo "PermitRootLogin yes" >> /etc/ssh/sshd_config
|
||||
RUN service ssh start
|
||||
|
||||
WORKDIR /ctf/art/
|
||||
|
||||
RUN useradd -m ctf
|
||||
RUN chown -R root:root /ctf/art/
|
||||
|
||||
COPY Gemfile .
|
||||
COPY app.rb .
|
||||
COPY start.sh .
|
||||
|
||||
RUN bundle install
|
||||
RUN touch /flag.txt
|
||||
|
||||
RUN chmod +x start.sh
|
||||
CMD ./start.sh
|
||||
@@ -0,0 +1,4 @@
|
||||
source "http://rubygems.org"
|
||||
|
||||
gem "sinatra"
|
||||
gem "slim"
|
||||
@@ -0,0 +1,14 @@
|
||||
require "sinatra"
|
||||
require "slim"
|
||||
|
||||
set :port, 8080
|
||||
set :bind, '0.0.0.0'
|
||||
set :environment, :production
|
||||
|
||||
get '/' do
|
||||
redirect '/art/gemastik'
|
||||
end
|
||||
|
||||
get '/art/:word' do
|
||||
return Slim::Template.new{ '<iframe height="100%" width="100%" frameborder="0" src=https://asciified.thelicato.io/api/v2/ascii?text=' + params[:word] + '></iframe>' }.render
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
Acquire::AllowInsecureRepositories "true";
|
||||
Acquire::AllowDowngradeToInsecureRepositories "true";
|
||||
Apt::Get::AllowUnauthenticated "true";
|
||||
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
art:
|
||||
container_name: art_container
|
||||
hostname: art
|
||||
restart: always
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
- PASSWORD=$PASSWORD_10000
|
||||
volumes:
|
||||
- ../receiver/flags/art.txt:/flag.txt:ro
|
||||
- ../utils/bashrc:/root/.bashrc:ro
|
||||
- ../utils/preexec.sh:/root/.preexec.sh:ro
|
||||
ports:
|
||||
- "10000:8080"
|
||||
- "10022:22"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
@@ -0,0 +1,5 @@
|
||||
# run sshd
|
||||
/usr/sbin/sshd -D &
|
||||
# run the command
|
||||
su ctf -c "bundle install"
|
||||
su ctf -c "ruby /ctf/art/app.rb"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user