Анализ защиты от взлома чита hake.me

Предисловие

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

В этой статье я постараюсь наглядно описать архитектуру защиты забугорного чита на доту «hake.me», и о том, какими методами я её обходил. Сразу хочу сказать, что многое из того что я делал выполнено очень костыльно и через жопу, и только сейчас я понимаю, что многие моменты можно было сделать в разы проще, однако на тот момент кряк нужен был как можно скорее, поэтому я не мог тратить время на размышления о том, как всё сделать более элегантно.

Благодарю за понимание, и надеюсь, что из этой статьи вы узнаете для себя что-то новое и интересное.

Я не буду слишком подробно описывать как я взламывал эту защиту и искал нужные места, ибо тогда материала было бы на частей так 10. Я продемонстрирую что такое НАСТОЯЩАЯ защита от взлома, а не то что сейчас есть на рынке, где почти в каждой теме пишут о «мега-защите от кряка», при этом не выходят за пределы максимальной настройки протектора и парсинга оффсетов с сервера.

Используемые инструменты

По традиции, в начале статьи кратко опишу какими утилитами я пользовался для взлома данной защиты:

  • x64dbg — мой основной инструмент для работы. Я очень редко провожу статический анализ, я сторонник динамического анализа, тем более что сейчас практически все софты снабжены каким либо шифрованием данных, или обфускацией кода.
    • Так же список плагинов для этого отладчика, которые я использовал:
      • ScyllaHide — плагин для скрытия отладчика от обнаружения протекторами. Работает практически на любой ОС, основной принцип работы — расстановка хуков на Nt функции для получения какой либо системной информации.
      • x64dbgpy — для выполнения скриптов написанных на языке Python, использующие SDK отладчика.
      • Multiline Ultimate Assembler — очень удобный плагин для модификации ассемблерных инструкций в запущенном приложении. Часто выручал меня когда нужно было написать большой код для какого то хука.
  • Scylla (x64) — утилита для работы с импортами.
  • Visual Studio 2017 — для написания своей DLL, которая выполняет все необходимые патчи.

Архитектура защиты

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

Протектор

В качестве протектора в клиенте используется Themida x64. Также поверх стоит некое подобие UPX, однако оно снимается простым дампом через ту же Scylla, после дохода до EP протектора, даже импорты фиксить не нужно. Благодаря тому, что стоял этот пакер разработчикам чита пришлось отключить проверку CRC в настройках протектора, что в свою очередь очень упростило привязку моей длл. Так же включена виртуализация пользовательского кода в особо важных функциях, которые касаются авторизации и инжекта.

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

Связь с сервером

Связь с сервером происходит через функции WSASend/WSARecv, с использованием SSL сертификатов, однако помимо шифрования от сертификатов данные так же дополнительно шифруются через шифрование Salsa20, понял я это благодаря ключевой строке, найденной в коде клиента, которая неоднократно используется «expand 32-byte k«:

Самым трудным здесь было вырезать SSL. Постоянно сталкивался с проверками на версию и валидность сертификата. В конечно итоге я смог найти все места и пропатчить. Не обошлось без ёбли и в поиске функции, которая возвращает «расшифрованный» ответ от сервера (ковычки я написал потому что данные всё ещё зашифрованы через Salsa20).

Я не буду здесь подробно останавливаться, потому что это материал для отдельной статьи, которую я возможно в скором времени сделаю, ибо я эту систему увидел в ещё одном чите, который продаётся в СНГ комьюнити, опишу лишь кратко алгоритм подмены сервера:

  1. Хук на getaddrinfo для подмены IP и порта подключения .
  2. Хук на WSASend и WSARecv для записи отправляемых/получаемых данных в свои буфера.
  3. Патч всех проверок для сертификатов.
  4. Патч функций которые отвечают за шифрование/расшифровку данных через сертификаты (таким образом в WSASend поступают чистые данные, а данные с WSARecv не трогаются).
  5. Патч функций которые отвечают за шифрование/расшифровку данных через Salsa20.
  6. Хук на функцию, которая возвращает конечный расшифрованный ответ. Данные берутся с буфера, который был заполнен из хука на WSARecv.

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

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

