— Папа, я бежал за троллейбусом и сэкономил пять копеек!
— Сынок, бежал бы за такси — сэкономил бы пять рублей!


Сегодня я хочу рассказать вам, как сэкономить 10 тысяч долларов. А заодно, что гораздо менее интересно – научить перехватывать вызовы Win32 API функций, и не только. Хотя, в первую очередь – конечно, именно их.


Основные положения


Общеизвестных методов перехвата API-функций существует ровно два, все остальные – их вариации.

Идея первого метода основана на том, что вызовы любых функций в процессе из сторонних DLL выполняется через таблицу импорта функций. Данная таблица заполняется при загрузке DLL в процесс и в ней прописываются адреса всех импортируемых функций, которые процессу могут понадобиться. Соответственно, для того, чтобы перехватить вызов API-функции, необходимо найти таблицу импорта, в ней – функцию, которую мы хотим перехватить, сохранить хранящийся там адрес (тот самый указатель на тело функции) в какую-нибудь переменную (чтобы иметь возможность самостоятельно вызвать оригинал), после чего поместить туда указатель на свою функцию. Естественно, что это надо проделать для каждого модуля (exe или dll), который находится в процессе, так как таблица импорта у каждого из них своя. Кроме того, для реализации перехвата функций, которые вызываются с использованием механизм позднего связывания (late binding), следует аналогичным образом внедриться в таблицу экспорта модуля, который эту функцию экспортирует (на сей раз, только в одну), и произвести аналогичную замену. После того, следует запретить выгрузку своей DLL на время перехвата (например, DllCanUnloadNow должна возвращать false, либо сделать лишний Lock), дабы в процессе работы dll не была выгружена, адрес перехвата не стал невалиден и вы не получили access violation со всеми вытекающими.

Данный метод, в принципе, неоднократно описывался в соответствующей литературе, а готовые реализации можно найти, например, на RSDN [1], [2]. Поэтому, мы не будем на нем останавливаться.

Гораздо более интересен второй метод – перехват функции через code injection. Идея его тоже довольно примитивна, и неоднократно описывалась. Все, что нам нужно – это затереть первые несколько байт оригинального кода функции, вставив туда инструкцию безусловного перехода на нашу функцию-перехватчик, выполнить необходимую обработку, после чего, если нам понадобится вызывать оригинальную функцию – выполнить сначала код затертого начала функции, после чего сделать безусловный переход на тело оригинальной функц��и, пропустив, естественно, затертое начало.

Звучит это достаточно просто, однако, для человека, всю жизнь писавшего на высокоуровневых языках, может стать неразрешимой задачей. Проблема осложняется еще и тем, что готовых реализаций данного метода не существует ввиду определенны проблем, о которых я расскажу чуть далее. Хотя… конечно, я слегка лукавлю. У Microsoft существует целый фреймворк, который посвещен решению именно этой задачи. Называется он Microsoft Detours [3], легко гуглится и стоит 10 тысяч долларов за коммерческую версию.

Естественно, что за такие деньги его будут покупать только в том случае, ну если очень надо. А если надо не очень, но хочется – тогда подойдет моя реализация второго метода, которую я сейчас здесь опишу. Естественно, что реализация эта далеко не универсальна, но некоторые особенности Win32 API позволяют ей работать в наших приложениях и с успехом заменять дорогущий фреймворк.

Реализация метода code injection



Начнем с самого начала. Подготовим себе небольшой тестовый стенд, на котором мы будем проверять успешность наших действий. Это будет консольный проект на C++. Для разработки я буду использовать MS Visual Studio 2010 BETA, а вы можете корректировать мои действия в зависимости от используемой IDE.

