Пишем упаковщик по шагам. Шаг второй. Пакуем.

Предыдущий шаг здесь

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

И мы продолжаем написание собственного упаковщика. В этом шаге пора переходить непосредственно к упаковке PE-файла. Я достаточно давно выкладывал простенький упаковщик, который был малоэффективным по двум причинам: во-первых, он использовал стандартные Windows-функции для упаковки и распаковки данных, обладающие достаточно низкой степенью сжатия и скоростью, во-вторых, паковались все секции PE-файла по отдельности, что не очень-то оптимально. В этот раз я сделаю по-другому. Мы будем считывать данные всех секций сразу, слеплять их в один кусок и упаковывать. В результирующем файле, таким образом, будет только одна секция (на самом деле две, потом поясню, почему), в которой мы сможем разместить и ресурсы, и код распаковщика, и сжатые данные, и вспомогательные таблицы. Мы получаем некоторый выигрыш, потому что не нужно тратить размер на файловое выравнивание, кроме того, алгоритм LZO явно более эффективен, чем RtlCompressBuffer, во всех отношениях.

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

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

Кроме того, нам пригодится структура, которая хранит различную полезную информацию об оригинальном файле, которая также понадобится распаковщику. Пока что в ней будет всего три поля, и скорее всего, я буду ее со временем расширять:

Обратите внимание, что обе структуры имеют выравнивание 1. Это нужно для того, чтобы они занимали как можно меньше места. Кроме того, явное указание величины выравнивания избавляет от всяческих проблем при считывании структур из файла во время распаковки.

Идем дальше. Перед упаковкой желательно бы просчитать энтропию секций файла, чтобы определить, есть ли смысл его упаковывать, или он уже сжат по максимуму. Моя библиотека предоставляет такую возможность. Кроме того, стоит проверить, не передали ли нам .NET-бинарник - такие мы упаковывать не будем.

Перейдем к упаковке секций. Добавим в начало main.cpp строку #include <string> - строки нам пригодятся для формирования блоков данных (они располагают данные последовательно, и мы сможем прямо из строки записывать их в файл). Можно было использовать и векторы (vector), однако особой разницы нет.

Для начала необходимо произвести инициализацию библиотеки LZO:

Переходим к считыванию секций файла:

Переходим непосредственно к упаковке файла.

Немного поясню по коду выше. Мы создали два буфера - packed_sections_info и raw_section_data. Не обращайте внимания, что это строки (std::string), они могут хранить бинарные данные. Первый буфер хранит идущие подряд структуры packed_section, создаваемые и заполняемые нами для всех имеющихся в PE-файле секций. Второй хранит сырые данные всех секций, слепленные вместе. Мы сможем эти данные после распаковки разделить и распихать по секциям заново, потому что информация о размере файловых данных секций хранится у нас в первом буфере и будет доступна распаковщику. Идем дальше - нужно полученный буфер raw_section_data упаковать. Можно вместе с ним упаковать и буфер packed_sections_info - пожалуй, так и сделаем. Для этого конкатенируем строки (читай: бинарные буферы) packed_sections_info и raw_section_data - это сделано в предыдущем блоке кода.

Далее мы займемся созданием новой секции PE-файла, в которой разместим наши упакованные данные:

Итак, мы создали новую секцию (но пока не добавили ее к PE-файлу). Почему я назвал ее .rsrc? Это сделано по одной простой причине. Все файлы, имеющие ресурсы, располагают их в секции с именем .rsrc. Главная иконка файла и информация о версии также хранятся в ресурсах. Увы, проводник Windows умеет считывать иконку файла и отображать ее ТОЛЬКО в том случае, если секция, хранящая ресурсы, называется .rsrc. Эту штуку вроде бы как поправили в последних версиях и сервис-паках Windows, но лучше перестраховаться. Мы пока что ресурсами не занимаемся, поэтому название дано на будущее.

Следующий шаг - сжатие данных. Немного низкоуровневый момент... И тут нам понадобится библиотека Boost. У вас ее еще нет? Пора скачать, установить и собрать! Тем более, делается это очень просто. Но для того класса из этой библиотеки, который я дальше собираюсь использовать, даже и собирать ее не надо. Просто скачайте библиотеку, распакуйте ее в какую-нибудь директорию, например, C:\boost, и укажите в include-директориях в проекте путь к заголовочным файлам буста, например C:\boost\boost. Если мне в дальнейшем из буста потребуется класс, требующий сборки, я поясню, как это делается.

Добавим к заголовкам main.cpp строку #include <boost/scoped_array.hpp>. Далее упаковываем данные.

Теперь осталось удалить уже ненужные нам секции PE-файла и добавить в него нашу новую секцию:

Что же здесь произошло? Поясню подробнее. Сначала мы определили виртуальный адрес самой первой секции в PE-файле (об этом ниже). После этого мы определили общий виртуальный размер всех секций. Так как виртуальный размер последующей секции равен виртуальному адресу + выровненному виртуальному размеру предыдущей, то, узнав виртуальный адрес и размер последней в файле секции, мы получили виртуальный суммарный размер всех секций плюс адрес самой первой секции. Вычтя из этого числа тот самый виртуальный адрес первой секции, получаем чистый виртуальный размер всех секций вместе взятых. Это, кстати, можно было сделать гораздо проще - вызвав функцию image.get_size_of_image(), которая вернула бы, по сути, то же самое, но из заголовка PE-файла, ну да ладно. Далее мы удалили все существующие секции PE-файла. После этого добавили нашу секцию в PE-файл и получили ссылку на добавленную секцию с пересчитанными адресами и размерами (после добавления мы работаем именно с этой ссылкой). Далее мы должны оставить себе достаточное количество памяти, чтобы потом в нее распаковать все секции - поэтому мы и меняем виртуальный размер свежедобавленной секции на общий размер всех ранее существовавших секций. Виртуальный адрес добавленной секции будет вычислен автоматически по умолчанию. Нас это не очень устраивает - нам необходимо, чтобы область в памяти, занимаемая нашей секцией, полностью совпала с областью, которую занимали все секции оригинального файла. Моя библиотека позволяет явно указать виртуальный адрес секции, если она будет первой в файле (т.е. до ее добавления никаких других секций не существует). Это как раз наша ситуация. Именно поэтому мы и определили виртуальный адрес первой секции и установили его для нашей новой секции.

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

Однако, одной секцией мы не обойдемся и нам придется создать и добавить еще одну. Зачем? - спросите вы. Ответ прост: первая секция после распаковки будет содержать данные всех секций оригинального файла. А нам еще надо где-то разместить распаковщик. Вы скажете: ну так помести его в конец секции. Но тогда он будет перезаписан при распаковке данными оригинального файла! Можно, конечно, действительно разместить его в той же самой секции, и перед самой распаковкой выделить память (с помощью VirtualAlloc или как-то еще) и скопировать туда тело распаковщика, и исполнять его уже оттуда. Но эту память потом нам нужно будет как-то освободить. И если мы это сделаем из нее самой, то произойдет падение приложения: память освобождена, и регистр процессора eip, указывающий на текущую исполняемую ассемблерную команду, указывает вникуда. Словом, без дополнительной секции не обойтись. Если вы посмотрите на тот же UPX или Upack, то увидите, что они тоже имеют по 2-3 секции.

Переходим к следующему шагу. Немного поиздеваемся над PE-файлом:

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

Далее я обрезал таблицу директорий, так как у нас большинство из них теперь удалено, и удалил стаб из заголовка (обычно в нем лежит DOS stub и Rich-сигнатуры MSVC++, это нам не нужно). Таблицу директорий уменьшаем минимум до 12 элементов, не меньше. Элементы с 1 по 12 могут присутствовать в оригинальном файле и их придется восстановить. Можно было бы, конечно, оставить и самый минимум элементов в таблице, но выигрыша в размере это не даст, зато кода в распаковщике прибавится, если вдруг нам придется расширять таблицу обратно. Почему урезаем таблицу именно до 12 элементов? Потому что четыре последних точно не нужны PE-файлу для успешного запуска, и без них можно спокойно обойтись. Можно было бы еще динамически проверять, есть ли у файла 12-я (Configuration directory), 11-я (TLS directory) и т.д директории, и если нет, то еще больше урезАть таблицу директорий, но, повторюсь, смысла особого в этом нет.

Последнее, что нам остается сделать - сохранить упакованный файл под новым именем:

В этой части кода ничего сложного не происходит, все должно быть более-менее понятно из комментариев. Итак, это все, что мы делаем в этом шаге. Шаг получился более чем насыщенным, и вам есть, о чем подумать. Естественно, упакованный файл не будет запускаться, потому что у него нет распаковщика, мы не обрабатываем импорты и не правим точку входа и еще много-много всего... Однако мы можем оценить степень сжатия и проверить в каком-нибудь просмотрщике PE-файлов (я использую CFF Explorer), что все пакуется так, как мы и задумали.

Оригинальный файл:

Упакованный файл:

Как видно, Virtual Address + Virtual Size первой секции на второй картинке совпадает с SizeOfImage на первой. Виртуальный адрес первой секции также не изменился. Это именно то, чего мы и хотели добиться. На второй картинке также видно содержимое второй секции kaimi.io. Степень сжатия неплоха - с 1266 кб до 362 кб.

До встречи в следующей статье! Вопросы приветствуются, их можно задать в комментариях.

И, как всегда, выкладываю последний вариант проекта с последними изменениями: own PE packer step 2

Пишем упаковщик по шагам. Шаг второй. Пакуем.: 10 комментариев

  1. Спасибо за вторую часть. Очень интересно, и даже степень сжатия меня порадовала. Молодец!
    Скажи, а почему ты говоришь, что проводник винды увидит уконку в случае опр. названия секции? Ведь многие пакеры полностью переименовывают секции и просто взять и переименовать - и иконка все-равно будет отображаться.

    1. если секция не .rsrc то проводник не сможет разглядеть иконку и версиюинфо, об этом уже много где писалось, врочем можно даже самому инициировать ситуацию. упакуй upx'ом и переименуй .rsrc в че нить другое... кстати даже сам upx сохраняет эту секцию :)

      1. До конца я увы не понимаю . Почему секцию с упакованными данными мы назвали .rsrc ? Самих ресурсов там все равно нет и отображать проводнику нечего .

        1. Там будет лежать иконка, манифест и информация о версии (если что-то из этого присутствует у упаковываемого бинарника).

  2. >Урезаем DOS-заголовок, накладывая на него NT-заголовки

    Если так делать, то capicom невнятно вылетает при попытке установить цифровую подпись на полученный файл, я не тестировал, но может и стандартными средствами не выйдет подписать такие файлы, так что урезать не надо и все ок.

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *