Хакер - Крепость эльфов. Как распаковать исполняемый файл Linux, накрытый UPX
hacker_frei
Ксения Кирилова
Содержание статьи
- Зачем это все?
- Принцип работы среднестатистического пакера
- The Ultimate Packer for eXecutables
- Заголовки UPX
- Распаковываем себя
- Что проверяется при распаковке
- Как патчат UPX
- Дампим!
Упаковщики и крипторы хорошо известны любителям усложнить обратную разработку кода. Для винды таких инструментов целый зоопарк, в Linux все куда сложнее: сейчас проверенно работает с эльфами под различные платформы лишь UPX. Его используют вирусописатели для упаковки ботов, майнеров и SSH-бэкдоров, а потому заострим на нем внимание и мы.
INFO
Эта статья написана по мотивам доклада с новогодней сходки, организованной сообществом SPbCTF в декабре 2020 года. Запись сходки со всеми докладами доступна на канале SPbCTF в YouTube.
ЗАЧЕМ ЭТО ВСЕ?
На страничке одного хорошего пакера можно прочесть, что основная цель его существования — уменьшить размер исполняемого файла с вытекающей отсюда экономией дискового пространства и сетевого трафика. Например, UPX позволяет сжать исполняемый файл на 50–70%, и он останется полностью самодостаточным, потому что код, выполняющий распаковку в память, добавляется к получившемуся бинарю. Для этого же упаковка используется и в PyInstaller при сборке кода на Python в независимые исполняемые файлы PE или ELF (при этом он может работать и в тандеме с UPX).
Однако, как ты догадываешься, пакеры отлично подходят еще и для того, чтобы немного подпортить жизнь реверс‑инженеру. В этом ключе родственны им крипторы. В мире Linux, однако, это скорее проекты, больше похожие на proof-of-concept, либо же просто что‑то старое и, прямо скажем, в живой природе почти не встречающееся.
Из упаковщиков для файлов ELF в настоящее время наиболее популярен UPX (в частности, среди вирусописателей), поскольку остальные пакеры либо поддерживают полторы архитектуры, либо уже очень давно не обновлялись (оценить количество канувших в Лету проектов можно, полистав давний обзор упаковщиков для Linux/BSD от Криса Касперски).
ПРИНЦИП РАБОТЫ СРЕДНЕСТАТИСТИЧЕСКОГО ПАКЕРА
Концептуально упаковщик работает так. Код и данные программы сжимаются без потерь каким‑либо алгоритмом (с использованием lzma, zlib или чего‑либо еще), добавляется код, выполняющий распаковку того, что получилось, затем добавляются собственные заголовки, и вуаля — у нас сжатый бинарь. Схематично процесс упаковки представлен ниже.

При запуске такого файла начнет выполняться загрузчик, отвечающий за распаковку сжатого кода и данных в память, после чего он передает управление в оригинальную точку входа. Грубо говоря, получается самораспаковывающийся архив. Детали реализации в разных пакерах могут различаться — например, PyInstaller при создании exe-файла помещает упакованные данные в оверлей, а загрузчик находится перед упакованными данными, а не после, как на схеме. Более изощренные упаковщики (скорее больше напоминающие крипторы), могут еще больше усложнять этот процесс, но в целом это не меняет сути.
Как правило, полученный после упаковки файл может затем распаковываться двумя способами: либо при запуске — в память загрузчиком, либо же без его запуска — путем статической распаковки. Результаты распаковки при этом будут несколько различаться, потому что исполняемый файл, загруженный в память, уже не тот, что исходный файл на диске.
THE ULTIMATE PACKER FOR EXECUTABLES
Этот проект празднует в нынешнем году 25-летие. Его исходники доступны на Гитхабе, он написан на плюсах с примесью ассемблера, поэтому разбираться в коде довольно‑таки весело. UPX поддерживает множество типов файлов и архитектур — не зря он такой популярный. Более того, PyInstaller имеет опцию --upx-dir, позволяющую при создании архива упаковать его при помощи UPX, тем самым уменьшая размер результирующего файла.
Как происходит упаковка программы в UPX? Вначале определяется ее формат — PE, ELF, образ ядра Linux или что‑либо еще (говорят, в UPX можно паковать даже sh-скрипты!). После этого нужно определить архитектуру, под которую скомпилирован файл. Это связано с тем, что загрузчик, который называется в коде UPX stub loader («заглушка», стаб), платформенно зависим. Его код написан на языке ассемблера, потому что при сжатии разработчики стараются экономить буквально каждый байт и добиться максимальной компрессии. В связи с этим бывает и так, что сам загрузчик тоже частично упакован. Так что UPX поддерживает те архитектуры, под которые у него есть реализация стаба. Оценить список архитектур и типов файлов можно, взглянув на содержимое директории src/stub в сорцах UPX.
Итак, если архитектура упаковываемого файла поддерживается UPX, то для него формируется загрузчик, сам файл сжимается, и в целом наш сжатый бинарь готов. Для возможности статической распаковки, которая выполняется командой upx -d, UPX добавляет в файл собственные заголовки.
Заголовки UPX
Добавляются четыре заголовка, три из которых можно увидеть в начале исполняемого файла:
- loader info (
l_info) содержит контрольную сумму, магические сигнатуры «UPX!», размер и некоторые параметры загрузчика; - packed program info (
p_info). В этом заголовке находятся размеры неупакованного блокаp_blocksizeи неупакованной (исходной) программыp_filesize, которые, как правило, равны; - block info (
b_info) предваряет каждый сжатый блок и содержит информацию о размерах до и после сжатия, алгоритме (методе), уровне сжатия блока и других параметрах. На рисунке ниже ты можешь видеть по смещению0x110сигнатуру ELF: это и есть начало упакованного файла.

