Files
validator/ctfcup2025-school-final/checkers/aws_sigma/checker.py

566 lines
17 KiB
Python
Executable File

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import dataclasses
import hashlib
import json
import os
import random
import string
import sys
from typing import Iterable, List, Optional, Sequence
import requests
EXIT_OK = 101
EXIT_CORRUPT = 102
EXIT_MUMBLE = 103
EXIT_DOWN = 104
EXIT_CHECKER_ERROR = 110
SCHEME = os.getenv("SIGMA_SCHEME", "http")
PORT = int(os.getenv("SIGMA_API_PORT") or "3000")
API_PREFIX = os.getenv("SIGMA_API_PREFIX", "/api/v1").rstrip("/")
REQUEST_TIMEOUT = float(os.getenv("SIGMA_TIMEOUT", "5"))
ALPHABET = string.ascii_lowercase + string.digits
PASSWORD_ALPHABET = string.ascii_letters + string.digits
def log(message: str) -> None:
print(message, file=sys.stderr)
class CheckerError(Exception):
exit_code = EXIT_MUMBLE
def __init__(self, message: str):
super().__init__(message)
class DownError(CheckerError):
exit_code = EXIT_DOWN
class CorruptError(CheckerError):
exit_code = EXIT_CORRUPT
class CheckerInternalError(CheckerError):
exit_code = EXIT_CHECKER_ERROR
class ApiClient:
def __init__(self, base_url: str, timeout: float = REQUEST_TIMEOUT):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({"User-Agent": "sigma-checker/1.0"})
def request(
self,
method: str,
path: str,
*,
expected: Optional[Iterable[int]] = None,
**kwargs,
) -> requests.Response:
url = self._build_url(path)
try:
response = self.session.request(
method,
url,
timeout=self.timeout,
**kwargs,
)
except requests.RequestException as exc:
raise DownError(
f"network error during {method.upper()} {path}: {exc}"
) from exc
if expected is not None:
allowed = set(expected)
if response.status_code not in allowed:
body = response.text[:400]
raise CheckerError(
"unexpected status "
f"{response.status_code} for "
f"{method.upper()} {path}: {body}"
)
return response
def _build_url(self, path: str) -> str:
if not path.startswith("/"):
path = "/" + path
return f"{self.base_url}{path}"
def seeded_random(flag_id: str, vuln: int) -> random.Random:
digest = hashlib.sha256(f"{flag_id}:{vuln}".encode()).digest()
return random.Random(int.from_bytes(digest, "big"))
def random_string(
rng: random.Random,
length: int,
alphabet: str = ALPHABET,
) -> str:
return "".join(rng.choice(alphabet) for _ in range(length))
@dataclasses.dataclass
class UserSpec:
username: str
password: str
@dataclasses.dataclass
class ProjectSpec:
name: str
slug: str
description_template: str
@dataclasses.dataclass
class FunctionSpec:
name: str
path: str
@dataclasses.dataclass
class Vuln1Context:
user: UserSpec
primary_project: ProjectSpec
decoy_project: ProjectSpec
@dataclasses.dataclass
class Vuln2Context:
user: UserSpec
project: ProjectSpec
store_function: FunctionSpec
fetch_function: FunctionSpec
def build_vuln1_context(flag_id: str) -> Vuln1Context:
rng = seeded_random(flag_id, 1)
username = f"user_{random_string(rng, 10)}"
password = random_string(rng, 16, PASSWORD_ALPHABET)
primary = ProjectSpec(
name=f"Sigma {random_string(rng, 5).title()}",
slug=f"proj{random_string(rng, 10)}",
description_template="Primary telemetry secret: {flag}",
)
decoy = ProjectSpec(
name=f"Telemetry {random_string(rng, 5).title()}",
slug=f"proj{random_string(rng, 10)}",
description_template="Routine system baseline",
)
return Vuln1Context(UserSpec(username, password), primary, decoy)
def build_vuln2_context(flag_id: str) -> Vuln2Context:
rng = seeded_random(flag_id, 2)
username = f"{random_string(rng, 12)}"
password = random_string(rng, 18, PASSWORD_ALPHABET)
project = ProjectSpec(
name=f"Runner {random_string(rng, 6).title()}",
slug=f"proj{random_string(rng, 12)}",
description_template="Serverless SQL vault",
)
store_fn = FunctionSpec(
name=f"store_{random_string(rng, 6)}",
path=f"/{random_string(rng, 6)}",
)
fetch_fn = FunctionSpec(
name=f"fetch_{random_string(rng, 6)}",
path=f"/{random_string(rng, 6)}",
)
return Vuln2Context(
UserSpec(username, password),
project,
store_fn,
fetch_fn,
)
class SigmaChecker:
def __init__(
self,
host: str,
*,
scheme: str = SCHEME,
port: Optional[int] = PORT,
) -> None:
base_url = self._build_base_url(host, scheme, port)
prefix = API_PREFIX if API_PREFIX else ""
self.client = ApiClient(f"{base_url}{prefix}")
@staticmethod
def _build_base_url(host: str, scheme: str, port: Optional[int]) -> str:
hostname = host
if ":" in host and not host.startswith("["):
hostname = f"[{host}]"
if port is None:
return f"{scheme}://{hostname}"
return f"{scheme}://{hostname}:{port}"
def check(self) -> None:
response = self.client.request("GET", "/health/cpu", expected={200})
try:
payload = response.json()
except ValueError as exc:
raise CheckerError(
"health endpoint returned invalid JSON"
) from exc
usage = payload.get("usage")
if not isinstance(usage, (int, float)):
raise CheckerError("health payload missing numeric usage")
def put(self, flag_id: str, flag: str, vuln: int) -> None:
if vuln == 1:
self._put_vuln1(build_vuln1_context(flag_id), flag)
elif vuln == 2:
self._put_vuln2(build_vuln2_context(flag_id), flag)
else:
raise CheckerInternalError(f"unsupported vuln {vuln}")
def get(self, flag_id: str, flag: str, vuln: int) -> None:
if vuln == 1:
self._get_vuln1(build_vuln1_context(flag_id), flag)
elif vuln == 2:
self._get_vuln2(build_vuln2_context(flag_id), flag)
else:
raise CheckerInternalError(f"unsupported vuln {vuln}")
def _ensure_user(self, user: UserSpec) -> None:
payload = {"username": user.username, "password": user.password}
response = self.client.request(
"POST",
"/users",
json=payload,
expected={201, 409},
)
if response.status_code == 201:
log(f"registered {user.username}")
def _login(self, user: UserSpec, *, corrupt_on_fail: bool = False) -> str:
payload = {"username": user.username, "password": user.password}
response = self.client.request(
"POST",
"/auth/login",
json=payload,
expected={200, 401},
)
if response.status_code == 401:
if corrupt_on_fail:
raise CorruptError("stored credentials rejected")
raise CheckerError("authentication failed")
try:
body = response.json()
except ValueError as exc:
raise CheckerError("login endpoint returned invalid JSON") from exc
token = body.get("auth_token")
if not isinstance(token, str) or not token:
raise CheckerError("login response missing token")
return token
def _auth_headers(self, token: str) -> dict:
return {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
def _create_project(
self,
headers: dict,
spec: ProjectSpec,
flag: Optional[str] = None,
) -> dict:
description = spec.description_template
if flag is not None:
description = description.format(flag=flag)
payload = {
"name": spec.name,
"slug": spec.slug,
"description": description,
}
response = self.client.request(
"POST",
"/projects",
headers=headers,
json=payload,
expected={201},
)
try:
project = response.json()
except ValueError as exc:
raise CheckerError(
"project creation returned invalid JSON"
) from exc
if not isinstance(project.get("_id"), str):
raise CheckerError("project creation missing id")
return project
def _list_projects(self, headers: dict) -> List[dict]:
response = self.client.request(
"GET",
"/projects",
headers=headers,
expected={200},
)
try:
projects = response.json()
except ValueError as exc:
raise CheckerError("projects list returned invalid JSON") from exc
if not isinstance(projects, list):
raise CheckerError("projects response is not a list")
return projects
def _find_project_by_slug(
self,
headers: dict,
slug: str,
) -> Optional[dict]:
for project in self._list_projects(headers):
if project.get("slug") == slug:
return project
return None
def _create_function(
self,
headers: dict,
project_id: str,
spec: FunctionSpec,
) -> dict:
payload = {"name": spec.name, "path": spec.path}
response = self.client.request(
"POST",
f"/projects/{project_id}/functions",
headers=headers,
json=payload,
expected={201},
)
try:
func = response.json()
except ValueError as exc:
raise CheckerError(
"function creation returned invalid JSON"
) from exc
if not isinstance(func.get("_id"), str):
raise CheckerError("function creation missing id")
return func
def _update_function(
self,
headers: dict,
project_id: str,
function_id: str,
code: str,
methods: Sequence[str],
) -> None:
payload = {"code": code, "methods": list(methods)}
self.client.request(
"PUT",
f"/projects/{project_id}/functions/{function_id}",
headers=headers,
json=payload,
expected={200},
)
def _execute_function(
self,
method: str,
slug: str,
path: str,
expected: Iterable[int] = (200,),
) -> requests.Response:
return self.client.request(
method,
f"/exec/{slug}{path}",
expected=set(expected),
)
def _put_vuln1(self, context: Vuln1Context, flag: str) -> None:
self._ensure_user(context.user)
token = self._login(context.user)
headers = self._auth_headers(token)
self._create_project(headers, context.primary_project, flag)
self._create_project(headers, context.decoy_project)
stored = self._find_project_by_slug(
headers,
context.primary_project.slug,
)
if not stored:
raise CheckerError("project missing after creation")
description = stored.get("description", "")
if flag not in description:
raise CheckerError("flag not present immediately after storing")
def _get_vuln1(self, context: Vuln1Context, flag: str) -> None:
token = self._login(context.user, corrupt_on_fail=True)
headers = self._auth_headers(token)
project = self._find_project_by_slug(
headers,
context.primary_project.slug,
)
if not project:
raise CorruptError("flag project missing")
description = project.get("description", "")
if flag not in description:
raise CorruptError("flag missing from project description")
def _put_vuln2(self, context: Vuln2Context, flag: str) -> None:
self._ensure_user(context.user)
token = self._login(context.user)
headers = self._auth_headers(token)
project = self._create_project(headers, context.project)
project_id = project["_id"]
func = self._create_function(
headers,
project_id,
context.store_function,
)
code = self._build_store_function_code(context.project.slug, flag)
self._update_function(headers, project_id, func["_id"], code, ["POST"])
self._execute_function(
"POST",
context.project.slug,
context.store_function.path,
expected=(200, 201),
)
def _get_vuln2(self, context: Vuln2Context, flag: str) -> None:
token = self._login(context.user, corrupt_on_fail=True)
headers = self._auth_headers(token)
project = self._find_project_by_slug(headers, context.project.slug)
if not project:
raise CorruptError("project not found during GET")
project_id = project.get("_id")
if not isinstance(project_id, str):
raise CheckerError("project missing id")
func = self._create_function(
headers,
project_id,
context.fetch_function,
)
code = self._build_fetch_function_code(context.project.slug)
self._update_function(headers, project_id, func["_id"], code, ["GET"])
response = self._execute_function(
"GET",
context.project.slug,
context.fetch_function.path,
expected=(200, 404),
)
if response.status_code == 404:
raise CorruptError("flag table returned empty result")
payload = self._parse_json(response.text)
if payload.get("flag") != flag:
raise CorruptError("retrieved flag mismatch")
def _build_store_function_code(self, slug: str, flag: str) -> str:
create_sql = (
f'CREATE TABLE IF NOT EXISTS "scope_{slug}" '
"(id SERIAL PRIMARY KEY, flag TEXT NOT NULL)"
)
insert_sql = f'INSERT INTO "scope_{slug}" (flag) VALUES ($1)'
return (
"""
;(async () => {
const CREATE_SQL = __CREATE__;
const INSERT_SQL = __INSERT__;
const FLAG_VALUE = __FLAG__;
await sql.execute(CREATE_SQL);
await sql.execute(INSERT_SQL, [FLAG_VALUE]);
res.setStatus(201).json({ status: "stored" });
})().catch((err) => {
res.setStatus(500).json({ error: String(err?.message ?? err) });
});
"""
.replace("__CREATE__", json.dumps(create_sql))
.replace("__INSERT__", json.dumps(insert_sql))
.replace("__FLAG__", json.dumps(flag))
.strip()
)
def _build_fetch_function_code(self, slug: str) -> str:
select_sql = f'SELECT flag FROM "scope_{slug}" ORDER BY id DESC LIMIT 1'
return (
"""
;(async () => {
const SELECT_SQL = __SELECT__;
const rows = await sql.query(SELECT_SQL);
if (
!Array.isArray(rows) ||
rows.length === 0 ||
typeof rows[0]?.flag !== "string"
) {
res.setStatus(404).json({ error: "flag missing" });
return;
}
res.setStatus(200).json({ flag: rows[0].flag });
})().catch((err) => {
res.setStatus(500).json({ error: String(err?.message ?? err) });
});
"""
.replace("__SELECT__", json.dumps(select_sql))
.strip()
)
@staticmethod
def _parse_json(body: str) -> dict:
try:
data = json.loads(body or "null")
except json.JSONDecodeError as exc:
raise CheckerError("function response is not valid JSON") from exc
if not isinstance(data, dict):
raise CheckerError("function response JSON is not an object")
return data
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Sigma ForcAD checker")
parser.add_argument(
"action",
choices=["check", "put", "get"],
help="Checker action",
)
parser.add_argument("host", help="Team IP address")
parser.add_argument("flag_id", nargs="?")
parser.add_argument("flag", nargs="?")
parser.add_argument("vuln", nargs="?", type=int)
return parser.parse_args()
def main() -> int:
args = parse_args()
checker = SigmaChecker(args.host)
try:
if args.action == "check":
checker.check()
elif args.action == "put":
if not (args.flag_id and args.flag and args.vuln is not None):
raise CheckerInternalError("put requires flag_id flag vuln")
checker.put(args.flag_id, args.flag, args.vuln)
elif args.action == "get":
if not (args.flag_id and args.flag and args.vuln is not None):
raise CheckerInternalError("get requires flag_id flag vuln")
checker.get(args.flag_id, args.flag, args.vuln)
else:
raise CheckerInternalError("unknown action")
except CheckerError as exc:
log(str(exc))
return exc.exit_code
except Exception as exc: # pragma: no cover
log(f"unexpected checker error: {exc}")
return EXIT_CHECKER_ERROR
log("OK")
return EXIT_OK
if __name__ == "__main__":
sys.exit(main())