adding validated services? patching forcad_local.py
This commit is contained in:
565
ctfcup2025-school-final/checkers/aws_sigma/checker.py
Executable file
565
ctfcup2025-school-final/checkers/aws_sigma/checker.py
Executable file
@@ -0,0 +1,565 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user