Решение сервиса perune с RuCTF 2023

Решение сервиса perune с RuCTF 2023

https://t.me/kerable

Общее описание работы сервиса

Сервис представляет собой интерфейс обращения к богу Перуну. Чтобы сделать обращение (далее Prayer) нужно создать подношение (далее Gift). Prayer и Gift это две основные сущности сервиса доступные публично и управляемые пользователем, то есть пользователь может контролировать их содержимое. Функционал сервиса представляет возможности создавать Gift, просматривать Gift, создавать Prayer на основе имеющегося Gift и вызвать бога Перуна (далее Call Perune) чтобы посмотреть будет ли выполнен Prayer или нет.

Сервис хранит Gift и Prayer в виде файлов с именами равными ID этих сущностей. Каждая сущность имеет свой ID в формате UUID. Данные сохраняются в директорию database/. Также при поверхностном изучении сервиса (без использования инструментов обратной разработки) можно обнаружить и третью сущность, которая является некоторой связью Gift и Prayer (далее Link). Но Link никак не доступен для пользователя и с точки зрения работы сервиса он является скрытым, то есть пользователь нигде не может напрямую влиять на его значения/состояние.

Интерфейс взаимодействия с сервисом представляет собой выше описанные функции.

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

Make gift

При создании Gift мы контролируем три поля объекта: тип (enum), количество (size_t) и пароль (std::string).

После этого мы получаем ID данного Gift, он является attack_data для сервиса, то есть он доступен публично для всех атакующих сервис. Мы не можем просто так просмотреть Gift, так как его безопасность обеспечивается наличием пароля, который неизвестен атакующему.

Make prayer

При создании Prayer нам нужно ввести ID Gift-а при этом данный Gift не должен быть соединён с другим Prayer. Также нас попросят ввести пароль для указанного Gift. Содержимое Prayer это просто текст (std::string).

После создания Prayer мы получим его ID по которому мы можем выполнить функцию Call Perune.

View Gift

При просмотре Gift нам нужно указать Gift ID и пароль. После этого мы получим отображение Gift-a и его Prayer если он есть.

Call Perune

Вызов Перуна по логике нужен для того, чтобы выполнить ваще обращение если оно удовлетворяет некоторым условия (их нужно узнать путём обратной разработки исполняемого файла). Для того чтобы выполнить вызов нужно указать Prayer ID. Вызов может быть успешным (как на картинке ниже), тогда мы увидим содержимое нашего Prayer.

Также вызов может быть неуспешным, тогда мы увидим сообщение о том, что мы ящер.

Логика работы сервиса

Пользователь может создать две сущности - Gift и Prayer. Просмотр сущности Gift реализован через функцию запрашивающую ID и пароль для Gift. Просмотр сущности Prayer реализован через некоторый алгоритм который привязан к содержимому Gift. Флаг находится в сущности Prayer. Зная ID сущности Prayer мы можем получить флаг. Зная ID сущности Gift и пароль мы можем получить флаг. Публично известен только ID сущности Gift.

То есть исходя из логики работы сервиса можно сразу выявить несколько векторов атаки по которым можно получить флаг:

  1. Попытаться узнать пароль Gift или обойти проверку
  2. Попытаться узнать Prayer ID для нужного Gift
  3. Найти какую либо другую бинарную уязвимость позволяющую читать память или получить выполнение произвольного кода, так как сервис бинарный

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

Анализ исполняемого файла.

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

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

MakeGift(void)

Данная функция вызывается при выборе опции "1" в главном меню. Она создаёт Gift принимая данные от пользователя. Рассмотрим как она работает.

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

Далее мы просим ввести количество для данного подношения (строки 74-75) и пароль (строки 77-89).

После ввода нужных значений мы создаём объект класса Gift с нашими аргументами (строка 91). А также добавляем его в кэш (строка 93) с помощью функции AddObjectToCache. В самом конце функции мы выводим на экран ID созданного Gift.

Из этой функции мы узнали о том, что Gift описывается с помощью класса Gift и что у нас есть некоторый кэш объектов, а также что размер объекта Gift равен 0x50 байт (строка 90 на скриншотах выше).

Рассмотрим другие интерфейсные функции.

ViewGift(void)

Данная функция вызывается при выборе опции "2" в меню.

В начале функции мы вызываем функцию GetGift() которая спрашивает у нас ID и пытается найти нужный Gift в кэше или базе, после чего спрашивает пароль.

В самой же функции просмотра мы просто достаём нужные поля из объекта и отображаем их на экран.

С помощью таких методов как GetCount(), GetGiftType(), GetPassword(), GetLink() мы можем достаточно достоверно восстановить структуру объекта Gift, так как мы увидим к каким смещениям будут обращаться данные методы.

Соединим всё это и можем создавать структуру описывающую Gift. Получим следующую структуру. Поля vtable и uuid_ появились потому что Gift является наследником класса Object и имеет визуальные методы, а класс Object описывает любой объект и имеет поле uuid_ которое является ID-шником объекта.

Посмотрим как данная структура ляжет на конструктор который вызывается при создании Gift.

Gift::Gift(...)

Как можно заметить, всё довольно красиво и приятно легло. Также обратим внимание, что Gift является потомком класса Object и имеет свою виртуальную таблицу.

Также обратим внимание на новую сущность Link, создаваемую в строке 11-12, можно увидеть её размер равный 0х28 байт.

Данная сущность не отображается пользователю и существует для связи объектов Gift и Prayer.

Посмотрим на конструктор для Link.

Link::Link(...)

Анализируя конструктор можно прийти к следующей структуре Link.

Наложим эту структуру на конструктор и получим следующий код.

Link по сути представляет собой Object у которого есть два указателя: один на Gift, второй на Prayer. При этом при создании Gift мы сразу создаём Link и проставляем туда указатель на Gift, а второй указатель будет проставлен только после создания Prayer.

В случае загрузки из базы Link-а будет вызван другой конструктор который инициализирует Link из json-а, впрочем как и для других объектов.

Подведём небольшой итог по текущему анализу:

  • У нас есть некоторый класс Object от которого наследуется Gift и Link (и ещё Prayer). Данный класс имеет виртуальные методы, что автоматически создаст указатель на vtable в структуре наследуемых классов, также Object имеет поля uuid_ которое представляет собой ID объекта.
  • Gift содержит поля описывающие его (type, сount, password), а также указатель на объект Link который связывает две сущности между собой.
  • Link представляет собой сущность из двух указателей и она создаётся в Gift при создании из сервиса (не при загрузке из базы) и сразу инициализирует указатель на Gift. Мы не управляем содержимым Link напрямую, потому что не контролируем её создание (то есть мы не можем создать Link с произвольными полями)

Теперь рассмотрим как устроен Prayer.

MakePrayer(void)

Данная функция будет вызвана при выборе опции "3" в меню.

В начале функции мы пытаемся получить Gift по указанному ID (это происходит внутри функции GetGift()) и проверяем нет ли у этого Gift уже выставленного Prayer.

Проверка реализуется через вызов метода IsLinked() у Link, данный метод проверяет, что оба указателя не нулевые.

Далее мы вводим текст для Prayer (строки 33-43) и создаём его с помощью конструктора (строка 46).

Также мы получаем указатель на Link у Gift-а (строка 44) и передаём его в конструктор Prayer-а. После этого мы добавляем в кэш Link (строка 48-49) и Prayer (строка 55), а также отображаем на экран ID созданного Prayer.

Взглянем на конструктор Prayer.

Prayer::Prayer(...)

В целом ничего интересного, просто копируем наш текст, а также линкуем Prayer в Link.

Структура Prayer следующая.

Отлично, мы разобрали как устроены объекты и как они связаны (с помощью Link), а также узнали про некоторый кэш. Осталось немного неясно, что это за кэш такой. Давайте взглянем на него.

AddObjectToCache(Object* pObject)

Данная функция добавляет любой объект в кэш. Кэш представляет собой std::unordered_map<std::string, Object*>. То есть мы добавляем объект в мап, где ключом является ID добавляемого объекта, то есть текстовое представление uuid.

Кэш ограничен по размеру на 256 элементов. Существование кэша объясняется несколькими вещами:

  1. В нём заложена уязвимость =)
  2. При создании Gift-а и Prayer-а они не будут добавлены в базу сразу, а будут находится в этом кэше, так немного можно защититься от спама файлами на систему
  3. Если нужные Gift + Prayer лежат в базе, то мы достаём их один раз и кладём в кэш и следующие обращения к ним уже будут идти через кэш, а не базу (которая представлена файликами)

Сброс кэша в базу осуществляется при успешном завершении Call Perune. А также удаление из кэша происходит при неуспешном завершении Call Perune. Получается нужно изучить что делает этот самый Call Perune.

CallPerune(void)

Данная функция начинается с считываний ID Prayer-а и поиска его в кэше и базе (строки 20-29). Также производится проверка что мы достали именно Prayer (строка 29).

После проверок и получения Prayer мы получаем Link и пытаемся достать из него Gift (строки 31-32) и проверяем, что мы действительно имеем не нулевой указатель на Gift в нашей Link (строка 33).

Далее происходит расчёт очков (score) для Gift-a, этот расчёт основан на максимально простой формуле. Каждому типу Gift-а соответствует некоторый коэффициент типа double, который умножается на количество Gift-a (count) и получается score. Если этот score меньше чем 1337 (строка 35), то мы считаемся ящерами и наши Gift и Prayer будут удалены из кэша и удалены как объекты (строки 42-51).

