Pull to refresh

Разработка своего PE-упаковщика

Reading time 13 min
Views 32K
Сегодня мы поговорим о разработке своего собственного упаковщика исполняемых файлов под Windows на С++.



Давным-давном, когда Windows XP еще не было, в поисках информации о пакерах мы забирались в самые дебри исходников тогда еще молодого UPX. Но то ли ацетилхолина у нас в мозгах синтезировалось меньше нужного, то ли UPX уже тогда был очень занудным — в общем, мы почти ничего из тех сорцов не извлекли. Мэтт Питрек, и тот помог больше. Сейчас с инфой значительно проще стало. Почти всё есть. Даже сорцы вполне себе нормального банковского троя можно скачать (Zeus 2.0.8.9). Да чего уж там, сорцы винды уже давно в паблике (Windows 2000).
Об упаковщиках информация тоже есть, но в основном исследовательская, непосредственно разработки касающаяся не с той стороны, с которой нам бы хотелось. Отличным примером тому является статья «Об упаковщиках в последний раз» в двух частях, написанная небезызвестными гуру Volodya и NEOx.
Мы, в свою очередь, постараемся максимально конкретно и последовательно дать информацию именно о разработке простейшего, но легко модифицируемого под любые свои нужды PE-пакера.

Алгоритм


Вот есть у нас, например, notepad.exe. В обычном своем 32-битном виде он весит где-нибудь 60 Кб. Мы хотим его существенно уменьшить, сохранив при этом всю его функциональность. Какими должны быть наши действия? Ну, для начала мы наш файлик от первого до последнего байтика прочтем в массив. Теперь мы можем делать с ним всё что угодно. А нам угодно его сжать. Берем его и отдаем какому-нибудь простому компрессору, в результате чего получаем массив уже не в 60 Кб, а, например, в 20 Кб. Это круто, но в сжатом виде образ нашего «Блокнота» — это просто набор байтов с высокой энтропией, это не экзешник, и его нельзя запустить, записав в файл и кликнув. Для массива со сжатым образом нам нужен носитель (загрузчик), очень маленький исполняемый файл, к которому мы прицепим наш массив и который его разожмет и запустит. Пишем носитель, компилируем, а затем дописываем к нему в конец наш сжатый «Блокнот». Соответственно, если полученный в результате всех действий файл (размер которого немного больше, чем у просто сжатого «Блокнота») запустить, он найдет в себе упакованный образ, распакует, распарсит его структуру и запустит.
Как видишь, нам предстоит автоматизировать не слишком сложный процесс. Нужно будет просто написать две программы, загрузчик и, собственно, упаковщик.
Алгоритм работы упаковщика:
  • считать PE-файл в массив;
  • сжать массив каким-нибудь алгоритмом сжатия без потерь;
  • в соответствии с форматом PE дописать сжатый массив к шаблону-загрузчику.
Алгоритм работы загрузчика:
  • найти в конце себя массив со сжатым PE-файлом;
  • разжать его;
  • распарсить заголовки PE-файла, расставить все права, выделить память и в итоге запустить.
Начнем разработку с загрузчика, так как именно им впоследствии будет манипулировать упаковщик.

Загрузчик


Итак, первое, что должен сделать наш загрузчик, — это найти в своем теле адрес массива со сжатым образом PE-файла. Способы поиска зависят от того, как упаковщик имплантировал этот массив в загрузчик.
Например, если бы он просто добавил новую секцию с данными, то поиск выглядел бы так:

Поиск сжатого образа в последней секции
// Получаем адрес начала PE-заголовка загрузчика в памяти
HMODULE hModule = GetModuleHandle(NULL);
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)hModule;
PIMAGE_NT_HEADERS pNTHeaders = MakePtr(PIMAGE_NT_HEADERS,hModule,pDosHeader->e_lfanew);
PIMAGE_SECTION_HEADER pSections = IMAGE_FIRST_SECTION(pNTHeaders);
// Структура, описывающая последнюю секцию нашего загрузчика
PIMAGE_SECTION_HEADER pLastSection = &pSections[pNTHeaders->FileHeader.NumberOfSections - 1];

// Собственно, найденный образ
LPBYTE pbPackedImage = MakePtr(LPBYTE, hModule, pLastSection->VirtualAddress);
// Его размер
DWORD dwPackedImageSize = pLastSection->SizeOfRawData;

Но, на наш взгляд, этим кодом в загрузчике можно пожертвовать. Вообще, всё, что может сделать упаковщик, пусть он и только он и делает. Адрес образа в адресном пространстве загрузчика можно вычислить заранее, при упаковке, а потом просто вписать в нужное место. Для этого оставляем в нашей программе две метки:

LPBYTE pbPackedImage = (LPBYTE) 0xDEADBEEF; 
DWORD dwPackedImageSize = 0xBEEFCACE;

Когда упаковщик будет имплантировать в загрузчик массив со сжатым образом, он пройдется сигнатурным поиском по телу загрузчика и заменит 0xDEADBEEF на адрес массива, а 0xBEEFCACE — на его размер.

Теперь, когда мы определились, как искать адрес, можно выбрать готовую реализацию алгоритма сжатия для использования в нашем упаковщике.
Неплохой вариант — использовать aplib, маленькую библиотеку с аккуратным и очень компактным кодом, реализующую сжатие на базе алгоритма Лемпеля-Зива (LZ). И мы обязательно его выбрали бы в любой другой день, однако сегодня у нас настроение для еще более простого и компактного решения — встроенных в Windows функций!

Начиная с XP, наша любимая ntdll.dll начала экспортировать две прекрасные функции:

NTSTATUS RtlCompressBuffer(
  __in   USHORT CompressionFormatAndEngine,
  __in   PUCHAR UncompressedBuffer,
  __in   ULONG UncompressedBufferSize,
  __out  PUCHAR CompressedBuffer,
  __in   ULONG CompressedBufferSize,
  __in   ULONG UncompressedChunkSize,
  __out  PULONG FinalCompressedSize,
  __in   PVOID WorkSpace
);
NTSTATUS RtlDecompressBuffer(
  __in   USHORT CompressionFormat,
  __out  PUCHAR UncompressedBuffer,
  __in   ULONG UncompressedBufferSize,
  __in   PUCHAR CompressedBuffer,
  __in   ULONG CompressedBufferSize,
  __out  PULONG FinalUncompressedSize
);

Названия их говорят сами за себя — одна функция для компрессии, другая для декомпрессии. Конечно, если бы мы разрабатывали действительно серьезный продукт, мы бы эти функции не трогали, ведь остались еще компьютеры и с Windows 2000, и даже с NT 4.0, ;) но для наших скромных целей RtlCompressBuffer\RtlDecompressBuffer вполне подойдут.
В хедерах Platform SDK этих функций нет, статически мы их прилинковать не сможем, поэтому придется воспользоваться GetProcAddress:

Определение адреса функции для распаковки
// Описываем переменную RtlDecompressBuffer типа функция с шестью параметрами
DWORD (__stdcall *RtlDecompressBuffer)(ULONG,PVOID,ULONG,PVOID,ULONG,PULONG);
// Присваиваем ей адрес RtlDecompressBuffer в ntdll.dll
(FARPROC&)RtlDecompressBuffer = GetProcAddress(LoadLibrary("ntdll.dll"), "RtlDecompressBuffer" );

Когда есть чем распаковать и есть что распаковать, можно уже, наконец, это сделать. Для этого мы выделим память с запасом (так как не знаем объем распакованного файла) и запустим определенную выше функцию:

DWORD dwImageSize = 0;	
DWORD dwImageTempSize = dwPackedImageSize * 15;
// Выделяю память под распакованный образ
LPVOID pbImage = VirtualAlloc( NULL, dwImageTempSize, MEM_COMMIT, PAGE_READWRITE );

// Распаковываю
RtlDecompressBuffer(COMPRESSION_FORMAT_LZNT1, 
	pbImage, dwImageTempSize,
	pbPackedImage, dwPackedImageSize,
	&dwImageSize);

Параметр COMPRESSION_FORMAT_LZNT1 означает, что мы хотим использовать классическое LZ-сжатие. Функция умеет сжимать и другими алгоритмами, но нам хватит и этого.
Теперь у нас в памяти (pbImage) есть сырой образ PE-файла. Чтобы его запустить, нужно провести ряд манипуляций, которые обычно делает нативный PE-загрузчик Windows. Мы сократим список до самых-самых необходимых:
  1. Разместить начало образа (хедеры) по адресу, указанному в поле Image Base опционального заголовка (OPTIONAL_HEADER).
  2. Разместить секции PE-файла по адресам, указанным в таблице секций.
  3. Распарсить таблицу импорта, найти все адреса функций и вписать в соответствующие им ячейки.
Естественно, стандартный PE-загрузчик выполняет целую кучу других действий, и тем, что мы их отметаем, мы ограничиваем совместимость нашего упаковщика с некоторыми PE-файлами. Но для абсолютного большинства хватит и этих действий — можно не фиксить релоки, фиксапы и прочую редкую и противную фигню.
Если вдруг тебе захочется серьезной совместимости, ты или сам напишешь крутой PE-лоадер, или найдешь в Сети наиболее полную его реализацию — нам было лень писать свою, и мы воспользовались трудами gr8 из hellknights выкинув из нее всё, что не поняли. ;) Даже в урезанном виде функция PE-лоадера — это строчек сто, не меньше, поэтому здесь мы приведем только ее прототип (полный код лежит на диске):

HMODULE LoadExecutable (LPBYTE image, DWORD* AddressOfEntryPoint)

Она принимает указатель на наш распакованный образ и возвращает дескриптор загруженного модуля (эквивалент адреса, по которому загружен PE-файл) и адрес точки входа (по указателю AddressOfEntryPoint). Эта функция делает всё, чтобы правильно разместить образ в памяти, но не всё, чтобы можно было, наконец, передать туда управление.
Дело в том, что система по-прежнему ничего не знает о загруженном нами модуле. Если мы прямо сейчас вызовем точку входа, с которой сжатая программа начнет выполнение, то может возникнуть ряд проблем. Работать программа будет, но криво.
Например, GetModuleHandle(NULL) будет возвращать Image Base модуля загрузчика, а не распакованной программы. Функции FindResource и LoadResource будут рыться в нашем загрузчике, в котором никаких ресурсов нет и в помине. Могут быть и более специфические глюки. Чтобы всего этого не происходило, нужно в системных структурах процесса по возможности везде обновить информацию, заменив адреса модуля загрузчика на адреса загруженного модуля.
В первую очередь нужно пофиксить PEB (Process Enviroment Block), в котором указан старый Image Base. Адрес PEB очень легко получить, в юзермоде он всегда лежит по смещению 0x30 в сегменте FS.

PPEB Peb;
__asm {
	push eax
	mov eax, FS:[0x30];
	mov Peb, eax
	pop eax
}
// hModule — адрес распакованного и загруженного нами PE-файла
Peb->ImageBaseAddress = hModule;

Также не помешает пофиксить списки модулей в структуре LDR_DATA, на которую ссылается PEB. Всего там три списка:
  • InLoadOrderModuleList — cписок модулей в порядке загрузки;
  • InMemoryOrderModuleList — cписок модулей в порядке расположения в памяти;
  • InInitializationOrderModuleList — cписок модулей в порядке инициализации.
Нам надо найти в каждом списке адрес нашего загрузчика и заменить его на адрес загруженного модуля. Как-нибудь так:

// Первым загружается наш модуль, так что
// по всему списку проходить не обязательно
PLDR_DATA_TABLE_ENTRY pLdrEntry = (PLDR_DATA_TABLE_ENTRY)(Peb->Ldr->ModuleListLoadOrder.Flink);
pLdrEntry->DllBase = hModule;
...

Вот теперь можно смело вызывать точку входа загруженного модуля. Он будет функционировать так, словно был вызван самым обычным образом.

LPVOID entry = (LPVOID)( (DWORD)hModule + AddressOfEntryPoint );
__asm call entry;

AddressOfEntryPoint — это относительный виртуальный адрес (RVA, Relative Virtual Address) точки входа, взятый из optional header в функции LoadExecutable. Для получения абсолютного адреса мы просто прибавили к RVA адрес базы (то есть свежезагруженного модуля).

Уменьшение размера загрузчика


Если наш загрузчик скомпилировать и собрать в VS 2010 с флагами по умолчанию, то мы получим не двухкилобайтную программку-носитель, а монстра размером более 10 Кб. Студия встроит туда целую кучу лишнего, а нам надо всё это оттуда выгрести.
Поэтому в свойствах компиляции проекта загрузчика (вкладка С/C++) мы делаем следующее:
  • В разделе «Оптимизация» выбираем «Минимальный размер (/O1)», чтобы компилятор старался сделать все функции более компактными.
  • Там же указываем приоритет размера над скоростью (флаг /Os).
  • В разделе «Создание кода» выключаем исключения С++, мы их не используем.
  • Проверка переполнения буфера нам тоже не нужна (/GS-). Это штука хорошая, но не в нашем случае.
В свойствах линкера (компоновщика):
  • Отключаем к чертям «Манифест». Он большой, и из-за него в загрузчике создается секция .rsrc, которая нам совершенно не нужна. Вообще, каждая лишняя секция в PE-файле — это минимум 512 совершенно ненужных байт, спасибо выравниванию.
  • Отключаем создание отладочной информации.
  • Лезем во вкладку «Дополнительно». Выключаем «Внесение случайности в базовый адрес» (/DYNAMICBASE:NO), иначе линкер создаст секцию релоков (.reloc).
  • Указываем базовый адрес. Выберем какой-нибудь нестандартный повыше, например 0x02000000. Именно это значение будет возвращать GetModuleHandle(NULL) в загрузчике. Можно его даже захардкодить.
  • Указываем нашу точку входа, а не CRT-шную: /ENTRY:WinMain. Вообще, мы привыкли это делать директивой pragma прямо из кода, но раз уж залезли в свойства, то можно и тут.
Остальные настройки для линкера задаем непосредственно из кода:

#pragma comment(linker,"/MERGE:.rdata=.text")

Здесь мы объединили секцию .rdata, в которой содержатся данные, доступные только для чтения (строки, таблица импорта и т. п.), с секцией кода .text. Если бы мы использовали глобальные переменные, то нам также надо было бы объединить с кодом секцию .data.

#pragma comment(linker,"/MERGE:.data=.text")
// К данным из .data нужен доступ на запись,
// а не только на чтение и выполнение
#pragma comment(linker,"/SECTION:.text,EWR")

Всего перечисленного хватит, чтобы получить лоадер размером в 1,5 Кб.

Упаковщик


Нам остается разработать консольную утилиту, которая будет сжимать отданные ей файлы и прицеплять к лоадеру. Первое, что она должна делать по описанному в начале статьи алгоритму, — это считывать файл в массив. Задача, с которой справится и школьник:

HANDLE hFile = CreateFile(argv[1], GENERIC_READ,FILE_SHARE_READ, NULL, 
   OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
DWORD dwImageSize = GetFileSize(hFile, 0);
LPBYTE lpImage = new BYTE[dwImageSize], lpCompressedImage = new BYTE[dwImageSize];
DWORD dwReaded; ReadFile(hFile, lpImage, dwImageSize, &dwReaded, 0);
CloseHandle(hFile);

Далее наш пакер должен сжать полученный файл. Мы не будем проверять, действительно ли это PE-файл, корректные ли у него заголовки и т. п., — всё оставляем на совести пользователя, сразу сжимаем. Для этого воспользуемся функциями RtlCompressBuffer и RtlGetCompressionWorkSpaceSize. Первую мы уже описали выше — она сжимает буфер, вторая же нужна, чтобы вычислить объем памяти, необходимой для работы сжимающего движка. Будем считать, что обе функции мы уже динамически подключили (как и в загрузчике), остается только их запустить:

DWORD format = COMPRESSION_FORMAT_LZNT1|COMPRESSION_ENGINE_STANDARD;
DWORD dwCompressedSize, dwBufferWsSize, dwFragmentWsSize;
RtlGetCompressionWorkSpaceSize(format, &dwBufferWsSize, &dwFragmentWsSize);
LPBYTE workspace = new BYTE [dwBufferWsSize];
RtlCompressBuffer(format , 	// тип сжатия и движок
	lpImage, 		// массив для сжатия
	dwImageSize, 		// его размер
	lpCompressedImage, 	// буфер для результата
	dwImageSize,		// его размер
	4096, 			// размер кусков, не важен
	&dwCompressedSize, 	// указатель на дворд для размера результата
	workspace);		// буфер для работы

В результате у нас есть сжатый буфер и его размер, можно прикрутить их к загрузчику. Чтобы это сделать, нужно для начала скомпилированный код нашего загрузчика встроить в упаковщик. Самый удобный способ засунуть его в программу — это воспользоваться утилитой bin2h. Она конвертнет любой бинарник в удобный сишный хедер, все данные в нем будут выглядеть как-то так:

unsigned int loader_size=1536;
unsigned char loader[] = {
0x4d,0x5a,0x00,0x00,0x01,0x00,0x00, ...


Создание хедера с помощью bin2h можно автоматизировать

Скармливаем ей файл с нашим лоадером и получаем всё необходимое для дальнейших извращений. Теперь, если придерживаться алгоритма, описанного в начале статьи, мы должны прицепить к загрузчику сжатый образ. Здесь нам придется вспомнить 90-е и свое вирмейкерское прошлое ;). Дело в том, что внедрение данных или кода в сторонний PE-файл — это чисто вирусная тема. Организуется внедрение большим количеством разных способов, но наиболее тривиальные и популярные — это расширение последней секции или добавление своей собственной. Добавление, на наш взгляд, чревато потерями при выравнивании, поэтому, чтобы встроить сжатый образ в наш загрузчик, мы расширим ему (загрузчику) последнюю секцию. Вернее, единственную секцию — мы же избавились от всего лишнего. ;)
Алгоритм действий будет такой:
  • Находим единственную секцию (.text) в загрузчике.
  • Изменяем ее физический размер, то есть размер на диске (SizeOfRawData). Он должен быть равен сумме старого размера и размера сжатого образа и при этом выравнен в соответствии с файловым выравниванием (FileAlignment).
  • Изменяем виртуальный размер памяти (Misc.VirtualSize), прибавляя к нему размер сжатого образа.
  • Изменяем размер всего образа загрузчика (OptionalHeader.SizeOfImage) по древней формуле [виртуальный размер последней секции] + [виртуальный адрес последней секции], не забывая выравнивать значение по FileAlignment.
  • Копируем сжатый образ в конец секции.
Тут есть небольшая хитрость. Дело в том, что наша студия делает виртуальный размер (Misc.VirtualSize) секции с кодом (.text) равным реальному невыравненному размеру кода, то есть указывает размер меньше физического. А значит, есть шанс сэкономить до 511 байт.
То есть так бы мы записали данные после кучи выравнивающих нулей, а зная фишку, можно записаться поверх этих нулей.
Вот как все наши мысли будут выглядеть в коде:

Расширение секции кода
// Создаем копию образа нашего загрузчика с запасом по памяти
PBYTE pbLoaderCopy = new BYTE[simple_packer_size + dwCompressedSize + 0x1000];
memcpy(pbLoaderCopy, (LPBYTE)&simple_packer, simple_packer_size);

// Определяем его заголовки
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)pbLoaderCopy;
PIMAGE_NT_HEADERS nt = MakePtr(PIMAGE_NT_HEADERS, pbLoaderCopy, dos->e_lfanew);
// Расширяемая секция
PIMAGE_SECTION_HEADER text = IMAGE_FIRST_SECTION(nt);

