На суд читателя представляется статья о внедрении собственного исполняемого кода в Windows программы.
В статье использованы различные технические термины, относящиеся к программированию под ОС Windows, в частности с применением Windows API. Тексты программ, приведенные в качестве примеров, тестировались в среде Borland C++ Builder 6.0. Работа приложения проверялась в среде Windows 2000, среда Windows 9x в силу морального устаревания не рассматривалась.
Введение в специальность
Внедрение своего кода в чужую программу может понадобится для множества задач, например, для отладки и анализа работы приложений, для установки различного рода программных защит, для "шпионских" целей. На сегодняшний день существует достаточно много методов внедрить свой код в чужую программу. Достаточно подробно они описаны в статье автора Tanaka "Программы-невидимки", Однако все описанные методы являются внешними по отношению к целевой программе, т.е. для внедрения кода каждый раз необходим запуск постороннего приложения, так называемого "загрузчика", осуществляющего внедрение. Предлагаемый способ отличается тем, что осуществляет разовую, достаточно простую модификацию внутренних структур .exe файла, не модифицирует исполняемый код программы, не увеличивает размер файла, не требует внешнего загрузчика, практически незаметен для пользователя. Метод позволяет модифицировать большинство .exe файлов.
Как это работает
Принцип работы основан на следующем: практически любая Windows-программа использует динамические библиотеки (DLL). В них могут храниться различные функции (в том числе системные - в USER32.DLL, GDI.DLL и т.д.), ресурсы типа диалогов, иконок, картинок, указателей мыши. Достаточно сложно найти программу, которая не использовала бы DLL вообще. Соответственно, программа, использующая DLL-библиотеки, содержит в своем .exe файле информацию о том, какие именно библиотеки ей нужны, и какие функции из этих библиотек она использует (импортирует). При запуске любой программы системный загрузчик Windows считывает список используемых этой программой DLL-библиотек и загружает (отображает) их в адресное пространство программы. После этого для каждой импортируемой программой функции загрузчик определяет адрес вызова.
Каждая DLL-библиотека содержит функцию с именем DllMain следующего вида:
BOOL WINAPI DllMain(HINSTANCE hinstDll, DWORD fdwReason, PVOID fImpLoad);
Назначение её - сугубо информационное. Вызывая эту функцию, загрузчик сообщает библиотеке о том, что она будет подключена к какому-то процессу, либо в контексте процесса происходит создание потока. Загрузка любой программы включает в себя последовательный вызов функций DllMain всех используемых DLL. Аналогично данный вызов осуществляется при отключении и выгрузке библиотеки. Думаю, что суть метода вам уже ясна: достаточно добавить к списку используемых программой DLL-библиотек свою, у которой функция DllMain содержит необходимый вам код. Весь код в рамках этой ф-ции будет выполнятся с приоритетом "заряженной" программы.
В меру скромного воображения приведу несколько примеров использования данной методики для решения практических задач. Первое, что приходит в голову - это система "навесной" защиты, которую можно установить на любое готовое приложение. Метод проверки допуска может быть любым - от простейшего пароля, до обращения к внешнему устройству, содержащему private-key для декодирования части исполняемого кода программы (например, USB-ключ). Далее - как было сказано в предисловии - любые виды троянов/вирусов.
Рецепт
Что же, теперь нам необходимо изменить .exe файл таким образом, что бы в списке используемых программой DLL библиотек появилась наша библиотека. На первый взгляд данная задача представляется как "темный лес" - где этот список взять, чего в нем искать, как чего менять? Но всё не так печально! К нашему с вами счастью, формат .exe файла Windows достаточно строго стандартизирован и подробно описан в документации. Желающим подробно поковыряться во внутренностях могу посоветовать ознакомиться с вот этим документом:
Peering Inside the PE: A Tour of the Win32 Portable Executable File Format,
для остальных я постараюсь привести здесь минимум информации, необходимый для реализации программы, "заряжающей" нашим кодом почти любой .exe файл. (Относительно ограничений метода - см. гл. Выводы) Приступам к пациенту. Формат файла программы, так же называемый "переносимым исполнительным" (PE - portable executable), определяет поведение операционной системы на всех этапах работы - начиная от отображения файла на адресное пространство процесса, загрузки необходимых библиотек, инициализации ресурсов, до собственно выгрузки программы. Самое важное из того, что следует знать о РЕ-файлах, это то, что исполняемый файл на диске и модуль, получаемый после загрузки, очень похожи. Причиной этого является то, что загрузчик Windows должен создать из дискового файла исполняемый процесс без больших усилий. Точнее говоря, загрузчик попросту использует механизмы отображения файлов в память, чтобы загрузить соответствующие части РЕ-файла в адресное пространство программы.* Так же просто загружается и DLL. После того как ЕХЕ или .DLL модули загружены, Windows обращается с ними так же, как и с другими отображенными в память файлами.
* См. справкупофункция CreateFile, MapViewOfFile, CreateFileMapping.
Так как структура исполнительного файла является довольно громоздкой, вникать подробно в описание каждого её элемента мы не будет, лишь кратко остановимся на необходимых нам элементах. Все файлы программ для Windows имеют следующий формат (см. рис. 1):
рис. 1 Структура исполнительного файла.
1. Заголовок MSDOS Начиная с нулевого смещения, в файле располагается заголовок MSDOS, имеющий формат IMAGE_DOS_HEADER (см. файл winnt.h). В этом заголовке нас интересует только одно поле:
IMAGE_DOS_HEADER->e_lfanew,
содержащее смещение сигнатуры файла.
2. Сигнатура PE-файла Сигнатура, иначе - подпись, означающая, что этот файл является исполнительным файлом для WIndows. Представляет собой строку
PE\0\0
и располагается в файле по смещению, указанному в IMAGE_DOS_HEADER->e_lfanew.
3. Заголовок IMAGE_NT_HEADERS Находится сразу же за сигнатурой PE, и представляет собой структуру из двух заголовков:
IMAGE_FILE_HEADER IMAGE_OPTIONAL_HEADER
Данные именно из этих структур определяют, как будет выглядеть изображение файла в памяти. В самом конце структуры IMAGE_OPTIONAL_HEADER располагается массив записей, имеющий тип IMAGE_DATA_DIRECTORY и называемый DataDirectory. Начальные элементы массива содержат стартовый RVA* и размеры важных частей исполняемого файла. В настоящее время некоторые элементы в конце массива в стандарте не используются. Первый элемент массива - это всегда адрес и размер экспортированной таблицы функций (если она присутствует). Второй элемент массива - адрес и размер импортированной таблицы функций (она то нас и интересует).
* RVA - Relative virtuall address - относительный виртуальный адрес. RVA - это просто смещение данного элемента по отношению к адресу, с которого начинается отображение файла в памяти. Пусть, к примеру, загрузчик Windows отобразил РЕ-файл в память, начиная с адреса 0х400000 в виртуальном адресном пространстве. Если некая таблица в отображении начинается с адреса 0х401464, то RVA данной таблицы 0х1464.
Вот практически все, что нам нужно знать о формате файла! Резюмируя , выпишем логическую цепочку доступа к таблице импорте:
IMAGE_DOS_HEADER->e_lfanew -> IMAGE_NT_HEADERS-> IMAGE_OPTIONAL_HEADER-> DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]
Теперь рассмотрим структуру самой таблицы импорта. Данная таблица находится в секции импорта (что такое секции в .exe-фале - см описание формата) и содержит всю информацию, необходимую загрузчику для подключения всех DLL и определения адресов вызываемых программой функций. Однозначно определить расположение таблицы импорта по имени секции не представляется возможным, т.к. в зависимости от компилятора данные имена значительно отличаются. Например, компилятор Borland C++ 5.5 (bcc32.exe) именует секцию импорта как ".idata"; компилятор от Microsoft хранит таблицу импорта в секции с именем ".text".*
* Строго говоря, компилятор совсем не обязан выделять данные об импортируемых функциях в отдельную секцию. Например, компилятор от Microsoft хранит данные импорта в одной секции с исполняемым кодом.
Рассмотрим подробнее, что представляет собой таблица импорта. Как и следовало ожидать, это массив записей определенного типа (IMAGE_IMPORT_DESCRIPTOR). Количество записей в массиве нигде в заголовках файла не хранится, а признаком конца массива является запись со всеми полями, установленными в NULL. Каждой используемой программой DLL библиотеке соответствует одна запись в таблице импорта следующего вида:
рис. 2 Структура таблицы импорта.
Поляструктуры IMAGE_IMPORT_DESCRIPTOR: 1. DWORD Characteristics Указательнатаблицууказателей (HintName Array) типа PIMAGE_IMPORT_BY_NAME. В данной таблице содержатся адреса структур, cодержащих информацию об импортируемых из данной библиотеки функциях. Формат структуры описан в winnt.h как IMAGE_IMPORT_BY_NAME, и включает в себя 2 поля: Hint - число, помогающее загрузчику найти адрес функции в самой библиотеке. Используется опционально, и не обязательно должно содержать верное значение.
Второй параметр - Name - строка, содержащая имя функции. 2. DWORD TimeDateStamp Дата и время создания файла. 3. DWORD Forwarder Chain Данное поле служит для реализации механизма "ссылочности" между DLL библиотеками. Например, одна библиотека часть своих экспортируемых функций представляет как экспортируемые функции другой библиотеки. К примеру, NTDLL.DLL перенаправляет вызовы частьи своих функций в KERNEL32.DLL. К сожалению, механизм передачи вызовов такого рода не описан, а программы, реализующие эту возможность, достаточно сложно найти. 3. DWORD Name Это RVA указатель на нуль-терминированную ASCII строку, содержащую имя файла DLL библиотеки. 4. PIMAGE_THUNK_DATA FirstThunk Указатель на вторую таблицу указателей, идентичную таблице HintName Array. До начала загрузки DLL библиотек в адресное пространство процесса эти указатели содержат адреса структур IMAGE_IMPORT_BY_NAME. В процессе загрузки программы этот массив заполняется адресами соответствующих функций.
Это практически всё, что нам нужно знать о формате .exe файла для решения поставленной задачи.
После таблицы импорта (последняя запись, содержащая во всех полях NULL) в файле как правило идут данные импорта (массивы HintName, строки, с именами библиотек и т.д.). Следовательно, добавление еще одной записи о нашей DLL в существующую таблицу представляется достаточно громоздким и труднореализуемым - потому как в случае "сдвига" данных, идущих после таблицы импорта необходимо будет пересчитать все указатели на эти данные. Выход из данной ситуации - создание новой таблицы импорта, содержащей всю существующую информацию + информация о нашей DLL. Чтобы где-то расположить новую таблицу импорта, необходимо наличие свободного места в файле. И тут как нельзя кстати обнаруживается такая замечательная особенность PE-файлов, как "страничность", или, иначе говоря, выравнивание данных в файле на определенные адреса. Размеры этих страниц определяются параметром
IMAGE_OPTIONAL_HEADER->FileAlignment.
Рассмотрим, как используется этот параметр. Допустим, что его величина равна 0x200h (стандартное значение), а исходный размер данных для какой-либо секции равен 0x500h (до создания компилятором файла). Так вот, после выравнивания на величину FileAlignment размер секции будет равен 0x600h, т.е. будет кратен величине FileAlignment. И хотя последние 0х100h байт нулей добавленные компилятором в секцию нигде не используются, они исправно отображаются на адресное пространство процесса. Соответственно все, что мы туда запишем, будет присутствовать в образе файла в памяти. Размер свободного места в секции зависит от конкретного файла, но, как правило, его достаточно для размещения новой таблицы импорта. В том случае, если ни в одной из секций файла нет свободного пространства, возможен вариант создания дополнительной секции либо увеличения длины самой последней из секций (в приводимой для примера программе не реализовано). Резюмируя, разбиваем процесс внедрения на следующие стадии:
Открытие файла
Анализ файла на возможность модификации (проверка наличия свободного места в файле для новой таблицы импорта)
Формирование новой таблицы импорта, соответствующих массивов HintName (ссылки FirstThunk и Characteristics), имени библиотеки (ссылка Name).
Расчёт RVA всех структур, заносимых нами в файл (обоих массивов HintName, строки с именем DLL, нового адреса таблицы импорта).
Запись в файл новой таблицы импорта и всех новых структур.
Установка нового указателя на таблицу импорта в заголовке (второй элемент массива в IMAGE_OPTIONAL_HEADER->DataDirectory[]).
В том случае, если планируется добавление новой секции либо увеличение размера существующей, необходима дополнительная модификация таблицы секций.
Реализация
Думаю, теории достаточно, приступаем к практике. Целиком проект можете загрузить тут. Разберем пошагово каждую операцию: 1. Открываем .exe файл, отображаем его для удобства работы на своё адресное пространство.
IMAGE_DOS_HEADER *mz_head;
IMAGE_FILE_HEADER *pe_head;
IMAGE_OPTIONAL_HEADER *pe_opt_head;
IMAGE_SECTION_HEADER *sect;
char pe[] = "PE\0\0";
HANDLE f = NULL;
//Открываемфайл
f = CreateFile(openF->FileName.c_str(), GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_WRITE, NULL,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
if (f == INVALID_HANDLE_VALUE)
{
Log->Lines->Add("Ошибка при открытии файла: ");
parse_error();
return;
}
//Создаемотображениефайла
HANDLE fMap = CreateFileMapping( f, NULL,
PAGE_READWRITE,
0, 0, NULL);
CloseHandle(f);
if (fMap == NULL)
{
Log->Lines->Add("Ошибкапривызове CreateFileMapping(): ");
parse_error();
return;
}
int size = sizeof( IMAGE_DOS_HEADER );
//Отображаем начало файла в память
LPVOID fBeg = MapViewOfFile( fMap, FILE_MAP_WRITE, 0, 0, size);
if (fBeg == NULL)
{
Log->Lines->Add("Ошибкапривызове MapViewOfFile(): ");
parse_error();
return;
}
2. Проверяем, является ли файл PE-executable:
//Определяем смещение РЕ-заголовка.
mz_head = (IMAGE_DOS_HEADER *)fBeg;
DWORD peOffset = mz_head->e_lfanew;
UnmapViewOfFile(fBeg);
//Отображаем в память с учетом смещения до РЕ-заголовка
size = peOffset + sizeof( DWORD ) + sizeof( IMAGE_FILE_HEADER )
+ sizeof( IMAGE_OPTIONAL_HEADER );
fBeg = MapViewOfFile( fMap, FILE_MAP_READ, 0, 0, size);
if (fBeg == NULL)
{
Log->Lines->Add("Ошибкапривызове MapViewOfFile(): ");
parse_error();
CloseHandle(fMap);return;
}
mz_head = (IMAGE_DOS_HEADER *)fBeg;
(DWORD)pe_head = (DWORD)fBeg + peOffset;
//Проверяем, PE или не PE файл
if ( strcmp(pe,(const char *)pe_head) != 0)
{
Log->Lines->Add("Этотфайлнеявляется Portable Executable - файлом.");
UnmapViewOfFile(fBeg);CloseHandle(fMap);
return;
}
UnmapViewOfFile(fBeg);
//По новой отображаем файл в память полностью
fBeg = MapViewOfFile( fMap, FILE_MAP_WRITE, 0, 0, 0);
if (fBeg == NULL)
{
Log->Lines->Add("Ошибкапривызове MapViewOfFile(): ");
parse_error();
CloseHandle(fMap); return;
}
3. Определяем расположение таблицы импорта, выводим информацию об используемых DLL.
mz_head = (IMAGE_DOS_HEADER *)fBeg;
(DWORD)pe_head = (DWORD)fBeg + peOffset + sizeof(DWORD);
(DWORD)pe_opt_head = (DWORD)pe_head + sizeof(IMAGE_FILE_HEADER);
//Определяем расположение таблицы импорта в секции импорта...
DWORD ImportRVA = pe_opt_head->
DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress;
int sect_num = -1;
//Ищем секцию с таблицей импорта...
(DWORD)sect = (DWORD)pe_opt_head + sizeof(IMAGE_OPTIONAL_HEADER);
int i;
for ( i=0; iNumberOfSections; i++)
{
if ( ImportRVA < sect->VirtualAddress )
{
sect--;
sect_num=i-1;
break;
}
sect++;
}
if (sect_num == -1)
{
Log->Lines->Add("Данная программа не использует динамические библиотеки!");
UnmapViewOfFile(fBeg);CloseHandle(fMap);
return;
}
sect++;
DWORD AfterImportSecBeg = (DWORD)fBeg + sect->PointerToRawData;
sect--;
//Получаем файловый указатель на раздел c таблицей импорта.
LPVOID ImportSecBeg;
(DWORD)ImportSecBeg = (DWORD)fBeg + sect->PointerToRawData;
//Вычисляем смещение таблицы импорта в секции
//импорта относительно ее начала (секции).
LPVOID ImportTable;
(DWORD)ImportTable = ImportRVA - sect->VirtualAddress;
(DWORD)ImportTable = (DWORD)ImportSecBeg
+ (DWORD)ImportTable;
IMAGE_IMPORT_DESCRIPTOR *DLLInfo = (IMAGE_IMPORT_DESCRIPTOR *)ImportTable;
LPVOID DLLName;
DWORD DLLCounter = 0;
//Выводим информацию об используемых DLL
while (DLLInfo->Name != NULL)
{
DLLCounter++;
(DWORD)DLLName = (DWORD)DLLInfo->Name - sect->VirtualAddress;
(DWORD)DLLName = (DWORD)ImportSecBeg + (DWORD)DLLName;
Log->Lines->Add(IntToStr(DLLCounter)+"->"+(LPSTR)DLLName);
Application->ProcessMessages();
DLLInfo++;
}
Log->Lines->Add("Всегоиспользуется "+IntToStr(DLLCounter) + " библиотек.");
4. Определяем, имеется ли в файле достаточно свободного места для размещения новой таблицы импорта.
//Вычисляем размер новой таблицы импорта:
//Суммируем количество уже используемых DLL + наша DLL + zero запись.
DWORD NewImportTableSize = sizeof(IMAGE_IMPORT_DESCRIPTOR)*(DLLCounter+2);
char dllName[] = "azx";
NewImportTableSize += strlen(dllName)+1;
//Получаем файловый указатель на конец секции импорта.
LPVOID pos;
(DWORD)pos = AfterImportSecBeg-1;
DWORD maxFree = 0;
DWORD prevPtr;
LPVOID FreePtr = NULL;
//Ищем максимальный кусок свободного места в секции...
while ( pos >= ImportSecBeg )
{
if ( *(BYTE *)pos == 0x00 )
{
prevPtr = (DWORD)pos;
while (*(BYTE *)pos == 0x00)
(DWORD)pos -= 1;
if ( ((DWORD)prevPtr - (DWORD)pos) > maxFree )
{
maxFree = ((DWORD)prevPtr - (DWORD)pos);
(DWORD)FreePtr = (DWORD)pos + 1;
}
}
(DWORD)pos -= 1;
}
//Модифицируем полученный указатель на свободный блок, т.к.
//он может указывать на завершающий нулевой DWORD
//какой-либо структуры
(LPDWORD)FreePtr +=1;
maxFree -=4;
//Проверяем объем свободного места
if ( maxFree < NewImportTableSize )
{
Log->Lines->Add("Недостаточно свободного места в таблице импорта \
для занесения информации об дополнительной библиотеке.");
UnmapViewOfFile(fBeg);
CloseHandle(fMap);
return;
}
else
Log->Lines->Add("Достаточно свободного \
места для занесения дополнительной информации.");
Application->ProcessMessages();
5. Финальная часть: Создаем в файле новую таблицу импорта и структуру IMAGE_IMPORT_BY_NAME. Записываем в файл строки с именем нашей библиотеки и импортируемой функции. Вычисляем все необходимые адреса, заносим их в структуры IMAGE_IMPORT_DESCRIPTOR, IMAGE_IMPORT_BY_NAME. Заносим в заголовок новый адрес таблицы импорта.
//1. Копируем старую таблицу импорта в новое место
memcpy(FreePtr, ImportTable, sizeof(IMAGE_IMPORT_DESCRIPTOR)*DLLCounter);
//2.1 Сохраняем строку с именем нашей DLL в старой таблице импорта
//(для экономии места)
memcpy(ImportTable, OUR_DLL_NAME, strlen(OUR_DLL_NAME));
LPDWORD zeroPtr;
(DWORD)zeroPtr = (DWORD)ImportTable + strlen(OUR_DLL_NAME);
//2.2 Сохраняем структуру IMAGE_IMPORT_BY_NAME в старой таблице импорта.
//(так же для экономии места)
IMAGE_IMPORT_BY_NAME myName;
myName.Hint = 0x00;
myName.Name[0] = 0x00;
WORD Hint = 0;
char myFuncName[] = OUR_FUNC_NAME;
hackRec patch;
patch.ZeroDword = NULL;
patch.IAT = ImportRVA + strlen(OUR_DLL_NAME) + sizeof(hackRec);
patch.IATEnd = NULL;
DWORD IIBN_Table;
memcpy(zeroPtr, &patch, sizeof(patch)); (DWORD)zeroPtr += sizeof(patch);
memcpy(zeroPtr, &Hint, sizeof(WORD)); (DWORD)zeroPtr += sizeof(WORD);
memcpy(zeroPtr, myFuncName, strlen(myFuncName)+1 );
(DWORD)zeroPtr += strlen(myFuncName)+1;
memcpy(zeroPtr, &myName, sizeof(IMAGE_IMPORT_BY_NAME) );
//2.3. Заполняем структуру IMAGE_IMPORT_DESCRIPTOR данными об нашей DLL
IMAGE_IMPORT_DESCRIPTOR myDLL;
//Вычисляем указатель на нашу структуру IMAGE_IMPORT_BY_NAME:
//это адрес начала старой таблицы импорта + длинна строки с именем
//нашей DLL + нулевой DWORD
IIBN_Table = ImportRVA + strlen( OUR_DLL_NAME ) + sizeof(DWORD);
//Указатель на таблицу Characteristics
myDLL.Characteristics = IIBN_Table;
myDLL.TimeDateStamp = NULL;
myDLL.ForwarderChain = NULL;
//Записываем адрес строки с именем файла нашей DLL
myDLL.Name = ImportRVA;
//Указатель на таблицу FirstThunk
myDLL.FirstThunk = IIBN_Table;
//Записываем в новую таблицу импорта запись о нашей DLL
LPVOID OldFreePtr = FreePtr;
(DWORD)FreePtr +=sizeof(IMAGE_IMPORT_DESCRIPTOR)*DLLCounter;
memcpy(FreePtr, &myDLL, sizeof(IMAGE_IMPORT_DESCRIPTOR));
//Создаем "финальную" нулевую запись со всеми полями равными нулю
myDLL.Characteristics = NULL;
myDLL.TimeDateStamp = NULL;
myDLL.ForwarderChain = NULL;
myDLL.Name = NULL;
myDLL.FirstThunk = NULL;
//И записываем её в конец новой таблицы импорта.
(DWORD)FreePtr +=sizeof(IMAGE_IMPORT_DESCRIPTOR)*DLLCounter;
memcpy(FreePtr, &myDLL, sizeof(IMAGE_IMPORT_DESCRIPTOR));
//3. Устанавливаем указатель на нашу таблицу импорта.
// Вычисляем RVA нашей таблицы
DWORD NewImportTableRVA = (DWORD)OldFreePtr - (DWORD)ImportSecBeg +
sect->VirtualAddress;
// Заносимегов DataDirectory
pe_opt_head->DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress =
NewImportTableRVA;
pe_opt_head->DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].Size =
(DLLCounter + 1) * sizeof(IMAGE_IMPORT_DESCRIPTOR);
UnmapViewOfFile(fBeg);
CloseHandle(fMap);
Вывод
Данная методика позволяет внедрять свою DLL библиотеку в программы, имеющие достаточно свободного места в секции с таблицей импорта. Приведенная программа может быть доработана в следующих направлениях:
Создание таблицы импорта в другой секции (если в секции с оригинальной таблицей не хватает места)
Создание новой секции и хранение новой таблицы импорта в ней.
Отдельное слово стоит сказать об .exe файлах, входящих в стандартную поставку Windows(таких как calc.exe, paint.exe, wordpad.exe, etc.). У них таблица импорта продублирована в начале файла, между MZ- и PE- заголовками, поэтому при модификации таких файлов необходимо в соответсвующих записях в DataDirectory обнулить адреса на эти таблицы (подробнее см. файл winnt.h, раздел Directory Entries).
|