Исследование алгоритма работы программ

Исследование алгоритма работы программ

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

Исследование алгоритма работы программ

Пятнадцать лет назад эпический труд Криса Касперски «Фундаментальные основы хакерства» был настольной книгой каждого начинающего исследователя в области компьютерной безопасности. Однако время идет, и знания, опубликованные Крисом, теряют актуальность. Мы попытались обновить этот объемный труд и перенести его из времен Windows 2000 и Visual Studio 6.0 во времена Windows 10 и Visual Studio 2017.

Современные дизассемблеры достаточно интеллектуальны и львиную долю распознавания ключевых структур берут на себя. В частности, IDA Pro успешно справляется с идентификацией стандартных библиотечных функций, локальных переменных, адресуемых через регистр ESP, case-ветвлений и прочего. Однако порой она ошибается, вводя исследователя в заблуждение, к тому же ее высокая стоимость не всегда оправдывает применение. Например, студентам, изучающим ассемблер (а лучшее средство изучения ассемблера — дизассемблирование чужих программ), она едва ли по карману.

РЕКОМЕНДУЕМ:
Лучший редактор бинарных файлов для Windows

Разумеется, на IDA свет клином не сошелся, существуют и другие дизассемблеры — скажем, тот же DUMPBIN, входящий в штатную поставку SDK. Почему бы на худой конец не воспользоваться им? Конечно, если под рукой нет ничего лучшего, сойдет и DUMPBIN, но в этом случае об интеллектуальности дизассемблера придется забыть и пользоваться исключительно своей головой.

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

Поставь любимую музыку, выбери любимый напиток и погрузись в глубины дизассемблерных листингов.
Неплохой сборник, как раз для продолжительной работы

Неплохой сборник, как раз для продолжительной работы

Идентификация функций

Функция (также называемая процедурой или подпрограммой) — основная структурная единица процедурных и объектно ориентированных языков, поэтому дизассемблирование кода обычно начинается с отождествления функций и идентификации передаваемых им аргументов.

Строго говоря, термин «функция» присутствует не во всех языках, но даже там, где он присутствует, его определение варьируется от языка к языку. Не вдаваясь в детали, мы будем понимать под функцией обособленную последовательность команд, вызываемую из различных частей программы. Функция может принимать один и более аргументов, а может не принимать ни одного; может возвращать результат своей работы, а может и не возвращать — это уже не суть важно. Ключевое свойство функции — возвращение управления на место ее вызова, а ее характерный признак — множественный вызов из различных частей программы (хотя некоторые функции вызываются лишь из одного места).

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

Инструкция CALL закидывает адрес следующей за ней инструкции на вершину стека, а RET стягивает и передает на него управление. Тот адрес, на который указывает инструкция CALL, и есть адрес начала функции. А замыкает функцию инструкция RET (но внимание: не всякий RET обозначает конец функции!).

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

Непосредственный вызов функции

Просматривая дизассемблерный код, находим все инструкции CALL — содержимое их операнда и будет искомым адресом начала функции. Адрес невиртуальных функций, вызываемых по имени, вычисляется еще на стадии компиляции, и операнд инструкции CALL в таких случаях представляет собой непосредственное значение. Благодаря этому адрес начала функции выявляется простым синтаксическим анализом: ищем контекстным поиском все подстроки CALL и запоминаем (записываем) непосредственные операнды.

Рассмотрим следующий пример (Listing1 в материалах к статье):

Компилируем привычным образом:

Результат компиляции в IDA Pro должен выглядеть приблизительно так:

Судя по адресам, «наша функция» в листинге расположена выше функции main:

Как видишь, все очень просто.

Вызов функции по указателю

Однако задача заметно усложняется, если программист (или компилятор) использует косвенные вызовы функций, передавая их адрес в регистре и динамически вычисляя его (адрес, а не регистр!) на стадии выполнения программы. Именно так, в частности, реализована работа с виртуальными функциями, однако в любом случае компилятор должен каким-то образом сохранить адрес функции в коде. Значит, его можно найти и вычислить! Еще проще загрузить исследуемое приложение в отладчик, установить на «подследственную» инструкцию CALL точку останова и, дождавшись всплытия отладчика, посмотреть, по какому адресу она передаст управление. Рассмотрим следующий пример (Listing2):

Результат его компиляции должен в общем случае выглядеть так (функция main):

Вызов функции по указателю с комплексным вычислением целевого адреса

В некоторых, достаточно немногочисленных программах встречается и косвенный вызов функции с комплексным вычислением ее адреса. Рассмотрим следующий пример (Listing3):

Результат дизассемблирования этого кода в общем случае должен выглядеть так:

В строке call [ebp+var_18] происходит косвенный вызов функции. А что у нас в [ebp+var_18]? Поднимаем глаза на строку вверх — в [ebp+var_18] у нас значение edx. А чему равен сам edx? Прокручиваем еще одну строку вверх — edx равен содержимому ячейки [ebp+ecx*4+var_10]. Вот дела! Мало того что нам надо узнать содержимое этой ячейки, так еще и предстоит вычислить ее адрес!

Чему равен ECX? Содержимому [ebp+var_14]. А оно чему равно? «Сейчас выясним…» — бормочем мы себе под нос, прокручивая экран дизассемблера вверх. Ага, нашли: в строке 0x401064 в него загружается содержимое EAX! Какая радость! И долго мы будем так блуждать по коду?

Конечно, можно, затратив неопределенное количество времени, усилий и бодрящего напитка, реконструировать весь ключевой алгоритм целиком (тем более что мы практически подошли к концу анализа), но где гарантия, что при этом не будут допущены ошибки?

РЕКОМЕНДУЕМ:
Взлом приложений для Андроид с помощью отладчика

Гораздо быстрее и надежнее загрузить исследуемую программу в отладчик, установить бряк на строку text:00401077 и, дождавшись всплытия окна отладчика, посмотреть, что у нас расположено в ячейке [ebp+var_18]. Отладчик будет всплывать трижды, причем каждый раз показывать новый адрес! Заметим, что определить этот факт в дизассемблере можно только после полной реконструкции алгоритма.

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

Дизассемблер — дело другое. Полная реконструкция алгоритма позволит однозначно и гарантированно отследить все адреса косвенных вызовов. Вот потому дизассемблер и отладчик должны скакать в одной упряжке!

Напоследок предлагаю взглянуть на такой участок дизассемблированного листинга:

Воспользуемся средствами IDA и посмотрим, что загружается в ячейки памяти [ebp+…]. А это как раз адреса трех наших функций, последовательно размещенных компилятором друг за дружкой:

«Ручной» вызов функции инструкцией JMP

Самый тяжелый случай представляют «ручные» вызовы функции командой JMP с предварительной засылкой в стек адреса возврата. Вызов через JMP в общем случае выглядит так: PUSH ret_addrr/JMP func_addr, где ret_addrr и func_addr — непосредственные или косвенные адреса возврата и начала функции соответственно. Кстати, заметим, что команды PUSH и JMP не всегда следуют одна за другой и порой бывают разделены другими командами.

Возникает резонный вопрос: чем же так плох CALL и зачем прибегать к JMP? Дело в том, что функция, вызванная по CALL, после возврата управления материнской функции всегда передает управление команде, следующей за CALL. В ряде случаев (например, при структурной обработке исключений) возникает необходимость после возврата из функции продолжать выполнение не со следующей за CALL командой, а совсем с другой ветки программы. Тогда-то и приходится вручную заносить требуемый адрес возврата и вызывать дочернюю функцию через JMP.

Идентифицировать такие функции очень сложно — контекстный поиск ничего не дает, поскольку команд JMP, использующихся для локальных переходов, в теле любой программы очень и очень много — попробуй-ка проанализируй их все! Если же этого не сделать, из поля зрения выпадут сразу две функции — вызываемая функция и функция, на которую передается управление после возврата. К сожалению, быстрых решений этой проблемы не существует, единственная зацепка — вызывающий JMP практически всегда выходит за границы функции, в теле которой он расположен. Определить же границы функции можно по эпилогу.

Рассмотрим следующий пример (Listing4):

Результат его компиляции в общем случае должен выглядеть так:

Смотри, казалось бы, тривиальный условный переход, что в нем такого? Ан нет! Это не простой переход, это замаскированный вызов функции! Откуда он следует? Давай-ка перейдем по смещению sub_401000 и посмотрим:

Как ты думаешь, куда этот retn возвращает управление? Естественно, по адресу, лежащему на верхушке стека. А что у нас лежит на стеке? PUSH EBP из строки 401000, обратно выталкивается инструкцией POP из строки 401005. Возвращаемся назад, к месту безусловного перехода, и начинаем медленно прокручивать экран дизассемблера вверх, отслеживая все обращения к стеку. Ага, попалась птичка! Инструкция PUSH ESI из строки 40101A закидывает на вершину стека содержимое регистра ESI, а он сам, в свою очередь, строкой выше принимает «на грудь» значение loc_401020 — это и есть адрес начала функции, вызываемой командой JMP (вернее, не адрес, а смещение, но это не принципиально важно):

Автоматическая идентификация функций посредством IDA Pro

Дизассемблер IDA Pro способен анализировать операнды инструкций CALL, что позволяет ему автоматически разбивать программу на функции. Причем IDA вполне успешно справляется с большинством косвенных вызовов. Между тем современные версии дизассемблера на раз-два справляются с комплексными и «ручными» вызовами функций командой JMP.

IDA успешно распознала «ручной» вызов функции

IDA успешно распознала «ручной» вызов функции

Пролог

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

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

Последовательность PUSH EBP/MOV EBP,ESP/SUB ESP,xx может служить хорошей сигнатурой для нахождения всех функций в исследуемом файле, включая и те, на которые нет прямых ссылок. Такой прием, в частности, использует в своей работе IDA Pro, однако оптимизирующие компиляторы умеют адресовать локальные переменные через регистр ESP и используют EBP как и любой другой регистр общего назначения. Пролог оптимизированных функций состоит из одной лишь команды SUB ESP, xxx — последовательность слишком короткая для использования ее в качестве сигнатуры функции, увы. Более подробный рассказ об эпилогах функций нас ждет впереди.

Эпилог

В конце своей жизни функция закрывает кадр стека, перемещая указатель вершины стека «вниз», и восстанавливает прежнее значение EBP (если только оптимизирующий компилятор не адресовал локальные переменные через ESP, используя EBP как обычный регистр общего назначения). Эпилог функции может выглядеть двояко: либо ESP увеличивается на нужное значение командой ADD, либо в него копируется значение EBP, указывающее на низ кадра стека.

Обобщенный код эпилога функции выглядит так. Эпилог 1:

Эпилог 2:

Важно отметить: между командами POP EBP/ADD ESP, xxx и MOV ESP,EBP/POP EBP могут находиться и другие команды — они необязательно должны следовать вплотную друг к другу. Поэтому для поиска эпилогов контекстный поиск непригоден — требуется применять поиск по маске.

Если функция написана с учетом соглашения PASCAL, то ей приходится самостоятельно очищать стек от аргументов. В подавляющем большинстве случаев это делается инструкцией RET n, где n — количество байтов, снимаемых из стека после возврата. Функции же, соблюдающие С-соглашение, предоставляют очистку стека вызывающему их коду и всегда оканчиваются командой RET. API-функции Windows представляют собой комбинацию соглашений С и PASCAL — аргументы заносятся в стек справа налево, но очищает стек сама функция.

Таким образом, RET может служить достаточным признаком эпилога функции, но не всякий эпилог — это конец. Если функция имеет в своем теле несколько операторов return (как часто и бывает), компилятор в общем случае генерирует для каждого из них свой собственный эпилог. Необходимо обратить внимание, находится ли за концом эпилога новый пролог или продолжается код старой функции. Также нельзя забывать и о том, что компиляторы обычно (но не всегда!) не помещают в исполняемый файл код, никогда не получающий управления. Иначе говоря, у функции будет всего один эпилог, а все находящееся после первого return будет выброшено как ненужное.

Между тем не стоит спешить вперед паровоза. Откомпилируем с параметрами по умолчанию следующий пример (Listing5):

Откомпилированный результат будет выглядеть так (приведен код только функции func):

Теперь посмотрим, какой код сгенерирует компилятор, когда внеплановый выход из функции происходит при срабатывании некоторого условия (Listing6):

Результат компиляции (только func):

Как и в предыдущем случае, компилятор создал только один эпилог. Обрати внимание: в начале функции в строке 00401004 аргумент сравнивается с нулем, если условие выполняется, происходит переход на метку loc_40101E, где выполняется деление, за которым сразу следует эпилог. Если же условие в строке 00401004 не выполняется, выполняется сложение и происходит безусловный прыжок на эпилог.

Специальное замечание

