— Папа, я бежал за троллейбусом и сэкономил пять копеек!
— Сынок, бежал бы за такси — сэкономил бы пять рублей!
Сегодня я хочу рассказать вам, как сэкономить 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 позволяют ей работать в наших приложениях и с успехом заменять дорогущий фреймворк.
Начнем с самого начала. Подготовим себе небольшой тестовый стенд, на котором мы будем проверять успешность наших действий. Это будет консольный проект на C++. Для разработки я буду использовать MS Visual Studio 2010 BETA, а вы можете корректировать мои действия в зависимости от используемой IDE.
Нашей задачей будет перехват функций CreateFile и CloseHandle.
Итак, начнем с самого начала. Запустим программу на выполнение, поставив breakpoint на функцию CreateFile. Как только программа остановится, в контекстном меню нашего кода выберем пункт Go To Disassembly. И вот что мы там увидим.
Теперь, нажимая F10, дойдем до инструкции call dword ptr [__imp__CreateFileW@28 (114527Ch)] – это, собственно, и есть вызов функции, и нажмем F11. Мы попадем в тело функции CreateFile.
Итак, что мы тут видим?
Первая команда – mov edi, edi – ни что иное, как двухбайтовый nop (not an operation). Смысл этой команды заключается в том, чтобы сожрать один такт процессора, не сделав ничего. Ну а заодно, занять два байта в коде. Казалось бы, какая расточительность, однако же, наличие этой инструкции нам очень на руку.
Дальнейшие ��ве команды занимают три байта и делают они следующее. Регистр esp, как известно, указывает на вершину стека, в котором хранятся все переданные в функцию через инструкцию push параметры. На вершине регистра esp (в ассемблере этот адрес записывается как [esp]) находится адрес точки возврата, который помещается туда инструкцией call (в нашем случае, это будет 0x01138AAA), а далее, вверх по стеку (стек, как известно, растет вниз) по адресу [esp + 4] находится имя файла, [esp + 8] — параметры открытия и так далее.
В стеке же хранятся локальные переменные, которые используются самой функцией. Если вы внимательно посмотрите на код, то увидите две инструкции.
Эти инструкции просто резервируют 8 байт в стеке, то есть оставляют место для двух переменных типа DWORD. Этот вызов трактуется именно так, потому что функция использует соглашение вызова stdcall (то есть передает параметры через стек, а не через регистры, как, например, fastcall), а регистр ecx является регистром общего назначения, и, если функция не помещала в него какого-либо значения, то в нем может лежать любой мусор, который был оставлен там предыдущим кодом. Передавать в вышестоящую функцию какие-либо мусорные данные смысла нет, поэтому мы и трактуем этот вызов именно таким образом.
Однако, после выполнения инструкции push вершина стека esp переместится на 4 байта вниз, и [esp] будет указывать уже не на адрес точки возврата, а на положенное туда только что мусорное значение. То есть мы потеряем доступ к переданным в функцию переменным! Этого допустить нельзя, а потому делается следующая вещь.
В стеке сохраняется текущее значение регистра базы, а в регистр базы помещается текущее начение регистра стека. Теперь мы можем адресовать переданные в функцию переменные через регистр базы (по адресу [ebp] у нас лежит сохраненное значение регистра стека, [ebp + 4] – адрес точки возврата, [ebp + 8] – имя файла, и т.д.), свободно манипулируя со стеком.
Данная пара инструкций (push ebp / mov ebp, esp) называется стандартным прологом и имет свое зеркальное отражение – стандартный эпилог, который выглядит так:
Однако здесь мы его не найдем – он заменен на команду leave, которая делает то же самое.
Последняя команда – это возврат из функции с извлечением из стека 0x1c байт, что требуется по соглашению stdcall, когда функция обязана сама очищать стек после окончания работы.
Проанализировав другие API-функции, можно понять, что все они начинаются абсолютно одинаково:
То есть, нам в 99% случаев для «бытового» перехвата гарантировано в начале функции 5 байт, которые мы можем спокойно заменить на свой код, а потом восстановить где-нибудь в другом месте. Это хорошо, значит предельный размер нашей инструкции перехода может быть 5 байт. Этого более, чем достаточно.
Итак, теперь мы разобрались, как происходит вызов функции и готовы к ее перехвату. Осталась одна деталь – а как же собственно сделать перехват?
Для этого, все что нам нужно – это поставить в начало функции инструкцию jmp с адресом, который будет указывать на начало нашей функции. Однако, не все так просто. Дело в том, что инструкция jmp, которая бы принимала абсолютный адрес нашей функции, размером 5 байт просто не существует. Единственный jump, который работает с абсолютными адресами – это jump far, который занимает 6 байт.
Поэтому, мы будем использовать jump near, который принимает относительный адрес (то есть разность между адресом точки назначения и следующей за jump near инструкцией). По факту, для вычисления параметра операции jump near, надо из адреса точки назначения вычесть адрес исходной точки и прибавить 5 байт (именно столько эта инструкция занимает).
Обратившись к литературе, узнаем, что опкод функции jump near – это 0xe9. Таким образом, мы можем выполнить перехват следующим образом:
Для того, чтобы устранить паразитные влияния оптимизации компилятором структур для выравнивания секций данных, мы воспользуемся дерективой #pragma pack, которая, в нашем случае, выравнивает данные по границе байта (то есть не выравнивает вовсе).
Запускаем на выполнение, и… оп, access violation. Дело в том, что страницы кода, для защиты от переполнения буфера, защищены от записи.
Однако, не все так плохо. Они защищены снаружи, а мы работаем изнутри, а потому можем обойти этот механизм, воспользовавшись функцией VirtualProtect. Поставим перед записью опкода вызов:
А после вызова:
Запускаем на выполнение – и, вуаля, перехват осуществлен.
Теперь, существует вторая проблема – нам нужно вызывать оригинальную функцию. Для этого, мы должны сделать следующее:
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.
По привычке, зайдем в Disassembly, дойдем до инструкции
И нажмем F11.
Куда мы попали?
Это же код перехода, установленный нашим перехватчиком. Значит, что перехват успешно выполнен!
Нажмем F10 пару раз (перескочив еще чере один промежуточный буфер, который ставит компилятор в DEBUG-версиях), и…
Теперь, самый интересный момент. Дойдем до вызова оригинальной функции.
И нажем F11.
Мы попали в так называемый трамплин – это кусок кода, который выполняет замещенные нами операции и передает управление оригинальной функции. Дойдем до jmp, нажмем F10 и увидим чудную картину.
На сей раз мы проскочили инструкцию jmp и попали сразу на первую значимую инструкцию – push ecx. Значит, все работает так, как надо.
К сожалению, код не универсален – он определяет возможность перехвата по наличию стандартного пролога WinAPI. Построить универсальное решение сложно – в начале функции в общем случае могут быть совершенно любые инструкции, включая инструкции косвенной адресации, которые при перенесении придется корректировать. Microsoft Detours решает эту проблему наличием табличного дизассемблера и корректора инструкций.
Кроме того, если размер функции меньше 5 байт, перехват просто невозможен. Такие функции иногда встречаются, но мне никогда не попадались задачи, которые требуют их перехвата. Microsoft Detours в этом случае складывает лапки.
Для создания в памяти функции-трамплина нельзя использовать операторы new и delete для работы с динамической памятью, так как память выделяется в секции данных с запретом на исполнение кода, а изменением прав на динамическую память вы открываете недоброжелателям возможность выполнить переполнение буфера. Сейчас программа работает нерационально, выделяя 4 кб памяти под каждый перехватчик – это связано с тем, что такой размер является минимальным для аллокации виртуальной памяти. По идее, нужно написать собственный менеджер памяти и использовать его. MS Detours так и поступает.
Однако, то, что я написал – вполне рабочий код, который пригодится в том случае, если ну очень надо, а денег нет. Отсутствие табличного анализатора в нем можно заменить анализатором собственным – для этого надо демопилировать требуемые функции и проанализировать их код, после чего добавить сигнатуры в _Analyze. А 4 кб памяти на перехватчик, если в программе 5-6 перехватчиков – не так много.
1. Барри Брей. Микропроцессоры Intel. Архитектура, программирование и интерфейсы. Шестое издание. «БХВ-Петербург», 2005
— Сынок, бежал бы за такси — сэкономил бы пять рублей!
Сегодня я хочу рассказать вам, как сэкономить 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
- int _tmain(int argc, _TCHAR* argv[])
- {
- HANDLE hFile = CreateFile(L"d:\\test.txt", GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
- CloseHandle(hFile);
- return 0;
- }
Нашей задачей будет перехват функций 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
- size_t _CalculateDispacement(void* lpFirst, void* lpSecond)
- {
- return reinterpret_cast<char*>(lpSecond) - (reinterpret_cast<char*>(lpFirst) + 5);
- }
Обратившись к литературе, узнаем, что опкод функции jump near – это 0xe9. Таким образом, мы можем выполнить перехват следующим образом:
Copy Source | Copy HTML
- HANDLE WINAPI _My_CreateFileW(LPCWSTR lpFileName, DWORD dwDesiredAccess, DWORD dwShareMode, LPSECURITY_ATTRIBUTES lpSecurity, DWORD dwCreationDisp, DWORD dwFlags, HANDLE hTemplate)
- {
- OutputDebugStringA(__FUNCTION__);
- return (HANDLE)-1;
- }
-
-
- #pragma pack(push, 1)
- struct jump_near
- {
- BYTE opcode; // 0xe9
- DWORD relativeAddress;
- };
- #pragma pack(pop)
-
- int _tmain(int argc, _TCHAR* argv[])
- {
- HMODULE hKernel32 = GetModuleHandle(L"kernel32.dll");
- jump_near* lpFunc = reinterpret_cast<jump_near*>(GetProcAddress(hKernel32, "CreateFileW"));
- lpFunc->opcode = 0xe9;
- lpFunc->relativeAddress = _CalculateDispacement(lpFunc, &_My_CreateFileW);
-
- HANDLE hFile = CreateFile(L"d:\\test.txt", GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
- CloseHandle(hFile);
- return 0;
- }
Для того, чтобы устранить паразитные влияния оптимизации компилятором структур для выравнивания секций данных, мы воспользуемся дерективой #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
- #include "Detours.h"
-
- typedef HANDLE (WINAPI *CreateFileWProc)(LPCWSTR, DWORD, DWORD, LPSECURITY_ATTRIBUTES, DWORD, DWORD, HANDLE);
- typedef BOOL (WINAPI* CloseHandleProc)(HANDLE);
-
- CreateFileWProc _Std_CreateFileW;
- CloseHandleProc _Std_CloseHandle;
-
- HANDLE WINAPI _My_CreateFileW(LPCWSTR lpFileName, DWORD dwDesiredAccess, DWORD dwShareMode, LPSECURITY_ATTRIBUTES lpSecurity, DWORD dwCreationDisp, DWORD dwFlags, HANDLE hTemplate)
- {
- OutputDebugStringA(__FUNCTION__);
- return _Std_CreateFileW(lpFileName, dwDesiredAccess, dwShareMode, lpSecurity, dwCreationDisp, dwFlags, hTemplate);
- }
-
- BOOL WINAPI _My_CloseHandle(HANDLE handle)
- {
- OutputDebugStringA(__FUNCTION__);
- return _Std_CloseHandle(handle);
- }
-
- int _tmain(int argc, _TCHAR* argv[])
- {
- HMODULE hKernel32 = GetModuleHandle(L"kernel32.dll");
- void* lpFunc = GetProcAddress(hKernel32, "CreateFileW");
- Detours::HookFunction(lpFunc, _My_CreateFileW, reinterpret_cast<void**>(&_Std_CreateFileW));
- lpFunc = GetProcAddress(hKernel32, "CloseHandle");
- Detours::HookFunction(lpFunc, _My_CloseHandle, reinterpret_cast<void**>(&_Std_CloseHandle));
- HANDLE hFile = CreateFile(L"d:\\test.txt", GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
- CloseHandle(hFile);
- return 0;
- }
По привычке, зайдем в 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