Смекни!
smekni.com

В большинстве остальных случаев параметры передаются через стек. При этом вызов функции выглядит примерно так:

push ... ; Параметр push ... ; Ещё один параметр push ... ; И последний параметр call xxxxxh ; Вызов

А стек к моменту начала выполнения функции – так:

API Spying

Рисунок 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 и т.д. пока не кончится место в стеке.