Сергей Холодилов
Я открываю свойства растений и трав..
Борис Гребенщиков
Словосочетанием «API Spying» называется слежение за вызовами функций API некоторым приложением. То есть, каждый факт вызова этим приложением выбранных функций каким-то образом фиксируется, например, добавляется запись в лог.
ПРИМЕЧАНИЕ Для ясности назовём «некоторое приложение» исследуемым приложением, а «выбранные функции» – отслеживаемыми функциями. |
API Spying может использоваться на одном из этапов исследования программы, логику работы которой вы пока не до конца понимаете. Хотя эта технология и не позволяет получить детальную информацию, она может значительно сузить область последующих этапов исследования, сконцентрировав ваше внимание на тех вызовах, которые происходят в ключевые моменты работы программы.
На первый взгляд может показаться, что задача лучше решается с помощью перехвата API, так как он даёт возможность не только отследить вызов, но и изучить/изменить параметры и возвращаемое значение, или даже полностью переписать функцию.
Действительно, перехват API – замечательная и часто упоминаемая техника (на данный момент на RSDN этой теме посвящены три статьи), позволяющая довольно глубоко изучить исследуемое приложение, но это и гораздо более трудоёмкое решение. Даже если реализации функций будут почти пустыми (только запись в лог и вызов оригинальной функции), ваш код будет примерно таким:
typedef int (__stdcall* Function1_type)(int i); Function_type _Function1; // Обёртка, логирующая вызовы int __stdcall MyFunction1(int i) { printf("MyFunction1n"); return _Function(i); // Вызов оригинальной функции } ... // Перехват всех функций void HookThemAll() { ... // Перехват функции _Function1, экспортируемой some.dll HookIt("some.dll", "_Function1@4", MyFunction1, &_Function1); ... } |
ПРИМЕЧАНИЕ Это приблизительный код, используемый при перехвате через таблицу импорта; другие варианты перехвата в данном случае не имеют существенных преимуществ. |
То есть, для каждой функции придётся:
определить тип;
определить переменную;
написать обёртку;
добавить строчку в HookThemAll.
Это, конечно, довольно простые операции… Но представьте, что таким образом вам нужно перехватить несколько сотен функций. А если не у всех функций известны прототипы? А если некоторые dll загружается динамически, и вы пока даже не знаете, какие их функции используются приложением? А если после того, как вы всё успешно перехватите и просмотрите получившиеся логи, станет понятно, что для детального понимания работы приложения нужно было перехватить всего две функции и изучить их параметры :) ?
Когда все эти вопросы встали передо мной, я занялся API Spying-ом.
API Spying не исключает перехвата API, но эти методики используются находятся на разных стадиях анализа программы. Сначала при помощи API Spying-а определяется несколько наиболее интересных функций, потом, если необходимо, эти функции перехватываются и изучаются «в более тесном контакте».
Постановка задачи
В самом общем виде задача выглядит так:
Необходимо получать информацию о фактах вызова выбранных функций исследуемым приложением.
Для получения статистики не обязательно заранее знать имена функций, которые будут вызываться приложением. Тем более, не нужно знать их прототипы.
Сбор статистики для любого (в том числе заранее неизвестного) количества функций из любых (в том числе, из загружаемых динамически) модулей не должен представлять трудностей.
Работоспособность исследуемого приложения не должна нарушаться.
Формулировка ТЗ
Логически разовьём требования, высказанные в постановке задачи:
Мы рассматриваем только программные реализации. Это значит, что статистика собирается программно, и этим занимается наш код.
Поскольку при каждом вызове отслеживаемых функций управление должно передаваться нашему коду, все отслеживаемые функции нужно перехватить (тем или иным способом; подробное рассмотрение способов перехвата API выходит за рамки статьи). Нашу функцию, которой в результате перехвата будет передаваться управление, назовём функцией-шпионом (терминология моя, сожалею, если не прав).
Чтобы вести статистику вызовов и не нарушать работу приложения, функция-шпион должна определить, какую именно отслеживаемую функцию собиралось вызвать исследуемое приложение. Единственный способ реализовать это – сопоставить каждой отслеживаемой функции свою функцию-шпиона, «знающую», как минимум, адрес оригинала.
По возможности, присутствие функции-шпиона не должно влиять на выполнение отслеживаемой функции. Это получается не всегда, разумные исключения описаны ниже, в разделе «Почему приложение может перестать работать».
Так как количество отслеживаемых функций может быть велико или заранее не известно, функции-шпионы должны генерироваться автоматически в процессе исполнения.
Несколько дополнительных пожеланий:
Автоматически генерировать сложные функции-шпионы непросто. :) Их даже писать на ассемблере замаешься... Хорошо бы подсчёт статистики взял на себя кто-то другой.
ПРИМЕЧАНИЕ Вы классно знаете ассемблер, и считаете, что это пара пустяков? Возможно, вы не учли, что код функций будет расположен в произвольном месте адресного пространства и что (забегая вперёд; но вы-то это всё должны понимать) функции не могут модифицировать стек и регистры. Если и это для вас не проблема, то, во-первых, примите моё искреннее восхищение (без шуток!), во-вторых, прочитайте следующий пункт. :) |
Автоматическая генерация подразумевает выделение памяти для кода функций, а, так как их может быть много, желательно чтобы функции были короткими. Поэтому, опять же, хорошо бы подсчёт статистики взял на себя кто-то другой.
И несколько ограничений:
Эта реализация поддерживает только Intel x86-совместимые процессоры.
Для работы функции-шпиона от ОС требуется только одно: она должна позволять исполнять динамически сгенерированный код. Это условие соблюдается во всех версиях Windows и, скорее всего, в подавляющем большинстве остальных ОС общего пользования. Но, поскольку нам необходим ещё и способ перехвата, ограничимся линейкой Windows NT/2000/XP. Используя другие способы перехвата, можно реализовать API Spying для других ОС.
Неизвестно, как на исполнение сгенерированного кода будут реагировать антивирусы. Возможно, они будут недостаточно толерантны. :)
СОВЕТ О подобных ограничениях лучше не забывать и в реальных проектах, так как иначе выполнить ТЗ будет практически невозможно. |
Почему приложение может перестать работать
Проблема заключается в том, что (увы!) статистика собирается не магически, её собирает наш код, внедрённый в исследуемое приложение. У этого простого факта есть три неприятных следствия:
При добавлении сбора статистики изменится скорость работы функций. Обычно это ни на что не влияет, но если в середине критичного к скорости выполнения кода мы неожиданно (для приложения и его автора) начнём запись в файл, может получиться плохо. Например, FPS упадёт раз в десять :) Но FPS – это не страшно, страшно, если вы исследуете многопоточное приложение c некорректно написанной синхронизацией, и изменение времени выполнения потоков приведет к дедлокам, падениям или просто непонятному поведению.
Помимо процессорного времени, наш код использует и другие ресурсы: память, стек, (возможно) окна, объекты ядра (файлы, события, и т.п.) и другие. В некотором фантастическом случае это может стать последней каплей, приводящей к исчерпанию доступных ресурсов потоком, процессом, или даже системой.
Если автор приложения решил позаботиться о защите своего детища, он вполне в состоянии засечь наши манипуляции, обидеться (он же не знает, что мы ничего плохого не хотели) и сделать какую-нибудь бяку. Например, откажется работать, или станет работать неправильно, или отформатирует случайно выбранный диск…
Все эти следствия в той или иной степени свойственны любой программной реализации API Spying-а, и ни в одной из этих ситуаций я не могу посоветовать вам ничего хорошего. Можно только попытаться уменьшить степень влияния и избежать столь пагубных последствий.
Предпроектные исследования: функции в Intel x86
Как вы уже, наверное, поняли, нам предстоит динамическая генерация кода функций-шпионов. Хотя ничего особо сложного в этом не будет (они действительно очень простые), небольшое теоретическое введение поможет вам понять (а мне – объяснить), как должна быть написана функция-шпион, чтобы вызов отслеживаемой функции завершился без помех.
Вызов
С точки зрения процессора вызов функции выполняет инструкция 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. Это значит, что вызывающий код:
Может рассчитывать на то, что эти регистры не поменяются.
Не должен рассчитывать на регистры EAX, ECX, EDX, EFLAGS (с ним немного сложнее, очевидно, часть флагов всё-таки должна остаться неизменной, просто MSDN об этом не упоминает), а также на регистры MMX, FPU, XMM.
ПРИМЕЧАНИЕ А как же остальные регистры? Сегментные, управляющие, GDTR, LDTR, ….? С ними просто: если функция меняет какой-то из этих регистров, то, либо это документированный побочный эффект (например, ожидаемый результат) её вызова, либо автор функции очень, очень плохо пошутил… |
Проектирование
Система в целом состоит из четырёх частей:
Функция-шпион.
Механизм установки шпионов.
Функция сбора статистики.
Механизм сбора и отображения статистики.
Функция-шпион
Задачи
Задачи работы функции-шпиона:
Вызвать функцию сбора статистики, каким-то образом сообщив ей, какая отслеживаемая функция вызывается.
Вызвать отслеживаемую функцию.
Ограничения
Ограничения связаны с тем, что отслеживаемая функция должна работать без изменений. Для этого перед её вызовом:
Необходимо привести стек в то же состояние, которое было до начала работы функции-шпиона. Это значит, что, во-первых, нельзя сохранить в стеке какое-нибудь значение для использования после возврата из отслеживаемой функции, во-вторых, нельзя использовать для вызова инструкцию call, так как она добавит в стек адрес возврата (на эту тему см. ниже, в разделе «Получение управления после возврата из отслеживаемой функции»).
Поскольку в принципе параметры могут передаваться и в регистрах, желательно привести регистры в то же состояние, которое было до начала работы функции-шпиона, или хотя бы в максимально близкое.
Код, который надо сгенерировать
Так как код функции-шпиона может располагаться в памяти по произвольному адресу, при вызове из неё функций необходимо либо использовать абсолютную адресацию, либо при генерации вычислять их адреса для каждой новой функции-шпиона.
Оба подхода одинаково просто реализуются, но из-за особенности системы команд Intel x86 ближний вызов/передача управления по абсолютному адресу будет выглядеть примерно так:
; Вызов mov eax, <абсолютный адрес функции > call eax ; Передача управления mov eax, <абсолютный адрес функции> jmp eax |
То есть, как ни старайся, а значение одного регистра (в данном примере регистра eax, но на его месте мог быть каждый) сохранить не удаётся.
Поэтому выбрана версия с относительной адресацией:
pusha ; сохраняем регистры и флаги. pushf ; Это, конечно, паранойя... push <номер> ; передаём в параметре номер отслеживаемой функции call <относительный адрес функция сбора статистик>
popf ; восстанавливаем флаги popa ; и регистры
jmp <относительный адрес отслеживаемой функции> |
Поскольку эта функция-шпион заканчивается непосредственным вызовом отслеживаемой функции, она может совместно работать только с методами перехвата, не изменяющими код перехватываемой функции. Это:
перехват через таблицу импорта;
перехват через таблицу экспорта;
перехват GetProcAddress и подмена адреса запрашиваемой функции.
Если вы используете другой метод перехвата (например, замену нескольких начальных байтов на команду jmp), вам придётся немного изменить мой код.
Получение управления после возврата из отслеживаемой функции
Если по каким-то причинам вам очень нужно получить возвращаемое значение отслеживаемой функции, или вы хотите измерить время её выполнения, или что-то ещё, недоступное моему пониманию, вы всё-таки можете написать функцию-шпион так, чтобы она использовала call для вызова отслеживаемой функции и получала управления после её завершения.
Для этого нужно:
Удалить из стека старый адрес возврата.
ПРИМЕЧАНИЕ А если функция вызвана дальним вызовом, то (сюрприз!) адрес возврата будет занимать 6 байт. Хуже того, новый адрес тоже должен быть шестибайтным, так как отслеживаемая функция очень на это рассчитывает. Вряд ли вы встретитесь с такой ситуацией в Windows, но про другие ОС я ничего сказать не могу. |
Где-то сохранить его на время вызова отслеживаемой функции.
Вызвать функцию.
Получить/измерить/.. то, что вы хотели.
Вернуть управление по старому адресу.
Ключевым вопросом этого алгоритма является: «где же это где-то, в котором можно сохранить адрес возврата?» Стек менять нельзя, поэтому он отпадает. Хранить в регистрах тоже нельзя: те регистры, которые могут измениться после вызова функции, может изменить отслеживаемая функция, и данные пропадут, а те регистры, которые не должны меняться после вызова, нельзя менять нам, так как восстановить их мы не сумеем – негде сохранить их старые значения :)
Остаётся только хранение в глобальной области памяти. Так как приложение может быть многопоточным, доступ к памяти нужно синхронизировать, и отдельно хранить данные для каждого потока. Так как возможна рекурсия, необходимо хранить не один адрес возврата, а стек адресов… И, несмотря на все эти предосторожности, что будет, если в отслеживаемой функции произойдёт исключение и начнётся развёртывание стека? Правильно, будет очень плохо…
В общем, это путь для людей, крепких духом и готовых к испытаниям. Далее в статье он не рассматривается.
Механизм установки шпионов
Алгоритм установки одной функции-шпиона:
Генерируется функция-шпион, при генерации устанавливается её номер, адрес отслеживаемой функции и адрес функции сбора статистики.
Перехватывается отслеживаемая функция, теперь вместо неё приложением должна вызываться функция-шпион.
Где-то сохраняется информация, о том, что перехвачена функция с таким-то именем и ей сопоставлен такой-то номер. Эта информация будет использована при вызове функции сбора статистики.
Очевидно, что этот алгоритм никак не зависит от прототипа/формата вызова/.. отслеживаемой функции, и может быть без изменений применён для любого количества функций. Тем не менее, рассмотрим два случая.
Отслеживание вызовов функций динамически загружаемых dll
Это самое простое. Поскольку адреса таких функций приложение получает через 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(unsigned long n) { // Что угодно, например такое functions[n].count++; printf(("called %s (%d)n", functions[n].name.c_str(), functions[n].count); } |
В этом примере статистическая информация состоит из имени функции и количества вызовов, всё это хранится в массиве functions, отображением статистики занимается само исследуемое приложение.
Механизм сбора и отображения статистики
Что собирать
Потенциально, функция сборки статистики может для каждого вызова сохранять следующие параметры:
Имя функции.
Имя модуля.
Имя модуля, из которого произошёл вызов.
Идентификатор текущего потока.Время вызова.
Дамп стека.
Состояние регистров процессора
и так далее.
В общем, уровень детализации может быть очень разным и зависит от задачи.
Политика отображения
Два принципиально разных подхода:
Данные доступны в реальном времени (посредством какого-нибудь GUI).
Данные доступны после завершения исследуемого приложения (в файле на диске).
Оба подхода имеют свои плюсы и минусы: с точки зрения получения данных, очевидно, что первый обладает всеми возможностями второго (если уж данные отображаются, параллельно сохранять их в лог не проблема), а, с точки зрения влияния на исследуемое приложение, второй может получиться гораздо мягче, и в какой-то ситуации это может оказаться критичным. Кроме того, второй подход может оказаться значительно проще в реализации.
ПРИМЕЧАНИЕ Например, если данные можно в течение всего времени выполнения хранить в памяти, а запись на диск сделать только в самом конце (в DllMain). Или, чуть более интеллектуально, попытаться записывать/передавать данные только в те моменты, когда исследуемое приложение само обращается к диску. |
Но, поскольку первый подход гораздо эффектнее (real-time, on-line, и даже мультимедиа, если постараться, – все эти слова можно обоснованно употребить в пресс-релизе :) ), далее рассматривается в основном он.
Где хранить и как отображать статистику
Есть три варианта реализации «сбора и отображения»:
Данные хранятся и отображаются dll, внедрённой в исследуемое приложение.
Данные хранятся dll, внедрённой в исследуемое приложение, для отображения она пересылает их внешнему приложению.
И хранением, и отображением занимается внешнее приложение, dll просто пересылает ему данные по мере поступления.
Наиболее интересен последний вариант (рассматриваем отображение в реальном времени), так как за счёт выноса части логики во внешнее приложение dll получается относительно простой, в результате чего снижается риск случайно испортить что-нибудь в исследуемом приложении, упрощается отладка и повышается надёжность системы в целом.
Реализация
Ограничимся простым случаем:
Отслеживаем только вызовы функций, адреса которых исследуемое приложение получает через GetProcAddress.
Сохраняем только имена функций и модулей.
Отображаем данные в реальном времени. В качестве GUI выступает консоль. :)
Данные хранятся и отображаются во внешнем приложении.
Генерация функции-шпиона
Основную работу по генерации выполняют следующие несложные классы:
// Класс, позволяющий работать с относительными адресами. // Позволяет копировать относительные адреса, сохраняя их корректными. struct relative_address { relative_address() : value(0) {} // Корректно копирует относительный адрес. relative_address(const relative_address& a) { // Копирование со смещением на расстояние между указателями. value = (unsigned long)a.value + (unsigned long)&a.value - (unsigned long)&value; } // Корректно присваивает относительный адрес. relative_address& operator = (const relative_address& a) { if (this != &a) { // Копирование со смещением на расстояние между указателями. value = (unsigned long)a.value + (unsigned long)&a.value - (unsigned long)&value; } return *this; } // Устанавливает относительный адрес соответствующим указанному абсолютному. void set_absolute(void* a) { // Относительный адрес отсчитывается от начала следующей инструкции. // Поскольку в тех инструкциях, в которые входит относительный адрес, // он находится в конце, начало следующей инструкции - это конец адреса. value = (unsigned long)a - (unsigned long)&value - sizeof(value); } unsigned long value; }; // Класс, упрощающий работу с однобайтной командой. template<unsigned char c> struct one_byte_command { one_byte_command() : code(c) {} unsigned char code; }; // Класс, упрощающий работу с командой с однобайтным кодом // и 4-байтным операндом. template<unsigned char c> struct one_byte_value_command { one_byte_value_command() : code(c) {} unsigned char code; unsigned long value; }; // Класс, упрощающий работу с командой с однобайтным кодом // и относительным адресом template<unsigned char c> struct one_byte_rel_address_command { one_byte_rel_address_command() : code(c) {} unsigned char code; relative_address address; }; |
С их помощью можно определить классы для команд процессора, а из них уже собрать функцию. Например, так:
// Команда pusha typedef one_byte_command<0x60> pusha; // Команда pushf typedef one_byte_command<0x9C> pushf; // Команда push xxx typedef one_byte_value_command<0x68> push_value; // Команда popa typedef one_byte_command<0x61> popa; // Команда popf typedef one_byte_command<0x9D> popf; // Команда call xxx typedef one_byte_rel_address_command<0xE8> call_address; // Команда jmp xxx typedef 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, продемонстрировано ниже.
myGetProcAddress
Не содержит в себе ничего сложного. Работает по алгоритму установки одной функции-шпиона, в качестве сохранения информации о перехваченной функции сообщает внешнему приложению имя функции и получает в ответ соответствующий этой функции номер.
void* __stdcall myGetProcAddress(HMODULE hLib, const char* 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
Поскольку данных мало и посылать их несложно, функция collectStatistic получилась просто замечательная:
void __stdcall collectStatistic(unsigned long 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) { // Вызвана GetProcAddress case WM_COPYDATA: { // Получаем указатель на переданную структуру COPYDATASTRUCT* pcd = reinterpret_cast<COPYDATASTRUCT*>(lParam); // Получаем имя char* str = (char*)pcd->lpData; printf("New function: %sn", 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 %sn", functions[wParam].name.c_str()); return 0; // Не удалось установиь перехватчик на функцию case WM_CANNOTHOOK: // Уведомляем пользователя printf("Can not hook %sn", functions[wParam].name.c_str()); return 0; } return DefWindowProc(hwnd, uMsg, wParam, lParam); } |
ПРИМЕЧАНИЕ Для простоты этот код не проверяет имя функции на уникальность, поэтому в functions может оказаться несколько записей для одной и той же функции. |
Внедрение в приложение и перехват GetProcAddress
Так как эта статья не посвящена ни перехвату, ни внедрению (на эти темы есть много других хороших статей), для реализации выбраны простые, но радикальные средства. Внедрение сделано через CreateRemoteThread, а перехват GetProcAddress – заменой её первых пяти байт на команду jmp.
Для передачи внедрённой dll описателя окна, которому она должна посылать сообщения (g_hSecretWindow в примере), использована техника из статьи «HOWTO: Вызов функции в другом процессе».
Чем же всё это закончится?
Будет завершение процесса. Как известно, во время завершения процесса все dll выгружаются, и вся выделенная память освобождается. При этом могут произойти следующие неприятности:
Наша dll будет выгружена раньше времени.
Раньше времени будет освобождена память, в которой расположены сгенерированные функции.
В обоих случаях исследуемое приложение получит Access Violation, после чего говорить о том, что его работа не нарушена, будет достаточно сложно.
Невыгружаемая dll
Поскольку у нашей dll счётчик ссылок всегда больше 0 (LoadLibrary была вызвана, а FreeLibrary нет), она выгружается одной из последних, но в некоторых случаях этого может оказаться недостаточно. Радикальным решением проблемы является «ручная» загрузка dll, описанная в статье Максима М. Гумерова «Загрузчик PE-файлов». Это довольно трудоёмкий, но зато практически гарантированный вариант. Другим возможным решением (для NT/2000/…) может быть удаление dll из списка загруженных модулей в PEB, но как это сделать и будет ли это работать, я пока не знаю…
Последняя идея, пришедшая мне в голову:
честно загрузить dll в процесс, позволить загрузчику выполнить свою работу
скопировать получившийся образ
выгрузить dll
записать в то же место адресного пространства образ dll.
молиться.
Это один из самых «грязных хаков», которые я когда-либо проворачивал :) Иногда оно работает, иногда – нет. И даже если всё на первый взгляд работает, я не берусь сказать, какие будут побочные эффекты.
Подводя итог: если задача и имеет хорошее решение, его описание выходит далеко за рамки этой статьи. Поэтому наша 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 функций-шпионов за две минуты :) Поэтому, если вы действительно будете реализовывать нечто подобное, то лучше фиксировать не всё подряд, а фильтровать вызовы хотя бы по имени модуля.
Тихомиров В.А. «Перехват API-функций в Windows NT/2000/XP».
Игорь Филимонов «Методы перехвата API-вызовов в Win32»
Intel Corporation «IA-32 Intel Architecture Software Developer’s Manual», части 2A и 2B
Максим М. Гумеров «Загрузчик PE-файлов»
Сергей Холодилов «HOWTO: Вызов функции в другом процессе»