От переводчика:
Основная цель перевода это попытка помочь тем программистам кто пишет статические распаковщики исполняемых файлов. Другими словами эта информация нацелена на практикующих reverse-engineer-ов. Под статичеческим распаковщиком понимаю программу которая поданный на вход упакованный или запротекченный исполняемый файл анализирует и создает на выходе файл, как будто бы тот создан каким-либо компилятором. Особенностью такого типа распаковщиков в том что он работает исключительно на знании структуры защиты или упаковки файла, т.е. без применения «сброса дампа», «востановления импорта» и др. типов «читерства».
При изучении упакованных файлов к примеру с помощью UPX, RlPack и др. часто встречаешься с кодом где делаются некоторые магические действиями с маш. инструкциями переходов байты 0xE8, 0xE9 и др. Этой магией является «фильтрация» и она направлена на улучшение степени сжатия исполняемого файла.
Достаточно часто иметь точный код фильтрации совсем необязательно. Достаточно понаблюдать на то как меняются данные. А иногда и вовсе невозможно за разумный срок получить этот кусок кода с фильтрацией, либо очень трудоемко, к примеру при работе с полиморфиками или с файлами где применяется виртуализация кода.
Ниже следует первод небольшого но крайне полезного текстового файла "%UPX_SOURCE%docfilter.txt". В этом пути под UPX_SOURCE подразумевается файловый путь до исходных кодов к UPX версии 3.91. Все что описано про UPX также применимо и к другим упаковщикам.
Этот документ поясняет концепцию «фильтрация» в UPX. В основном фильтрация это предварительная обработка данных, которая может улучшить коэффициент сжатия файлов с помощью UPX.
В настоящее время фильтры UPX используют метод основаный на одном очень специфичном алгоритме. Он хорошо походит для исполняемых файлов архитектуры i386. В UPX он известен как «naive» реализация. Также существует способ «clever», который подходит только для 32-битных исполняемых файлов и в-первые был реализован в UPX.
Давайте-ка возьмем примера и рассмотрим фрагмент кода(это место из 32-битного файла):
00025970: E877410600 calln FatalError
00025975: 8B414C mov eax,[ecx+4C]
00025978: 85C0 test eax,eax
0002597A: 7419 je file:00025995
0002597C: 85F6 test esi,esi
0002597E: 7504 jne file:00025984
00025980: 89C6 mov esi,eax
00025982: EB11 jmps file:00025995
00025984: 39C6 cmp esi,eax
00025986: 740D je file:00025995
00025988: 83C4F4 add (d) esp,F4
0002598B: 68A0A91608 push 0816A9A0
00025990: E857410600 calln FatalError
00025995: FF45F4 inc [ebp-0C]
Здесь Вы можете наблюдать имеется две инструкции CALL вызывающих «FatalError». Возможно Вы догадались, что степень сжатия будет лучше если «движок» компрессора найдет больше последовательностей повторяющихся строк. В нашем случае движок имеет следующие два байта последовательностей:
E877 410600 8B and
E857 410600 FF.
Поэтому он может найти 3-байтовых совпадения.
Сейчас применим трюк. Для архитектуры i386, ближние вызовы(«near calls») кодируются как 0xE8 затем следует 32-битное относительное смещение адресу перехода. Теперь посмотрим что же случится, если значение позиции вызова добавить к значению смещения:
0x64177 + 0x25970 = 0x89AE7
0x64157 + 0x25990 = 0x89AE7
E8 E79A0800 8B
E8 E79A0800 FF
Теперь «движок» компрессора нашел 5-байтные совпадения. Это позволяет нам просто сохранить 2 байта сжимаемых данных. Неплохо.
Это и есть основная идея метода «naive» реализации. Все что Вы должны сделать это использовать метод «filter» перед сжатием и «unfilter» после декомпрессии. Просто перейдите к памяти, найдя байты 0xE8 и обработайте следующие 4 байта как сказано выше.
Конечно, есть несколько возможностей где эта схема может быть улучшена. Во-первых, не только CALL-ы могут быть обработаны, но и near jmp-ы(0xE9 + 32-битное смещение) работает аналогично.
Второе улучшение может быть, если мы ограничим это фильтрование только для области занимаемой действительным кодом, нет смысла обрабатывать основные данные.
Еще одним улучшением будет, если поменять порядок байт 32-битного смещения. Почему? Вот другой CALL который следует в фрагменте выше:
000261FA: E8C9390600 calln ErrorF
0x639C9 + 0x261FA = 0x89BC3
E8 C39B 0800 сравним с
E8 E79A 0800
Вы можете заметить, что эти две функции достаточно близки друг к другу, но компрессор не в состоянии использовать эту информацию (2-байтные совпадения, как правило не используются), если подрядок байт смещений повернуть. В таком случае:
E8 0008 9AE7
E8 0008 9BC3
Таким образом, «движок» компрессора ищет и такие 3-байтные совпадения. Это хорошее улучшение, теперь в «движке» также используются совпадения близлежайших смещений.
Это хорошо, но что случится когда мы найдем 'fake' CALL? Другими словами такой 0xE8, который является частью другой инструкции? К примеру такой:
0002A3B1: C745 E8 00000000 mov [ebp-18],00000000
Тогда в таком случае эти замечательные 0x00 байты перезапишутся неcколько менее сжимаемыми данными. Это невыгодная «naive» реализация.
Давайте станем умнее и попытаемся обнаруживать и обрабатывать только «действительные» CALL-ы. В UPX используется простой метод для поиска этих CALL-ов. Мы просто проверяем адресацию(destination) этих CALL-ов внутри некоторой области как и сами CALL-ы(поэтому указанный код выше ложное срабатыване, но это в целом помогает). Лучшим методом будет это дизассемблирование кода, будем рады помощи :)
Но это только часть работы. Мы не можем просто обрабатать один CALL, затем поскипать другой, процесс дефильтрации нуждается в некоторой информации, чтобы иметь возможность откатить фильтрацию.
UPX использует следующую идею, которая работает хорошо. Сначала мы предполагаем, что размер области, который будет фильтроваться менее чем 16 МБ. Затем UPX сканирует эту область и сохраняет байты, которые следуют за 0xE8 байтами. Если нам везет, то найдутся байты, которые не следуют за следующим 0xE8. Эти байты наши кандидаты для использования в качестве маркеров.
Помните что мы предполагали размер области сканирования менее чем 16 МБ? Хорошо, это означает что мы обрабатываем реальный CALL, в результате будет смещение тоже меньше чем 0x00FFFFFF. Поэтому MSB всегда 0xFF. Какое прекрассное место для хранения нашего маркера. Конечно мы реверснем порядок байт в получаемом смещении, так что этот маркер будет появляться только после 0xE8 байта и не в 4-х байтах после него.
Вот и все! Просто работайте с областью памяти, идентифицируя «реальные» CALL-ы и используйте этот метод для их помечания. После этого работа дефильтрации очень проста, он просто ищет за 0xE8 + последовательность маркера и дефильтрует, если нашел его. Это умно, не так ли? :)
Говоря по правде это не так просто в UPX. Может использоваться дополнительный параметр(«add_value»), что делает вещи немного более сложными(к примеру, может быть найден маркер непригодный к использованию, потому что некоторого переполнения во время сложения).
И алгоритм в целом оптимизирован для простоты дефильтрации(коротко и быстро ассемблируемо насколько это возможно, смотри stub/macros.ash), который делает процесс фильтрации менее сложной(fcto_ml.ch, fcto_ml2.ch, filteri.cpp).
Как это может быть видно в filteri.cpp, есть множество вариантов этих фильтрующих реализаций: — native/clever, calls/jumps/calls&jumps, с поворотом смещения/или без — в сумме где-то 18 различных фильтров(и 9 других вариантов для 16-битных программ).
Можете выбрать один из них используя параметр командной строки "--filter=" или испытать большинство из них "--all-filters". Или просто дать UPX использовать один заданных нами по умолчанию для исполнимых форматов.
От переводчика:
С удовольствием рассмотрю баг-репорты об ошибках у себя в списке личных сообщений:
* Связанные с переводом, орфографией или грамматикой;
* Технического характера допущенные при переводе, убедитесь что в оригинале все верно!
Автор: EvilsInterrupt