Хакер - Крепость эльфов. Как распаковать исполняемый файл Linux, накрытый UPX

Хакер - Крепость эльфов. Как распаковать исполняемый файл Linux, накрытый UPX

hacker_frei

https://t.me/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 переби­рает все воз­можные фун­кции упа­ков­ки перед тем, как сдать­ся

Распаковываем себя

Что же про­исхо­дит, ког­да бинарь рас­паковы­вает­ся не коман­дой upx -d, а сво­им собс­твен­ным кодом? В этом слу­чае за про­исхо­дящее отве­чает заг­рузчик. Рас­паковы­вать он может либо сра­зу в память, либо с исполь­зовани­ем вре­мен­ных фай­лов с пос­леду­ющим их запус­ком — это будет зависеть от кон­крет­ной реали­зации.

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

Наб­людать про­исхо­дящее мож­но, нап­ример, исполь­зуя strace. Цепоч­ка вызовов mmap()/mprotect() соз­дает отоб­ражения для сег­ментов кода и дан­ных рас­паковы­ваемой прог­раммы с нуж­ными пра­вами дос­тупа. Завер­шает­ся этот марафон одним вызовом munmap(), который приз­ван снять отоб­ражение кода заг­лушки‑рас­паков­щика. Затем управле­ние переда­ется в ори­гиналь­ную точ­ку вхо­да и начина­ет исполнять­ся код прог­раммы, которая была упа­кова­на.

Для при­мера вос­поль­зуем­ся прог­раммой, которая выводит содер­жимое сво­его /proc/<pid>/maps. Пос­мотрим, что она выведет, будучи упа­кован­ной. Инте­ресу­ющая нас информа­ция находит­ся, как и ска­зано, ниже вызова munmap(). Бла­года­ря strace ты можешь так­же уви­деть, как соз­дают­ся сег­менты прог­раммы с нуж­ными адре­сами и пра­вами дос­тупа.

Ар­тефак­ты в сег­ментах

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

Кар­та памяти неупа­кован­ного бинаря

Дру­гой «арте­факт» зак­люча­ется в том, что /proc/self/exe про­цес­са будет ука­зывать на упа­кован­ный файл, по которо­му прог­рамма может понять, была ли она упа­кова­на.

Что проверяется при распаковке

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

NotPackedException при замене сиг­натуры UPX! на 2021

Итак, во вре­мя ста­тичес­кой рас­паков­ки в пер­вую оче­редь про­веря­ются сиг­натуры 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 при помощи GDB

Ес­ли ты хочешь получ­ше разоб­рать­ся в прин­ципах работы UPX под Linux — ниже нес­коль­ко полез­ных ссы­лок.

Читайте ещё больше платных статей бесплатно: https://t.me/hacker_frei


Report Page