А знаешь как? Я всего-лишь публикую (создаю темы), а админ мне платит. Трачу деньги на мороженое, робуксы и сервера в Minecraft. А ещё на паль из Китая.
Вы используете устаревший браузер. Этот и другие сайты могут отображаться в нём некорректно. Вам необходимо обновить браузер или попробовать использовать другой.
Гайд[Reverse-Engineering] Как взламывался LEGENDWARE V5
Спустя три месяца отсутствия на платформе Boosty, в которой я обязан генерировать куда чаще материал, я всё же соизволил поднять пятую точку и найти достойный контент для своей следующей статьи, коим стал недавно выпустившийся чит под названием Legendware на CS2.
Изначально я не обратил внимание на этот чит, но затем в ЛС мне начали писать, что им предоставляет защиту от взлома некий проект под названием "orion-security.pro", и поскольку я не был занят чем-то важным на тот момент времени, то мне стало любопытно проверить насколько она защищена от взлома.
С предисловием закончили, переходим к инжектору.
Процесс инжекта чита в игру
Процесс инжекта представляет собой нестандартную технику, выходящую за рамки обычных методов, где обычно выделяют лишь один большой регион для чита и записывают всё туда, вызываю NtCreateThreadEx.
Лоадер использует подход к инжекту, основанный на фрагментации образа чита и его распределении по адресному пространству целевого процесса. Лоадер разбивает его на множество мелких фрагментов (около 60 отдельных регионов), под которые он исправляет релокации, импорты, джамп тейблы, вместе с этим также и релативные инструкции (lea [rip+offset], ...) , поскольку они взаимосвязаны друг с другом.
При этом ему еще нельзя забывать исправлять адреса, чтобы он мог правильно передать управление другому региону чита. Далее регионы распределяются по адресному пространству игры, используя NtMapViewOfSection вместо привычного NtWriteVirtualMemory и NtAllocateVirtualMemory. Примерно такое же решение можно было наблюдать в проекте Onetap V4 на CS:GO, или же в этом репозитории:
Причём также стоит подметить, что все эти регионы (за исключением .rdata) заново собираются на сервере, меняя способы декрипта, размер их соответственно, тоже меняется, но думаю это и так логично. Единственный, у кого размер не меняется, так это регион, в котором находится точка входа чита, она там фиксированного значения - 4096 байт, и тем не менее каждый запуск там будет отличаться последовательность инструкций, как и у остального мутированного кода.
Так вот, сохраняем все эти регионы для нашего стим модуля, и переходим к вызову EP.
После маппинга всех регионов чита в целевой процесс, лоадер вызывает удаленный поток не напрямую в EP (точке входа) чита, а в функции TppWorkerThread из NTDLL.DLL.
TppWorkerThread - это внутренняя функция Windows Thread Pool API (
), которая обрабатывает задачи пула потоков. Когда приложение отправляет задачу в пул потоков через API типа CreateThreadpoolWork, TppWorkerThread - это функция, которая фактически выполняет эту задачу.
В контексте инжекта чита, TppWorkerThread используется в обход прямого NtCreateThreadEx, но тем не менее, отловить поток чита всё ещё можно, поскольку TppWorkerThread в конце концов дойдет до RtlUserThreadStart, в аргументах которого будет содержаться адрес точки входа чита.
Учитывая всё это, я поставил хук на NtContinue. (Поскольку LdrInitializeThunk является точкой входа, с которой начинают выполнение все потоки пользовательского режима в масштабе всей системы, она в свою очередь вызывает NtContinue) и в хуке я проверял, если адрес, находящийся в Context->Rcx является мапнутым (MEM_MAPPED), то я дампил все регионы чита.
Импорты
Адреса импортов у нас также зашифрованы, и не то чтобы я обращал на это какое-либо внимание, поскольку в шифрованных импортах нет чего-то особенного, это в целом штука давно не новая. Но примера ради покажу как выглядит обычный зашифрованный вызов импорта ucrtbase.dll::___lc_codepage_func: Посмотреть вложение 306323
Как временное решение я поставил обработчик исключений, который перенаправлял попытку обращения к старым адресам на новые, заранее сдампив все экспорты модулей игры: Посмотреть вложение 306327
Сессия
Сессия представляет собой набор уникальных данных, которые меняются при каждом запуске игры или перезагрузке системы. Это позволяет разработчикам создавать динамическую привязку, которая существенно усложняет воскрешение дампа чита как на своей системе, так и на чужой. В качестве сессионных данных может выступать абсолютно разная информация, будь это PID игры, адрес какого-либо модуля целевого процесса, характеристики процессора или системы.
В случае Legendware, разработчик хорошо позаботился об этом, закинув кучу привязок в чит, которые начинают создавать проблемы чуть ли не с точки входа.
Дело в том, чтобы передать региону №1 управление региону №2, ему нужно правильно дешифровать адрес, который потом будет вызван через CALL REG64. Но проблема в том, что адрес дешифровывается за счёт этих самых данных сессии.
Поскольку в регионах, где происходит декрипт каких-либо данных загрязнён Control Flow множественными безусловными прыжками (JMP), то я покажу сразу очищенный вид: Посмотреть вложение 306328
Как вы уже возможно догадались, в инструкции mov rax, qword ptr ss:[rbp], происходит чтение значения по некому адресу, от которого зависит дальнейший результат дешифровки. Оно пытается прочитать HYPERVISOR_SHARED_DATA, у которого после перезапуска ПК меняется адрес. В моём случае это адрес 0x7FFE6000 и его интересует то, что находится по офсету +8: Посмотреть вложение 306329
Сразу поясню вообще откуда взялась в системе структура HYPERVISOR_SHARED_DATA, поскольку по ней довольно мало информации.
В Windows 10 (REDSTONE4) появилась новая общая страница, которая расположена рядом с KUSER_SHARED_DATA, она идет следом после неё. Как я уже упомянул выше, адрес этой страницы не является фиксированным как у KUSD (Сокращенно от KUSER_SHARED_DATA), получать его можно через вызов NtQuerySystemInformation с параметром SystemHypervisorSharedPageInformation.
И имеет следующую структуру:
Из этой структуры чит читает поле QpcMultiplier, используя его как ключ для декрипта, оно тоже имеет свойство меняться после перезапуска ПК, поэтому использовать его в качестве одного из ключей для декрипта было неплохим решением.
Из-за такой рандомизации попытка прочитать по тому же адресу вызовет у нас исключение из-за невалидного адреса уже после того, как мы после ребута системы попробуем поднять дамп.
Помимо всего прочего, сессионные данные также используются для того, чтобы расшифровать указатель, по которому необходимо записать некое значение:
Исходя из всей этой информации мы можем отлавливать инструкции формата mov reg, memory ptr и проверять что они читают.
Таким способом я поймал самые разные поля в KUSER_SHARED_DATA. Для самых маленьких кратко расскажу для чего существует эта структура.
KUSER_SHARED_DATA - это специальная структура данных в системе, которая содержит информацию, разделяемую между режимом ядра и пользовательским режимом. Эта структура располагается по фиксированному виртуальному адресу 0x7FFE0000 во всех процессах Windows, делая её доступной из любого процесса.
Самые разные API прибегают к чтению этой структуры, например тот же GetSystemTimeAsFileTime для извлечения времени, или же экспорты в NTDLL.DLL, которые перед исполнением системного вызова проверяют поле SystemCall.
Возвращаясь к тому, что чит читает из этой структуры, там довольно много адресов, которые не относятся к какому-либо поле:
Вместе с этим меня настигла проверка времени, для получения которой шло обращение к полю 0x7FFE0014 (KUSD->SystemTime), суть которой в том, чтобы проверять максимальное время работы чита, которая составляет 24 часа с начала инжекта. Ему я возвращал константу, которое было при оригинальном инжекте. Что-то подобное я уже наблюдал у проекта hake.me (Чит на Dota 2), который тоже любил читать это поле будучи под виртуализацией Themida, но в этом чите тут пошли ещё дальше.
После спуфа всех этих данных мы переходим к виртуализации кода. Да, я не сказал об этом, в отдельном регионе тусуется секция с VMProtect > 3.7 вместе с мутацией кода, там тоже своего рода куча неприятных проверок.
Когда я попал под виртуализированный пользовательский код, то меня уже поджидала очередная проверка на время с обращением в тот же адрес. Возвращать ей сохраненную константу как прежде я это делал в мутированных регионах было ошибкой, поскольку это приводило к тому, что чит ломает стек, по поведению я подумал, она проверяет валидацию временных параметров, смотря как именно протекает время. В данном случае чит, вероятно, сохраняет предыдущие значения времени и анализирует скорость его изменения. Если время меняется нелинейно (слишком быстро, слишком медленно или непоследовательно), то это воспринимается как попытка спуфать время.
Я решил попробовать сделать так, чтобы при каждом обращении чита к KUSD->SystemTime оно эмулировало течение времени.
Суть метода заключается в создании консистентной временной шкалы для чита, которая коррелирует с реальным течением времени, но начинается с момента оригинального инжекта. Для этого необходимо сохранить исходное системное время в момент первого запуска чита (назовем его T_base), а также текущее системное время в момент запуска дампа (T_start). При каждом обращении чита к KUSD->SystemTime выполняется следующий алгоритм:
Получаем текущее реальное системное время (T_current)
Вычисляем, сколько времени прошло с момента запуска дампа: T_delta = T_current - T_start
Возвращаем читу значение T_base + T_delta
Таким образом, с точки зрения чита, время течет естественным образом, начиная с того момента, когда он был изначально запущен:
В итоге мы проходим этот чек и переходим к следующему.
Следующая проверка завязана на проверке адреса PEB (Process Environment Block), которая меняется каждый запуск. Если чек не будет пройден, то будет ожидаемый результат – поломанный стек, что вызовет краш приложения (как и предыдущая проверка)
Подобную проверку можно было наблюдать и в Gamesense (CS:GO/CS2), чтобы её обойти, достаточно мапнуть в игру регион с PEB, который был при оригинальном инжекте, и перенаправлять в перехваченной инструкции новый адрес на старый.
После всего этого, под виртуализацией начинается полная вакханалия: чит начинает парсить PEB->NumberOfProcessors, PEB->OsBuildNumber, сразу же после этого начинается сбор информации из KUSER_SHARED_DATA: из этой структуры он снова получает значения полей NtBuildNumber, NumberOfPhysicalPages, Cookie, ActiveProcessorCount. Далее он задаёт определенный диапазон адресов, в котором он будет читать фиксированное количество байтов.
Например, для такого диапазона 0x7FFE0030 - 0x7FFE0236 он читает по два байта.
Для диапазона 0x7FFE0274 - 0x7FFE02B3 он читает по одному байту.
А, ну и ещё, не стоит забывать про HYPERVISOR_SHARED_DATA, которая тоже читается под виртуализацией.
Когда чит прочитал все эти поля, он несколько раз исполняет инструкцию CPUID (инструкция, получающая информацию о процессоре) с параметрами 0,0x80000002, 0x80000003, 0x80000004.
И всё это походит на некий сбор уникального хэша пользователя, и который, видимо, сравнивается с тем, что был вшит в чит маппером.
Спуфинг данных
Проанализировав всё, что проверяет чит, я начал определяться с тем, как можно автоматизировано подменить данные. Мой подход заключался в создании отдельной библиотеки, которая устанавливает точки останова (INT3) на первый байт каждой инструкции, которая пыталась прочитать данные.
Когда происходит исполнение перехваченной инструкции, вызывается мой обработчик, который модифицирует контекст выполнения - подменяя читаемые значения на те, что были собраны во время оригинального инжекта. После этого исполнение оригинальной инструкции пропускается, и программа продолжает работу с моими старыми данными, которые были при оригинальном инжекте на другой системе.
Так что при следующем оригинальном инжекте в хуке NtContinue я сдампил весь KUSER_SHARED_DATA + HYPERVISOR_SHARED_DATA + CPUID.
Всего пришлось обработать около 10 000 инструкций. Особую сложность представляли циклические чтения в виртуализированном коде, где одна и та же инструкция могла читать многократно разные указатели - для них приходилось создавать специальные обработчики контекстно-зависимой подмены.
В итоге мы имеем следующее: стим модуль выделяет регионы чита по тем же адресам и записывает их туда же, проворачивая те же действия и с PEB. Далее запускается мой маппер, который ставит хуки, и создаёт поток на точку входа самого чита.
Спуфинг занимает достаточное количество времени (включая переадресацию импортов и логгирование), в следствие чего, я не мог подключиться к серверу (по факту это был обычный timeout): Посмотреть вложение 306335
Отключив избыточное логирование и оставив только информацию о важных вызовах, я наконец добился полной инициализации чита (которая занимает довольно много времени при спуфе всего, поэтому при зависании игры можно подумать, что чит просто встал на месте).
По такому логу я понял, что чит завершил авторизацию на сервере и начал устанавливать хуки, используя MinHook:
Авторизация на сервере
Как бы я не хвалил разносторонние привязки, вшитые в чит, я не могу похвалить точно также нетворкинг, который используется для авторизации.
Несмотря на то, что поверх реквестов используется SSL (WolfSSL?), сами запросы оказались донельзя простыми.
Маппер вшивает в секцию .rdata токен, который будет задействован при авторизации, допустим это будет строка "kFnUjuSNuEK7HBbtm7A".
Далее чит делает GET запрос с такими параметрами:
Код:
dump: GET /auth.php?cheat=1&loader_session_id=kFnUjuSNuEK7HBbtm7A
Выглядит так, что ~~на нормальный нетворкинг не хватило бюджета~~ нетворкинг здесь чисто для галки (и для получения ресурсов (пример запроса ниже)). Основной упор, по видимому, делался на сбор HWID.
Код:
dump: GET /data/legendware/cheat_resources/legendware.pak HTTP/1.1
Итоги
Хочу сказать сразу спасибо тем, кто остался в платных подписчиках несмотря на долгое отсутствие активности. Крякая нынешний Legendware я не ожидал увидеть что-то прикольное, но разработчики из "orion-security.pro" меня порадовали, хоть и не во всех аспектах :) Защита хороша, но своих проблем тоже достаточно, у меня, например, частенько были краши во время оригинального инжекта. Связано ли это с тем, что кто-то криво написал алгоритм с декриптом адресов, или же это последствия проваленных проверок, которые не должны были фейлиться - остаётся загадкой.