Задача монитора проста — по сути он наблюдатель, хотя может выступать и в качестве управляющего, кстати один из вариантов перевода «Monitor» — наставник :). Задачи его так же очевидны, отдавать набор данных, на основе которых можно произвести анализ ситуации и сделать соответствующие выводы.
В современном ПО мониторы встречаются практически повсеместно, ну для примера тот же Punto Switcher — классический пример легального монитора. Практически все антивирусы являются мониторами, профилировщики, ну и я уж не говорю об основном нашем инструментарии — отладчике, который так же является монитором.
С обратной стороны баррикад выступают целые кучи зловредного ПО, некоторые из которых так же предпочитают используют мониторинг для достижения своих основных целей. Впрочем не о них сейчас…
В интернете на данный момент существует просто невероятное количество примеров программных мониторов функций, правда в большинстве случаев рассматривается перехват правкой таблицы импорта, либо установка перехватчика в начале перехватываемой функции (т.н. сплайсинг). Но не смотря на доступность этих материалов иногда они не помогают разработчикам, так как написаны слишком (скажем так) заумным языком, либо вообще представляют из себя кусок вырванного из контекста кода, непонятный для не подкованного в предметной области разработчика.
Так случилось и у меня, за последний месяц ко мне обратилось несколько человек с вопросом как правильно реализовать перехватчик и ссылки на примеры им практически не помогли, пришлось все разжевывать с нуля. Зато теперь я в курсе основных ошибок, встречающихся у людей, только начинающих разбираться с методиками перехвата 🙂
В итоге, дабы не объяснять в следующий раз все заново, я решил сделать обзорную статью где попробую рассказать все максимально простым языком о том, «как это работает» 🙂
1. Суть монитора
Основная задача монитора любым доступным ему способом узнать о передаче управления к контролируемой им функции.
Для этого используются различные варианта перехвата вызовов оригинальной функции при помощи которых передается управление на «обработчик перехвата» (ну или «перехватчик», кому как удобнее).
Что делать с перехваченной функцией далее — зависит от реализации перехватчика. Можно вывести в лог параметры ее вызова, ну и по желанию прочие параметры, например ее результаты, состояние стека вызовов и т.п. Главное перехватить управление на себя.
Могу утверждать что вы практически гарантированно ранее сами писали перехватчики функций. Этому очень способствует концепция ООП. Любой класс, в котором вы перекрываете виртуальные или динамические методы, уже можно назвать находящимся под наблюдением вашего монитора. Ведь в действительности перекрытые через override методы являются непосредственно конечными обработчиками перехваченных функций, причем нам даже не приходится задумываться о том, как оно там внутри на самом деле работает и вызов оригинального метода у нас даже не вызовет затруднений, для этого есть штатный вызов inherited. За нас уже все сделал компилятор.
Но нам придется копнуть немного глубже. Например со статическими методами такой фокус уже не пройдет и если есть необходимость в таком перекрытии, придется все делать самостоятельно. Правда, прежде чем перейти непосредственно к мониторингу, необходимо разобраться с реализацией обработчика перехвата.
2. Правильная декларация обработчика перехвата
Прежде чем приступить к перехвату какой либо функции, необходимо точно знать параметры ее вызова и соглашение вызова. То есть если мы хотим перехватить к примеру MessageBoxA, то перехватчик, который будет работать вместо оригинальной функции, должен иметь следующий вид:
function InterceptedMessageBoxA(hWnd: HWND; lpText, lpCaption: PAnsiChar; uType: UINT): Integer; stdcall; begin // TODO... end;
То есть параметры обработчика перехвата и соглашение вызова (stdcall/cdecl и т.п.) должны в точности совпадать с оригинальной функцией. Если этого не сделать, то практически гарантированно будет ошибка после вызова обработчика.
Правильная декларация обработчика нужна прежде всего для удобства работы. Можно конечно написать и вот такой обработчик:
procedure InterceptedFunc; begin // TODO... end;
Но в этом случае получать параметры вызова и производить правильную финализацию вызова с учетом соглашений и количества параметров придется самостоятельно при помощи ассемблерных вставок.
Небольшой нюанс будет с реализацией перехватчика функций/процедур, реализованных в виде методов класса.
Допустим мы перехватываем TApplication.MessageBox.
Если обработчик перехвата реализован в виде метода класса, то его декларация будет выглядеть как у оригинальной функции:
function TTestClas.InterceptedApplicationMessageBox( const Text, Caption: PChar; Flags: Longint): Integer; begin // TODO... end;
Если же перехватчик является самостоятельной функцией, то его реализация будет выглядеть немного по другому:
function InterceptedApplicationMessageBox( Self: TObject; const Text, Caption: PChar; Flags: Longint): Integer; begin // TODO... end;
Такие различия в декларации обработчика обусловлены тем, что у методов класса самым первым параметром идет не явно декларируемая переменная Self.
Кстати если вы попробуете в первом перехватчике получить имя класса у данной переменной, например вот так:
function TTestClas.InterceptedApplicationMessageBox( const Text, Caption: PChar; Flags: Longint): Integer; begin ShowMessage(Self.ClassName); end;
… то выведется текст TApplication, а не TTestClass, т.к. параметр Self будет содержать в себе данные об оригинальном классе, а не о классе в котором реализован перехватчик.
В принципе это все что нужно знать о декларации обработчика перехвата, теперь можно приступить к рассмотрению различных способов перехвата функций.
3. Сабклассинг оконной процедуры
Я долго выбирал откуда начать техническую часть и в итоге решил остановиться на документированных методиках. Действительно, зачем изобретать очередной велосипед — проще погнуть тот, что есть.
Не думаю что для вас будет секретом то, что VCL по сути является просто оберткой над API. Большинство визуальных элементов на форме, да и сама форма является окном, у которого помимо прочих параметров присутствует оконная процедура. Если у программиста появляется задача, требующая изменения стандартного поведения окна, он применяет сабклассинг, подменяя обработчик оконной процедуры на собственный, где и реализует требуемый ему функционал.
Методика достаточно распространенная, причем при реализации собственных элементов управления в Delphi вы практически всегда встречаетесь с результатом перекрытия оконной процедуры всех ваших окон на глобальный обработчик TWinControl.MainWndProc. Это действительно достаточно удобное решение, позволяющее нам в коде перекрывать определенные сообщения через указание message + константа сообщения, работать с виртуальной WndProc и DefaultHandler и прочее…
Общее описание данного метода можно найти по данной ссылке: Subclassing a Window
Алгоритм реализации можно представить в виде пяти пунктов:
- получение адреса оригинальной оконной процедуры
- сохранение его в любом доступном для обработчика месте
- назначение нового обработчика оконной процедуры
- отработка вызова перехватчика (логирование/изменение параметров вызова и т.п.)
- по необходимости получение адреса старого обработчика и его вызов.
В виде кода все выглядит достаточно просто.
unit uSubClass; interface uses Windows, Messages, Classes, Controls, Forms, StdCtrls; type TForm1 = class(TForm) Button1: TButton; procedure Button1Click(Sender: TObject); end; var Form1: TForm1; implementation {$R *.dfm} function MainFormSubclassProc(hwnd: THandle; uMsg: UINT; wParam: WPARAM; lParam: WPARAM): LRESULT; stdcall; var OldWndProc: Pointer; begin if uMsg = WM_WINDOWPOSCHANGING then PWindowPos(lParam)^.flags := PWindowPos(lParam)^.flags or SWP_NOSIZE or SWP_NOMOVE; OldWndProc := Pointer(GetWindowLong(hwnd, GWL_USERDATA)); Result := CallWindowProc(OldWndProc, hwnd, uMsg, wParam, lParam); end; procedure TForm1.Button1Click(Sender: TObject); var OldWndProc: THandle; begin OldWndProc := GetWindowLong(Handle, GWL_WNDPROC); SetWindowLong(Handle, GWL_USERDATA, OldWndProc); SetWindowLong(Handle, GWL_WNDPROC, Integer(@MainFormSubclassProc)); MessageBox(Handle, 'Изменение размеров формы и ее позиции заблокировано', PChar(Application.Title), MB_ICONINFORMATION); end; end.
В данном примере подменяется оконная процедура главной формы приложения.
Адрес старой процедуры сохраняется в пользовательском буфере окна, доступ к которому осуществляется через константу GWL_USERDATA.
При вызове нового обработчика MainFormSubclassProc проверяется код сообщения. Если это сообщение о изменении размеров или координат окна, то данная возможность блокируется выставлением флагов SWP_NOSIZE и SWP_NOMOVE.
Попробуйте запустить данное приложение и нажать на кнопку после чего попытайтесь изменить размеры главной формы. У вас это не получится.
Небольшой нюанс: в данном коде нет проверки на двойное перекрытие. Если вы нажмете на кнопку второй раз, то получите ошибку о переполнении стека. Это вызвано тем что при втором перекрытии оконной процедуры, при получении адреса старой вернется тот же адрес обработчика MainFormSubclassProc и соответственно при первом же вызове CallWindowProc вы войдем в бесконечный цикл, т.к. будем вызывать сами себя, выход из которого произойдет по переполнению.
Применение: Данный вариант перехвата может применяться только для окон. Для диалогов необходимо использовать константу DWL_DLGPROC.
В итоге: в рамках концепции монитора, в качестве контролируемой функции здесь у нас выступает старая оконная процедура, в качестве монитора новый обработчик MainFormSubclassProc в котором мы следим за всеми данными приходящими к контролируемой функции. В нем мы может проводить логирование всех параметров, а так же управлять поведением старого обработчика подменяя данные перед его вызовом или вообще не вызывать его и реализовать собственное поведение.
К сожалению данный код будет работать только для окон нашего приложения. Ну точнее мы конечно можем получить хэндл окна из чужого приложения и назначить ему новый перехватчик таким же способом, как показано выше, но это не приведет ни к чему хорошему, т.к. в этом случае мы укажем адрес нового обработчика, который расположен в нашем адресном пространстве, а в чужом по этому адресу будет совершенно другой код (ну либо вообще данный участок памяти может быть не выделен) что может привести к скоропостижной кончине чужого процесса.
Чтобы такого не произошло, нужно каким либо образом разместить код перехватчика в чужом процессе и только после этого делать подмену. Делается это несколькими способами, но на них мы остановимся немного позднее…
4. Перехват правкой VMT таблицы.
В принципе это очень редко встречающийся вариант перехвата, но раз уж я решил рассказать о всех возможных способах, то его тоже придется рассмотреть 🙂
Вероятно вы встречались с ситуациями когда поведение определенного элемента управления вас полностью устраивает, но вот немножко чего-то не хватает. Для решения проблемы обычно приходится писать наследника проблемного класса, в котором перекрывать соответствующие виртуальные методы, где писать желательное поведение контрола. Но если сильно уж лениво этого можно добиться и без реализации наследника, просто перекрыв необходимый метод снаружи.
В следующем примере я покажу как можно перекрыть метод TForm.CanResize который вызывается при изменении размеров формы. Задача кода, не дать изменить размеры формы по ширине больше чем 500 пикселей.
Конечно в этом примере целая куча минусов, во первых для достижения поставленной цели можно было перекрыть обработчик OnResize, во вторых, так как перекрывается CanResize от TForm, то эта модификация повлияет на все формы в проекте, но на то он и пример — его задача просто показать данную возможность.
Применение: Данный вариант перехвата может использоваться только для виртуальных методов классов, причем только в рамках собственного PE файла. Если данный код поместить в библиотеку и попробовать перехватить таким образом метод у основного приложения — ничего не выйдет, так как TForm приложения и TForm библиотеки это разные классы.
Код выглядит следующим образом:
unit uVMT; interface uses Windows, Classes, Controls, Forms, StdCtrls; type TForm1 = class(TForm) Button1: TButton; procedure Button1Click(Sender: TObject); end; var Form1: TForm1; implementation {$R *.dfm} function NewCanResizeHandler(Self: TObject; var NewWidth, NewHeight: Integer): Boolean; begin Result := True; if NewWidth > 500 then NewWidth := 500; end; procedure TForm1.Button1Click(Sender: TObject); var VMTAddr: PPointer; OldProtect: Cardinal; begin asm mov eax, Self mov eax, [eax] // получаем указатель на VMT таблицу add eax, VMTOFFSET TForm.CanResize // получаем адрес указателя на TForm.CanResize mov VMTAddr, eax end; // снимаем блокировку модификации страницы VirtualProtect(VMTAddr, 4, PAGE_EXECUTE_READWRITE, OldProtect); try // назначаем новый обработчик VMTAddr^ := @NewCanResizeHandler; finally // возвращаем атрибуты страницы VirtualProtect(Pointer(VMTAddr), 4, OldProtect, OldProtect); FlushInstructionCache(GetCurrentProcess, VMTAddr, 4); end; if Width > 500 then Width := 500; MessageBox(Handle, 'Максимальная ширина формы установлена в 500 пикселей', PChar(Application.Title), MB_ICONINFORMATION); end; end.
Теперь поподробнее:
1. Декларация обработчика перехвата выполнена с учетом нюансов, описанных в втором разделе данной статьи (появился параметр Self).
2. Для перехвата используется документированная директива VMTOFFSET, описанная в справке Delphi.
Вот кусочек с ее описанием:
Two additional directives allow assembly code to access dynamic and virtual method: VMTOFFSET and DMTINDEX.
VMTOFFSET retrives the offset in bytes of the virtual method pointer table entry of the virtual method argument from the beginning of the virtual method table (VMT). This directive needs a fully specified class name with a method name as a parameter, for example,TExample.VirtualMethod.
Как ясно из описания, ее задача вернуть смещение на адрес виртуального метода относительно начала таблицы виртуальных методов (VMT). На начало VMT таблицы указывает непосредственно параметр Self. То есть, если изобразить это в виде кода, то получится следующее:
VMT := Pointer(Self)^;
3. После того как при помощи ассемблерной вставки получен указатель на адрес метода, производится его подмена на адрес нового обработчика виртуального метода.
4. Так как подмена производится в коде приложения, который обычно расположен в странице памяти у которой отсутствуют права на запись (обычно это права чтение и исполнение), то перед назначением нового обработчика выставляются права на запись.
Ну а теперь, запустите пример и проверьте его работу.
Как видите, по сути мы выполнили практически все те же шаги, что и в первом примере, т.е. получили адрес контролируемой функции, и заменили его на адрес нового обработчика, в котором происходит управление параметрами оригинального метода. Код для вызова оригинального обработчика (аналог inherited) я приводить не буду, т.к. все же данный метод перехвата показан только для расширения кругозора и крайне не желателен для реализации в боевом приложении 🙂
Если у вас возникли затруднения с пониманием принципа правки VMT, то могу порекомендовать для чтения данную статью, за авторством Hallvard Vassbotn: Method calls compiler implementation.
Либо ее перевод, любезно предоставленный Александром Алексеевым: Реализация вызовов методов компилятором.
Вариант с правкой динамических методов класса я рассматривать так же не буду — но принцип примерно похож.
5. Перехват правкой таблицы импорта
Что такое таблица импорта. Когда вы пишете приложение и вызываете API функции приложение должно каким-то образом вычислить адрес данной функции, для того чтобы передать управление на нее. Большинство функций объявлены статически, ну для примера:
{$EXTERNALSYM MessageBox} function MessageBox(hWnd: HWND; lpText, lpCaption: PChar; uType: UINT): Integer; stdcall; ... function MessageBox; external user32 name 'MessageBoxA';
Здесь явно указывается полная декларация вызова функции MessageBox и соглашения о ее вызове. Данная информация требуется компилятору для правильного выравнивания стека при вызове функции. А так же указано имя библиотеки в которой функция реализована и имя, под которым она экспортируется.
Как видите адреса функции здесь нет, да и быть не может, ведь библиотека, экспортирующая функцию может быть подгружена по любому адресу и в большинстве случаев для каждого из процесса адрес одной и той же функции будет разным. Правда тут есть небольшой нюанс, библиотеки user32.dll, kernel32.dll и ntdll.dll грузятся для всех процессов по фиксированному адресу (по крайней мере в 32-битных системах) но все равно уповать на некий статический адрес не стоит.
При компиляции приложения, информация о статически объявленных функциях размещается в теле таблицы импорта. В момент запуска приложения загрузчик анализирует данную таблицу, подгружает указанные в ней библиотеки, на основании таблицы экспорта библиотеки узнает реальный адрес функции, который помещает в соответствующее поле таблицы импорта приложения.
Общее описание принципа работы таблицы импорта можно узнать из статьи Мэтта Питрека: Peering Inside the PE: A Tour of the Win32 Portable Executable File Format.
На RSDN доступен ее перевод: Форматы РЕ и COFF объектных файлов.
Если потребуется более подробная информация, можно изучить следующую статью: PE. Урок 6. Таблица импорта за авторством Iczelion-а.
Теперь, что можно сделать с этой информацией? Тут все просто, для назначения обработчика перехвата потребуется всего лишь изменить рассчитанный загрузчиком адрес на адрес обработчика, после чего перехват можно считать состоявшимся.
Применение: Данный вариант перехвата применяется только для API функций со статической линковкой.
Для поиска адреса оригинальной функции и его замены на адрес перехватчика напишем вот такую функцию:
function ReplaceIATEntry(const OldProc, NewProc: FARPROC): Boolean; var ImportEntry: PImageImportDescriptor; Thunk: PImageThunkData; Protect: DWORD; ImageBase: Cardinal; DOSHeader: PImageDosHeader; NTHeader: PImageNtHeaders; begin Result := False; if OldProc = nil then Exit; if NewProc = nil then Exit; ImageBase := GetModuleHandle(nil); // Получаем адрес таблицы импорта DOSHeader := PImageDosHeader(ImageBase); NTHeader := PImageNtHeaders(DWORD(DOSHeader) + DWORD(DOSHeader^._lfanew)); ImportEntry := PImageImportDescriptor(DWORD(ImageBase) + DWORD(NTHeader^.OptionalHeader.DataDirectory[ IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress)); // Бежим по записям таблицы ... while ImportEntry^.Name <> 0 do begin Thunk := PImageThunkData(DWORD(ImageBase) + DWORD(ImportEntry^.FirstThunk)); // ... пока таблица не кончится ... while Pointer(Thunk^._function) <> nil do begin // ... или не найдем нужную нам запись. if Pointer(Thunk^._function) = OldProc then begin // Производим подмену if VirtualProtect(@Thunk^._function, SizeOf(DWORD), PAGE_EXECUTE_READWRITE, Protect) then try // Можно вот так... //Thunk^._function := DWORD(NewProc); // ... но лучше атомарно. InterlockedExchange(Integer(Thunk^._function), Integer(NewProc)); Result := True; finally VirtualProtect(@Thunk^._function, SizeOf(DWORD), Protect, Protect); FlushInstructionCache(GetCurrentProcess, @Thunk^._function, SizeOf(DWORD)); end; end else Inc(PAnsiChar(Thunk), SizeOf(TImageThunkData32)); end; ImportEntry := Pointer(Integer(ImportEntry) + SizeOf(TImageImportDescriptor)); end; end;
Она производит правку таблицы импорта в теле текущего исполняемого файла, о чем говорит код получения базы образа GetModuleHandle(nil). Непосредственно установка адреса перехватчика выполнена атомарно, при помощи вызова InterlockedExchange. Это достаточно тонкий момент, впрочем о причинах именно такого способа изменения адреса перехватчика я остановлюсь немного позже.
Теперь осталось только написать пример вызова данной функции.
Создайте новый проект, разместите на главной форме кнопку, пропишите реализацию функции ReplaceIATEntry после чего добавьте следующий код:
var OrigAddr: Pointer = nil; function InterceptedMessageBoxA(Wnd: HWND; lpText, lpCaption: PAnsiChar; uType: UINT): Integer; stdcall; type TOrigMessageBoxA = function(Wnd: HWND; lpText, lpCaption: PAnsiChar; uType: UINT): Integer; stdcall; var S: AnsiString; begin S := AnsiString('Function interepted. Original message: ' + lpText); Result := TOrigMessageBoxA(OrigAddr)(Wnd, PAnsiChar(S), lpCaption, uType); end; procedure TForm1.FormCreate(Sender: TObject); begin OrigAddr := GetProcAddress(GetModuleHandle(user32), 'MessageBoxA'); ReplaceIATEntry(OrigAddr, @InterceptedMessageBoxA); end; procedure TForm1.Button1Click(Sender: TObject); begin MessageBoxA(0, 'Test Message', nil, 0); end;
Здесь конструктор формы устанавливает перехват функции MessageBoxA, а в обработчике кнопки производится вызов MessageBoxA, с целью продемонстрировать работу перехвата.
Сам обработчик перехвата очень простой. Так как была изменена всего лишь запись в таблице импорта и тело оригинальной функции не правилось, для передачи управления оригинальной функции достаточно произвести вызов по запомненному ранее адресу функции, который хранится в переменной OrigAddr.
Есть небольшой нюанс:
Статическая функция может располагаться в секции отложенного импорта. Делается такая декларация достаточно просто, давайте немного изменим код примера вот таким образом:
function DelayedMessageBoxA(hWnd: HWND; lpText, lpCaption: PAnsiChar; uType: UINT): Integer; stdcall; external user32 name 'MessageBoxA' delayed; procedure TForm1.Button1Click(Sender: TObject); begin DelayedMessageBoxA(0, 'Test Message', nil, 0); end;
Функция DelayedMessageBoxA на самом деле является той же MessageBoxA.
Но параметр "delayed" производит регистрацию вызова данной функции в секции отложенного импорта.
Нюанс: параметр "delayed" не доступен в ранних версиях Delphi, поэтому в демопримерах к статье данный кусок кода вы не найдете.
Так как реализованный выше перехватчик производит изменения только в таблице импорта, вызов функции, объявленной таким образом, не будет им контролироваться. Для этого нужно произвести правки в IMAGE DIRECTORY_ENTRY DELAYED IMPORT.
Вариант правки таблицы отложенного импорта в статье не рассматривается, в принципе в нем нет ничего интересного, принцип такой же как и с обычным импортом, только используются немного разные структуры. (Если заинтересовались, то будем считать реализацию данного перехватчика вашим домашним заданием :).
6. Перехват правкой таблицы экспорта.
Правка таблицы импорта и отложенного импорта отлично подходит для статически объявленных функций, но не сработает против динамического объявления и вызова функции.
Если простым языком, попробуйте добавить к прошлому примеру еще одну кнопку и в ее обработчике напишите следующий код:
procedure TForm1.Button2Click(Sender: TObject); type TOrigMessageBoxA = function(Wnd: HWND; lpText, lpCaption: PAnsiChar; uType: UINT): Integer; stdcall; var OrigMessageBoxA: TOrigMessageBoxA; begin @OrigMessageBoxA := GetProcAddress(GetModuleHandle(user32), 'MessageBoxA'); OrigMessageBoxA(0, 'Test Message', nil, 0); end;
Нажмите кнопку и вы увидите что перехватчик не сработал. Дело в том что в этом виновата функция GetProcAddress, которая получает адрес функции в обход таблицы импорта, ориентирующаяся на таблицу экспорта библиотеки.
Чтобы заставить обработчик перехвата реагировать на функции, вызываемые выше показанным способом, нужно внести изменения в таблицу экспорта требуемой библиотеки.
Вкратце в таблице экспорта содержится информация о экспортируемых PE файлом функциях, их наименование, индекс (Ordinal) и адрес функции. Некоторые функции экспортируются только по индексу и не имеют имени, в таких случаях доступ к ним производится только посредством индекса.
Общее описание работы таблицы экспорта вы можете узнать в той же статье Мэтта Питрека.
Если потребуется более подробная информация, можно изучить следующую статью:
PE. Урок 7. Таблица экспорта за авторством Iczelion-а.
Применение: Данный вариант перехвата применяется только для API функций вызываемых динамически.
Следующий пример практически ничем не отличается от предыдущего, за исключением минорных изменений в функции устанавливающей адрес перехватчика. Собственно сам код:
unit uEAT; interface uses Windows, Classes, Controls, Forms, StdCtrls; type TForm1 = class(TForm) Button1: TButton; procedure Button1Click(Sender: TObject); procedure FormCreate(Sender: TObject); end; var Form1: TForm1; implementation {$R *.dfm} uses DeclaredTypes; function ReplaceEATEntry(const DllName: string; OldProc, NewProc: FARPROC): Boolean; var ImageBase: Cardinal; DOSHeader: PImageDosHeader; NTHeader: PImageNtHeaders; ExportDirectory: PImageExportDirectory; pFuntionAddr: PDWORD; OrdinalCursor: PWORD; Ordinal, Protect: DWORD; FuntionAddr: FARPROC; I: Integer; begin Result := False; if OldProc = nil then Exit; if NewProc = nil then Exit; ImageBase := GetModuleHandle(PChar(DllName)); // Получаем адрес таблицы экспорта DOSHeader := PImageDosHeader(ImageBase); NTHeader := PImageNtHeaders(DWORD(DOSHeader) + DWORD(DOSHeader^._lfanew)); ExportDirectory := PImageExportDirectory(DWORD(ImageBase) + DWORD(NTHeader^.OptionalHeader.DataDirectory[ IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress)); I := 1; // запоминаем адрес таблицы ординалов OrdinalCursor := Pointer(ImageBase + DWORD(ExportDirectory^.AddressOfNameOrdinals)); while I < Integer(ExportDirectory^.NumberOfNames) do begin // Смотрим номер функции в таблице ординалов Ordinal := OrdinalCursor^; // Через ординал вычисляем реальный адрес функции FuntionAddr := Pointer(ImageBase + DWORD(ExportDirectory^.AddressOfFunctions)); FuntionAddr := Pointer(ImageBase + PDWORD(DWORD(FuntionAddr) + Ordinal * 4)^); // смотрим, наша ли это функция? if FuntionAddr = OldProc then begin // если наша, рассчитываем адрес, по которому хранится адрес функции pFuntionAddr := PDWORD(ImageBase + DWORD(ExportDirectory^.AddressOfFunctions) + Ordinal * 4); // правим адрес нового обработчика вычитая из него hInstance библиотеки NewProc := Pointer(DWORD(NewProc) - ImageBase); // пишем новый адрес if VirtualProtect(pFuntionAddr, SizeOf(DWORD), PAGE_EXECUTE_READWRITE, Protect) then try // Можно вот так... //pFuntionAddr^ := Integer(NewProc); // ... но лучше атомарно. InterlockedExchange(Integer(PImageThunkData(pFuntionAddr)^._function), Integer(NewProc)); Result := True; finally VirtualProtect(pFuntionAddr, SizeOf(DWORD), Protect, Protect); FlushInstructionCache(GetCurrentProcess, pFuntionAddr, SizeOf(DWORD)); end; Break; end; Inc(I); Inc(OrdinalCursor); end; end; var OrigAddr: Pointer = nil; function InterceptedMessageBoxA(Wnd: HWND; lpText, lpCaption: PAnsiChar; uType: UINT): Integer; stdcall; type TOrigMessageBoxA = function(Wnd: HWND; lpText, lpCaption: PAnsiChar; uType: UINT): Integer; stdcall; var S: AnsiString; begin S := AnsiString('Function interepted. Original message: ' + lpText); Result := TOrigMessageBoxA(OrigAddr)(Wnd, PAnsiChar(S), lpCaption, uType); end; procedure TForm1.FormCreate(Sender: TObject); begin OrigAddr := GetProcAddress(GetModuleHandle(user32), 'MessageBoxA'); ReplaceEATEntry(user32, OrigAddr, @InterceptedMessageBoxA); end; procedure TForm1.Button1Click(Sender: TObject); type TOrigMessageBoxA = function(Wnd: HWND; lpText, lpCaption: PAnsiChar; uType: UINT): Integer; stdcall; var OrigMessageBoxA: TOrigMessageBoxA; begin @OrigMessageBoxA := GetProcAddress(GetModuleHandle(user32), 'MessageBoxA'); OrigMessageBoxA(0, 'Test Message', nil, 0); end; end.
Как и в случае с правкой таблиц импорта тело функции не модифицируется, поэтому для передаче управления на оригинал так же достаточно произвести вызов по старому адресу.
7. Перехват сплайсингом точки входа функции.
Сплайсинг является самым эффективным способом установки перехвата. Он заключается в том, что в начале перехватываемой функции устанавливается асм-код перехода на обработчик перехвата размером в пять-шесть (и более) байт. В отличие от предыдущих вариантов установки перехватчика, данный метод может применяться в любых условиях. Им можно перехватывать статические, виртуальные и динамические методы классов, API функции, да в принципе вообще все что угодно 🙂
При использовании сплайсинга не имеет разницы как объявлена API функция, статически или вызывается динамически — это не важно, так как в любом случае установленный в начале тела функции код перехвата поймает ее вызов.
Правда это все плюсы данного метода, дальше начинаются минусы.
Во первых меняется тело функции и для того чтобы вызвать из обработчика перехвата оригинальную функцию нужно восстановить затертые байты в ее начале, а после окончания выполнения вернуть асм-код перехвата на место.
Во вторых, в тот момент, когда асм-код перехвата снят, контролируемая функция может быть вызвана из другой нити и этот вызов отследить не получится.
В третьих может произойти еще более плохая вещь — вызов контролируемой функции из другой нити может быть произведен в тот момент, когда мы изменяем заголовок функции восстанавливая или удаляя код перехвата. Запись пяти и более байт ни разу не атомарная операция, поэтому в некоторый момент времени вместо нормального кода в начале функции будет находится мусор. В итоге, в этом случае скорее всего произойдет крах нити, из которой был произведен вызов, т.к. вместо нормального асм-кода будет выполнен мусорный код.
В некоторых случаях такого поведения можно будет избежать, но об этом попозже.
Теперь немного о непосредственно асм-коде перехвата, а именно откуда он берется?
Запись асм-кода перехвата в начало инструкции не означает что вы будете писать туда строку «JMP 100» к примеру. Процессор ничего не знает об ассемблере, однако он знает о машинном коде. Поэтому после того как разработчик определится какую конкретно конструкцию перехватчика он будет использовать, ему нужно преобразовать ее в машинный код понятный процессору, который и будет помещаться в начало функции.
В действительности, хоть и звучит это достаточно сложно, выполнить это проще простого. Для этого пригодятся интеловские мануалы из которых можно узнать опкоды используемых в перехватчике инструкций при помощи которых сгенерировать необходимую последовательность. Я делаю еще проще, просто пишу требуемый мне код в асм-вставке и смотрю какой машкод в итоге сгенерировал мне компилятор.
Если не совсем понятно, то давайте рассмотрим несколько наиболее часто встречающихся вариантов перехватчиков.
1. JMP NEAR OFFSET
Пятибайтовая инструкция, первым байтом идет $E9, являющийся опкодом инструкции JMP NEAR rel32, остальные 4 являются параметром OFFSET.
OFFSET рассчитывается по следующей формуле: OFFSET = DestinationAddr — CurrentAddr — размер машкода инструкции
Где DestinationAddr — адрес обработчика перехвата
CurrentAddr — адрес по которому размещена инструкция JMP NEAR OFFSET
Что в виде кода выглядит так:
procedure SliceNearJmp(OldProc, NewProc: FARPROC); var SpliceRec: packed record JmpOpcode: Byte; Offset: DWORD; end; Tmp: DWORD; begin SpliceRec.JmpOpcode := $E9; SpliceRec.Offset := DWORD(NewProc) - DWORD(OldProc) - SizeOf(SpliceRec); VirtualProtect(OldProc, SizeOf(SpliceRec), PAGE_EXECUTE_READWRITE, OldProtect); Move(SpliceRec, OldProc^, SizeOf(SpliceRec)); VirtualProtect(OldProc, SizeOf(SpliceRec), OldProtect, OldProtect); end;
2. PUSH ADDR + RET
Шестибайтовая инструкция, первым байтом идет $68, являющийся опкодом инструкции PUSH imm32, следующие 4 байта являются параметром ADDR, означающим адрес, по которому должен быть произведен прыжок и последний шестой байт $C3, являющийся опкодом инструкции RET.
Вкратце работает эта связка инструкций просто. Первая инструкция PUSH помещает на стек адрес прыжка ADDR вторая инструкция RET совершает переход на указанный адрес попутно правя стек.
В виде кода выглядит примерно вот так:
procedure SlicePushRet(OldProc, NewProc: FARPROC); var SpliceRec: packed record PushOpcode: Byte; Offset: FARPROC; RetOpcode: Byte; end; begin SpliceRec.PushOpcode := $68; SpliceRec.Offset := NewProc; SpliceRec.RetOpcode := $C3; VirtualProtect(OldProc, SizeOf(SpliceRec), PAGE_EXECUTE_READWRITE, OldProtect); Move(SpliceRec, OldProc^, SizeOf(SpliceRec)); VirtualProtect(OldProc, SizeOf(SpliceRec), OldProtect, OldProtect); end;
Оба варианта в принципе имеют право на жизнь и достаточно часто используются. Но оба являются не безопасными.
Встречается еще и такой вариант перехватчика из двух инструкций: MOV EAX, ADDR + JMP EAX. Эта конструкция из семи байт является плохим решением, в случае если она применяется для перехвата функций использующих соглашение FASTCALL. Дело в том, что данное соглашение применяется в Delphi по умолчанию и его особенность в том, что первые три параметра функции размещаются в регистрах EAX, ECX и EDX. Применив такой тип перехватчика, параметр идущий в регистре EAX будет затерт. Поэтому данный вариант рассматривать я не буду.
Теперь по поводу безопасности применения таких перехватчиков. Вышеозвученные два варианта перехвата желательно использовать только в тех случаях, когда вы уверены в том, что перехватываемые вами функции не используют механизм HotPatch-а.
Дело в том, что ребята из Редмонда предусмотрели для нас небольшую лазейку, позволяющую сделать сплайсинг точки входа более безопасным. И таки-да, называется она «HotPatch» 🙂
Вкратце, если открыть любую библиотеку от MS в дизассемблере, то можно увидеть что тело каждой API функции предваряется пятью инструкциями NOP, а тело функции начинается с ничего не делающей инструкции MOV EDI, EDI (либо двубайтовой PUSH xxx)
Размер первых пяти инструкций как раз позволяет нам записать вместо них машкод инструкции JMP NEAR OFFSET. При этом в момент записи тело самой функции изменяться не будет. После записи данной инструкции мы можем атомарно изменить первые два байта функции, добавив вместо них двухбайтовый машкод инструкции JMP SHORT -7 представляющий из себя байты $EB и $F9.
Примерный код HotPach-а будет выглядеть следующим образом:
procedure SliceHotPath(OldProc, NewProc: FARPROC); var SpliceRec: packed record JmpOpcode: Byte; Offset: DWORD; end; NopAddr: Pointer; OldProtect: DWORD; begin SpliceRec.JmpOpcode := $E9; NopAddr := PAnsiChar(OldProc) - SizeOf(SpliceRec); SpliceRec.Offset := DWORD(NewProc) - DWORD(NopAddr) - SizeOf(SpliceRec); VirtualProtect(NopAddr, 7, PAGE_EXECUTE_READWRITE, OldProtect); Move(SpliceRec, NopAddr^, SizeOf(SpliceRec)); asm mov ax, $F9EB mov ecx, OldProc lock xchg word ptr [ecx], ax end; VirtualProtect(NopAddr, 7, OldProtect, OldProtect); end;
Для отключения такого перехватчика достаточно вернуть обратно самую первую двухбайтовую инструкцию и не нужно заботится об оставленных пяти байтах, в которых происходит прыжок на само тело перехватчика.
К сожалению для обычных функций выполнить перехват по методике HotPach-а не получится, так как у них отсутствуют требуемое пустое место предваряющее функцию. Для них подойдет какой-то из первых двух рассмотренных вариантов патча.
Но, если вы хотите чтобы ваши библиотеки так же были подготовлены к HotPatch-у то можете ознакомится с данной инструкцией: Create Hotpatchable Image правда это только для MS VC.
Все три примера перехвата являются примерным кодом. В них показан только момент установки перехватывающего кода. Для полноценной работы перехватчика нужно восстанавливать изначальный код оригинальной функции, который требуется перед установкой перехватчика где-то сохранить.
Сейчас я покажу перехват первым способом. Для этого нам потребуется создать новый проект, разместить на главной форме кнопку после чего написать следующий код:
unit uNearJmpSplice; interface uses Windows, Classes, Controls, Forms, StdCtrls; type TForm1 = class(TForm) Button1: TButton; procedure FormCreate(Sender: TObject); procedure Button1Click(Sender: TObject); end; var Form1: TForm1; implementation {$R *.dfm} type // структура для обычного сплайса через JMP NEAR OFFSET TNearJmpSpliceRec = packed record JmpOpcode: Byte; Offset: DWORD; end; // структура хранящая данные о затертых данных для перехватчика JMP NEAR OFFSET TNearJmpSpliceData = packed record FuncAddr: FARPROC; OldData: TNearJmpSpliceRec; NewData: TNearJmpSpliceRec; end; var NearJmpSpliceRec: TNearJmpSpliceData; // процедура пишет новый блок данных по адресу функции procedure SpliceNearJmp(FuncAddr: Pointer; NewData: TNearJmpSpliceRec); var OldProtect: DWORD; begin VirtualProtect(FuncAddr, SizeOf(TNearJmpSpliceRec), PAGE_EXECUTE_READWRITE, OldProtect); try // не атомарная операция!!! Move(NewData, FuncAddr^, SizeOf(TNearJmpSpliceRec)); finally VirtualProtect(FuncAddr, SizeOf(TNearJmpSpliceRec), OldProtect, OldProtect); FlushInstructionCache(GetCurrentProcess, FuncAddr, SizeOf(TNearJmpSpliceRec)); end; end; // обработчик перехвата функции MessageBoxA function InterceptedMessageBoxA(Wnd: HWND; lpText, lpCaption: PAnsiChar; uType: UINT): Integer; stdcall; var S: AnsiString; begin // снимаем перехват SpliceNearJmp(NearJmpSpliceRec.FuncAddr, NearJmpSpliceRec.OldData); try // вызываем оригинальную функцию S := AnsiString('Function interepted. Original message: ' + lpText); Result := MessageBoxA(Wnd, PAnsiChar(S), lpCaption, uType); finally // восстанавливаем перехват SpliceNearJmp(NearJmpSpliceRec.FuncAddr, NearJmpSpliceRec.NewData); end; end; procedure InitNearJmpSpliceRec; begin // запоминаем оригинальный адрес перехватываемой функции NearJmpSpliceRec.FuncAddr := GetProcAddress(GetModuleHandle(user32), 'MessageBoxA'); // читаем пять байт с ее начала, их мы будем перезатирать Move(NearJmpSpliceRec.FuncAddr^, NearJmpSpliceRec.OldData, SizeOf(TNearJmpSpliceRec)); // инициализируем опкод JMP NEAR NearJmpSpliceRec.NewData.JmpOpcode := $E9; // рассчитываем адрес прыжка NearJmpSpliceRec.NewData.Offset := PAnsiChar(@InterceptedMessageBoxA) - PAnsiChar(NearJmpSpliceRec.FuncAddr) - SizeOf(TNearJmpSpliceRec); end; procedure TForm1.FormCreate(Sender: TObject); begin // инициализируем структуру для перехватчика InitNearJmpSpliceRec; // перехватываем MessageBoxA SpliceNearJmp(NearJmpSpliceRec.FuncAddr, NearJmpSpliceRec.NewData); end; procedure TForm1.Button1Click(Sender: TObject); begin MessageBoxA(0, 'Test MessageBoxA Message', nil, 0); end; end.
Для хранения информации о затертых кодом перехвата байтах используется структура TNearJmpSpliceRec. Эта же структура используется для хранения машкодов кода перехвата.
Общая информация о установленном перехвате хранится в структуре TNearJmpSpliceData, где помимо двух полей о оригинальных данных функции и перехватчике так же хранится адрес функции.
Первоначально в процедуре InitNearJmpSpliceRec данная структура инициализируется, после чего при помощи процедуры SpliceNearJmp устанавливается код перехвата.
При нажатии на кнопку происходит вызов обработчика перехвата InterceptedMessageBoxA в котором для того чтобы произвести вызов оригинальной функции перехватчик снимается и восстанавливается после вызова.
Если делать перехват вторым способом, при помощи PUSH ADDR + RET то нужно немного изменить описание структуры TNearJmpSpliceRec и процедуры инициализации структкры InitNearJmpSpliceRec, все остальное останется прежним.
Ну а вот так будет выглядеть код перехвата третьим способом (через HotPatch).
Его я буду использовать в следующих двух главах, потому чтобы не повторятся, вынесу его в отдельный модуль.
unit CommonHotPatch; interface uses Windows; const LOCK_JMP_OPKODE: Word = $F9EB; type // структура для обычного сплайса через JMP NEAR OFFSET TNearJmpSpliceRec = packed record JmpOpcode: Byte; Offset: DWORD; end; THotPachSpliceData = packed record FuncAddr: FARPROC; SpliceRec: TNearJmpSpliceRec; LockJmp: Word; end; var HotPathSpliceRec: THotPachSpliceData; procedure InitHotPatchSpliceRec; procedure SpliceNearJmp(FuncAddr: Pointer; NewData: TNearJmpSpliceRec); procedure SpliceLockJmp(FuncAddr: Pointer; NewData: Word); implementation // процедура пищет новый блок данных по адресу функции procedure SpliceNearJmp(FuncAddr: Pointer; NewData: TNearJmpSpliceRec); var OldProtect: DWORD; begin VirtualProtect(FuncAddr, SizeOf(TNearJmpSpliceRec), PAGE_EXECUTE_READWRITE, OldProtect); try Move(NewData, FuncAddr^, SizeOf(TNearJmpSpliceRec)); finally VirtualProtect(FuncAddr, SizeOf(TNearJmpSpliceRec), OldProtect, OldProtect); FlushInstructionCache(GetCurrentProcess, FuncAddr, SizeOf(TNearJmpSpliceRec)); end; end; // процедура атомарно изменяет два байта по переданному адресу procedure SpliceLockJmp(FuncAddr: Pointer; NewData: Word); var OldProtect: DWORD; begin VirtualProtect(FuncAddr, 2, PAGE_EXECUTE_READWRITE, OldProtect); try asm mov ax, NewData mov ecx, FuncAddr lock xchg word ptr [ecx], ax end; finally VirtualProtect(FuncAddr, 2, OldProtect, OldProtect); FlushInstructionCache(GetCurrentProcess, FuncAddr, 2); end; end; function InterceptedMessageBoxA(Wnd: HWND; lpText, lpCaption: PAnsiChar; uType: UINT): Integer; stdcall; var S: AnsiString; begin // снимаем перехват SpliceLockJmp(HotPathSpliceRec.FuncAddr, HotPathSpliceRec.LockJmp); try // вызываем оригинальную функцию S := AnsiString('Function interepted. Original message: ' + lpText); Result := MessageBoxA(Wnd, PAnsiChar(S), lpCaption, uType); finally // восстанавливаем перехват SpliceLockJmp(HotPathSpliceRec.FuncAddr, LOCK_JMP_OPKODE); end; end; procedure InitHotPatchSpliceRec; begin // запоминаем оригинальный адрес перехватываемой функции HotPathSpliceRec.FuncAddr := GetProcAddress(GetModuleHandle(user32), 'MessageBoxA'); // читаем два байта с ее начала, их мы будем перезатирать Move(HotPathSpliceRec.FuncAddr^, HotPathSpliceRec.LockJmp, 2); // инициализируем опкод JMP NEAR HotPathSpliceRec.SpliceRec.JmpOpcode := $E9; // рассчитываем адрес прыжка HotPathSpliceRec.SpliceRec.Offset := PAnsiChar(@InterceptedMessageBoxA) + 5 - PAnsiChar(HotPathSpliceRec.FuncAddr) - SizeOf(TNearJmpSpliceRec); end; end.
Использование данного модуля в приложении самое тривиальное:
unit uHotPachSplice; interface uses Windows, Classes, Controls, Forms, StdCtrls; type TForm1 = class(TForm) Button1: TButton; procedure FormCreate(Sender: TObject); procedure Button1Click(Sender: TObject); end; var Form1: TForm1; implementation uses CommonHotPatch; {$R *.dfm} procedure TForm1.FormCreate(Sender: TObject); begin // инициализируем структуру для перехватчика InitHotPatchSpliceRec; // пишем прыжок в область NOP-ов SpliceNearJmp(PAnsiChar(HotPathSpliceRec.FuncAddr) - 5, HotPathSpliceRec.SpliceRec); // перехватываем MessageBoxW SpliceLockJmp(HotPathSpliceRec.FuncAddr, LOCK_JMP_OPKODE); end; procedure TForm1.Button1Click(Sender: TObject); const TestStr: AnsiString = 'Test MessageBoxA Message'; begin MessageBoxA(0, PAnsiChar(TestStr), nil, 0); end; end.
Второй код оставлю без объяснений, т.к. отличия от предыдущего варианта перехвата минимальны.
8. Внедрение библиотеки через установку ловушки.
Все что было описано выше относилось к перехвату в локальном приложении, но как правильно разработчику требуется осуществить перехват в чужом процессе.
Разместить перехватчик в теле чужого процесса можно несколькими способами, от самого сложного — выделив требуемый блок памяти через VirtualAllocEx и записью в него кода перехватчика (его я рассматривать не буду), до более простых вариантов, через подгрузку библиотеки.
Самый простой способ внедрения перехватчика, это размещение кода перехватчика в теле библиотеки, подгружаемой в чужое адресное пространство установленной глобальной ловушкой.
Код загрузчика библиотеки выглядит так:
program hook_loader; {$APPTYPE CONSOLE} uses Windows; var hLib: THandle; HookProcAddr: Pointer; HookHandle: HHOOK; begin hLib := LoadLibrary('hook_splice_lib.dll'); try HookProcAddr := GetProcAddress(hLib, 'HookProc'); Writeln('MessageBoxA intercepted, press ENTER to resume...'); HookHandle := SetWindowsHookEx(WH_GETMESSAGE, HookProcAddr, hLib, 0); Readln; UnhookWindowsHookEx(HookHandle); finally FreeLibrary(hLib); end; end.
Как только выполнится функция SetWindowsHookEx будет установлена глобальная ловушка, обработчик которой расположен в библиотеке hook_splice_lib.dll. Данная библиотека будет автоматически подгружена во все процессы работающие с очередью сообщений посредством вызова функций GetMessage или PeekMessage.
Код библиотеки практически один в один повторяет один из выше приведенных примеров перехвата:
library hook_splice_lib; uses Windows, CommonHotPatch in '..\common\CommonHotPatch.pas'; {$R *.res} procedure DLLEntryPoint(dwReason: DWORD); begin case dwReason of DLL_PROCESS_ATTACH: begin // инициализируем структуру для перехватчика InitHotPatchSpliceRec; // пишем прыжок в область NOP-ов SpliceNearJmp(PAnsiChar(HotPathSpliceRec.FuncAddr) - 5, HotPathSpliceRec.SpliceRec); // перехватываем MessageBoxW SpliceLockJmp(HotPathSpliceRec.FuncAddr, LOCK_JMP_OPKODE); end; DLL_PROCESS_DETACH: begin // при выгрузке библиотеки снимаем перехват SpliceLockJmp(HotPathSpliceRec.FuncAddr, HotPathSpliceRec.LockJmp); end; end; end; function HookProc(Code: Integer; WParam: WPARAM; LParam: LPARAM): LRESULT; stdcall; begin Result := CallNextHookEx(0, Code, WParam, LParam); end; exports HookProc; begin DLLProc := @DLLEntryPoint; DLLEntryPoint(DLL_PROCESS_ATTACH); end.
Отличия минимальны. В теле библиотеки, помимо самого кода перехвата реализована функция обработчика ловушки HookProc, которая не делает ничего кроме вызова следующей ловушки в очереди. Она нужна только для корректной работы ловушки WH_GETMESSAGE.
Установка перехватчика производится в DLLEntryPoint при получении уведомления о загрузке DLL_PROCESS_ATTACH, при получении уведомления о выгрузке DLL_PROCESS_DETACH перехватчик снимается.
Немного остановлюсь на коде перекрытия DLLProc. По сути перекрытие данной процедуры предназначено только для одного, для получения уведомления DLL_PROCESS_DETACH. Дело в том, что когда наш код получает управление, уведомление DLL_PROCESS_ATTACH уже было передано библиотеке и было обработано внутри DLLProc. Вызов DLLEntryPoint(DLL_PROCESS_ATTACH) по сути просто дублирует уже полученный вызов. А вот когда библиотека будет выгружаться, DLLProc уже изменена на наш обработчик DLLEntryPoint и уведомление о выгрузке придет именно в него, где мы сможем произвести необходимые действия для деинициализации перехватчика.
9. Внедрение библиотеки через создание нити в удаленном процессе.
Внедрение библиотеки глобально во все процессы часто не оправдано. Если мы хотим перехватить определенное АПИ в конкретном процессе, не имеет смысла отвлекаться на другие. Более того в некоторых случаях, не смотря на установленную ловушку, библиотека может быть не подгружена в требуемый процесс, по причине того что в нем не выполняются необходимые для срабатывания ловушки действия. В качестве примера давайте рассмотрим вот такое консольное приложение:
program test_console8; {$APPTYPE CONSOLE} uses Windows; begin Writeln('Press enter to show message...'); Readln; MessageBoxA(0, 'First message', '', 0); Writeln('Press enter to show message...'); Readln; MessageBoxA(0, 'Second message', '', 0); end.
Консольное приложение обычно не работает с очередью сообщений и как правило не использует вызовы функций GetMessage или PeekMessage, поэтому при показе первого сообщения оно не будет перехвачено.
Но, не смотря на это, второе сообщение, все же будет перехвачено установленной ловушкой. Дело в том что для отображения самого первого MessageBox (впрочем как и любого другого) все же используется очередь сообщений (ибо по сути происходит отображение модального окна) и наша библиотека будет загружена при его показе, чем и объясняется перехват второго вызова MessageBox.
Чтобы избежать такого поведения (невозможность перехвата первого вызова MessageBox в консоли), можно подгрузить библиотеку принудительно, зная идентификатор процесса (PID).
Библиотека с перехватчиком останется практически такой же как и предыдущая, единственным изменением в ней будет то, что вместо обработчика ловушки HookProc в ней будет реализована функция выгрузки библиотеки следующего вида:
procedure SelfUnload(lpParametr: Pointer); stdcall; begin FreeLibraryAndExitThread(HInstance, 0); end; exports SelfUnload;
Внедрение библиотеки делается следующим кодом:
const DllName = 'thread_splice_lib.dll'; function InjectLib(ProcessID: Integer): Boolean; var Process: HWND; ThreadRtn: FARPROC; DllPath: AnsiString; RemoteDll: Pointer; BytesWriten: DWORD; Thread: DWORD; ThreadId: DWORD; begin Result := False; // Открываем процесс Process := OpenProcess(PROCESS_CREATE_THREAD or PROCESS_VM_OPERATION or PROCESS_VM_WRITE, True, ProcessID); if Process = 0 then Exit; try // Выделяем в нем память под строку DllPath := AnsiString(ExtractFilePath(ParamStr(0)) + DLLName) + #0; RemoteDll := VirtualAllocEx(Process, nil, Length(DllPath), MEM_COMMIT or MEM_TOP_DOWN, PAGE_READWRITE); if RemoteDll = nil then Exit; try // Пишем путь к длл в его адресное пространство if not WriteProcessMemory(Process, RemoteDll, PChar(DllPath), Length(DllPath), BytesWriten) then Exit; if BytesWriten <> DWORD(Length(DllPath)) then Exit; // Получаем адрес функции из Kernel32.dll ThreadRtn := GetProcAddress(GetModuleHandle('Kernel32.dll'), 'LoadLibraryA'); if ThreadRtn = nil then Exit; // Запускаем удаленный поток Thread := CreateRemoteThread(Process, nil, 0, ThreadRtn, RemoteDll, 0, ThreadId); if Thread = 0 then Exit; try // Ждем пока удаленный поток отработает... Result := WaitForSingleObject(Thread, INFINITE) = WAIT_OBJECT_0; finally CloseHandle(Thread); end; finally VirtualFreeEx(Process, RemoteDll, 0, MEM_RELEASE); end; finally CloseHandle(Process); end; end;
Принцип данного метода был описан еще у Рихтера, поэтому на нем останавливаться не буду.
А для выгрузки необходимо реализовать следующий код:
function ResumeLib(ProcessID: Integer): Boolean; var hLibHandle: THandle; hModuleSnap: THandle; ModuleEntry: TModuleEntry32; OpCodeData: Word; Process: HWND; BytesWriten: DWORD; Thread: DWORD; ThreadId: DWORD; ExitCode: DWORD; PLibHandle: PDWORD; OpCode: PWORD; CurrUnloadAddrOffset: DWORD; UnloadAddrOffset: DWORD; begin Result := False; // рассчитываем оффсет адреса выгрузки библиотеки относительно адреса ее загрузки hLibHandle := LoadLibrary(PChar(DLLName)); try UnloadAddrOffset := DWORD(GetProcAddress(hLibHandle, 'SelfUnload')) - hLibHandle; if UnloadAddrOffset = -hLibHandle then Exit; finally FreeLibrary(hLibHandle); end; // Находим адрес библиотеки в чужом адресном пространстве hModuleSnap := CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, ProcessID); if hModuleSnap <> INVALID_HANDLE_VALUE then try FillChar(ModuleEntry, SizeOf(TModuleEntry32), #0); ModuleEntry.dwSize := SizeOf(TModuleEntry32); if not Module32First(hModuleSnap, ModuleEntry) then Exit; repeat if AnsiUpperCase(ModuleEntry.szModule) = AnsiUpperCase(DLLName) then begin // Получаем адрес функции выгрузки CurrUnloadAddrOffset := ModuleEntry.hModule + UnloadAddrOffset; Break; end; until not Module32Next(hModuleSnap, ModuleEntry); finally CloseHandle(hModuleSnap); end; // Открываем процесс Process := OpenProcess(PROCESS_CREATE_THREAD or PROCESS_VM_OPERATION or PROCESS_VM_WRITE, True, ProcessID); if Process = 0 then Exit; try // Пишем опкод jmp [ebx] OpCode := VirtualAllocEx(Process, nil, 2, MEM_COMMIT or MEM_TOP_DOWN, PAGE_READWRITE); if OpCode = nil then Exit; try OpCodeData := $23FF; if not WriteProcessMemory(Process, OpCode, @OpCodeData, 2, BytesWriten) then Exit; // Пишем адрес функции выгрузки (он будет лежать в EBX при старте потока) PLibHandle := VirtualAllocEx(Process, nil, 4, MEM_COMMIT or MEM_TOP_DOWN, PAGE_READWRITE); if PLibHandle = nil then Exit; try if not WriteProcessMemory(Process, PLibHandle, @CurrUnloadAddrOffset, 4, BytesWriten) then Exit; // запускаем поток Thread := CreateRemoteThread(Process, nil, 0, OpCode, PLibHandle, 0, ThreadId); if Thread = 0 then Exit; try // Ждем пока удаленный поток отработает... if (WaitForSingleObject(Thread, INFINITE) = WAIT_OBJECT_0) then if GetExitCodeThread(Thread, ExitCode) then Result := ExitCode = 0; finally CloseHandle(Thread); end; finally VirtualFreeEx(Process, PLibHandle, 0, MEM_RELEASE); end; finally VirtualFreeEx(Process, OpCode, 0, MEM_RELEASE); end; finally CloseHandle(Process); end; end;
Здесь иcпользуется один недокументированный механизм, работающий в линейках от Windows 2000 до Windows 7 (на восьмерке не проверял, но по логике и там будет то же самое). Дело в том что значение, передаваемое в параметре lpParameter функции CreateRemoteThread всегда размещается в регистре EBX при старте нити в удаленном приложении. Задача данного кода разместить в удаленном процессе инструкцию JMP [EBX] с которой будет начинаться работа нити. Если в EBX будет находится адрес процедуры SelfUnload, произойдет передача управления на нее, ну а внутри данной процедуры уже реализован код выгрузки библиотеки и закрытия текущей нити.
Можно конечно стартовать механизм выгрузки сразу непосредственно с функции SelfUnload, но уж очень хотелось показать данный момент, поэтому не ругайте сильно 🙂
10. Мониторинг
С практической частью будем считать закончили, теперь капельку теоретической.
На вопрос как правильно производить лог вызова перехваченных функций общего ответа, конечно же нет, но есть общие рекомендации.
Прежде всего необходимо определится с тем, какие именно параметры мы собираемся логировать и разбить их по следующим группам.
- Входящие параметры всегда логируются до вызова оригинальной функции в перехватчике.
- Исходящие параметры всегда логируются после вызова оригинальной перехваченной функции.
- Если параметр объявлен как IN/OUT — производим двойное логирование.
Не знаю почему, но у моих «студентов» был затык именно на данном аспекте, постоянно путали порядок ведения лога, вызывая его до вызова ReadFile и удивлялись отсутствию буфера с данными.
Ну и еще немножко по первому пункту — всеж таки всегда логируйте входящие данные до вызова функции, иначе однажды столкнетесь с тем, что буфер, переданный на WriteFile придет немножко измененный драйвером, обработавшим данный вызов.
11. Резюмируя
На этом пожалуй стоит закончить с описанием методик перехвата. Да их собственно практически и не осталось. Описывать методики перехвата в ядре я не вижу смысла — т.к. это уже будет статья не для сообщества Delphi, как и рассматривать трюки с перехватом на переполнении я тоже не буду, т.к. слишком долго объяснять сам принцип почему и как «это сработало».
Единственный не рассмотренный нюанс остался с перехватом при использовании HotPatch, а именно: не всегда нужно снимать перехватчик, можно обойтись и без лишних телодвижений.
Но, к сожалению, я не успел подготовить демонстрационные приложения, описывающий данный нюанс целиком, да и излишне раздувать объем материала не хотелось, поэтому этот момент я рассмотрю во второй части статьи.
Собственно это все что я хотел рассказать по данной теме, как применять эти знания решать уже вам 🙂 Да, конечно я понимаю что данную информация можно применить и при разработке различных вредоносных вещей, но ее так же можно использовать и для более полезного софта. В частности я обычно пользуюсь перехватом для такой тривиальной задачи, как анализ трафика, проходящего через браузер. Зачем платить сотни долларов за софт, осуществляющий такой же функционал, когда можно это реализовать самому в свободное время?
Примеры к статье можно забрать по данной ссылке
Удачного мониторинга 🙂
Александр (Rouse_) Багель
Январь, 2013
ссылка на оригинал статьи http://habrahabr.ru/post/178393/
Добавить комментарий