Site icon Заметки разработчика

Перехват вызова функций заменой байтов заголовка инструкциями JMP или CALL

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

Суть метода в прописывании инструкции JMP или CALL в первые несколько (5 байт, если прописывается инструкция перехода или вызова с 32-битным смещением относительно EIP) байт заголовка функции с адресом функции перехвата.

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

 Оригинальная функция:
  004013E0 55                   push        ebp
  004013E1 8B EC                mov         ebp,esp
  004013E3 81 EC F4 00 00 00    sub         esp,0F4h
  004013E9 53                   push        ebx
  004013EA 56                   push        esi

 После модификации:
  004013E0 E9 xx xx xx xx       jmp         HookFunction
  004013E5 F4 00 00 00          db          0F4h, 0, 0, 0   ; Байты остались от
                                                      ; частично перезаписанной
                                                      ; инструкции "sub esp,0F4h"
  004013E9 53                   push        ebx
  004013EA 56                   push        esi

Если оригинальную функцию вызывать не нужно, то проблем нет. Просто нужно подготовить массив байт инструкций перехода, а далее, используя метод, который я описал в статье Самомодифицирующиеся программы, прописать их в заголовок функции. Если же вызывать оригинальную функцию необходимо, то все гораздо сложнее…

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

 Вентиль вызова:
  XXXXXXX0 55                   push        ebp
  XXXXXXX1 8B EC                mov         ebp,esp
  XXXXXXX3 81 EC F4 00 00 00    sub         esp,0F4h
  XXXXXXX9 E9 xx xx xx xx       jmp         4013E9h

 Вызов оригинального обработчика:
  HookFunction(...)
  {
    ...
    PFNORIFN pfn = (PFNORIFN)0xXXXXXXX0; // Адрес вентиля вызова
    pfn(...);
    ...
  }

Если первые байты кода заголовка содержат инструкцию перехода относительно EIP, то смещения нужно пересчитать. Ну, а в случае, если использовалась команда близкого перехода, преобразовать инструкцию в команду дальнего перехода.

 Оригинальная функция с командами перехода в заголовке:
  77f6054e 39 c8                cmp     eax,ecx
  77f60550 74 02                je      77f60554h
  77f60552 50                   push    eax
  77f60553 52                   push    edx
  77f60554 33 c0                xor     eax,eax

 Вентиль вызова в этом случае:
  XXXXXXX0 39 c8                cmp     eax,ecx
  XXXXXXX2 8B EC                je      XXXXXXXA
  XXXXXXX4 50                   push    eax
  XXXXXXX5 E9 xx xx xx xx       jmp     77f60553h
  XXXXXXXA E9 xx xx xx xx       jmp     77f60554h

Как видно из приведенных примеров, для создания вентиля вызова необходимо дизассемблирование «на лету» заголовка функции, а также определенные умения ассемблера для пересчета смещений и создания команд длинных переходов. Ну, естественно, это нужно, если не закладываться на некий неизменный вид заголовка. Как показывает опыт, закладываться на фиксированный вид заголовка весьма опасно. Рано или поздно код перестанет работать.

Сохранение значений регистров

Автор данной статьи писал код по перехвату этим методом вызовов функций драйверов ядра Windows 98 и Windows ME. Некоторые, думаю, еще помнят такие операционные системы, а, возможно, и знают, что драйвера для них писались на ассемблере и параметры передавались в регистрах. Напомнил я об этом, чтобы обратить внимание на то, что, если функция перехвата пишется на языке высокого уровня, оригинальную функцию нужно вызывать, и оригинальная функция чувствительна на входе к флагам процессора и содержимому регистров, которые выбранный язык высокого уровня считает «мусорными», то всего перечисленного не достаточно для корректной работы. Необходимо еще сохранять регистры и флаги на входе в новый обработчик, уметь их устанавливать в нужные значения перед вызовом оригинальной функции, а также перед возвратом в вызывающий код.

    mov     eax, 1
    call    IntercepringFunc        // Вызов функции, которую мы перехватываем
    ...

IntercepringFunc:
    jmp     HookFunction
    ...

HookFunction:
    сохранение регистров и флагов в некую структуру
    ...
    изменение значений в этой структуре, если необходимо
    ..
    вызов оригинальной функции через расширенный вентиль вызова (CallValveEx)
    ...
    восстановление регистров и флагов
    возврат в вызывающую функцию

CallValveEx:
    восстановить регистры и флаги из данных структуры
    вызов оригинальной функции
    обновление значений регистров и флагов в данных
    возврат в HookFunction

Заключение

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

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

Exit mobile version