Подготовка DLL к инжекту

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

После того как вы нажали «Start», клиент начинает искать путь к игре и создаёт замороженный процесс dota2.exe (в нашем случае мы рассматриваем 64-битную версию).

Защита системных вызовов

На этом этапе я столкнулся с проблемой, которую добавили в защиту спустя 3-4 кряка. Разработчики вместо привычных VirtualAllocEx/WriteProcessMemory/VirtualFreeEx и т. д. начали вызывать NtAllocateVirtualMemory, NtWriteVirtualMemory, NtFreeVirtualMemory. Но не просто вызывать из ntdll.dll, а выполнять шелл код в выделенной памяти, выглядит это добро вот так:

Где 0000000002140000 — адрес выделенной памяти, а 0x3A — номер вызываемой функции (в моём случе 0x3A — это номер NtWriteVirtualMemory).

Вся сложность опять таки заключалась в поиске этого места (из-за виртуальной машины). Как только я наткнулся на одну из этих функций, я составил паттерн, благодаря которому смог найти все остальные места:  B8 ?? ?? 00 00 49 89 CA 0F 05 C3 00 00 00 00

Этим методом вызываются несколько Nt функций, но нам нужны только некоторые:

  • NtWriteVirtualMemory — для патча нужного кода сразу после того как чит записывается в память игры. На этом этапе я так же инжектил свою длл с нужными патчами и хуками.
  • NtAllocateVirtualMemory — для выделения памяти по тому адресу, который всегда свободен (поскольку чистую длл сервер нам не возвращает — освобождаем себя от необходимости фиксить релокации).
  • NtQueryInformationProcess — через эту функцию получается адрес системного буфера в процессе игры, который затем вшивается в код чита и активно используется.

Указатели на эти функции в клиенте найти довольно просто, по этому для хука нужно лишь подменить адрес функции на свой.

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

Клиент открывает все системные длл, которые присутствуют в таблице импорта чита, и начинает получать адреса ВСЕХ экспортируемых функций (позже вы поймёте зачем). Все эти данные он записывает примерно в таком виде:
[DWORD_1] [DWORD_2, __int64] [DWORD_2, __int64][DWORD_1]

  • DWORD_1 — CRC32 строки имени файла модуля.
  • DWORD_2 — CRC32 строки имени экспротируемой функции.
  • __int64 — адрес экспротируемой функции записанный задом наперёд.

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

Проверки на время

Одна из самых неприятных частей защиты. В первый раз текущее время получается до отправки всех данных на сервер. Вся сложность заключается в том, что для получения текущего времени не используется никакая WinAPI функция, клиент всё читает напрямую из структуры KUSER_SHARED_DATA, и что ещё неприятнее, всё это происходит под виртуальной машиной протектора.

Благодаря breakpoint на access я нашёл 2 места в ВМ, где читаются эти данные. Поскольку ВМ Темиды имеет линейный вид, в первом месте достаточно было заменить инструкцию чтения по адресу на инструкцию записи нужной константы в регистр:

Второе место производит чтение уже по другому адресу (0x7FFE0014) и находится в интерпретаторе ВМ:

Поскольку это же место вызывается многократно — пишем хук:

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

  • Максимальное время инициализации (инжекта, сбора игровых данных) — 5 минут.
  • Максимальное время работы чита за 1 сеанс — 12 часов.

Ограничение в 5 минут ставило в довольно суровые временные рамки, по этому перезаходить в чит приходилось по многу раз.

Вы наверное можете спросить «А какой смысл вообще патчить эти проверки?», я думал так же, но по какой то причине бинарник чита, который мы сдампим и будем возвращать всегда клиенту со сэмулированного сервера — вызывает краш, если эти константы не пропатчены (видимо обработку бинарника сервер тоже как то связывает со временем, я не смог это нормально изучить из за виртуализированного кода, по этому решил просто пропатчить все проверки) + это очень затрудняло анализ кода самого чита, в частности его инициализацию. Как устроены эти проверки в самом коде чита мы рассмотрим позже.

Ответ от сервера

После отправки всех необходимых данных сервер возвращает нам настоящий ад вместо ожидаемого DLL файла чита. Мы не получаем ни PE хеадеры, ни таблицу импорта, ни таблицу релокаций. Всё что мы получаем это только сам код с пофикшенными релокациями, с пофикшенными адресами импортов, зашифрованные по CPUID куски кода, а также некоторые куски кода под неудобной обфускацией. Это всё дело мы сохраняем в бинарник и будем каждый раз возвращать клиенту с эмулировающего сервера.

Самописная обфускация кода

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

В этот код идёт прыжок прямо посреди кода функции, то есть никаких функций где можно было бы сделать единый патч — нет. Во ВСЕХ местах, где идёт вызов системной функции из таблицы импорта, сервер ставит хук и вшивает туда код расшифровки адреса и вызова функции по нему.

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

Обход системы защиты

Фикс бинарника

Чтобы наш сдампленный бинарник работал всегда и на всех системах, нам необходимо выполнять ряд действий:

  • Выделять память для чита всегда по одному и тому же адресу.
  • Фиксить системные адреса и данные для текущей системной среды.
  • Пропатчить проверки на время в чите.

Выделение памяти по идентичному адресу

Это действие нужно провернуть ДО отправки данных на сервер. Клиент уже знает сколько места будет занимать бинарник в памяти, он получил эту информацию от сервера ещё до отправки всех данных. Поскольку бинарник у нас всегда один и тот же, то и размер у него каждый раз будет одним и тем же, этим и воспользуемся, ставим хук на NtAllocateVirtualMemory:

Таким образом память для чита будет всегда выделяться по адресу 0x100000000. Этот адрес всегда будет свободным при создании замороженного процесса. Можно использовать и другой, значения не имеет, главное что бы он был свободным.

Патчим вызовы из таблицы импорта

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

Как вы можете видеть, вместо обычных CALL QWORD PTR DS:[<&GetSystemTimeAsFileTime>] и т. д.  установлены хуки, код которых расшифровывает адрес и вызывает по нему функцию. Наша задача найти все возможные места и сгенерировать код, который будет получать адреса функций для текущей системной среды, записывать их в свою таблицу, и заменять хук на вызов по адресу из нашей таблицы.

Я не буду здесь прикладывать скрипты которые писал, их там было несколько и все реализованы через одно место, опишу сам алгоритм:

  1. С помощью паттернов (6 штук) находим все места в памяти, которые отвечают за вызов функции из таблицы импорта.
  2. Находим первую инструкцию начала расшифровки адреса.
  3. Выполняем код расшифровки адреса.
  4. Записываем данные в лог в таком формате: {расшифрованный адрес}_{адрес вызова системной функции (вместо которого стоит хук)}.
  5. Записываем все расшифрованные адреса в выделенную память в том же порядке что и в логе.
  6. Генерируем дерево импортов через программу Scylla и сохраняем в текстовик.
  7. Из полученных данных генерируем код для патча всех участков.

Результат:

Функция фикса:

Помимо вызовов есть ещё записи адресов из таблицы импорта в регистры. Самой часто-получаемой была ucrtbase.dll.towlower, однако вызывалась она всего лишь в одной функции, поэтому хватило хука в её прологе. Остальные MOV’ы я искал и фиксил вручную, их было штук 5-7.

Патч вызовов в обфусцированном коде

Некоторые функции, которые отвечают за инициализацию во время запуска игры — в обфусцированном виде передаются читу через локальный сервер, исполняются, и затем затираются новой (именно поэтому клиент не сразу закрывается после запуска игры). Эти функции по паттернам так просто не найдёшь, там пришлось бы целую систему писать (что я изначально и делал xD), но затем я заметил, что в этом коде вызывается в основном только 4 импортируемые функции, а именно: ucrtbase.dll.strlen, ucrtbase.dll.wcslen, ucrtbase.dll.towlower и msvcp140.dll.[email protected]@@YAIXZ.