Если же мы успешно вызвали Перуна и наш score оказался больше, то мы получим вывод на экран нашего text в Prayer и уменьшим score в Gift, после чего сохраняем весь наш кэш в базу.

Ещё раз внимательно посмотрим на код вызываемый при неверном вызове Перуна. Если мы посмотрим на то, что происходит с кэшем, то увидим, что из него удаляется Gift (строка 49) и Prayer (строка 43), но вот Link не удаляется. Возможно он удаляется из кэша в другом месте, давайте посмотрим где ещё вызывается функция DeleteFromCache().

Оказывается, что это шаблонная функция и у неё произошло всего два инстанцирования на Gift и Prayer. То есть мы теряем в кэше наш Link. Но что вообще с ним произошло? Давайте посмотрим на владельца Link, то есть на Gift, так как он создаёт Link в своём конструкторе.

Gift::~Gift(...)

Деструктор Gift-а вызывает метод DestroyLink, изучим его.

Метод DestroyLink()

Данный метод достаёт указатель на Link (строка 6) и проверяет, что он не нулевой (строка 7). После чего мы проверяем, что наш объект (а это Gift в данном случае) всё ещё находится в Link (строка 9), если это так, то мы отлинковываем его (строка 10).

После чего происходит вызов метода HasLinked() который проверяет остались ли указатели в Link (картинка ниже). Если Link пустой, то мы вызываем деструктор для Link (строка 17) и удаляем указатель. Если в Link ещё остался какой-то объект, то мы просто зануляем поле link у Gift-a.

В нашем случае сначала удаляется Prayer, а потом Gift, то есть на момент удаления Gift-а в Link останется только он и он будет отлинкован (в строке 10), что приведёт к вызову деструктора на Link и мы получим ситуацию, что в нашем кэше окажется указатель на уже освобожденный объект размером 0x28.

Уязвимость.

Таким образом мы получаем уязвимость связанную с тем, что у нас есть висящий указатель на освобожденный объект в кэше. Сделать из этого примитива UAF (Use After Free), то есть использование после освобождения нам также поможет функция Call Perune.

Давайте обратим внимание на то, что происходит при успешном завершении работы Call Perune.

Мы вызываем функцию FluashCacheToDb() которая должна сбросить кэш в базу, а если мы сделали висячий указатель, то мы будем сбрасывать непонятно что, посмотрим что происходит в данной функции.

Мы проходимся в цикле по всем объектам и вызываем для них функцию AddObjectToDb(). Взглянем и на эту функцию.

Обратим внимание на строк 11 и увидим, что для каждого объекта будет вызываться метод ToJson из vtable. То есть и для нашего уже освобождённого объекта он также будет вызываться и скорее всего произойдёт SegFault, так как там будут лежать уже совсем другие данные и указатель на vtable просто не сможет разыменоваться.

Давайте попробую сделать триггер руками.

Триггер.

Для этого нам нужно сделать последовательно следующие действия

  1. Создать Gift с маленьким count
  2. Создать Prayer для Gift из пункта 1
  3. Gift с большим count, чтобы пройти Call Perune
  4. Создать Prayer для Gift из пункта 3
  5. Сделать Call Perune над Prayer из пункта 2 и получить освобождённый Link в кэше
  6. Сделать Call Perune над Prayer из пункта 4 и получить Segmentation Fault

Итак создаём два Gift-a

Получаем, что Gift с ID - 3d6606ca-0364-424a-9897-d82b27a3d8fc не пройдёт проверку, а с ID - 3e5940e1-6a16-4b9a-9afd-30aaf7287551 пройдёт.

Далее создаём по Prayer-у для каждого Gift.

Итак, получаем два Prayer-a: 22178cc0-045b-4e98-9822-234d0408ab5e (неверный) и f4c3b342-1978-4109-9608-4c08b42c35a5 (верный)

Делаем Call Perune от неверного, а потом от верного. Первый вызов оставит в кэше освобождённый Link, а второй будет тригерить вызов ToJson() из vtable освобождённого объекта, где лежит какой-то мусор.

Получаем segmentation fault, отлично, триггер работает. Теперь надо думать над планом эксплуатации.

План эксплуатации.

Итак, опишем нашу текущую ситуацию

  • На куче есть некоторый освобождённый объект указатель на который есть у нас в кэше
  • Данный объект имеет указатель на vtable
  • Триггер пытается разыменовать указатель на vtable и вызвать первый метод из него

Мы можем попытаться переписать указатель на сам vtable чтобы указать свой новый vtable, но с этим есть некоторая проблема, связанная с тем, что у нас нет никаких утечек памяти и мы не можем точно сказать где мы будет контролировать данные и какой у них будет адрес. Можно обратить внимание на то, что исполняемый файл не имеет PIE, то есть загружает свой код и данные всегда по известным адресам, но никакой прямой записи в глобальные данные бинаря, кажется, нет.

Идея которую мы использовали для решения заключается в том, что мы делаем такой фэншуй на куче, чтобы в наш освобождённый объект на место, где должен находиться указатель на vtablе положился указатель на кучу, где лежат наши данные. То есть мы хотим получить такую ситуацию, когда наш объект будет переиспользован каким-то объектом который будет иметь указатель на данные которые мы контролируем.

То есть идея не в том, чтобы напрямую переписать указатель на vtable, а именно получить ситуацию, когда он будет переписан кем-то другим но на такой указатель где мы контролируем данные (на картинке мы контролируем данные по адресу 0xff00). Эта концепция несколько отличается от обычный vtable-hijack эксплуатаций, потому что обычно напрямую переписывают содержимое освобождённого объекта, но мы не можем так сделать потому что не обладаем никакими утечками и поэтому нам надо сделать аккуратный фэншуй чтобы указатель сам переписался на наши данные. И если это получится, то мы получим контроль над регистром RIP и сможем перенаправить выполнение программы в любое место, а вот куда - уже дальше думать будем.

Итак, наш текущий план

  1. Создаём нужные объекты и делаем освобождённый Link в кэше
  2. Каким-то образом делаем так, чтобы введённый нами данные оказались на том месте куда будет указывать указатель в первом поле переиспользованного объекта Link.
  3. Записываем какой либо адрес, чтобы увидеть что мы получили контроль над RIP

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

На строку 100 пока что не обращаем внимание это нужно нам будет далее для получения флага и мы ещё вернёмся к этому.

После создания нужных объектов нам нужно сделать ситуацию с освобождённым Link в кэше. Поэтому мы делаем Call Perune.

Теперь самое основное - мы должны каким-то образом попасть на освобождённый объект каким-то указателем, который будет указывать на введённые нами данные. На этом этапе мы просто пытаемся подобрать размер строки там образом, чтобы попасть в нужное место.

В итоге у нас вышел вот такой способ для попадания.

leak_gadget в данном случае это адрес функции внутри vtable.

Посмотрим это дело в отладчике.

Видим, что внутри функции AddObjectToDb() мы дошли до момента вызова регистра (вызов виртуального метода) и видим, что содержимое регистра контролируется нами.

Теперь нам надо придумать как получить флаг. Мы пытались найти способ сделать произвольное выполнение кода и получить RCE, но у нас не вышло, в итоге мы нашли гаджет который может прочитать содержимое кучи (для этого мы изначально читаем Gift с флагом и он помещается на кучу, даже если мы вводим неверный пароль).

Гаджет который мы использовали на рисунке ниже. Он был найден путём просмотра различных функций которые могут выводить на экран данные.

Если обратим внимание на регистр r12 в моменте вызова виртуального метода, то он равен 1, это как раз то, что нам нужно, так как мы выводим в stdout. Вообще этот гаджет оказался идеально подходящим для нас и мы довольно долго его искали. Регистр RSI указывает на кучу, а RDX также указывает на кучу и имеет большое число, что позволяет считывать много данных.

В итоге полный план эксплуатации следующий:

  1. Создаём два Gift и два Prayer
  2. Использую attack_data читаем по ID Gift с флагом и вводим любой пароль (нам нужно чтобы Gift просто был добавлен в кэш и оказался на куче вместе с Prayer)
  3. Делаем Call Perune и создаём ситуацию освобожденного Link в кэше
  4. Делаем View Gift c ID в виде строки специального размера в которой лежит наш гаджет. Это позволит нам получить такую ситуацию в которой указатель на vtable станет указателем на наш чанк
  5. Делаем Call Perune ещё раз и тригерим баг

В итоге получим такую ситуацию.

Как видим на картинке RCX указывает на наш гаджет, перейдём туда и посмотрим как выглядят регистры перед вызовом write.

Всё как надо, мы будем писать в stdout и пишем данные с кучи огромного размера.

В итоге получаем такой эксплоит.

Опишем по строкам.

96 - создаём gift1

98 - создаём gift2

99 - создаём prayer2

101 - делаем просмотр Gift по attack_data чтобы положить Prayer с флагом на кучу

103 - делаем prayer1

108 - делаем Call Perune чтобы получить освобождённый Link в кэше

111-112 - заполняем кучу так, чтобы получить указатель на vtable в освобождённом Link который будет указывать на наш write_gadget

114 - триггер UAF и вызов нашего гаджета

Пример выполнения сплотила на сервисе.

Полный эксплоит можно найти в репозитории RuCTF 2023 - https://github.com/HackerDom/ructf-2023

Report Page