// Копируем сжатый образ в конец секции поверх нулей
memcpy(&pbLoaderCopy[text->PointerToRawData + text->Misc.VirtualSize], 
   lpCompressedImage, dwCompressedSize);
// Фиксим физический размер, учитывая фишку с Misc.VirtualSize
text->SizeOfRawData = ALIGN(text->Misc.VirtualSize + dwCompressedSize, 
   nt->OptionalHeader.FileAlignment);
// Фиксим виртуальный (а на самом деле реальный) размер
text->Misc.VirtualSize += dwCompressedSize;
// Фиксим размер образа
nt->OptionalHeader.SizeOfImage = ALIGN(test->Misc.VirtualSize + test->VirtualAddress,
   nt->OptionalHeader.FileAlignment);
// Вычисляем размер получившегося файла
DWORD dwNewFileSize = pSections->SizeOfRawData + test->PointerToRawData;

О, мы едва не забыли заменить метки 0xDEADBEEF и 0xBEEFCACE, оставленные в загрузчике, на реальные значения! 0xBEEFCACE у нас меняется на размер сжатого образа, а 0xDEADBEEF — на его абсолютный адрес. Адрес образа вычисляется по формуле [адрес образа] + [виртуальный адрес секции] + [смещение образа относительно начала секции]. Следует отметить, что замену надо производить еще до обновления значения Misc.VirtualSize, иначе полученный в результате файл не заработает.
Ищем и заменяем метки с помощью очень простого цикла:

for (int i = 0; i < simple_packer_size; i++)
   if (*(DWORD*)(&pbLoaderCopy[i]) == 0xBEEFCACE)
      *(DWORD*)(&pbLoaderCopy[i]) = dwCompressedSize;
   else if (*(DWORD*)(&pbLoaderCopy[i]) == 0xDEADBEEF)
      *(DWORD*)(&pbLoaderCopy[i]) = nt->OptionalHeader.ImageBase + text->VirtualAddress + text->Misc.VirtualSize;

Вот, собственно, и всё. Теперь в памяти у нас есть упакованный и готовый к работе файл, достаточно сохранить его на диске с помощью функций CreateFile/WriteFile.

Процесс отладки бажного файла в OllyDbg

Выводы


Если сравнивать эффективность сжатия нашего упаковщика с UPX на примере notepad.exe — мы выигрываем примерно 1 Кб: 46 592 байта у нас против 48 128 у UPX. Однако наш пакер далеко не совершенен. И это очень заметно.
Дело в том, что мы сознательно проигнорировали такую важную вещь, как перенос ресурсов. Полученный в результате сжатия файл потеряет иконку! Реализовать недостающую функцию предстоит тебе самому. Благодаря полученным из этого материала знаниям, никаких сложностей у тебя с этим делом не возникнет.

Исходник к статье.

Наш упаковщик сжал notepad.exe сильнее, чем UPX!



Переделываем в криптор


Собственно, от криптора наш пакет отличает совсем немногое: отсутствие функции шифрования и противоэмуляционных приемов. Самое простое, что можно с ходу сделать, — это добавить xor всего образа сразу после распаковки в загрузчике. Но, чтобы эмуляторы антивирусов подавились, этого недостаточно. Нужно как-то усложнить задачу. Например, не прописывать ключ xor’а в теле загрузчика. То есть загрузчик не будет знать, каким ключом ему надо расшифровывать код, он будет его перебирать в определенных нами рамках. Это может занять какое-то время, которое есть у пользователя, в отличие от антивируса.
Также ключ можно сделать зависимым от какой-нибудь неэмулируемой функции или структуры. Только их еще найти надо.
Чтобы код загрузчика не палился сигнатурно, можно прикрутить к упаковщику какие-нибудь продвинутые вирусные движки для генерации мусора и всяческого видоизменения кода, благо их в Сети навалом.



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



image
Журнал Хакер, Февраль (02) 157
Peter and the Wolf
.

Подпишись на «Хакер»


Tags:
Hubs:
+38
Comments 12
Comments Comments 12

Articles

Information

Website
xakep.ru
Registered
Founded
Employees
51–100 employees
Location
Россия