Чтобы пропатчить эти вызовы я просто выделил память по адресу 0x150000000 и записал туда вызовы этих функций через инструкцию JMP QWORD PTR, затем в клиенте на этапе получения адресов всех экспортов я установил хук на функцию, которая отвечает за возврат адреса, и сделал проверку, что если запрашивается адрес одной из этих 4-х функций — то возвращать мой адрес с выделенной памяти. После этого патча сервер вошьёт МОЙ адрес, в котором хранится просто прыжок в нужное место, таким образом управление перейдёт в нужную системную функцию без повреждений контекста.

Патч проверок на время в чите

В чите было 2 типа проверок на время:

  • Первый тип проверок находился либо в открытой, либо в зашифрованной памяти кода, которая расшифровывалась только при исполнении через CPUID (большая часть проверок была именно здесь). Задача этих проверок — проверять не закончился ли сеанс использования чита (12 часов), и если закончился — вызывать краш. Вот только функция краша для всех проверок была одной, поэтому пропатчить все проверки разом не составило труда, просто поставив RET в пролог функции краша. Стоит отметить, кстати, что краш был не простым: он сначала затирал все регистры, стек, и затем прыгал в рандомное место секции кода модуля доты, чтобы реверсер подумал, что краш вызван игрой.
  • Второй тип проверок (и самый противный) записывался шеллкодом в выделенную память, и при успешной проверке выполнял вызов критически важных функций инициализации. Тут, опять же, большую часть времени я потратил именно на изучение, ибо изначально я не знал что здесь такая система и не понимал почему чёрт возьми не появляется меню чита 🙂 После долгой трассировки обфусцированного кода я случайно наткнулся на функции, которые отвечают за генерацию кода проверок, и сделал патч, чтобы вместо инструкций условных переходов — всегда записывались NOP’ы, в итоге, все проверки проходили и функции инициализации вызывались.

Зашифрованный код по CPUID

Ну здесь всё довольно просто: достаточно было найти функцию расшифровки памяти, поставить хук на инструкцию CPUID и записать данные системной среды, на которой происходил запуск чита.

Патч адреса системного буфера

Помните я говорил, что клиент получает адрес системного буфера через NtQueryInformationProcess? Так вот, в чит он тоже вшивается, поэтому фиксить его тоже нужно. Этот буфер используется только при поиске игрового кода по паттерну, и, как ни странно, расшифровывается этот адрес сразу после функции, в которой всегда вызывается функция ucrtbase.dll.towlower (о ней я писал в главе о фиксе адресов системных функций). В общем, я дополнил свой хук в этой функции проверкой адреса возврата на наличие там кода расшифровки адреса системного буфера, и если он там присутствует — затирал его и подставлял валидный адрес буфера текущей системной среды. Зачем так геморрно? Как и в случае с обфускацией каждый раз были разные регистры, а ключи ксора и вовсе в стеке хранились по разным смещениям.

Заключение

В заключении хочу поблагодарить авторов чита hake.me, а именно Kawaii и Stephani, за тот огромный опыт, который я получил ломая их защиту, было весело). Вряд ли я когда-либо встречу защиту ещё сильнее или хотя бы на уровне этой (некоторые уже пытались, получилось смешно), и даже если и встречу — то вряд ли буду ею заниматься, так как сейчас не располагаю таким количеством свободного времени.

Ну и конечно же, спасибо тем, кто прочитал эту статью до конца, надеюсь сильную головную боль она у вас не вызвала. Следите за моим блогом в телеге, там будет много интересного: https://t.me/arting_blog

С вами был Arting.

Ещё увидимся!

4 thoughts on “Анализ защиты от взлома чита hake.me

  1. Инкогнито says:

    Спасибо. Очень много полезного, сохранил (чтобы не получилось как в прошлый раз xd)
    Сейчас как раз изучаю защиту чита, там что-то похожее. Только я не допер что там тоже сальса, хотя видел что библиотека крипто++ присутствует.

  2. Давай есчо says:

    Блок div#secondary.widget-area так и не пофиксил для просмотра на телефонах. Он наезжает на основной контент. Хорошая статья. Кряк аимвара поднял бы твой блог до небес.

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *