Как стать автором
Обновить

Перехват вызовов API-функций

Время на прочтение 11 мин
Количество просмотров 73K
— Папа, я бежал за троллейбусом и сэкономил пять копеек!
— Сынок, бежал бы за такси — сэкономил бы пять рублей!


Сегодня я хочу рассказать вам, как сэкономить 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
Теги:
Хабы:
+84
Комментарии 72
Комментарии Комментарии 72

Публикации

Истории

Работа

Программист C++
128 вакансий
QT разработчик
15 вакансий

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн