Сообщений 7 Оценка 240 [+1/-0] Оценить |
Я открываю свойства растений и трав..
Борис Гребенщиков
Исходные тексты примеров к статье
Словосочетанием «API Spying» называется слежение за вызовами функций API некоторым приложением. То есть, каждый факт вызова этим приложением выбранных функций каким-то образом фиксируется, например, добавляется запись в лог.
ПРИМЕЧАНИЕ Для ясности назовём «некоторое приложение» исследуемым приложением, а «выбранные функции» – отслеживаемыми функциями. |
API Spying может использоваться на одном из этапов исследования программы, логику работы которой вы пока не до конца понимаете. Хотя эта технология и не позволяет получить детальную информацию, она может значительно сузить область последующих этапов исследования, сконцентрировав ваше внимание на тех вызовах, которые происходят в ключевые моменты работы программы.
На первый взгляд может показаться, что задача лучше решается с помощью перехвата API, так как он даёт возможность не только отследить вызов, но и изучить/изменить параметры и возвращаемое значение, или даже полностью переписать функцию.
Действительно, перехват API – замечательная и часто упоминаемая техника (на данный момент на RSDN этой теме посвящены три статьи), позволяющая довольно глубоко изучить исследуемое приложение, но это и гораздо более трудоёмкое решение. Даже если реализации функций будут почти пустыми (только запись в лог и вызов оригинальной функции), ваш код будет примерно таким:
typedef int (__stdcall* Function1_type)(int i); Function_type _Function1; // Обёртка, логирующая вызовыint__stdcall MyFunction1(int i) { printf("MyFunction1\n"); return _Function(i); // Вызов оригинальной функции } ... // Перехват всех функцийvoid HookThemAll() { ... // Перехват функции _Function1, экспортируемой some.dll HookIt("some.dll", "_Function1@4", MyFunction1, &_Function1); ... } |
ПРИМЕЧАНИЕ Это приблизительный код, используемый при перехвате через таблицу импорта; другие варианты перехвата в данном случае не имеют существенных преимуществ. |
То есть, для каждой функции придётся:
Это, конечно, довольно простые операции… Но представьте, что таким образом вам нужно перехватить несколько сотен функций. А если не у всех функций известны прототипы? А если некоторые dll загружается динамически, и вы пока даже не знаете, какие их функции используются приложением? А если после того, как вы всё успешно перехватите и просмотрите получившиеся логи, станет понятно, что для детального понимания работы приложения нужно было перехватить всего две функции и изучить их параметры :) ?
Когда все эти вопросы встали передо мной, я занялся API Spying-ом.
API Spying не исключает перехвата API, но эти методики используются находятся на разных стадиях анализа программы. Сначала при помощи API Spying-а определяется несколько наиболее интересных функций, потом, если необходимо, эти функции перехватываются и изучаются «в более тесном контакте».
В самом общем виде задача выглядит так:
Логически разовьём требования, высказанные в постановке задачи:
Несколько дополнительных пожеланий:
ПРИМЕЧАНИЕ Вы классно знаете ассемблер, и считаете, что это пара пустяков? Возможно, вы не учли, что код функций будет расположен в произвольном месте адресного пространства и что (забегая вперёд; но вы-то это всё должны понимать) функции не могут модифицировать стек и регистры. Если и это для вас не проблема, то, во-первых, примите моё искреннее восхищение (без шуток!), во-вторых, прочитайте следующий пункт. :) |
И несколько ограничений:
СОВЕТ О подобных ограничениях лучше не забывать и в реальных проектах, так как иначе выполнить ТЗ будет практически невозможно. |
Проблема заключается в том, что (увы!) статистика собирается не магически, её собирает наш код, внедрённый в исследуемое приложение. У этого простого факта есть три неприятных следствия:
Все эти следствия в той или иной степени свойственны любой программной реализации API Spying-а, и ни в одной из этих ситуаций я не могу посоветовать вам ничего хорошего. Можно только попытаться уменьшить степень влияния и избежать столь пагубных последствий.
Как вы уже, наверное, поняли, нам предстоит динамическая генерация кода функций-шпионов. Хотя ничего особо сложного в этом не будет (они действительно очень простые), небольшое теоретическое введение поможет вам понять (а мне – объяснить), как должна быть написана функция-шпион, чтобы вызов отслеживаемой функции завершился без помех.
С точки зрения процессора вызов функции выполняет инструкция call, имеющая несколько разных форм:
call xxxxxxh call xxxxh:xxxxxxh call eax call [eax] ... |
Она сохраняет в стеке адрес, по которому нужно передать управление после окончания функции, и передаёт управление на начало функции.
Процессор Intel x86 ничего не знает о параметрах вызываемых функций, поэтому механизм передачи параметров может быть произвольным, главное чтобы вызывающий и вызываемый код договорились о нём заранее. Мест, где можно сохранить параметры, не так уж и много: либо в регистрах, либо в стеке, либо часть там, а часть там.
ПРИМЕЧАНИЕ Конечно, можно передавать параметры по ссылке или по значению, в прямом порядке или в обратном, но это для нас не важно, важно только то, где передаваемая информация (параметры или их адреса) находится. |
Передача параметров через регистры используется в основном в двух случаях:
В большинстве остальных случаев параметры передаются через стек. При этом вызов функции выглядит примерно так:
push ... ; Параметр push ... ; Ещё один параметр push ... ; И последний параметр call xxxxxh ; Вызов |
А стек к моменту начала выполнения функции – так:
Рисунок 1. Состояние стека в начале выполнения функции.
Возврат управления производит инструкция ret, имеющая четыре различные формы:
ret ret xxxh retf retf xxxh |
Модификация retf предназначена для возврата из функции, которую вызвали из другого сегмента («дальним вызовом»). Ниже она не упоминается, так как, во-первых, в Windows вы её вряд ли встретите, во-вторых, с точки зрения реализации API Spying-а, она практически не отличается от ret. |
Задача, выполняемая ret*:
При этом все версии ret* предполагают, что адрес возврата находится на вершине стека, а байты, которые надо удалить (если надо) – сразу за ним.
Поскольку, как и при вызове, процессор ничего не знает о параметрах, удалять их из стека при возврате или нет – личное дело функции и вызывающего её кода. Распространены оба варианта: согласно формату вызова функций __cdecl за очистку стека отвечает вызывающий код, а согласно формату __stdcall этим занимается сама функция.
Почти все стандартные API Windows придерживаются __stdcall, и большинство функций, экспортируемых из dll сторонних разработчиков, также следуют этому формату. |
Как и в случае с параметрами, про возвращаемые значения процессор тоже ничего не знает, и то, как именно и что именно вы будете возвращать, его не касается. Обычно возвращаемое значение передаётся через регистр eax или через пару eax:edx.
И этот вопрос остаётся полностью на совести программиста (в случае языка высокого уровня – программиста, писавшего компилятор). Если верить статье «Arguments Passing and Naming Conventions» в MSDN, для всех стандартных форматов вызова функций компилятор гарантирует сохранность регистров ESI, EDI, EBX и EBP. Это значит, что вызывающий код:
ПРИМЕЧАНИЕ А как же остальные регистры? Сегментные, управляющие, GDTR, LDTR, ….? С ними просто: если функция меняет какой-то из этих регистров, то, либо это документированный побочный эффект (например, ожидаемый результат) её вызова, либо автор функции очень, очень плохо пошутил… |
Система в целом состоит из четырёх частей:
Задачи работы функции-шпиона:
Ограничения связаны с тем, что отслеживаемая функция должна работать без изменений. Для этого перед её вызовом:
Так как код функции-шпиона может располагаться в памяти по произвольному адресу, при вызове из неё функций необходимо либо использовать абсолютную адресацию, либо при генерации вычислять их адреса для каждой новой функции-шпиона.
Оба подхода одинаково просто реализуются, но из-за особенности системы команд Intel x86 ближний вызов/передача управления по абсолютному адресу будет выглядеть примерно так:
; Вызов mov eax, <абсолютный адрес функции > call eax; Передача управления mov eax, <абсолютный адрес функции> jmp eax |
То есть, как ни старайся, а значение одного регистра (в данном примере регистра eax, но на его месте мог быть каждый) сохранить не удаётся.
Поэтому выбрана версия с относительной адресацией:
pusha ; сохраняем регистры и флаги. pushf ; Это, конечно, паранойя... push <номер> ; передаём в параметре номер отслеживаемой функции call <относительный адрес функция сбора статистик> popf ; восстанавливаем флаги popa ; и регистры jmp <относительный адрес отслеживаемой функции> |
Поскольку эта функция-шпион заканчивается непосредственным вызовом отслеживаемой функции, она может совместно работать только с методами перехвата, не изменяющими код перехватываемой функции. Это:
Если вы используете другой метод перехвата (например, замену нескольких начальных байтов на команду jmp), вам придётся немного изменить мой код.
Если по каким-то причинам вам очень нужно получить возвращаемое значение отслеживаемой функции, или вы хотите измерить время её выполнения, или что-то ещё, недоступное моему пониманию, вы всё-таки можете написать функцию-шпион так, чтобы она использовала call для вызова отслеживаемой функции и получала управления после её завершения.
Для этого нужно:
ПРИМЕЧАНИЕ А если функция вызвана дальним вызовом, то (сюрприз!) адрес возврата будет занимать 6 байт. Хуже того, новый адрес тоже должен быть шестибайтным, так как отслеживаемая функция очень на это рассчитывает. Вряд ли вы встретитесь с такой ситуацией в Windows, но про другие ОС я ничего сказать не могу. |
Ключевым вопросом этого алгоритма является: «где же это где-то, в котором можно сохранить адрес возврата?» Стек менять нельзя, поэтому он отпадает. Хранить в регистрах тоже нельзя: те регистры, которые могут измениться после вызова функции, может изменить отслеживаемая функция, и данные пропадут, а те регистры, которые не должны меняться после вызова, нельзя менять нам, так как восстановить их мы не сумеем – негде сохранить их старые значения :)
Остаётся только хранение в глобальной области памяти. Так как приложение может быть многопоточным, доступ к памяти нужно синхронизировать, и отдельно хранить данные для каждого потока. Так как возможна рекурсия, необходимо хранить не один адрес возврата, а стек адресов… И, несмотря на все эти предосторожности, что будет, если в отслеживаемой функции произойдёт исключение и начнётся развёртывание стека? Правильно, будет очень плохо…
В общем, это путь для людей, крепких духом и готовых к испытаниям. Далее в статье он не рассматривается.
Алгоритм установки одной функции-шпиона:
Очевидно, что этот алгоритм никак не зависит от прототипа/формата вызова/.. отслеживаемой функции, и может быть без изменений применён для любого количества функций. Тем не менее, рассмотрим два случая.
Это самое простое. Поскольку адреса таких функций приложение получает через GetProcAddress, достаточно просто перехватить GetProcAddress и производить описанную выше процедуру для всех запрашиваемых функций.
Общая идея: пройтись по таблицам импорта загруженных модулей и, не особо задумываясь, перехватить все упомянутые там функции. Кроме того, нужно позаботиться о GetProcAddress (см. предыдущий пункт) и о ещё не загруженных модулях: их таблицы импорта тоже необходимо обработать. Чтобы не пропустить появление новых модулей, можно, например, перехватить все версии LoadLibrary[Ex]A/W.
Просто, правда? Просто, но, к сожалению, в таком виде работать, скорее всего, не будет.
ПРЕДУПРЕЖДЕНИЕ Этот вариант я так и не реализовал (незачем было), поэтому о его неизбежных маленьких особенностях почти ничего не знаю. Мои попытки поразмышлять представлены ниже, но практики за ними не стоит, и гарантировать отсутствие проблем я не могу. Сожалею. |
Проблема этого подхода заключается в почти гарантированном возникновении бесконечной рекурсии. Например, пусть collectStatistic записывает данные в файл при помощи функции WriteFile. Если эта функция оказалась перехвачена и в вашем модуле, то попытка записи приведёт к вызову вашей функции-шпиона, которая вызовет collectStatistic и т.д. пока не кончится место в стеке.
Ладно, вы поняли свою ошибку и больше не меняете таблицу импорта своего модуля. Но дело в том, что для реализации WriteFile kernel32.dll вызывает функцию NtWriteFile из ntdll.dll. А, поскольку таблицу импорта kernel32.dll вы изменили, опять вызывается функция-шпион, которая вызывает colleclStatistic и всё начинается заново.
Отсюда вывод: при проведении перехвата необходимо пропустить модули, которые вы сами прямо или косвенно используете. Идеально было бы менять таблицы импорта только в «нестандартных» модулях, так как, скорее всего, именно это вам и нужно: вряд ли вас интересует, какие функции ntdll.dll вызываются во время вызова WriteFile, обычно достаточно просто знать, что приложение вызвало WriteFile. Определять нестандартные модули можно разными способами, мне пришли в голову следующие:
Кроме того, всегда есть радикальное решение: написать графический интерфейс и взвалить эту задачу на пользователя. :)
В соответствии с тем, как она используется функциями-шпионами, функция сбора статистики должна иметь следующие характеристики:
На C++ это реализуется примерно так:
void __stdcall collectStatistic(unsignedlong n) { // Что угодно, например такое functions[n].count++; printf(("called %s (%d)\n", functions[n].name.c_str(), functions[n].count); } |
В этом примере статистическая информация состоит из имени функции и количества вызовов, всё это хранится в массиве functions, отображением статистики занимается само исследуемое приложение.
Потенциально, функция сборки статистики может для каждого вызова сохранять следующие параметры:
В общем, уровень детализации может быть очень разным и зависит от задачи.
Два принципиально разных подхода:
Оба подхода имеют свои плюсы и минусы: с точки зрения получения данных, очевидно, что первый обладает всеми возможностями второго (если уж данные отображаются, параллельно сохранять их в лог не проблема), а, с точки зрения влияния на исследуемое приложение, второй может получиться гораздо мягче, и в какой-то ситуации это может оказаться критичным. Кроме того, второй подход может оказаться значительно проще в реализации.
ПРИМЕЧАНИЕ Например, если данные можно в течение всего времени выполнения хранить в памяти, а запись на диск сделать только в самом конце (в DllMain). Или, чуть более интеллектуально, попытаться записывать/передавать данные только в те моменты, когда исследуемое приложение само обращается к диску. |
Но, поскольку первый подход гораздо эффектнее (real-time, on-line, и даже мультимедиа, если постараться, – все эти слова можно обоснованно употребить в пресс-релизе :) ), далее рассматривается в основном он.
Есть три варианта реализации «сбора и отображения»:
Наиболее интересен последний вариант (рассматриваем отображение в реальном времени), так как за счёт выноса части логики во внешнее приложение dll получается относительно простой, в результате чего снижается риск случайно испортить что-нибудь в исследуемом приложении, упрощается отладка и повышается надёжность системы в целом.
Ограничимся простым случаем:
Основную работу по генерации выполняют следующие несложные классы:
// Класс, позволяющий работать с относительными адресами. // Позволяет копировать относительные адреса, сохраняя их корректными. struct relative_address { relative_address() : value(0) {} // Корректно копирует относительный адрес. relative_address(const relative_address& a) { // Копирование со смещением на расстояние между указателями. value = (unsignedlong)a.value + (unsignedlong)&a.value - (unsignedlong)&value; } // Корректно присваивает относительный адрес. relative_address& operator = (const relative_address& a) { if (this != &a) { // Копирование со смещением на расстояние между указателями. value = (unsignedlong)a.value + (unsignedlong)&a.value - (unsignedlong)&value; } return *this; } // Устанавливает относительный адрес соответствующим указанному абсолютному.void set_absolute(void* a) { // Относительный адрес отсчитывается от начала следующей инструкции.// Поскольку в тех инструкциях, в которые входит относительный адрес,// он находится в конце, начало следующей инструкции - это конец адреса. value = (unsignedlong)a - (unsignedlong)&value - sizeof(value); } unsignedlong value; }; // Класс, упрощающий работу с однобайтной командой.template<unsignedchar c> struct one_byte_command { one_byte_command() : code(c) {} unsignedchar code; }; // Класс, упрощающий работу с командой с однобайтным кодом // и 4-байтным операндом.template<unsignedchar c> struct one_byte_value_command { one_byte_value_command() : code(c) {} unsignedchar code; unsignedlong value; }; // Класс, упрощающий работу с командой с однобайтным кодом // и относительным адресомtemplate<unsignedchar c> struct one_byte_rel_address_command { one_byte_rel_address_command() : code(c) {} unsignedchar code; relative_address address; }; |
С их помощью можно определить классы для команд процессора, а из них уже собрать функцию. Например, так:
// Команда pusha typedef one_byte_command<0x60> pusha; // Команда pushftypedef one_byte_command<0x9C> pushf; // Команда push xxxtypedef one_byte_value_command<0x68> push_value; // Команда popatypedef one_byte_command<0x61> popa; // Команда popftypedef one_byte_command<0x9D> popf; // Команда call xxxtypedef one_byte_rel_address_command<0xE8> call_address; // Команда jmp xxxtypedef one_byte_rel_address_command<0xE9> jmp_address; //// Функция-шпион, собранная из этих командstruct spy_function { pusha c1; pushf c2; push_value number; call_address statistic; popf c5; popa c6; jmp_address func; }; |
ПРИМЕЧАНИЕ Естественно, чтобы это работало, необходимо при объявлении классов установить выравнивание данных по границе одного байта. В Visual C++ это делается так: #pragma pack(1, push) … // здесь все объявления #pragma pack(pop) |
Как пользоваться получившимся в итоге классом spy_function, продемонстрировано ниже.
Не содержит в себе ничего сложного. Работает по алгоритму установки одной функции-шпиона, в качестве сохранения информации о перехваченной функции сообщает внешнему приложению имя функции и получает в ответ соответствующий этой функции номер.
void* __stdcall myGetProcAddress(HMODULE hLib, constchar* name) { // Вызываем настоящую GetProcAddress, получаем адрес функцииvoid* address = _GetProcAddress(hLib, name); if (address == 0) { // Не судьбаreturn NULL; } char full_name[MAX_PATH * 2]; GetModuleFileNameA(hLib, full_name, sizeof(full_name)/sizeof(full_name[0])); strcat(full_name, " "); if (reinterpret_cast<int>(name) > 0x0000ffff) { // Копируем имя strcat(full_name, name); } else { // А некоторые функции экспортируются по ординалам...char ordinal[10]; strcat(full_name, "by ordinal: "); strcat(full_name, itoa(reinterpret_cast<int>(name), ordinal, 16)); } COPYDATASTRUCT cd = {0}; // 1 требуется, чтобы учесть в длине завершающий NULL-символ. cd.cbData = strlen(full_name) + 1; cd.lpData = full_name; // посылаем строчкуint number = SendMessage(g_hSecretWindow, WM_COPYDATA, 0, reinterpret_cast<LPARAM>(&cd)); // Генерируем функцию-шпионаtry { // См. «Чем же всё это закончится?»void* spyMem = HeapAlloc(GetProcessHeap(), 0, sizeof(spy_function)); spy_function* spy = new(spyMem) spy_function; // Устанавливаем её параметры. spy->number.value = number; spy->statistic.address.set_absolute(collectStatistic); spy->func.address.set_absolute(address); // Возвращаем указатель на функцию-шпион.return spy; } catch (...) { // Не судьба PostMessage(g_hSecretWindow, WM_CANNOTHOOK, number, 0); // Возвращаем указатель на функциюreturn address; } } |
Поскольку данных мало и посылать их несложно, функция collectStatistic получилась просто замечательная:
void __stdcall collectStatistic(unsignedlong n) { // Посылаем номер вызываемой функции PostMessage(g_hSecretWindow, WM_CALLED, n, 0); } |
И тем и другим занимается внешнее приложение. Реализовано всё крайне незамысловато:
// Структура, хранящая статистику для одной функции struct func_descrition { std::string name; // Имя функцииint count; // Количество вызовов }; // Вектор, хранящий всю статистику вообще std::vector<func_descrition> functions; #define WM_CALLED (WM_USER + 1) #define WM_CANNOTHOOK (WM_USER + 2) // Процедура окна, которому внедрённая dll посылает данные LRESULT CALLBACK WndProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam) { switch (uMsg) { // Вызвана GetProcAddresscase WM_COPYDATA: { // Получаем указатель на переданную структуру COPYDATASTRUCT* pcd = reinterpret_cast<COPYDATASTRUCT*>(lParam); // Получаем имяchar* str = (char*)pcd->lpData; printf("New function: %s\n", str); // Новая функция func_descrition f; f.count = 0; f.name = str; // Добавляем её в вектор functions.push_back(f); } // Возвращаем номерreturn (functions.size() - 1); // Вызвана перехваченная функцияcase WM_CALLED: // Увеличиваем количество вызовов functions[wParam].count++; printf("Called %s\n", functions[wParam].name.c_str()); return 0; // Не удалось установиь перехватчик на функциюcase WM_CANNOTHOOK: // Уведомляем пользователя printf("Can not hook %s\n", functions[wParam].name.c_str()); return 0; } return DefWindowProc(hwnd, uMsg, wParam, lParam); } |
ПРИМЕЧАНИЕ Для простоты этот код не проверяет имя функции на уникальность, поэтому в functions может оказаться несколько записей для одной и той же функции. |
Так как эта статья не посвящена ни перехвату, ни внедрению (на эти темы есть много других хороших статей), для реализации выбраны простые, но радикальные средства. Внедрение сделано через CreateRemoteThread, а перехват GetProcAddress – заменой её первых пяти байт на команду jmp.
Для передачи внедрённой dll описателя окна, которому она должна посылать сообщения (g_hSecretWindow в примере), использована техника из статьи «HOWTO: Вызов функции в другом процессе».
Будет завершение процесса. Как известно, во время завершения процесса все dll выгружаются, и вся выделенная память освобождается. При этом могут произойти следующие неприятности:
В обоих случаях исследуемое приложение получит Access Violation, после чего говорить о том, что его работа не нарушена, будет достаточно сложно.
Поскольку у нашей dll счётчик ссылок всегда больше 0 (LoadLibrary была вызвана, а FreeLibrary нет), она выгружается одной из последних, но в некоторых случаях этого может оказаться недостаточно. Радикальным решением проблемы является «ручная» загрузка dll, описанная в статье Максима М. Гумерова «Загрузчик PE-файлов». Это довольно трудоёмкий, но зато практически гарантированный вариант. Другим возможным решением (для NT/2000/…) может быть удаление dll из списка загруженных модулей в PEB, но как это сделать и будет ли это работать, я пока не знаю…
Последняя идея, пришедшая мне в голову:
Это один из самых «грязных хаков», которые я когда-либо проворачивал :) Иногда оно работает, иногда – нет. И даже если всё на первый взгляд работает, я не берусь сказать, какие будут побочные эффекты.
Подводя итог: если задача и имеет хорошее решение, его описание выходит далеко за рамки этой статьи. Поэтому наша dll будет выгружаться, хотя иногда это и может привести к проблемам.
С памятью проще: чтобы её точно никто не освободил, достаточно отказаться от стандартного оператора new, и использовать вместо него placement new, выделяя память как-нибудь иначе.
Во время тестов обнаружилось, что в Windows XP, при выделении памяти обычным new и статической линковке CRT, некоторые (не все и не всегда, но вполне воспроизводимо) блоки памяти с функциями-шпионами оказываются освобождены. При использовании CRT в dll этой проблемы не было, с чем всё это связано, я не знаю. |
Yes! Оно работает!! :)
ПРЕДУПРЕЖДЕНИЕ Нормального тестирования не проводилось, кроме того, у меня под рукой не оказалось Windows NT 4. Но на Windows 2000, XP и 2003 Server проверил, на первый взгляд всё путём… И даже XP SP2 не страшен :) |
Для успешного старта надо положить spyloader.exe и apispy.dll в один каталог, после чего запустить spyloader, передав ему в командной строке путь к exe-файлу исследуемого приложения.
Только приготовьтесь к тому, что GetProcAddress – довольно популярная функция, и получить сотню функций-шпионов (то есть вызовов GetProcAddress) при исследовании notepad.exe – не вопрос, достаточно попытаться открыть какой-нибудь файл. А уж если вы запустите справку и немного по ней походите… У меня получилось 530 функций-шпионов за две минуты :) Поэтому, если вы действительно будете реализовывать нечто подобное, то лучше фиксировать не всё подряд, а фильтровать вызовы хотя бы по имени модуля.
Сообщений 7 Оценка 240 [+1/-0] Оценить |