- packheader добавляется в конце файла, обычно занимая немного больше 0x24 байт в зависимости от выравнивания. Его выделяют две сигнатуры
UPX!, вторая из которых выровнена по четырехбайтовой границе. В packheader записывается информация, необходимая самому UPX, чтобы статически распаковать бинарь, не прибегая к коду загрузчика. В нее входят, в частности, уже названные данные — размер распакованного файла и некоторые параметры сжатия. В результате этого получается некоторая избыточность. Как видим, обведенное значение совпадает с хранящимися вp_infoразмерами неупакованных блока и файла. Это может немного помочь нам в дальнейшем при рассмотрении способов защиты от статической распаковки.

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

Распаковываем себя
Что же происходит, когда бинарь распаковывается не командой upx -d, а своим собственным кодом? В этом случае за происходящее отвечает загрузчик. Распаковывать он может либо сразу в память, либо с использованием временных файлов с последующим их запуском — это будет зависеть от конкретной реализации.
В первом случае, который нас интересует больше в рамках данной статьи, загрузчик при помощи хитрой магии выделения памяти и назначения выделенным областям памяти нужных прав подготавливает области для сегментов оригинального исполняемого файла — блоков кода, данных, кучи, стека и при необходимости динамических библиотек. В идеале все должно выглядеть так, чтобы запакованный файл и не понял, что был упакован, и работал целиком и полностью так же, как до распаковки.
Наблюдать происходящее можно, например, используя strace. Цепочка вызовов mmap()/mprotect() создает отображения для сегментов кода и данных распаковываемой программы с нужными правами доступа. Завершается этот марафон одним вызовом munmap(), который призван снять отображение кода заглушки‑распаковщика. Затем управление передается в оригинальную точку входа и начинает исполняться код программы, которая была упакована.
Для примера воспользуемся программой, которая выводит содержимое своего /proc/<pid>/maps. Посмотрим, что она выведет, будучи упакованной. Интересующая нас информация находится, как и сказано, ниже вызова munmap(). Благодаря strace ты можешь также увидеть, как создаются сегменты программы с нужными адресами и правами доступа.

Тем не менее внимательный взгляд на /proc/<pid>/maps процесса покажет, что с его памятью явно что‑то не так, — после распаковки остаются некоторые артефакты. Как ты можешь заметить ниже, сегменты запущенного из упакованного файла процесса не связаны ни с каким устройством (4-я колонка выводит старший и младший номера устройства) или файлом (5-я колонка показывает номер i-node на соответствующем устройстве, в нашем случае равный нулю, поскольку используется анонимное отображение), в отличие от процесса для несжатого файла. Хотя адреса и права сегментов бинаря действительно одинаковы. А заодно мы увидим, что присутствует отображение, явно связанное с запакованным файлом, в нем хранится его первая страница и один сегмент вообще без каких‑либо прав. Для сравнения вывод неупакованной программы выглядит следующим образом.

Другой «артефакт» заключается в том, что /proc/self/exe процесса будет указывать на упакованный файл, по которому программа может понять, была ли она упакована.
Что проверяется при распаковке
Проверки необходимы, потому что данные сжаты и тут, ровно как в криптографии, изменение одного байта может привести к порче всего файла после распаковки. А в случае, если это код, непредсказуемых ошибок при его исполнении не миновать. Поэтому хороший пакер проверяет, соответствует ли то, что он распаковал, тому, что было упаковано. Множество выполняющихся проверок можно поизучать в исходниках (а их число действительно велико, как и разнообразие исключений). Также ошибки распаковки можно спровоцировать, изменяя байты в различных заголовках и ища в исходниках соответствующие им строки, как было сделано в статье, посвященной использованию UPX в линуксовых вредоносах для IoT.

Итак, во время статической распаковки в первую очередь проверяются сигнатуры UPX!, по которым UPX понимает, что перед ним запакованный им файл. Затем он смотрит на различные контрольные суммы, на значения полей размеров файлов и, если файл испорчен, не позволит так просто его распаковать, потому что не сможет дать гарантий, что тот будет соответствовать оригиналу. Однако же, когда файл распаковывает себя сам, он использует не те структуры, которые нужны для команды upx -d. Если знать, что править, можно получить такой файл, который будет запускаться и работать как ни в чем не бывало, но при этом распаковать его в одно мгновение единственной командой уже не получится. Давай посмотрим, как такое можно провернуть.
КАК ПАТЧАТ UPX
Первое, что можно пропатчить в файлах, упакованных при помощи UPX, — это его сигнатуры. Три сигнатуры могут быть заменены на любые четыре байта каждая, и этого достаточно, чтобы сбить с толку «ванильный» UPX. Кстати, таков был первый рубеж в одном задании для прохода на конференцию area41, подробнее о котором можно почитать вот в этом райтапе.
Также бывает, что в малварных семплах зануляют поля, хранящие размеры файла и блока. Но при этом порой забывают, что это же значение хранится в конечном заголовке packheader, откуда его можно восстановить.
В обоих рассмотренных случаях файл легко починить. Однако никто не мешает пропатчить то, что попросту неоткуда будет восстановить: например, занулить все три размера файла. Еще можно удалить заголовок packheader в конце файла — при этом файл будет преспокойно себе запускаться. Либо можно использовать какой‑нибудь кастомизированный UPX, ведь его открытые исходники располагают и к такому. В общем, существует масса способов сделать так, чтобы файл выглядел упакованным, но UPX кидался исключениями. Тем не менее выход есть и в подобных случаях...
INFO
Не могу не упомянуть довольно интересный случай с упакованной малварью под Linux. Недавно 360 Netlab опубликовали отчет о вредоносе, который содержал конфигурацию для связи с управляющим сервером в небольшом оверлее за упакованным файлом. Этот оверлей мешал статической распаковке, и, убрав его, можно было распаковать файл. Однако тогда при запуске распакованный семпл отказывался работать, сетуя на отсутствующую конфигурацию. Этот подход позволяет злоумышленникам удобно изменять конфигурацию без перекомпиляции и переупаковки файлов.
Дампим!
Чтобы получить распакованный файл, нужно сначала решить, в какой момент его следует дампить. В случае UPX нам поможет знание о цепочке системных вызовов, используемых загрузчиком. Для нас важно, что в конце, после подготовки памяти, вызывается munmap(). Таким образом, чтобы сдампить распакованный в память файл, можно поставить точку останова после выполнения этого сискола, посмотреть адреса сегментов памяти и сохранить нужные из них. Звучит несложно. И к примеру, в GDB такой трюк можно сделать в пять команд и даже оформить в виде скрипта.
При этом следует учитывать, что полученный дамп будет отличаться от файла на диске: в частности, в нем не будет таблицы секций, а сегмент данных, скорее всего, окажется смещенным из‑за выравнивания сегментов по границе страниц. В результате этого сдампленный бинарь не будет работать, однако полученного файла может быть вполне достаточно для анализа его функциональности в дизассемблере. Для восстановления запускаемых эльфов существуют различные проекты и исследования, но, к сожалению, в большинстве своем они также довольно старые. Еще известна схожая задача по восстановлению исполняемых файлов из core dump’ов, имеющая proof-of-concept и решения с некоторыми ограничениями.

Если ты хочешь получше разобраться в принципах работы UPX под Linux — ниже несколько полезных ссылок.
Читайте ещё больше платных статей бесплатно: https://t.me/hacker_frei