MHook vs Zuma

в 7:38, , рубрики: Mhook, велосипеды, ненормальное программирование, метки: ,

MHook vs ZumaТут не совсем про зуму, и не совсем про mhook. Дело в том, что я сделал небольшую обертку над mhook (чтобы помочь своему труду), хотел бы показать что получилось, как я его использую, и получить немного конструктивной критику. А чтобы не использовать синтетических примеров, пойду по накатанной, и поиздеваюсь над zuma. Рассказывать я буду в такой последовательности: сначала пара слов (действительно мало) о том как перехватывает mhook, потом немного о том как я это использовал, затем опишу что я все-таки сделал, и закончу тем, что вживлю пару электродов в любимую жабку. Так что сами можете решать что вам интересно, и соответственно, с чего начинать читать.

В начале, как и общал, пара слов про Mhook. Mhook — это библиотека для внедрения своего кода в поток кода чужого приложения. На хабре уже неоднократно поднимался вопрос инъекции кода, поэтому если кому интересно детальное рассмотрение вопроса, может поискать про это на сайте. Если коротко, то для перехвата кода убираются 5 байт по адресу, который будет перехватываться и вместо них записывается код безусловного перехода (jmp #addr) на функцию перехвата. А те 5 байт, которые убрали, переносят в специально выделенное место, которое еще трамплином называется. Когда функция перехвата отработает, она может сделать безусловный переход на трамплин, там выполнятся те самые 5 сохраненных байт и произойдет переход на перехваченный код. Так это будет выглядеть схематически:

MHook vs Zuma

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

Ужасный ужас

// функция-обработчик перехваченных данных
void onProc(DWORD backTrace, DWORD arg1) {
     printf("%d %d", backTrace, arg1);
}
 
// промежуточная функция перехвата
__declspec(naked) void hookProc() {
// сохранить регистры
     pushad
// забираю данные, которые мне нужно
      __asm {
            push [esp+0x20]
            pop backTrace 
            push [esp+0x24]
            pop arg1
     };
// вызываю обработчик
     onProc(backTrace, player);     
     __asm {
// восстановить регистры и перейти к трамплину на перехваченную функцию
          popad
          jmp realProc
     };
}
// установить перехват
Mhook_SetHook(&(PVOID&)realProc, hookProc);

Как видно, выглядит оно не очень. Пользоваться тоже так себе, всё время лениво делать новый перехват, потому что приходится писать все эти обвязки. Я решил выкинуть эту самую промежуточную функцию (hookProc). А в функцию-обработчик передавать значение регистров в виде с++ класса. Я назвал этот класс Context. Он содержит все регистры общего назначения, и обеспечивает к ним доступ. Для полного счастья мне нужны такие функции:

Context context;
// можно считать значение из регистра
DWORD var = context.EAX;
// а можно записать в регистр
context.EAX = var
// можно прочитать значение из памяти. 
// Индексом может быть как константа
var = context[0xBEDABEDA]
// Так и регистр
var = context[EAX]
// а еще должна быть возможность записывать в память
context[0xBEDABEDA] = var
context[EAX] = var
// и чтобы арифметические операции над регистрами выполнять можно было
context.EAX = context.EAX + context.EBX * 4
// и чтобы индексом могло быть это арифметическое выражение 
context[context.EAX + context.EBX * 4] = var
// и не совсем очевидное, но мне почему-то захотелось.
// чтобы смещение от регистра можно было указывать в квадратных скобочках
var = context.ESI[0x20]

Функция из предыдущего примера приймет следующий вид:

void onProc(Context context) {
     DWORD backTrace = context[esp]
     DWORD arg1 = context[esp+4]
     printf("%d %d", backTrace, arg1);
}
RegisterHook(realProc, hookProc);

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

Как же это всё попадает в программу? Я уже описал выше, как работает mhook. Я добавил еще один этап:

; сохранить регистры
pushad
; передать указатель на них в функцию-обработчик
push esp
; вызвать функцию-обработчик
call hook
; очистить стек
add  esp, 4
; восстановить регистры
popad
; перейти к трамплину на реальную функцию
jmp trampoline

Этот код хранится в строке "x60x54xE8x00x00x00x00x83xC4x04x61xE9x00x00x00x00". Когда нужно установить хук, выделяется память, туда копируется строка, в оператор call подставляется смещение функции-обработчика, в оператор jmp — смещение трамплина. pushad, кроме того, что сохраняет регистры, так сказать, подготавливает данные для функциии-обработчика. Если функция-обработчик изменит эти данные, то изменения применятся командой popad. Не уверен, что так понятно. Попробую на картинках показать.

MHook vs Zuma

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

А теперь — десерт! Использование этого всего в реальных условиях, на игре про жабу и шарики. Задачу возьму полегче. Например, сделать чтобы вознаграждение за шарики увеличилось в 10 раз.
Для начала, пробую найти где же хранится счет. Чтобы было интереснее, я не использую отладчик и утилиты типа Art Money.

В Zuma, как и во многих казуальных играх, счет кратен десятку или сотне. Насколько я знаю, это делают потому, что счет с нолем в конце почему-то воспринимается игроками как более «приятный», и повышает удовольствие от игры. И я делаю странное умозаключение, прямо как в анекдоте про Ваньку-Косого. Если счет кратен десяти, то где-то его делят. И, по-идее, должны применять для этого целочисленное деление, так как счет — целое число и всегда будет делится нацело на десять. Можно перехватить все эти операции деления, записывать делимое и адрес, где это деление происходит, а потом сравнивать со счетом. Это упрощается тем, что компилятор оптимизирует целочисленное деление на десять, заменяя умножением на константу 0x66666667 с последующим сдвигом вправо(если интересно, можно почитать об этом в книге «Алгоритмические трюки для программистов» глава 10.3). Примерно так:

mov     eax, 66666667h ; магическое число для деления на 2,5
imul    ecx                      ;EDX:EAX ← EAX (в edx - результат деления на 2,5)
sar     edx, 2                   ; результат делится на 4. получается edx = ecx / 10

Еще одна удача — то что mov eax, 66666667 занимает ровно пять байт и легко заменяется jmp или call. Обхожу в цикле секцию TEXT, ищу oпкоды операции(0xB8, 0x67, 0x66, 0x66, 0x66) и заменяю их на вызов ловушки. Так как деление на десять — довольно популярная операция, показывать все операции которые попали в ловушку абсолютно не информативно, и крайне отрицательно влияет на скорость выполнения игры. Нужно фильтровать так — чтобы один из операндов был равен текущему счету. А откуда узнать счет, если я как раз его ищу? Нужно выработать ряд правил по которым будет проходить тестирование, чтобы можно было предсказать каким будет счет в определенный момент времени.
1) Игра начинается со счетом равным нолю
2) Каждое нажатие мыши — выстрел
3) Каждый выстрел — результативный
4) Каждый выстрел приносит тридцать очков
5) Следствие из второго правила: так как перед игрой приходится сделать несколько нажатий мыши, то подсчет очков программой перехвата должен начинаться с момента, только после того как игра стартовала и уже можно собирать последовательности из шариков.
После того как правила сформулированы, становится понятно, что нужно поставить ловушку на оконную процедуру игры. Тогда будет известно о всех нажатиях на кнопку мыши, и можно будет просигнализировать о начале игры, нажав кнопку на клавиатуре.
Узнать адрес оконной процедуры не сложно, для этого нужно посмотреть аргументы функции RegisterClass в IDA. Вот как будет выглядеть ловушка оконной процедуры с учетом всех требований:

void onMainProc(Context *context) {
// кнопкой 0 на нумпаде переключается состояние: идет ли подсчет очков или нет
      if ((context->ESP[0x08] == WM_KEYUP) && (context->ESP[0x0C] == VK_NUMPAD0)) {
            if (state == kCounting) {
                  state = kNotCounting;
            } else {
                  state = kCounting;
            }                 
      }
// Если идет подсчет, и нажата левая кнопка мыши, то ожидаемый счет увеличивается
      if ((state == kCounting) && (context->ESP[0x08] == WM_LBUTTONUP)) {
            score += 30;
      }
}
 
// А так будет выглядит ловушка для деления
void onDivision(Context *context) {
      char buf[512];
      if (state == kCounting) {
// Если один из возможных делимых равен предполагаемому счету, то в отладочный вывод отправляется адрес, по которому происходит деление, и значения регистров
            if ((context->EBX == score) || (context->ECX == score) || (context->EDX == score)) {
                  sprintf(buf, "%x: EBX=%d, ECX=%d, EDX=%d", context->ESP[0], context->EBX, context->ECX, context->EDX);
                  OutputDebugStringA(buf);
            }
      }
// Вместо этого кода был вставлен jmp, поэтому нужно его выполнить
      context->EAX = 0x66666667;
}

На видео показано, как я пытаюсь отыскать адрес (довольно интересная модификация правил получилась. «Zuma в поддавки»).

www.youtube.com/watch?v=fS3-u_rQG-o

После того, как адрес найден, изучаю функцию, которой он принадлежит.

MHook vs Zuma

Та часть, где происходит деление, выполняет подсчет разрядов в сумме очков, которую игрок набрал с начала уровня. А вообще функция, по-видимому отвечает за обновления счетчика очков после уничтожения последовательности шариков. В нее передается два аргумента. Первый аргумент — указатель на какую-то структуру (может быть сам счетчик). По смещению 0x18C находится текущее количество очков, а по смещению 0x190 находится количество очков на начало уровня. Второй аргумент — текущее количество очков. Чтобы узнать откуда оно берется — поднимаюсь вверх по стеку. Сразу же обнаруживаю, что текущий счет передается в функции из некоторой структуры, где он находится по смещению 0x104. Чуть повыше обнаруживаю операцию add [esi + 0x104], edi. Очевидно, что это то что я ищу, и если установить здесь ловушку, то можно оказывать некоторое влияние на количество очков, получаемых за комбинацию. Код ловушки крайне простой. Регистр EDI увеличивается в десять раз, давая нам увеличенное количество очков за успешную комбинацию

void onAddScore(Context *context) {
context->EDI = context->EDI * 10;
}

Вроде всё. Осталось извиниться за большой интервал между постами и конские иллюстрации (если кто подскажет как их лучше разместить то большое спасибо).

А, и код, конечно же. Но это на большого любителя, для ознакомления и за последствия я не в ответе.

Автор: k_d


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js