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:
MythEclipse
2026-09-25 14:04:33 +08:00
parent c35b23a37f
commit c6fd9ec268
1317 changed files with 306586 additions and 128 deletions
+145
View File
@@ -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")]
+293
View File
@@ -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()
+271
View File
@@ -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()
+11
View File
@@ -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():
+120
View File
@@ -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
View File
@@ -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
View File
@@ -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} &nbsp;·&nbsp; pass: ${esc(t.team.chall_passwords[name])}</span>
<b>${name}</b><span>ssh ctfuser@${name}.attackdefense.imrnes.team -p ${p.ssh} &nbsp;·&nbsp; pass: ${esc(t.team.chall_passwords[name])}</span>
</div>`;
}
document.getElementById('mtcTitle').textContent = `Kredensial Team ${idx}`;
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -41,7 +41,7 @@
<div class="logo">G</div>
<div>
<h1>Gemastik XVIII — Attack &amp; 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>
+1 -1
View File
@@ -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
View File
@@ -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:
+4 -1
View File
@@ -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,
}
+40
View File
@@ -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
+35
View File
@@ -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
+124
View File
@@ -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
+38
View File
@@ -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,
}
+38
View File
@@ -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
+67
View File
@@ -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
+67
View File
@@ -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
+42
View File
@@ -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
+109
View File
@@ -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
+44
View File
@@ -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
+66
View File
@@ -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
+36
View 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()
+38
View File
@@ -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,
}
+199
View File
@@ -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
+25
View File
@@ -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" ]
+3
View File
@@ -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
+34
View File
@@ -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
+6
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
PLACEHOLDER
+3
View File
@@ -0,0 +1,3 @@
Flask
gunicorn
psycopg2-binary
+83
View File
@@ -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()
+119
View File
@@ -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)
+73
View File
@@ -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
+1
View File
@@ -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}
+1
View File
@@ -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>
+25
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
source "http://rubygems.org"
gem "sinatra"
gem "slim"
+14
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
Acquire::AllowInsecureRepositories "true";
Acquire::AllowDowngradeToInsecureRepositories "true";
Apt::Get::AllowUnauthenticated "true";
+18
View File
@@ -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"
+5
View File
@@ -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