; Вызов
mov eax, <абсолютный адрес функции
>
call eax
; Передача управления
mov eax, <абсолютный адрес
функции>
jmp 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
{
// А некоторые функции
экспортируются по ординалам...
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: %s\n", str);
// Новая функция
func_descrition
f;
f.count = 0;
f.name = str;
// Добавляем её в вектор
}
// Возвращаем номер
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 может оказаться
несколько записей для одной и той же функции.
|
|
|
|
Внедрение в приложение и перехват 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: Вызов функции в другом
процессе»
Для подготовки данной работы были использованы
материалы с сайта http://www.rsdn.ru/
Похожие работы на - Пасьянс "Три пика"
|