Пользователь
- Статус
- Оффлайн
- Регистрация
- 20 Янв 2022
- Сообщения
- 248
- Реакции
- 69
Как взламывалась INIURIA — версия 2.0: VMP, тайм-чеки и kernel-прокладка
Всем шалом, народ.
Некоторое время назад по сети гулял разбор защиты Iniuria, в котором @colby57 описывал довольно примитивные проверки под виртуализацией Oreans и нехитрые фокусы с ReadProcessMemory. С тех пор проект обновился, стафф решил, что пора выходить из 2010 года, и натянул на чит VMP 3.9+. Но, как выяснилось, даже современный протектор при неправильном применении не спасает от рук реверсера. В этом тексте я расскажу, что изменилось в защите после того легендарного слива, с чем пришлось столкнуться мне (cisco) и почему все крики «кряк — фейк, там майнер и ратник» — это лишь стадия принятия неизбежного.
Некоторое время назад по сети гулял разбор защиты Iniuria, в котором @colby57 описывал довольно примитивные проверки под виртуализацией Oreans и нехитрые фокусы с ReadProcessMemory. С тех пор проект обновился, стафф решил, что пора выходить из 2010 года, и натянул на чит VMP 3.9+. Но, как выяснилось, даже современный протектор при неправильном применении не спасает от рук реверсера. В этом тексте я расскажу, что изменилось в защите после того легендарного слива, с чем пришлось столкнуться мне (cisco) и почему все крики «кряк — фейк, там майнер и ратник» — это лишь стадия принятия неизбежного.
Связь лоадера и драйвера
Здесь суть осталась прежней. Драйвер Iniuria по-прежнему требует TESTSIGNING, отключает PatchGuard и служит мостом между Ring-3 лоадером и целевым процессом. Взаимодействие идёт через вызов ReadProcessMemory с магическим хендлом 0xFFFF1337, где:
- lpBaseAddress — код команды.
- lpBuffer — указатель на входные/выходные данные.
- lpNumberOfBytesRead — статус (1 — успех, 0 или 2 — fail).
Драйвер перехватывает NtReadVirtualMemory в ядре, проверяет хендл и выполняет запрошенную операцию: аллокация памяти под чит, запись модуля, парсинг адресов, вызов EntryPoint. Процесс лоадера после инициализации скрывается из списка через перехваченный NtQuerySystemInformation, а регион с читом в игре пропадает из видимости Ring-3 (вероятно, через манипуляции с VAD или NtQueryVirtualMemory).
Способ вызова EntryPoint всё так же не использует стандартные CreateRemoteThread, Thread Hijacking или APC — скорее всего, по-прежнему подменяется указатель в Steam Overlay Present.
Что нового: в текущей версии добавился четвёртый RPM-запрос на после основных проверок. Теперь последовательность ожидаемых ответов от драйвера выглядит так:
Способ вызова EntryPoint всё так же не использует стандартные CreateRemoteThread, Thread Hijacking или APC — скорее всего, по-прежнему подменяется указатель в Steam Overlay Present.
Что нового: в текущей версии добавился четвёртый RPM-запрос на после основных проверок. Теперь последовательность ожидаемых ответов от драйвера выглядит так:
- 0x4D4 — проверка присутствия.
- 0x4D5 — дополнительный handshake.
- 0x4D4 — проверка присутствия.
- 0x4D4 — проверка присутствия.
В моём прокси-хендлере это решается тривиально — добавляем ещё один else if с нужными байтами. Никакой магии.
Защита внутри чита: встречаем VMP 3.9+ и грабли виртуализации
Главное изменение, которое сразу бросается в глаза — переход с Oreans Code Virtualizer на VMProtect 3.9+. Это не «простая виртуалка с предсказуемым VMEXIT», а полноценный мутатор кода с агрессивной обфускацией и виртуализацией критических секций.Трансформация CMP в мусор
В старом разборе автор использовал хук на инструкции cmp reg32, reg32 и cmp mem32, reg32, чтобы отслеживать, что именно сравнивает чит. В новой версии этот фокус не пройдёт. VMP перемалывает условные переходы в кашу:
Код:
cmp A, B → sub A, B
sub A, B → not(add(B, not(A)))
В результате в виртуализированном потоке вы не увидите ни одного прямого cmp. Вместо этого — цепочки арифметических операций над регистрами виртуальной машины, которые нужно отслеживать через эмуляцию или продвинутый символьный анализ. Я пошёл проще: вместо охоты за сравнениями перехватил все точки обращения к системным структурам и API, на которых базируются проверки. VMP не может скрыть вызов нативного импорта — здесь он вынужден выходить из виртуализации.
Двойной вызов NtQuerySystemInformation и NtQueryInformationProcess: лоадер против чита
Одной из самых интересных находок стала агрессивная проверка окружения через NtQuerySystemInformation и NtQueryInformationProcess. Чит запрашивает множество классов информации, причём дважды на разных этапах:
- Лоадер (внешний процесс) на этапе загрузки собирает информацию о системе через те же API. Все полученные структуры он хеширует и отправляет на сервер маппера. Тот в ответ присылает прошитые в билд чита эталонные значения, которые будут использоваться для сравнения.
- Чит после инжекта повторно вызывает те же самые системные запросы, причём два раза:
- Первый вызов — получение актуальных данных окружения.
- Второй вызов — уже находясь глубоко в мутированном и виртуализированном коде VMP, где сравнивает свежесобранные данные с эталонными, пришедшими от сервера.
Вот выдержка из моего лога перехвата NtQuerySystemInformation:
Код:
[ nt_query_system_information ] SystemProcessorPerformanceInformation -> 0x2068f1c3e0e
[ nt_query_system_information ] SystemVdmInstemulInformation -> 0x2068f1c3e0e
[ nt_query_system_information ] SystemObjectInformation -> 0x2068f1c3e0e
...
[ nt_query_system_information ] SystemProcessInformation -> 0x2068f1c3e0e
...
[ nt_query_system_information ] SystemHandleInformation -> 0x2068f1c3e0e
[ nt_query_system_information ] SystemModuleInformation -> 0x2068f1c3e0e
...
Обрати внимание на адрес 0x2068f1c3e0e (и иногда 0x2068f1c3df0) — это возвратный адрес обратно в код чита. После завершения системного вызова управление возвращается именно сюда, в мутированную секцию VMP, где и происходит сравнение полученных данных с эталонными хешами от сервера.
Таким образом, просто подменить буфер на лету недостаточно — нужно вернуть именно те данные, которые были на момент оригинального инжекта и зашиты сервером. Я сдампил эталонные ответы для каждого класса информации и подсовываю их через хук. Для корректной работы мутированных сравнений дополнительно фиксирую регистры в момент VMEXIT, чтобы чит думал, что он сам всё честно прочитал.
Таким образом, просто подменить буфер на лету недостаточно — нужно вернуть именно те данные, которые были на момент оригинального инжекта и зашиты сервером. Я сдампил эталонные ответы для каждого класса информации и подсовываю их через хук. Для корректной работы мутированных сравнений дополнительно фиксирую регистры в момент VMEXIT, чтобы чит думал, что он сам всё честно прочитал.
Проверка времени и анти-хук GetTickCount
Время всё так же валидируется через GetTickCount и KUSER_SHARED_DATA, но теперь с двумя важными усложнениями.
- Проверка на хук на GetTickCount. После возврата из API виртуализированный код проверяет, не был ли подменён адрес функции в IAT или не стоит ли инлайн-хук. Делается это путём чтения первых байт по адресу GetTickCount и сравнения их с эталоном. Если обнаружено расхождение — чит молча завершает инициализацию без каких-либо ошибок, и продолжает выполнение.
- VMPCALL: GetTickCount, вызывается внутри виртуализированного кода.
Обход анти-хука потребовал более тонкого подхода. Я использовал Vectored Exception Handler (VEH). Установил аппаратную точку останова на инструкцию ret внутри GetTickCount (благо адрес возврата известен заранее). В момент срабатывания VEH я читаю значение RAX, в котором уже лежит оригинальный результат функции, и принудительно заменяю его на эталонное значение, которое чит ожидает увидеть. Таким образом, сама функция не тронута (первые байты целы), но данные в регистре подменяются уже после её выполнения. Никаких инлайн-хуков, чисто работа с контекстом потока.
KUSER_SHARED_DATA: циклическая прогулка по странице
Если в старых версиях чит просто дёргал пару полей из KUSER_SHARED_DATA по прямым смещениям, то теперь разработчики усложнили подход. Они решили не ограничиваться точечными чтениями, а пройтись по всему региону размером 0x1000 байт, читая его по 4 байта за итерацию в цикле. Причём это происходит внутри самого мутационного и виртуализированного кода VMP.
В моём случае инструкции выглядели примерно так:
Комбинации регистров и смещений меняются от сборки к сборке, но суть одна: VMP генерирует сложные адресные выражения, чтобы усложнить анализ. Цикл проходит по всей странице KUSER_SHARED_DATA, аккумулируя прочитанные DWORD'ы в стеке виртуальной машины.
Зачем это нужно? Чтобы поймать любые попытки подмены отдельных значений в структуре. Если раньше можно было хукнуть только KUSD + 0x324 и KUSD + 0x320, то теперь чит собирает цифровой «отпечаток» всего региона. Любое расхождение хотя бы в одном байте — и проверка провалена.
Обход этой гадости потребовал перехвата каждого обращения к памяти в диапазоне KUSER_SHARED_DATA. Я сделал это через VEH с фильтрацией по адресу инструкции чтения. Как только VMP выходит на чтение из нужного региона, я подменяю возвращаемое в регистр значение на эталонный DWORD, заранее подсчитанный с оригинальной системы.
В моём случае инструкции выглядели примерно так:
Код:
mov r11d, [rcx + rdx*2 + 0x123]
Зачем это нужно? Чтобы поймать любые попытки подмены отдельных значений в структуре. Если раньше можно было хукнуть только KUSD + 0x324 и KUSD + 0x320, то теперь чит собирает цифровой «отпечаток» всего региона. Любое расхождение хотя бы в одном байте — и проверка провалена.
Обход этой гадости потребовал перехвата каждого обращения к памяти в диапазоне KUSER_SHARED_DATA. Я сделал это через VEH с фильтрацией по адресу инструкции чтения. Как только VMP выходит на чтение из нужного региона, я подменяю возвращаемое в регистр значение на эталонный DWORD, заранее подсчитанный с оригинальной системы.
C++:
interception::add( 0x1E9B8D22799 , 0x8,
[]( PCONTEXT context ) -> int {
uint32_t ptr;
memcpy( ( void* ) &ptr, ( void* ) context->Rdx, 0x4 );
printf( "{0x%p, 0x%p},", reinterpret_cast< void* >( context->Rdx - ( uintptr_t )
0x7ffe0000 ), ptr );
context->R11 = reinterpret_cast< DWORD64 >( ( void* ) ptr );
return interception::e_callback_status_handled;
} );
Проверки имени компьютера, CPUID и PID
Они остались без значительных изменений, за исключением того, что теперь все они обёрнуты в VMP и вызываются не напрямую, а через обфусцированные переходники. Но конечный результат тот же:
- GetComputerNameA возвращает хеш имени.
- CPUID с листами 0x00000000-0x80000004 даёт строку процессора.
- TEB->ClientId.UniqueProcess сравнивается с оригинальным PID.
Спуфятся они аналогично предыдущему разбору: подмена результата GetComputerNameA, перехват CPUID и возврат эталонных регистров, патч поля UniqueProcess в TEB текущего потока (поскольку маппер и чит работают в одном контексте).
Все проверки пройдены, чит распаковывается и работает как оригинал.
Все проверки пройдены, чит распаковывается и работает как оригинал.
Заключение
Разработчики Iniuria действительно проделали работу над ошибками: ушли от дырявого Oreans к VMP 3.9+, усложнили проверки окружения через множественные вызовы NtQuerySystemInformation и NtQueryInformationProcess, добавили анти-хук на временные функции и циклическое чтение KUSER_SHARED_DATA в виртуализированном коде. Однако ключевая архитектурная ошибка осталась прежней — критическая логика проверок выполняется в пользовательском режиме и опирается на данные, которые можно подменить до того, как они попадут в виртуализированный код. VMP хорош для защиты алгоритмов, но не для защиты от подмены системной информации.
Время, потраченное на обход обновлённой защиты, оказалось чуть больше, чем в предыдущей версии, но всё равно измерялось днями, а не неделями. Так что слухи о «непробиваемости» нового Iniuria слегка преувеличены.
Так же. я специально не указал о еще одной проверке, которая вызывается самой первой, ведь, при попытке кряка, она не сразу покажет себя.
Если у вас есть вопросы, идеи или вы хотите обсудить технические детали — пишите в мой телеграм-канал:
Ещё увидимся!
— cisco
Время, потраченное на обход обновлённой защиты, оказалось чуть больше, чем в предыдущей версии, но всё равно измерялось днями, а не неделями. Так что слухи о «непробиваемости» нового Iniuria слегка преувеличены.
Так же. я специально не указал о еще одной проверке, которая вызывается самой первой, ведь, при попытке кряка, она не сразу покажет себя.
Если у вас есть вопросы, идеи или вы хотите обсудить технические детали — пишите в мой телеграм-канал:
Пожалуйста, авторизуйтесь для просмотра ссылки.
. Там же иногда появляются интересные сниппеты и заметки по другим проектам.Ещё увидимся!
— cisco
Последнее редактирование:
вроде норм