Но как быть с остальными методами интерфейса, количество и сигнатуры которых неизвестны? Для решения этой проблемы QIThunk использует универсальную функцию-перехватчик, адресом которой заполняется vtbl. Виртуальные методы объявляются в QIThunk так:
STDMETHOD(f3)();STDMETHOD(f4)();...STDMETHOD(f1023)(); |
Vtbl QIThunk содержит 1024 адреса. Интерфейсы, объявляющие большее количество методов, встречаются нечасто.
Реализация этих методов задается с помощью макроса:
ATL_IMPL_THUNK(3)ATL_IMPL_THUNK(4)...ATL_IMPL_THUNK(1023) |
Метод-перехватчик будет вызываться клиентом с заранее неизвестным количеством параметров, поэтому написать такую функцию на языке высокого уровня невозможно – не подходят ни стандартные пролог/эпилог, генерируемые компилятором C++, ни “нормальное” завершение функции вызовом инструкции ret, так как stdcall-функции должны очищать стек сами, передавая размер стека параметров в ret.
Рисунок 2. Вызов COM метода.
На рисунке 2 приведен пример дизассемблированного кода вызова метода COM-интерфейса (ссылка на который находится в pUnk) с передачей двух параметров, arg1 и arg2.
Отключить генерирование стандартного пролога и эпилога можно с помощью директивы _declspec(naked) перед определением функции. Проблема, связанная с нормальным завершением путем вызова ret, решается за счет использования другой инструкции процессора – jmp. Вместо того, чтобы вызывать исходный метод с помощью инструкции call (мы не можем подготовить стек параметров для call, так как не знаем их количество) и затем выполнить “ret n” (нам неизвестно n – количество параметров * 4) – перехватчик определяет адрес исходного метода, заменяет в стеке указатель на объект (который внутри вызова будет рассматриваться как this), к методу которого производится вызов, а затем просто “перепрыгивает” по нужному адресу с помощью jmp. После вызова jmp в стеке не остается ничего, что напоминало бы о перехватчике – настоящая функция получает нетронутый стек параметров и после ее завершения мы попадем в клиентский код, минуя перехватчик. Ниже приведен код перехватчика, реализованный с помощью ATL:
mov eax, [esp+4] // первый параметр в стеке - thiscmp dword ptr [eax+8], 0 // проверяем счетчик ссылок QIThunk::m_dwRefjg goodrefcall atlBadThunkCallgoodref:mov eax, [esp+4] // первый параметр в стеке - this mov eax, dword ptr [eax+4] // получаем переменную-член QIThunk::m_pUnkmov [esp+4], eax // заменяем this-перехватчика в стеке на m_pUnkmov eax, dword ptr [eax] // получаем vptr (указатель на vtbl)// n – порядковый номер метода в vtblmov eax, dword ptr [eax+4*n] // получаем адрес нужного виртуального методаjmp eax // переходим в нужный метод (обратно не вернемся) |
Необходимо отметить, что подобная техника позволяет выполнить предварительную обработку в перехватчике (в случае ATL – проверка счетчика ссылок перед вызовом), но не пост-обработку. После инструкции “jmp eax” мы больше не вернемся в код перехватчика (в стеке лежит адрес возврата в клиентский код, и после ret мы попадем именно туда).
Например, мы могли бы попытаться расширить код перехватчика так, чтобы писать отладочные сообщения, если вызов метода завершился с ошибкой. Чтобы решить эту задачу, нам пришлось бы заменить адрес возврата в стеке на код перехватчика (вместо адреса возврата в клиентский код), но тогда между пред- и пост-обработкой нужно было бы где-то хранить исходный адрес возврата. Стек не подходит в качестве такого хранилища, так как он будет использоваться вызываемым методом. Один из возможных вариантов – использование TLS или динамической памяти, кроме того, доступ к этому хранилищу должен синхронизироваться для многопоточных приложений.
ПРИМЕЧАНИЕКоличество слотов TLS ограничено, а вызовы к перехватчику в одном потоке могут быть вложенными. Поэтому для хранения адресов возврата пришлось бы использовать связанный список или аналогичную структуру данных, а также обеспечить быстрые выделения/освобождения памяти для элементов списка, чтобы уменьшить влияние перехватчика на скорость выполнения приложения. |
Подход, используемый ATL для перехвата вызовов COM-объектов, сводится к следующему:
Указатель на интерфейс заменяется на перехватчик в методе CComObjectRootBase::InternalQueryInterface при вызове QueryInterface. Поэтому перехватываются только вызовы COM-объектов, разработанных с помощью ATL.
vtbl перехватчика создается путем ручного объявления большого количества (1024) виртуальных методов, имеющих одинаковую реализацию.
ПРИМЕЧАНИЕТакое решение нельзя назвать изящным – исходные тексты QIThunk получаются большими, но, с другой стороны это наиболее эффективный способ генерации vtbl. Альтернативный способ мог бы заключаться в заполнении vtbl во время выполнения приложения: |
HRESULT __stdcall thunk(void* pthis){ return S_OK;}typedef HRESULT (__stdcall * pthunk)(void* pthis);pthunk vtbl[1024];for(int i = 0 ; i < sizeof(vtbl) / sizeof(vtbl[0]); ++i) vtbl[i] = &thunk;pthunk* vptr = vtbl;IUnknown* pUnk = reinterpret_cast<IUnknown*>(&vptr);pUnk->AddRef(); |
Метод-перехватчик, используя ассемблер, выполняет пред-обработку (проверку счетчика ссылок) и после этого передает управление исходному методу с помощью инструкции безусловного перехода jmp.
Для отладочных целей в приведенном выше примере нам было бы достаточно перехватывать только вызовы AddRef, Release и QueryInterface. Но для перехвата всех остальных методов интерфейса, сигнатура которых неизвестна на этапе компиляции, требуется более универсальный код.
Альтернативный способ перехвата вызовов методов интерфейса заключается в том, чтобы заменить в исходной vtbl интерфейса указатели на те методы, которые мы собираемся перехватывать. Эта технология была замечательно описана в статье “
Перехват методов интерфейса IUnknown”.Если нам известны сигнатуры перехватываемых методов (как в случае с методами IUnknown), нам не потребуется универсальный перехватчик, так как вызовы всех остальных методов будут осуществляться напрямую. Такой способ имеет следующие особенности по сравнению с рассмотренным выше:
Перехватываются все вызовы через любой указатель на интерфейс, так как мы меняем исходную vtbl интерфейса.
Vtbl обычно размещается в R/O секции памяти, поэтому код установки перехватчика должен менять настройки защиты этой секции.
Нет необходимости в генерации vtbl нужного размера (мы используем исходную vtbl), в некоторых случаях нет необходимости в универсальном коде перехвата методов с неизвестной сигнатурой.
В многопоточном приложении после установки перехватчика часть вызовов может выполниться напрямую, так как некоторые потоки могли уже успеть получить адрес метода из vtbl, но еще не выполнить вызов call.
Эта технология не подходит, когда в приложении нужно перехватывать вызовы через конкретный указатель на интерфейс, а не через любые указатели на этот интерфейс, и когда нужно контролировать время жизни перехватчика.
ПРИМЕЧАНИЕПосмотрим, например, что произойдет при выгрузке модуля перехватчика. При этом он должен восстановить исходные адреса методов в vtbl, после чего выгрузиться. В многопоточном приложении один из потоков мог успеть получить адрес метода из vtbl (который все еще указывал на перехватчик), но не успеть сделать вызов по этому адресу. Если модуль перехватчика не будет предпринимать специальных мер по синхронизации, вызов по адресу выгруженного модуля закончится AV (access violation – ошибка доступа к памяти). |
Вернемся снова к методу перехвата, используемому в ATL. Код перехватчика позволяет с легкостью выполнить подготовку к вызову – предобработку, но затем он выполняет безусловный переход jmp в исходную функцию. Попробуем дополнить его код так, чтобы позволить выполнить постобработку после вызова.
Первая задача, которую необходимо решить – генерация vtbl перехватчика. ATL использует с этой целью макросы ATL_IMPL_THUNK, явно объявляя 1024 метода в теле класса. Рассмотрим альтернативный подход, заключающийся в динамическом создании vtbl нужного вида в runtime.
Код перехватчика должен знать порядковый номер n метода интерфейса, чтобы выполнить его вызов. Мы можем разделить весь код универсального перехватчика на 2 части – первая будет зависеть от порядкового номера перехватываемого метода (n) и будет передавать управление второй, передавая n через стек, а вторая часть будет одинаковой для всех методов.
Код первой части тривиален – мы опускаем в стек n и затем выполняем переход на тело универсального перехватчика. Мы будем использовать технику ATL (которая используется для создания оконных процедур обработки сообщений, смысл этого будет описан ниже) – создадим структуру, содержащую нужные инструкции:
#pragma pack(push, 1) struct vthunk { BYTE m_push; DWORD m_n; BYTE m_jmp; DWORD m_offset; void init(DWORD_PTR proc, int n) { m_push = 0x68; m_n = n; m_jmp = 0xE9; m_offset = DWORD((INT_PTR)proc - ((INT_PTR)this+sizeof(vthunk))); FlushInstructionCache(GetCurrentProcess(), this, sizeof(vthunk));} };#pragma pack(pop) |
Структуру vtbl можно имитировать с помощью массива указателей на vthunk:
struct ThunkVtbl{ ThunkVtbl(DWORD_PTR pthunk) { for(int i = 0; i < thunk_n; ++ i) { code[i].init(pthunk, i); vtbl[i] = reinterpret_cast<DWORD_PTR>(&code[i]); } } static const int thunk_n = 1024; DWORD_PTR vtbl[thunk_n];vthunk code[thunk_n];}; |
В конструкторе ThunkVtbl мы инициализируем каждый из перехватчиков vthunk порядковым номером n и адресом универсального перехватчика pthunk. Теперь массив vtbl содержит 1024 указателя на структуры vthunk, каждая из которых содержит код для вызова перехватчика:
push njmp pthunk |
Для постобработки нам потребуется хранить адрес возврата в клиентский код. С этой целью мы будем использовать TLS и контейнер std::deque (так как в одном потоке вызовы могут быть вложенными, нам нужен именно стек).