Copy Source | Copy HTML
  1. int _tmain(int argc, _TCHAR* argv[])
  2. {
  3.         HANDLE hFile = CreateFile(L"d:\\test.txt", GENERIC_WRITE,  0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
  4.         CloseHandle(hFile);
  5.         return  0;
  6. }


Нашей задачей будет перехват функций CreateFile и CloseHandle.

Итак, начнем с самого начала. Запустим программу на выполнение, поставив breakpoint на функцию CreateFile. Как только программа остановится, в контекстном меню нашего кода выберем пункт Go To Disassembly. И вот что мы там увидим.

HANDLE hFile = CreateFile(L"d:\\test.txt", GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
01138A8B mov esi,esp
01138A8D push 0
01138A8F push 80h
01138A94 push 2
01138A96 push 0
01138A98 push 0
01138A9A push 40000000h
01138A9F push offset string L"d:\\test.txt" (11415B0h)
01138AA4 call dword ptr [__imp__CreateFileW@28 (114527Ch)]
01138AAA cmp esi,esp
01138AAC call @ILT+1000(__RTC_CheckEsp) (11313EDh)
01138AB1 mov dword ptr [hFile],eax


Теперь, нажимая F10, дойдем до инструкции call dword ptr [__imp__CreateFileW@28 (114527Ch)] – это, собственно, и есть вызов функции, и нажмем F11. Мы попадем в тело функции CreateFile.

76D60B7D mov edi,edi
76D60B7F push ebp
76D60B80 mov ebp,esp
76D60B82 push ecx
76D60B83 push ecx
76D60B84 push dword ptr [ebp+8]
76D60B87 lea eax,[ebp-8]
76D60B8A push eax
76D60B8B call dword ptr ds:[76D11568h]


Итак, что мы тут видим?

Первая команда – mov edi, edi – ни что иное, как двухбайтовый nop (not an operation). Смысл этой команды заключается в том, чтобы сожрать один такт процессора, не сделав ничего. Ну а заодно, занять два байта в коде. Казалось бы, какая расточительность, однако же, наличие этой инструкции нам очень на руку.

Дальнейшие ��ве команды занимают три байта и делают они следующее. Регистр esp, как известно, указывает на вершину стека, в котором хранятся все переданные в функцию через инструкцию push параметры. На вершине регистра esp (в ассемблере этот адрес записывается как [esp]) находится адрес точки возврата, который помещается туда инструкцией call (в нашем случае, это будет 0x01138AAA), а далее, вверх по стеку (стек, как известно, растет вниз) по адресу [esp + 4] находится имя файла, [esp + 8] — параметры открытия и так далее.

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

76D60B82 push ecx
76D60B83 push ecx


Эти инструкции просто резервируют 8 байт в стеке, то есть оставляют место для двух переменных типа DWORD. Этот вызов трактуется именно так, потому что функция использует соглашение вызова stdcall (то есть передает параметры через стек, а не через регистры, как, например, fastcall), а регистр ecx является регистром общего назначения, и, если функция не помещала в него какого-либо значения, то в нем может лежать любой мусор, который был оставлен там предыдущим кодом. Передавать в вышестоящую функцию какие-либо мусорные данные смысла нет, поэтому мы и трактуем этот вызов именно таким образом.

Однако, после выполнения инструкции push вершина стека esp переместится на 4 байта вниз, и [esp] будет указывать уже не на адрес точки возврата, а на положенное туда только что мусорное значение. То есть мы потеряем доступ к переданным в функцию переменным! Этого допустить нельзя, а потому делается следующая вещь.

76D60B7F push ebp
76D60B80 mov ebp,esp


В стеке сохраняется текущее значение регистра базы, а в регистр базы помещается текущее начение регистра стека. Теперь мы можем адресовать переданные в функцию переменные через регистр базы (по адресу [ebp] у нас лежит сохраненное значение регистра стека, [ebp + 4] – адрес точки возврата, [ebp + 8] – имя файла, и т.д.), свободно манипулируя со стеком.

Данная пара инструкций (push ebp / mov ebp, esp) называется стандартным прологом и имет свое зеркальное отражение – стандартный эпилог, который выглядит так:

pop ebp


Однако здесь мы его не найдем – он заменен на команду leave, которая делает то же самое.

76D60BC7 leave
76D60BC8 ret 1Ch


Последняя команда – это возврат из функции с извлечением из стека 0x1c байт, что требуется по соглашению stdcall, когда функция обязана сама очищать стек после окончания работы.

Проанализировав другие API-функции, можно понять, что все они начинаются абсолютно одинаково:

mov edi,edi
push ebp
mov ebp,esp


То есть, нам в 99% случаев для «бытового» перехвата гарантировано в начале функции 5 байт, которые мы можем спокойно заменить на свой код, а потом восстановить где-нибудь в другом месте. Это хорошо, значит предельный размер нашей инструкции перехода может быть 5 байт. Этого более, чем достаточно.

Итак, теперь мы разобрались, как происходит вызов функции и готовы к ее перехвату. Осталась одна деталь – а как же собственно сделать перехват?

Для этого, все что нам нужно – это поставить в начало функции инструкцию jmp с адресом, который будет указывать на начало нашей функции. Однако, не все так просто. Дело в том, что инструкция jmp, которая бы принимала абсолютный адрес нашей функции, размером 5 байт просто не существует. Единственный jump, который работает с абсолютными адресами – это jump far, который занимает 6 байт.

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

Copy Source | Copy HTML
  1. size_t _CalculateDispacement(void* lpFirst, void* lpSecond)
  2. {
  3.     return reinterpret_cast<char*>(lpSecond) - (reinterpret_cast<char*>(lpFirst) + 5);
  4. }


Обратившись к литературе, узнаем, что опкод функции jump near – это 0xe9. Таким образом, мы можем выполнить перехват следующим образом:

Copy Source | Copy HTML
  1. HANDLE WINAPI _My_CreateFileW(LPCWSTR lpFileName, DWORD dwDesiredAccess, DWORD dwShareMode, LPSECURITY_ATTRIBUTES lpSecurity, DWORD dwCreationDisp, DWORD dwFlags, HANDLE hTemplate)
  2. {
  3.     OutputDebugStringA(__FUNCTION__);
  4.     return (HANDLE)-1;
  5. }
  6.  
  7.  
  8. #pragma pack(push, 1)
  9. struct jump_near
  10. {
  11.     BYTE opcode; // 0xe9
  12.     DWORD relativeAddress;
  13. };
  14. #pragma pack(pop)
  15.  
  16. int _tmain(int argc, _TCHAR* argv[])
  17. {
  18.     HMODULE hKernel32 = GetModuleHandle(L"kernel32.dll");
  19.     jump_near* lpFunc = reinterpret_cast<jump_near*>(GetProcAddress(hKernel32, "CreateFileW"));
  20.     lpFunc->opcode = 0xe9;
  21.     lpFunc->relativeAddress = _CalculateDispacement(lpFunc, &_My_CreateFileW);
  22.  
  23.     HANDLE hFile = CreateFile(L"d:\\test.txt", GENERIC_WRITE,  0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
  24.     CloseHandle(hFile);
  25.     return  0;
  26. }


Для того, чтобы устранить паразитные влияния оптимизации компилятором структур для выравнивания секций данных, мы воспользуемся дерективой #pragma pack, которая, в нашем случае, выравнивает данные по границе байта (то есть не выравнивает вовсе).

Запускаем на выполнение, и… оп, access violation. Дело в том, что страницы кода, для защиты от переполнения буфера, защищены от записи.

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

DWORD dwProtect = PAGE_READWRITE;
VirtualProtect(lpFunc, sizeof(jump_near), dwProtect, &dwProtect);


А после вызова:

VirtualProtect(lpFunc, sizeof(jump_near), dwProtect, &dwProtect);


Запускаем на выполнение – и, вуаля, перехват осуществлен.

Теперь, существует вторая проблема – нам нужно вызывать оригинальную функцию. Для этого, мы должны сделать следующее:
1. Сохранить указатель на начало функции.
2. Создать в памяти блок размером 10 байт с правами на выполнение кода (без них, при попытке выполнить код, мы будем получать access violation из-за реализации системы защиты NX-Bit)
3. Скопировать туда первые 5 байт исходной функции до установки туда перехватчика.
4. Создать в последних 5 байтах аналогичную инструкцию jump near, которая будет переправлять выполнение функции на оригинальный обработчик, пропуская затертые нами 5 байт.
5. Сохранить адрес 10-байтового блока и привести его к типу CreateFileWProc, который описан следующим образом:
typedef HANDLE (WINAPI *CreateFileWProc)(LPCWSTR, DWORD, DWORD, LPSECURITY_ATTRIBUTES, DWORD, DWORD, HANDLE);
6. Теперь, если нам нужно вызывать оригинал – мы просто пользуемся этим указателем.

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

pastebin.com/5gZdr6Hm (заголовочный файл Detours.h)
pastebin.com/RCJ896TM (реализация Detours.cpp)

Я же, вкратце, расскажу, как это в итоге работает.

Подключим оба файла в наш проект, определим пару перехватчиков и запустим код на исполнение с breakpoint на CreateFile.

Copy Source | Copy HTML
  1. #include "Detours.h"
  2.  
  3. typedef HANDLE (WINAPI *CreateFileWProc)(LPCWSTR, DWORD, DWORD, LPSECURITY_ATTRIBUTES, DWORD, DWORD, HANDLE);
  4. typedef BOOL (WINAPI* CloseHandleProc)(HANDLE);
  5.  
  6. CreateFileWProc _Std_CreateFileW;
  7. CloseHandleProc _Std_CloseHandle;
  8.  
  9. HANDLE WINAPI _My_CreateFileW(LPCWSTR lpFileName, DWORD dwDesiredAccess, DWORD dwShareMode, LPSECURITY_ATTRIBUTES lpSecurity, DWORD dwCreationDisp, DWORD dwFlags, HANDLE hTemplate)
  10. {
  11.     OutputDebugStringA(__FUNCTION__);
  12.     return _Std_CreateFileW(lpFileName, dwDesiredAccess, dwShareMode, lpSecurity, dwCreationDisp, dwFlags, hTemplate);
  13. }
  14.  
  15. BOOL WINAPI _My_CloseHandle(HANDLE handle)
  16. {
  17.     OutputDebugStringA(__FUNCTION__);
  18.     return _Std_CloseHandle(handle);
  19. }
  20.  
  21. int _tmain(int argc, _TCHAR* argv[])
  22. {
  23.     HMODULE hKernel32 = GetModuleHandle(L"kernel32.dll");
  24.     void* lpFunc = GetProcAddress(hKernel32, "CreateFileW");
  25.     Detours::HookFunction(lpFunc, _My_CreateFileW, reinterpret_cast<void**>(&_Std_CreateFileW));
  26.     lpFunc = GetProcAddress(hKernel32, "CloseHandle");
  27.     Detours::HookFunction(lpFunc, _My_CloseHandle, reinterpret_cast<void**>(&_Std_CloseHandle));
  28.     HANDLE hFile = CreateFile(L"d:\\test.txt", GENERIC_WRITE,  0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
  29.     CloseHandle(hFile);
  30.     return  0;
  31. }


По привычке, зайдем в Disassembly, дойдем до инструкции

000D8AD0 call dword ptr [__imp__CreateFileW@28 (0E527Ch)]


И нажмем F11.

Куда мы попали?

76D60B7D jmp _My_CreateFileW (0D13E8h)


Это же код перехода, установленный нашим перехватчиком. Значит, что перехват успешно выполнен!

Нажмем F10 пару раз (перескочив еще чере один промежуточный буфер, который ставит компилятор в DEBUG-версиях), и…

HANDLE WINAPI _My_CreateFileW(LPCWSTR lpFileName, DWORD dwDesiredAccess, DWORD dwShareMode, LPSECURITY_ATTRIBUTES lpSecurity, DWORD dwCreationDisp, DWORD dwFlags, HANDLE hTemplate)
{
000D8910 push ebp
000D8911 mov ebp,esp
000D8913 sub esp,0C0h
000D8919 push ebx
000D891A push esi
000D891B push edi
000D891C lea edi,[ebp-0C0h]
000D8922 mov ecx,30h
000D8927 mov eax,0CCCCCCCCh
000D892C rep stos dword ptr es:[edi]


Теперь, самый интересный момент. Дойдем до вызова оригинальной функции.

000D8960 call dword ptr [_Std_CreateFileW (0E4240h)]


И нажем F11.

00060000 mov edi,edi
00060002 push ebp
00060003 mov ebp,esp
00060005 jmp 76D60B82


Мы попали в так называемый трамплин – это кусок кода, который выполняет замещенные нами операции и передает управление оригинальной функции. Дойдем до jmp, нажмем F10 и увидим чудную картину.

76D60B7D jmp _My_CreateFileW (0D13E8h)
76D60B82 push ecx
76D60B83 push ecx


На сей раз мы проскочили инструкцию jmp и попали сразу на первую значимую инструкцию – push ecx. Значит, все работает так, как надо.

Потенциальные проблемы и возможности модернизации



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

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

Для создания в памяти функции-трамплина нельзя использовать операторы new и delete для работы с динамической памятью, так как память выделяется в секции данных с запретом на исполнение кода, а изменением прав на динамическую память вы открываете недоброжелателям возможность выполнить переполнение буфера. Сейчас программа работает нерационально, выделяя 4 кб памяти под каждый перехватчик – это связано с тем, что такой размер является минимальным для аллокации виртуальной памяти. По идее, нужно написать собственный менеджер памяти и использовать его. MS Detours так и поступает.

Однако, то, что я написал – вполне рабочий код, который пригодится в том случае, если ну очень надо, а денег нет. Отсутствие табличного анализатора в нем можно заменить анализатором собственным – для этого надо демопилировать требуемые функции и проанализировать их код, после чего добавить сигнатуры в _Analyze. А 4 кб памяти на перехватчик, если в программе 5-6 перехватчиков – не так много.

Использованная литература


1. Барри Брей. Микропроцессоры Intel. Архитектура, программирование и интерфейсы. Шестое издание. «БХВ-Петербург», 2005