Создаем jump на thunk
Написать перехватчик можно и на чистом Си, но настоящие хакеры так не поступают, да и не интересно это. Мы будем писать на ассемблере! Для упрощения интеграции перехватчика с кодом защищаемой программы используем ассемблерные вставки. Microsoft Visual C++ поддерживает спецификатор naked ("голый"), запрещающий компилятору самовольничать. "Обнаженные" функции не содержат ни пролога, ни эпилога, компилятор даже не вставляет ret— все это мы должны сделать самостоятельно. Если, конечно, захотим.
Лучшего средства для создания защитных механизмов, пожалуй, и не придумаешь! Подробное описание naked-функций можно найти в SDK (см. раздел "Naked Function Calls"), нам же достаточно знать, что naked-функции объявляются как __declspec(naked) function_name(), придерживаются cdecl-соглашения (аргументы передаются справа налево и удаляются из стека материнской функцией) и не формируют стековый фрейм, то есть смещения локальных переменных и аргументов нам придется рассчитывать самостоятельно.
Рисунок 2 голые функции на MSDN
Попробуем в качестве разминки создать процедуру, внедряющуюся в начало некоторой API-функции и передающую управление на thunk. Проще всего это сделать с помощью команды jmp thunk, однако, при этом значение thunk придется рассчитывать вручную, поскольку в x86 процессорах команда jmp ожидает не само смещение, а разницу между смещением целевого адреса и концом команды jmp. Вычислить-то его несложно, просто отнял/прибавил — и все, но… это же нужно высаживаться на самомодифицрующийся код, что не входит в наши планы. Лучше пойти другим путем, обратившись к конструкции mov reg32, offset thunk/jmp reg32. Это на один байт длиннее и к тому же портит регистр reg32, однако, это не страшно. Все API-функции придерживаются соглашения stdcall, то есть принимают аргументы через стек, а возвращают значение через регистровую пару [edx]:eax, то есть значение eax при входе в функцию не играет никакой роли и может быть безболезненно искажено.
А вот с "лишним" байтом все значительно сложнее. Некоторые (впрочем, очень немногочисленные функции) состоят из одного jmp xxx, следом за которым расположен другой jmp. Естественно, это не сами функции, это просто линкер сформировал таблицу переходов, но нам-то от этого не легче! К тому же, иногда встречаются функции короче пяти байт (например, GetCurrentProcess) и внедрить в них jmp (даже без mov) уже невозможно!
.text:77E956D7 ; HANDLE GetCurrentProcess(void)
.text:77E956D7 public GetCurrentProcess
.text:77E956D7 GetCurrentProcess proc near ; CODE XREF: UnhandledExceptionF
.text:77E956D7 83 C8 FF or eax, 0FFFFFFFFh
.text:77E956DA C3 retn
.text:77E956DA GetCurrentProcess endp
.text:77E956DA
.text:77E956DB ; Exported entry 315. GetModuleHandleA
.text:77E956DB
.text:77E956DB ; HMODULE __stdcall GetModuleHandleA(LPCSTR lpModuleName)
.text:77E956DB public GetModuleHandleA
.text:77E956DB GetModuleHandleA proc near ; CODE XREF: .text:77E815D6^p
.text:77E956DB
.text:77E956DB lpModuleName = dword ptr 8
.text:77E956DB
.text:77E956DB 55 push ebp
.text:77E956DC 8B EC mov ebp, esp
Листинг 1 API-функция GetCurrentProcess занимает всего 4 байта и внедрить в нее jump, не испортив начала следующей функции уже невозможно (на самом деле — можно, но сложно! в GetCurrrentProcess мы пишем push esp/push esp/push esp/push esp/push esp, а в GetModeleHandleA внедряем jump на sub_thunk, анализирующий что находится на вершине стека — если там четыре esp, то был вызван GetCurrentProcress, в противном случае это GetModuleHandleA)
Ладно, не будем высаживаться на измену. Все это заморочки. В общем случае, перехват работает вполне корректно и простейший перехватчик выглядит так:
#define JUMP_SZ 0x6 // размер jump
__declspec( naked
) jump() // "голая" функция без пролога и эпилога
{
__asm
{
mov eax, offset thunk ; заносим в eax
смещение нашего thunk'а
jmp eax ; передаем на него управление
}
}
Листинг 2 код, внедряющийся в начало API-функции и передающий управление на thunk
Единственная проблема — как определить его длину? Должны же мы знать сколько байт копировать? Оператор sizeof возвращает размер указателя на функцию, но отнюдь не размер самой функции. Какие еще есть пути? Можно, например, определить размер jump'а вручную (в данном случае он равен 6 байтам) или расположить за его концом фиктивную процедуру fictonic(). Разница смещений fictonic() и jump() в общем случае и будет размером jump. Почему в общем случае? Да потому, что компилятор не подписывался всегда размещать функции в порядке их объявления (хотя чаще всего все происходит именно так). Но это еще что! Если откомпилировать программу с ключом /Zi (отладочная информация), компилятор будет возвращать совсем не адрес функции jump(), а указатель на "переходник", расположенный совсем в другом месте!
.text:00401019 loc_401019: ; DATA XREF: .text:loc_40106Dvo
.text:00401019 ; _main+39vo
.text:00401019 jmp _jump
.text:00401019 ; --------------------------------------------------------------------
.text:0040101E dd 8 dup(0CCCCCCCCh)
.text:0040103E align 10h
.text:00401040
.text:00401040 _thunk proc near ; CODE XREF: .text:loc_401005^j
.text:00401040 ;
.text:0040106D ; [мыщъх поскипал]
.text:0040106D ;
.text:0040106D _thunk endp;
.text:00401081; ---------------------------------------------------------------------
.text:00401081
.text:00401081 _jump proc near ; CODE XREF: .text:loc_401019^j
.text:00401081 mov eax, offset loc_401005
.text:00401086 jmp eax
.text:00401086 _jump endp
.text:00401086
Листинг 3 при компиляции с ключом /Zi компилятор MS VC вместо указателя на саму функцию возвращает указатель на переходник, что есть бэд (правда, можно написать простейший анализатор, распознающий jmp и вычисляющий эффективный адрес функции, но это же лишний код!)
Переходник и указатель разделяют целых 68h байт, а в некоторых случаях и побольше. Кошмар! Даже если копировать с "запасом" мы все равно уйдем лесом и рухнем в прорубь. Тем не менее, без ключа /Zi все работает вполне нормально, а отлаживать программу можно и в машинных кодах.