Предыдущий шаг здесь
Сразу скажу, что по мере написания этого цикла статей я кое-что правлю и дорабатываю в своей библиотеке для работы с PE-файлами. Поэтому вам стоит ее перекачать и пересобрать - сейчас уже есть версия 0.1.3.
И мы продолжаем написание собственного упаковщика. В этом шаге пора переходить непосредственно к упаковке PE-файла. Я достаточно давно выкладывал простенький упаковщик, который был малоэффективным по двум причинам: во-первых, он использовал стандартные Windows-функции для упаковки и распаковки данных, обладающие достаточно низкой степенью сжатия и скоростью, во-вторых, паковались все секции PE-файла по отдельности, что не очень-то оптимально. В этот раз я сделаю по-другому. Мы будем считывать данные всех секций сразу, слеплять их в один кусок и упаковывать. В результирующем файле, таким образом, будет только одна секция (на самом деле две, потом поясню, почему), в которой мы сможем разместить и ресурсы, и код распаковщика, и сжатые данные, и вспомогательные таблицы. Мы получаем некоторый выигрыш, потому что не нужно тратить размер на файловое выравнивание, кроме того, алгоритм LZO явно более эффективен, чем RtlCompressBuffer, во всех отношениях.
Таким образом, алгоритм действий упаковщика будет примерно таким: считывание всех секций, слепление их данных в один буфер и его упаковка, расположение упакованного буфера в новой секции, удаление всех остальных имеющихся секций. Нам придется сохранить все параметры существовавших в оригинальном файле секций, чтобы потом распаковщик смог их восстановить. Напишем для этого специальную структуру:
1 2 3 4 5 6 7 8 9 10 11 |
#pragma pack(push, 1) //Структура, хранящая информацию об упакованной секции struct packed_section { char name[8]; //Имя секции DWORD virtual_size; //Виртуальный размер DWORD virtual_address; //Виртуальный адрес (RVA) DWORD size_of_raw_data; //Размер "сырых" данных DWORD pointer_to_raw_data; //Файловое смещение сырых данных DWORD characteristics; //Характеристики секции }; |
Эту структуру мы будем записывать в какое-либо место упакованного файла для каждой секции, а код распаковщика будет эти структуры считывать. В этих структурах будет храниться вся необходимая информация для восстановления секций PE-файла.
Кроме того, нам пригодится структура, которая хранит различную полезную информацию об оригинальном файле, которая также понадобится распаковщику. Пока что в ней будет всего три поля, и скорее всего, я буду ее со временем расширять:
1 2 3 4 5 6 7 8 |
//Структура, хранящая информацию об упакованном файле struct packed_file_info { BYTE number_of_sections; //Количество секций в оригинальном файле DWORD size_of_packed_data; //Размер упакованных данных DWORD size_of_unpacked_data; //Размер оригинальных данных }; #pragma pack(pop) |
Обратите внимание, что обе структуры имеют выравнивание 1. Это нужно для того, чтобы они занимали как можно меньше места. Кроме того, явное указание величины выравнивания избавляет от всяческих проблем при считывании структур из файла во время распаковки.
Идем дальше. Перед упаковкой желательно бы просчитать энтропию секций файла, чтобы определить, есть ли смысл его упаковывать, или он уже сжат по максимуму. Моя библиотека предоставляет такую возможность. Кроме того, стоит проверить, не передали ли нам .NET-бинарник - такие мы упаковывать не будем.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
... try { //Пытаемся открыть файл как 32-битный PE-файл //Последние два аргумента false, потому что нам не нужны //"сырые" данные привязанных импортов файла и //"сырые" данные отладочной информации //При упаковке они не используются, поэтому не загружаем эти данные pe32 image(file, false, false); //Проверим, не .NET ли образ нам подсунули if(image.is_dotnet()) { std::cout << ".NEt image cannot be packed!" << std::endl; return -1; } //Просчитаем энтропию секций файла, чтобы убедиться, что файл не упакован { std::cout << "Entropy of sections: "; double entropy = image.calculate_entropy(); std::cout << entropy << std::endl; //На wasm.ru есть статья, в которой говорится, //что у PE-файлов нормальная энтропия до 6.8 //Если больше, то файл, скорее всего, сжат //Поэтому (пока что) не будем упаковывать файлы //с высокой энтропией, в этом мало смысла if(entropy > 6.8) { std::cout << "File has already been packed!" << std::endl; return -1; } } ... |
Перейдем к упаковке секций. Добавим в начало main.cpp строку #include <string> - строки нам пригодятся для формирования блоков данных (они располагают данные последовательно, и мы сможем прямо из строки записывать их в файл). Можно было использовать и векторы (vector), однако особой разницы нет.
Для начала необходимо произвести инициализацию библиотеки LZO:
1 2 3 4 5 6 |
//Инициализируем библиотеку сжатия LZO if(lzo_init() != LZO_E_OK) { std::cout << "Error initializing LZO library" << std::endl; return -1; } |
Переходим к считыванию секций файла:
1 2 3 4 5 6 7 8 9 10 |
std::cout << "Reading sections..." << std::endl; //Получаем список секций PE-файла const pe_base::section_list& sections = image.get_image_sections(); if(sections.empty()) { //Если у файла нет ни одной секции, нам нечего упаковывать std::cout << "File has no sections!" << std::endl; return -1; } |
Переходим непосредственно к упаковке файла.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
//Структура базовой информации о PE-файле packed_file_info basic_info = {0}; //Получаем и сохраняем изначальное количество секций basic_info.number_of_sections = sections.size(); //Строка, которая будет хранить последовательно //структуры packed_section для каждой секции std::string packed_sections_info; { //Выделим в строке необходимое количество памяти для этих стркуткр packed_sections_info.resize(sections.size() * sizeof(packed_section)); //"Сырые" данные всех секций, считанные из файла и слепленные воедино std::string raw_section_data; //Индекс текущей секции unsigned long current_section = 0; //Перечисляем все секции for(pe_base::section_list::const_iterator it = sections.begin(); it != sections.end(); ++it, ++current_section) { //Ссылка на очередную секцию const pe_base::section& s = *it; { //Создаем структуру информации //о секции в строке и заполняем ее packed_section& info = reinterpret_cast<packed_section&>(packed_sections_info[current_section * sizeof(packed_section)]); //Характеристики секции info.characteristics = s.get_characteristics(); //Указатель на файловые данные info.pointer_to_raw_data = s.get_pointer_to_raw_data(); //Размер файловых данных info.size_of_raw_data = s.get_size_of_raw_data(); //Относительный виртуальный адрес секции info.virtual_address = s.get_virtual_address(); //Виртуальный размер секции info.virtual_size = s.get_virtual_size(); //Копируем имя секции (оно максимально 8 символов) memset(info.name, 0, sizeof(info.name)); memcpy(info.name, s.get_name().c_str(), s.get_name().length()); } //Если секция пустая, переходим к следующей if(s.get_raw_data().empty()) continue; //А если не пустая - копируем ее данные в строку //с данными всех секций raw_section_data += s.get_raw_data(); } //Если все секции оказались пустыми, то паковать нечего! if(raw_section_data.empty()) { std::cout << "All sections of PE file are empty!" << std::endl; return -1; } //Будем упаковывать оба буфера, слепленные вместе //(читайте ниже) packed_sections_info += raw_section_data; } |
Немного поясню по коду выше. Мы создали два буфера - packed_sections_info и raw_section_data. Не обращайте внимания, что это строки (std::string), они могут хранить бинарные данные. Первый буфер хранит идущие подряд структуры packed_section, создаваемые и заполняемые нами для всех имеющихся в PE-файле секций. Второй хранит сырые данные всех секций, слепленные вместе. Мы сможем эти данные после распаковки разделить и распихать по секциям заново, потому что информация о размере файловых данных секций хранится у нас в первом буфере и будет доступна распаковщику. Идем дальше - нужно полученный буфер raw_section_data упаковать. Можно вместе с ним упаковать и буфер packed_sections_info - пожалуй, так и сделаем. Для этого конкатенируем строки (читай: бинарные буферы) packed_sections_info и raw_section_data - это сделано в предыдущем блоке кода.
Далее мы займемся созданием новой секции PE-файла, в которой разместим наши упакованные данные:
1 2 3 4 5 6 7 8 |
//Новая секция pe_base::section new_section; //Имя - .rsrc (пояснение ниже) new_section.set_name(".rsrc"); //Доступна на чтение, запись, исполнение new_section.readable(true).writeable(true).executable(true); //Ссылка на сырые данные секции std::string& out_buf = new_section.get_raw_data(); |
Итак, мы создали новую секцию (но пока не добавили ее к PE-файлу). Почему я назвал ее .rsrc? Это сделано по одной простой причине. Все файлы, имеющие ресурсы, располагают их в секции с именем .rsrc. Главная иконка файла и информация о версии также хранятся в ресурсах. Увы, проводник Windows умеет считывать иконку файла и отображать ее ТОЛЬКО в том случае, если секция, хранящая ресурсы, называется .rsrc. Эту штуку вроде бы как поправили в последних версиях и сервис-паках Windows, но лучше перестраховаться. Мы пока что ресурсами не занимаемся, поэтому название дано на будущее.
Следующий шаг - сжатие данных. Немного низкоуровневый момент... И тут нам понадобится библиотека Boost. У вас ее еще нет? Пора скачать, установить и собрать! Тем более, делается это очень просто. Но для того класса из этой библиотеки, который я дальше собираюсь использовать, даже и собирать ее не надо. Просто скачайте библиотеку, распакуйте ее в какую-нибудь директорию, например, C:\boost, и укажите в include-директориях в проекте путь к заголовочным файлам буста, например C:\boost\boost. Если мне в дальнейшем из буста потребуется класс, требующий сборки, я поясню, как это делается.
Добавим к заголовкам main.cpp строку #include <boost/scoped_array.hpp>. Далее упаковываем данные.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
//Создаем "умный" указатель //и выделяем необходимую для сжатия алгоритму LZO память //Умный указатель в случае чего автоматически //эту память освободит //Мы используем тип lzo_align_t для того, чтобы //память была выровняна как надо //(из документации к LZO) boost::scoped_array<lzo_align_t> work_memory(new lzo_align_t[LZO1Z_999_MEM_COMPRESS]); //Длина неупакованных данных lzo_uint src_length = packed_sections_info.size(); //Сохраним ее в нашу структуру информации о файле basic_info.size_of_unpacked_data = src_length; //Длина упакованных данных //(пока нам неизвестна) lzo_uint out_length = 0; //Необходимый буфер для сжатых данных //(длина опять-таки исходя из документации к LZO) out_buf.resize(src_length + src_length / 16 + 64 + 3); //Производим сжатие данных std::cout << "Packing data..." << std::endl; if(LZO_E_OK != lzo1z_999_compress(reinterpret_cast<const unsigned char*>(packed_sections_info.data()), src_length, reinterpret_cast<unsigned char*>(&out_buf[0]), &out_length, work_memory.get()) ) { //Если что-то не так, выйдем std::cout << "Error compressing data!" << std::endl; return -1; } //Сохраним длину упакованных данных в нашу структуру basic_info.size_of_packed_data = out_length; //Ресайзим выходной буфер со сжатыми данными по //результирующей длине сжатых данных, которая //теперь нам известна out_buf.resize(out_length); //Собираем буфер воедино, это и будут //финальные данные нашей новой секции out_buf = //Данные структуры basic_info std::string(reinterpret_cast<const char*>(&basic_info), sizeof(basic_info)) //Выходной буфер + out_buf; //Проверим, что файл реально стал меньше if(out_buf.size() >= src_length) { std::cout << "File is incompressible!" << std::endl; return -1; } |
Теперь осталось удалить уже ненужные нам секции PE-файла и добавить в него нашу новую секцию:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
{ //Сначала получим ссылку на самую первую //существующую секцию PE-файла const pe_base::section& first_section = image.get_image_sections().front(); //Установим виртуальный адрес для добавляемой секции (читай ниже) new_section.set_virtual_address(first_section.get_virtual_address()); //Теперь получим ссылку на самую последнюю //существующую секцию PE-файла const pe_base::section& last_section = image.get_image_sections().back(); //Посчитаем общий размер виртуальных данных DWORD total_virtual_size = //Виртуальный адрес последней секции last_section.get_virtual_address() //Выровненный виртуальный размер последней секции + pe_base::align_up(last_section.get_virtual_size(), image.get_section_alignment()) //Минус виртуальный размер первой секции - first_section.get_virtual_address(); //Удаляем все секции PE-файла image.get_image_sections().clear(); //Изменяем файловое выравнивание, если вдруг оно было //больше, чем 0x200 - это минимально допустимое //для выровненных PE-файлов image.realign_file(0x200); //Добавляем нашу секцию и получаем ссылку на //уже добавленную секцию с пересчитанными адресами и размерами pe_base::section& added_section = image.add_section(new_section); //Устанавливаем для нее необходимый виртуальный размер image.set_section_virtual_size(added_section, total_virtual_size); } |
Что же здесь произошло? Поясню подробнее. Сначала мы определили виртуальный адрес самой первой секции в PE-файле (об этом ниже). После этого мы определили общий виртуальный размер всех секций. Так как виртуальный размер последующей секции равен виртуальному адресу + выровненному виртуальному размеру предыдущей, то, узнав виртуальный адрес и размер последней в файле секции, мы получили виртуальный суммарный размер всех секций плюс адрес самой первой секции. Вычтя из этого числа тот самый виртуальный адрес первой секции, получаем чистый виртуальный размер всех секций вместе взятых. Это, кстати, можно было сделать гораздо проще - вызвав функцию image.get_size_of_image(), которая вернула бы, по сути, то же самое, но из заголовка PE-файла, ну да ладно. Далее мы удалили все существующие секции PE-файла. После этого добавили нашу секцию в PE-файл и получили ссылку на добавленную секцию с пересчитанными адресами и размерами (после добавления мы работаем именно с этой ссылкой). Далее мы должны оставить себе достаточное количество памяти, чтобы потом в нее распаковать все секции - поэтому мы и меняем виртуальный размер свежедобавленной секции на общий размер всех ранее существовавших секций. Виртуальный адрес добавленной секции будет вычислен автоматически по умолчанию. Нас это не очень устраивает - нам необходимо, чтобы область в памяти, занимаемая нашей секцией, полностью совпала с областью, которую занимали все секции оригинального файла. Моя библиотека позволяет явно указать виртуальный адрес секции, если она будет первой в файле (т.е. до ее добавления никаких других секций не существует). Это как раз наша ситуация. Именно поэтому мы и определили виртуальный адрес первой секции и установили его для нашей новой секции.
Тут же мы изменили и файловое выравнивание на минимально допустимое для выровненных файлов, пока у файла не было ни одной секции, чтобы все прошло быстрее.
Однако, одной секцией мы не обойдемся и нам придется создать и добавить еще одну. Зачем? - спросите вы. Ответ прост: первая секция после распаковки будет содержать данные всех секций оригинального файла. А нам еще надо где-то разместить распаковщик. Вы скажете: ну так помести его в конец секции. Но тогда он будет перезаписан при распаковке данными оригинального файла! Можно, конечно, действительно разместить его в той же самой секции, и перед самой распаковкой выделить память (с помощью VirtualAlloc или как-то еще) и скопировать туда тело распаковщика, и исполнять его уже оттуда. Но эту память потом нам нужно будет как-то освободить. И если мы это сделаем из нее самой, то произойдет падение приложения: память освобождена, и регистр процессора eip, указывающий на текущую исполняемую ассемблерную команду, указывает вникуда. Словом, без дополнительной секции не обойтись. Если вы посмотрите на тот же UPX или Upack, то увидите, что они тоже имеют по 2-3 секции.
1 2 3 4 5 6 7 8 9 10 11 12 |
{ //Новая секция pe_base::section unpacker_section; //Имя - kaimi.io unpacker_section.set_name("kaimi.io"); //Доступна на чтение и исполнение unpacker_section.readable(true).executable(true); //В будущем тут будет код распаковщика и что-то еще unpacker_section.get_raw_data() = "Nothing interesting here..."; //Добавляем и эту секцию image.add_section(unpacker_section); } |
Переходим к следующему шагу. Немного поиздеваемся над PE-файлом:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
//Удалим все часто используемые директории //В дальнейшем мы будем их возвращать обратно //и корректно обрабатывать, но пока так //Оставим только импорты (и то, обрабатывать их пока не будем) image.remove_directory(IMAGE_DIRECTORY_ENTRY_BASERELOC); image.remove_directory(IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT); image.remove_directory(IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT); image.remove_directory(IMAGE_DIRECTORY_ENTRY_EXPORT); image.remove_directory(IMAGE_DIRECTORY_ENTRY_IAT); image.remove_directory(IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG); image.remove_directory(IMAGE_DIRECTORY_ENTRY_RESOURCE); image.remove_directory(IMAGE_DIRECTORY_ENTRY_SECURITY); image.remove_directory(IMAGE_DIRECTORY_ENTRY_TLS); image.remove_directory(IMAGE_DIRECTORY_ENTRY_DEBUG); //Урезаем таблицу директорий, удаляя все нулевые //Урезаем не полностью, а минимум до 12 элементов, так как в оригинальном //файле могут присутствовать первые 12 и использоваться image.strip_data_directories(16 - 4); //Удаляем стаб из заголовка, если какой-то был image.strip_stub_overlay(); |
Я удалил практически все более-менее используемые директории из заголовков. Это крайне неправильно, потому что большинство файлов после такого откажутся работать. Но вы же понимаете, что упаковщик мы совершенствуем шаг за шагом, поэтому пока что будет так. Оставил я только директорию импортов, и то, никак ее не обрабатывал. Импорты - первое, что нам придется корректно обрабатывать, потому что найти файл без импортов очень проблематично, а нам на чем-то надо будет проверять упаковщик.
Далее я обрезал таблицу директорий, так как у нас большинство из них теперь удалено, и удалил стаб из заголовка (обычно в нем лежит DOS stub и Rich-сигнатуры MSVC++, это нам не нужно). Таблицу директорий уменьшаем минимум до 12 элементов, не меньше. Элементы с 1 по 12 могут присутствовать в оригинальном файле и их придется восстановить. Можно было бы, конечно, оставить и самый минимум элементов в таблице, но выигрыша в размере это не даст, зато кода в распаковщике прибавится, если вдруг нам придется расширять таблицу обратно. Почему урезаем таблицу именно до 12 элементов? Потому что четыре последних точно не нужны PE-файлу для успешного запуска, и без них можно спокойно обойтись. Можно было бы еще динамически проверять, есть ли у файла 12-я (Configuration directory), 11-я (TLS directory) и т.д директории, и если нет, то еще больше урезАть таблицу директорий, но, повторюсь, смысла особого в этом нет.
Последнее, что нам остается сделать - сохранить упакованный файл под новым именем:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
//Создаем новый PE-файл //Вычислим имя переданного нам файла без директории std::string base_file_name(argv[1]); std::string dir_name; std::string::size_type slash_pos; if((slash_pos = base_file_name.find_last_of("/\\")) != std::string::npos) { dir_name = base_file_name.substr(0, slash_pos + 1); //Директория исходного файла base_file_name = base_file_name.substr(slash_pos + 1); //Имя исходного файла } //Дадим новому файлу имя packed_ + имя_оригинального_файла //Вернем к нему исходную директорию, чтобы сохранить //файл туда, где лежит оригинал base_file_name = dir_name + "packed_" + base_file_name; //Создадим файл std::ofstream new_pe_file(base_file_name.c_str(), std::ios::out | std::ios::binary | std::ios::trunc); if(!new_pe_file) { //Если не удалось создать файл - выведем ошибку std::cout << "Cannot create " << base_file_name << std::endl; return -1; } //Пересобираем PE-образ //Урезаем DOS-заголовок, накладывая на него NT-заголовки //(за это отвечает второй параметр true) //Не пересчитываем SizeOfHeaders - за это отвечает третий параметр image.rebuild_pe(new_pe_file, true, false); //Оповестим пользователя, что файл упакован успешно std::cout << "Packed image was saved to " << base_file_name << std::endl; |
В этой части кода ничего сложного не происходит, все должно быть более-менее понятно из комментариев. Итак, это все, что мы делаем в этом шаге. Шаг получился более чем насыщенным, и вам есть, о чем подумать. Естественно, упакованный файл не будет запускаться, потому что у него нет распаковщика, мы не обрабатываем импорты и не правим точку входа и еще много-много всего... Однако мы можем оценить степень сжатия и проверить в каком-нибудь просмотрщике PE-файлов (я использую CFF Explorer), что все пакуется так, как мы и задумали.
Оригинальный файл:
Упакованный файл:
Как видно, Virtual Address + Virtual Size первой секции на второй картинке совпадает с SizeOfImage на первой. Виртуальный адрес первой секции также не изменился. Это именно то, чего мы и хотели добиться. На второй картинке также видно содержимое второй секции kaimi.io. Степень сжатия неплоха - с 1266 кб до 362 кб.
До встречи в следующей статье! Вопросы приветствуются, их можно задать в комментариях.
И, как всегда, выкладываю последний вариант проекта с последними изменениями: own PE packer step 2
Спасибо за вторую часть. Очень интересно, и даже степень сжатия меня порадовала. Молодец!
Скажи, а почему ты говоришь, что проводник винды увидит уконку в случае опр. названия секции? Ведь многие пакеры полностью переименовывают секции и просто взять и переименовать - и иконка все-равно будет отображаться.
Была точно такая проблема, правда не в курсе, в каких версиях. Крис об этом писал, в сорсах UPX об этом написано.
если секция не .rsrc то проводник не сможет разглядеть иконку и версиюинфо, об этом уже много где писалось, врочем можно даже самому инициировать ситуацию. упакуй upx'ом и переименуй .rsrc в че нить другое... кстати даже сам upx сохраняет эту секцию :)
До конца я увы не понимаю . Почему секцию с упакованными данными мы назвали .rsrc ? Самих ресурсов там все равно нет и отображать проводнику нечего .
Там будет лежать иконка, манифест и информация о версии (если что-то из этого присутствует у упаковываемого бинарника).
>Урезаем DOS-заголовок, накладывая на него NT-заголовки
Если так делать, то capicom невнятно вылетает при попытке установить цифровую подпись на полученный файл, я не тестировал, но может и стандартными средствами не выйдет подписать такие файлы, так что урезать не надо и все ок.
Можно и не урезать, это не столь важный момент.
Что с wasm ru на сегодняшний день и где вся тусовка сейчас?
Даже не представляю