Начиная с процессора 80286 в наборе команд появились две инструкции ENTER и LEAVE, предназначенные специально для открытия и закрытия кадра стека. Однако они практически никогда не используются современными компиляторами. Почему?

Причина в том, что ENTER и LEAVE очень медлительны, намного медлительнее PUSH EBP/MOV EBP,ESP/SUB ESB, xxx и MOV ESP,EBP/POP EBP. Так, на старом добром Pentium ENTER выполняется за десять тактов, а приведенная последовательность команд — за семь. Аналогично LEAVE требует пять тактов, хотя ту же операцию можно выполнить за два (и даже быстрее, если разделить MOV ESP,EBP/POP EBP какой-нибудь командой).

РЕКОМЕНДУЕМ:
Реверс Андроид приложений

Поэтому современный исследователь никогда не столкнется ни с ENTER, ни с LEAVE. Хотя помнить об их назначении будет нелишне (мало ли, вдруг придется дизассемблировать древние программы или программы, написанные на ассемблере, — не секрет, что многие пишущие на ассемблере очень плохо знают тонкости работы процессора и их «ручная оптимизация» заметно уступает компилятору по производительности).

«Голые» (naked) функции

Компилятор Microsoft Visual C++ поддерживает нестандартный квалификатор naked, позволяющий программистам создавать функции без пролога и эпилога. Компилятор даже не помещает в конце функции RET, и это приходится делать «вручную», прибегая к ассемблерной вставке __asm{ret} (использование return не приводит к желаемому результату).

Вообще-то поддержка naked-функций задумывалась исключительно для написания драйверов на чистом С (с небольшой примесью ассемблерных включений), но она нашла неожиданное признание и среди разработчиков защитных механизмов. Действительно, приятно иметь возможность «ручного» создания функций, не беспокоясь, что их непредсказуемым образом «изуродует» компилятор.

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

Идентификация встраиваемых (inline) функций

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

Чем плоха развертка функций для исследования программы? Прежде всего, она увеличивает размер материнской функции и делает ее код менее наглядным — вместо CALL\TEST EAX,EAX\JZ xxx с бросающимся в глаза условным переходом мы видим кучу ничего не напоминающих инструкций, в логике работы которых еще предстоит разобраться.

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

Рассмотрим следующий пример, чтобы увидеть, как компилятор оптимизирует встраиваемую функцию (Listing7):

Результат компиляции этого кода будет иметь следующий вид (функция main):

«Так-так», — шепчем себе под нос. И что же он тут накомпилировал? Встраиваемую функцию представил в виде обычной! Вот дела! Компилятор забил на наше желание сделать функцию встраиваемой (мы ведь написали модификатор inline). Ситуацию не исправляет даже использование параметров компилятора: /Od или /Oi. Первый служит для отключения оптимизации, второй — для создания встраиваемых функций. Такими темпами компилятор вскоре будет генерировать код, угодный собственным предпочтениям или предпочтениям его разработчика, а не программиста, его использующего! Остальное ты можешь увидеть в комментариях к дизассемблированному листингу.

Сравнивающая функция max в дизассемблированном виде будет выглядеть так:

Здесь тоже все важные фрагменты прокомментированы.

Напоследок предлагаю откомпилировать и рассмотреть следующий пример (Listing8). Он немного усложнен по сравнению с предыдущим, в нем в качестве одного из значений для сравнения используется аргумент командной строки, который преобразуется из строки в число и при выводе обратно.

VS Code

VS Code

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

Следующим действием программа берет параметр командной строки. Она различает числа двух форматов: десятичные и шестнадцатеричные, определяя их по отсутствию или наличию префикса 0x. Два последующих оператора идентичны, в них происходят вызовы функции max, которой оба раза передаются одинаковые параметры: 0x666 и параметр командной строки, преобразованный из строки в число. Эти два последовательных оператора, как и в прошлый раз, позволят нам проследить вызовы функции.
Вывод приложения

Вывод приложения

Вместе с дополнительной функциональностью соответственно увеличился дизассемблерный листинг. Тем не менее суть происходящего не изменилась. Чтобы не приводить его здесь (он занимает реально много места), предлагаю тебе разобраться с ним самостоятельно.

Заключение

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

РЕКОМЕНДУЕМ:
Как обмануть нейронную сеть

 

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

Понравилась статья? Поделиться с друзьями:
Добавить комментарий