Научная статья на тему 'Обзор алгоритмов декомпиляции'

Обзор алгоритмов декомпиляции Текст научной статьи по специальности «Компьютерные и информационные науки»

CC BY
795
94
i Надоели баннеры? Вы всегда можете отключить рекламу.
i Надоели баннеры? Вы всегда можете отключить рекламу.
iНе можете найти то, что вам нужно? Попробуйте сервис подбора литературы.
i Надоели баннеры? Вы всегда можете отключить рекламу.

Текст научной работы на тему «Обзор алгоритмов декомпиляции»

ОБЗОР АЛГОРИТМОВ ДЕКОМПИЛЯЦИИ Щеглов К.Е. (k_scheglov@stinol.ru)

Липецкий государственный технический университет

1. Введение

1.1. Определение

В литературе можно встретить несколько различных определений декомпилятора. Например, в [1]:

Декомпилятор - программа, получающая на вход программу в машинном коде и выдающая эквивалентную программу на языке программирования.

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

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

1.2. История развития декомпиляции

Первые декомпиляторы появились в 60-х годах и сразу же стали рассматриваться как полезные инструменты для практического применения. В то время они использовались для облегчения переноса программ с ЭВМ второго поколения на третье [2,3,4]. Речь, разумеется, шла не о полностью автоматической декомпиляции (даже сейчас это не получится), хорошим результатом считалось получение программы, в которой правильно восстановлено 90% кода.

В 70-е и 80-е декомпиляторы также использовались для переноса программ, а также для документирования, отладки, восстановления утерянных исходных текстов и модификации существующих исполняемых модулей [5,6,7].

В 90-х годах декомпиляторы стали использоваться как средство reverse engineering'а, помогающее пользователю в таких задачах как проверка программного обеспечения на наличие злонамеренного кода, тестирование компиляторов, а также перевод программ с одной машины на другую.

В настоящее время существует ряд фирм, предлагающих услуги по декомпиляции программ для IBM 360/370/390 в исходный код на Cobol [8,9,10]. Это было особенно актуально в конце 20-го века из-за "проблемы 2000", так как часто программы были написаны много лет назад, а исходные тексты полностью или частично утрачены из-за сбоя диска, износа магнитной ленты и т.д.

В связи с высокой популярностью языка Java, а также из-за особенностей хранения скомпилированных программ (байт-код), несколько фирм предлагают услуги по восстановлению кода и защите от подобного восстановления, например [11].

Также существуют программы, облегчающие reverse engineering (в основном под ЭВМ на базе Intel-процессоров), например Sourcer, IDA Pro [12].

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

В принятом в 1999 году дополнении к закону об охране авторских прав [13] разработчикам программного обеспечения дается право декомпилировать чужие программные продукты, с тем, чтобы добиться их совместимости с существующим программным обеспечением. «Декомпиляцию программы можно будет осуществлять без разрешения владельца только в целях обеспечения интероперабельности или проверки уровня защиты, если информация о программных интерфейсах или о гарантиях безопасности не является общедоступной», — говорится в документе. При этом информация, извлеченная из декомпилированной программы, не может использоваться для каких-либо иных целей или передаваться другим лицам без разрешения владельца авторского права.

Исследование легально приобретенной вами программы, её изучение или изменение её кода совершенно законно в Евросоюзе [14], пока:

• Вы делаете это только для собственного использования или в «образовательных целях» (т. е. изучаете)

• Вы не используете больших фрагментов кода для написания программ, которые вы продаете

Вы можете, например, полностью переделать Wordpad для вашего собственного использования, чтобы открывать им по умолчанию файлы *.txt, *.alf и *.asm, вместо практически бесполезных файлов *.doc. Вы можете извлечь любой код из любой программы на ваше усмотрение, чтобы его использовать, изменить или выбросить в мусор.

Что касается России, то здесь ситуация весьма запутанная. Как указано в [15], в России, как и в Евросоюзе, разрешено исследование программ, однако (цитата закона): "Предоставляемая настоящим Законом правовая охрана не распространяется на идеи и принципы, лежащие в основе программы для ЭВМ или базы данных или какого-либо их элемента, в том числе на идеи и принципы организации интерфейса и алгоритма, а также языки программирования". Таким образом, алгоритмы не охраняются законом об авторских правах! Поэтому можно дизассемблировать программу для извлечения оных с очевидной выгодой. (Строго говоря, они могут быть защищены другими законами, например патентными, но, в отличие от авторского права, они автоматически не попадают под защиту закона).

Таким образом, декомпиляция программ является законной при соблюдении определенных условий. Однако в любом случае, исследование методов и алгоритмов декомпиляции является вполне законным, чем мы и займемся.

1.3. Проблемы декомпиляции

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

Вот список некоторых проблем, выявленных автором, и методы, которые помогают (иногда) в их решении:

• Разделение кода и данных. В архитектуре Фон-Неймана инструкции и данные представляются в памяти одинаково. Взяв случайную последовательность байт из бинарного файла, мы не может однозначно сказать, что это - инструкции или данные. Даже если процессор поддерживает разделение сегментов кода и данных, небольшое количество данных (например, таблицы ветвлений типа case) может оставаться в сегменте кода. Есть несколько путей решения этой проблемы. Один подходит в случае, когда множество инструкций процессора разреженное и не покрывает весь диапазон значений машинного слова, как, например, в процессоре ЭВМ PDP-11 [16]. Другой, более универсальный способ заключается в эмуляции работы процессора и последовательной трассировке программы, начиная с точки старта и заканчивая точкой останова. При этом также учитываются условные и безусловные переходы, вызовы подпрограмм, плюс параллельно происходит построение графа управления. Именно этот подход мы и будем использовать.

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

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

• Подпрограммы, добавляемые компилятором и линковщиком. Компилятор почти всегда добавляет подпрограммы для инициализации, а также библиотеки поддержки выполнения (RTL). Такие процедуры обычно написаны на ассемблере и в большинстве случаев не могут быть декомпилированны в высокоуровневый язык. Решение состоит в том, чтобы, зная с помощью какого компилятора создавалась программа, идентифицировать библиотечные подпрограммы и не проводить их декомпиляцию. Хорошая статья на эту тему может быть найдена на сервере разработчика программы IDA [10].

2. Фазы декомпиляции

Декомпилятор имеет структуру весьма похожую на структуру компилятора [6] - серия этапов, которые преобразуют исходную программу из одного представления в другое. Типичные фазы таковы: [бинарная программа]^[синтаксический анализатор]^[семантический анализатор] ^[генератор промежуточного кода] ^[генератор графа потоков управления] ^[анализатор потоков данных] ^[анализатор потоков управления] ^[генератор кода] ^[высокоуровневая программа]. На практике некоторые из этих фаз опускаются или объединяются в одну.

2.1. Синтаксический анализ

Парсер, или синтаксический анализатор, группирует байты исходной программы в грамматические фразы (или предложения) исходного машинного языка. Эти фразы можно представить в виде простых выражений присваивания (ecx := ecx - 50) или перехода (jump some_address).

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

2.2. Семантический анализ

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

Для проверки семантического значения группы инструкций используется

набор идиом. Так, например, инструкция:_

| shl eax,2

может быть преобразована в:_

| mul eax,4

2.3. Генерация промежуточного кода

Для дальнейшего анализа программы декомпилятор должен преобразовать программу в промежуточное представление. Это представление должно легко генерироваться из исходной программы, а так же оно должно быть подходящим для генерации в дальнейшем выходной программы. Для платформы Intel хорошо подходит трехадресное представление (приемник, источник_1, источник_2), так как все инструкции имеют максимум три операнда. В дальнейшем эти операнды можно легко расширить до выражений, представляющих высокоуровневые конструкции.

Идея генерации промежуточного кода также хорошо известна среди разработчиков компиляторов. Использование промежуточного кода позволяет проводить более сильную оптимизацию, а также писать компиляторы, работающие на большом количестве платформ и понимающих большое количество языков [19,20].

2.4. Генерация графа потоков управления

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

2.5. Анализ потоков данных

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

2.6. Анализ потоков управления

На фазе анализа потоков управления производится структурирование графа потоков управления каждой процедуры, путем выделения множества обобщенных высокоуровневых конструкций [21]. Это обобщенное множество должно содержать управляющие инструкции, используемые в большинстве языков; такие как циклы и условия. Однако не должны включаться конструкции, специфичные для каких-либо языков, так как желательно обеспечить как можно большую независимость от конкретного языка.

На рисунке 1 представлены два примера графов: if-then-else и -^Пе().

2.6. Генерация кода

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

Рисунок 1.

3. Группировка фаз

Фазы декомпиляции, представленные в пункте 2 при реализации обычно объединяют в несколько более крупных этапов. Например, так, как это

Рисунок 2.

Front-end состоит из фаз, которые зависимы от ЭВМ или используемого машинного языка. Эти фазы включают лексический, синтаксический и семантический анализ, генерацию промежуточного кода и графа потоков управления. В конечном итоге получается промежуточное, машинно-независимое представление программы.

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

Анализатор идиом производит анализ промежуточного кода на предмет наличия в нем специфических конструкций, которые не являются общими для всех языков. Однако этот этап не может быть перенесен в фазу back-end, так как после анализа идиом могут продолжаться другие виды универсального анализа. Примером таких идиом может служить поиск вызовов виртуальных функций, после которого должен продолжаться анализ потоков данных.

Наконец, back-end генерирует текст программы. Этот этап состоит из кодогенератора.

В теории компиляторов подобная группировка фаз используется разработчиками компиляторов для создания компиляторов для разных исходных языков и разных машин. Если переписать back-end компилятора для новой платформы, новый компилятор для этой платформы по-прежнему может использовать старый front-end. На практике существуют ограничения в использовании такого подхода, главным образом связанные с выбором вида промежуточного кода.

Аналогично компиляторам, можно утверждать, что путем замены front-end и back-end частей, можно получать декомпиляторы, которые способны "читать"

программы на разных машинных языках и "писать" на разных языках высокого уровня.

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

3.1. Определения

Для начала формализуем некоторые широко известные понятия. Известно, что программа состоит из данных и инструкций по их обработке:

Определение 1. Пусть:

• P - программа

• I = {, !, in} - инструкции P

• D = {{, !,dn}- данные P Тогда P = I U D .

Инструкции удобно рассматривать не по отдельности, а группами, последовательностями. Это позволяет уменьшить размеры графов, а также абстрагироваться от отдельных инструкций, когда они не играют роли. Определение 2. Пусть:

• P - программа

• I = {{, !, in} - инструкции P

Тогда S будем называть последовательностью инструкций тогда и только тогда, когда S = \ij, ij+1, !, ij+к ] 1 < j < j + к < n a ij+1 обозначает

область памяти непосредственно следующую после ij, V1 < j < к — 1.

Промежуточные инструкции можно разделить на два множества:

• Инструкции перехода (transfer instructions, TI): множество инструкций, которые передают управление на адрес, отличный от адреса непосредственно следующей инструкции - условный и безусловный переходы, вызов подпрограммы, возврат из подпрограммы, конец программы.

• Инструкции, не содержащие перехода (non transfer instructions NTI).

В [19] вводится понятие базисного блока, которое можно формально записать так.

Определение 3. Базисный блок b = [i, !, in—1, in ] n > 1 - это

последовательность инструкций, которая удовлетворяет следующим условиям:

1. [,!,in—!] NTI

2. in e TI или

1. [,!,in—1,in]e NTI

2. in+1 - первая инструкция следующего базисного блока

Таким образом, базисный блок - это последовательность инструкций, которая имеет один вход и один выход. Если исполняется одна инструкция блока, исполняется и весь блок.

Множество инструкций программы может быть разделено на множество непересекающихся базисных блоков, начиная с точки старта программы [20].

Определение 4. Пусть:

• I = {,..., in} - инструкции P

• h - точка старта P

Тогда 3B = {b,..., Ъп} b П Ъ2 ...П Ьп = 0 а I = Ъ, U Ъ2 ...U Ъп a h - точка входа Ъ1.

Граф потоков управления [20] - это направленный граф, который представляет потоки управления в программе. Вершинами этого графа служат базисные блоки (определение 3), а ребрами - передача управления между блоками.

Определение 5. Граф потоков управления G = (N, E, h) для программы P - это связанный направленный граф, который удовлетворяет следующим условиям:

• Уп е N, п является базисным блоком P

• Уе = (ni, п;. )е E, e представляет собой передачу управления от одного базисного блока другому.

4. Анализ потоков данных

Низкоуровневый промежуточный код, генерируемый front-end' ом, подобно ассемблерному листингу, использует регистры и коды условий. Это представление может быть преобразовано в другое, более высокоуровневое, которое не использует столь низкоуровневые концепции. Преобразование низкоуровневого промежуточного кода в высокоуровневый код традиционно называют оптимизацией.

Типы преобразований, которые производятся на этапе анализа потоков данных, включают [21]: 1 . Анализ инструкций в целом.

1.1. Удаление бесполезных инструкций (useless instructions elimination).

iНе можете найти то, что вам нужно? Попробуйте сервис подбора литературы.

1.2. Устранение дублированных выражений.

1 .3. Устранение спекулятивных вычислений (speculative scheduling). 2. Анализ регистров.

2. 1 . Распространение регистров (register propagation).

1 .2. Удаление мертвых регистров (dead register elimination).

1.3. Определение регистров-результатов в функциях. 1 .4. Определение регистровых параметров.

3. Анализ флагов условий (далее просто флагов).

3.1. Распространение флагов (conditional code propagation).

3.2. Удаление мертвых флагов (dead conditional code elimination).

Большинство из этих преобразований улучшают качество низкоуровневого кода и позволяют восстановить часть информации, потерянной во время процесса компиляции. Кроме того, некоторые из этих преобразований служат для устранения последствий оптимизаций, произведенных компилятором [23], подготавливая код для дальнейших преобразований более простыми алгоритмами,

К преобразованиям предъявляется несколько требований: 1 . Преобразование должно сохранять логику программы. 2. Преобразование должно быть выгодно.

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

Мы не будем рассматривать все преобразования, а остановимся на нескольких наиболее интересных и полезных.

4.1. Удаление мертвых регистров

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

Инструкцию, которая определяет только мертвый регистр, будем называть бесполезной. Бесполезные инструкции могут быть удалены из программы. Рассмотрим следующий псевдокод:_

1 eax = tmp/edi

2 edx = tmp%edi

3 edx = 3

4 edx:eax = eax*edx

Инструкция 1 определяет регистр eax, инструкция 2 - edx, а инструкция 3 переопределяет регистр edx. Между инструкциями 2 и 3 регистр edx не используется, поэтому определение регистра edx в инструкции 2 является мертвым (определение 6), а сама инструкция 2 - бесполезной, так как определяет только регистр edx. Таким образом, предыдущий код можно заменить таким:

1 eax = tmp/edi

3 edx = 3

4 edx:eax = eax*edx

4.2. Удаление мертвых флагов

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

1 cmp eax,edx опр.флаги ZF,CF,SF

2 jg Label исп.флаги SF

Инструкция 1 определяет три флага: нуля ^Б), переноса (СБ) и знака (ББ). Инструкция 2 использует флаг знака. Ни один из последующих двух базисных блоков не использует флаги нуля и переноса, поэтому определение этих двух флагов является бесполезным и может быть удалено. Мы можем заменить инструкцию 1:__

1 | cmp eax,edx | опр.флаги SF

4.3. Распространение флагов

Флаги используются в ЭВМ для обозначения наступления некоторого условия. В общем случае несколько инструкций определяют флаги, от 1 до 3

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

1 cmp eax,edx опр.флаги SF

2 jg Label исп.флаги SF

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

перехода. Инструкции 1-2 могут быть заменены на:_

2 | jcond (eax > edx) Label |

4.4. Распространение регистров

Определение 7. Будем называть инструкцию промежуточной, если она определяет значение регистра, используемое в дальнейшем единственной инструкцией.

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

1 eax = [ebp-12] опр. {eax} исп. {}

2 eax = eax - [ebp-8] опр. {eax} исп. {eax}

3 [ebp-4] = eax опр• {} исп. {eax}

Инструкция 1 определяет регистр eax, копируя в него значение из локальной переменной по адресу ebp-12. Это регистр затем используется в инструкции 2 как операнд сложения. Результат помещается в тот же регистр, который затем копируется в локальную переменную по адресу ebp-4. Как можно видеть, инструкция 1 определяет регистр eax с тем, чтобы он был использован как временный в операции вычитания и сохранения результата в другую локальную переменную в инструкции 3. Эти использования регистра eax как промежуточного можно устранить, путем замены регистра на значение, которое в него загружается. Так, объединяя инструкции 1 и 2 получим: _

2 | eax = [ebp-12] - [ebp-8] |опр. {eax} | исп. {}

Аналогично можно объединить 2 и 3: _ __ _

3 | [ebp-4] = [ebp-12] - [ebp-8] | опр. {eax} | исп. {}

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

5. Анализ потоков управления

Граф потоков управления (определение 5), реконструированный front-end'ом, не содержит информации о высокоуровневых управляющих конструкциях, таких как if-then-else, или while(). Такой граф можно преобразовать в высокоуровневые конструкции при помощи алгоритма

структурирования. Используется множество наиболее распространенных конструкций [3], доступных в С, Pascal, Modula-2 и Fortran. Эти конструкции являются различными видами циклов и условных переходов.

Для начала дадим несколько определений, касающихся графов. Все они используют граф G = (N, E, h). Часть из этих определений является общеизвестной в теории графов, часть является специфичной для анализа потоков управления [22].

Определение 8. Путем из n1 в nk (n1 ^ nk ); n1, nk e N будем называть

такую последовательность ребер (, n2), (n2, n3),..., (nk ч, nk ), что

(п,, ni+1 )e E, V1 < i < k, k > 1.

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

Определение 9. Потомками ni e N будем называть {n. e N | ni ^ n.} (то

есть все вершины, достижимые из n,).

Определение 10. Непосредственными потомками n, e N будем

называть {n . e N | (n,, n. )e E} .

Определение 11. Предками n. e N будем называть {ni e N | ni ^ n.} (то

есть все вершины, из которых достигается из n.).

Определение 12. Непосредственными предками n. e N будем называть

n e N 1 (п, , n.)e E} .

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

Определение 13. Вершина ni e N доминирует над вершиной nk e N,

если ni находится на каждом пути h ^ nk .

Определение 14. Вершина ni e N непосредственно доминирует над

вершиной nk e N, если ! 3n., n. доминирует над nk a ni доминирует над n. (то есть ni является ближайшей доминирующей над nk вершиной).

Понятия доминирования и непосредственного доминирования являются важными для определения точек начала и конца ветвления (условия и циклы).

Определение 15. Поиск в глубину (deep first search, DFS) - это метод перебора, который выбирает ребра вершин, которые были посещены позднее всего и имеют еще не посещенные ребра.

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

5.1. Слияние общего кода

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

являются крайне нежелательными для современных микропроцессоров. Но иногда возможно сокращение числа переходов. Рассмотрим следующий код: if ( a < b )

c = a; else

c = b; return;

При компиляции этого кода после then-блока будет вставлен безусловный переход на return. Но почему просто не продублировать код возврата, добавив его после then-блока, как это показано ниже? if ( a < b ) { c = a; return; } else { c = b; return;

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

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

5.2. Структурирование циклов

Циклы в графах определяются по наличию обратного ребра, то есть ребра от "нижней" вершины к "верхней", в терминах нумерации вершин. Рассмотрим два примера графов на рисунке 3.

Рисунок 3.

На них есть два обратных ребра: 3-1 и 6-4, и, соответственно, два цикла: 1-3 и 46.

}

1/ 2)

3 Г

Тип цикла определяется путем проверки первой (заголовок цикла) и последней (конец цикла) вершин цикла. Цикл 1-3 не содержит условного перехода в заголовке, но в конце цикла находится проверка, должен ли цикл исполниться еще раз или нет. Поэтому 1 -3 - цикл с постусловием, такой как repeat-until в Pascal, или do-while в C. Этот цикл может быть заменен на одну единственную вершину, которая хранит в себе тело цикла, условие цикла и тип цикла, и, кроме того, имеет один вход и один выход. Таким образом, эта вершина не будет отличаться от других вершин графа и к ней можно будет применять те же самые алгоритмы структурирования, как и к обычной вершине, например, включить ее в тело другого цикла, если граф содержит вложенные циклы.

Цикл 4-6 содержит условие цикла в заголовке. Последняя вершина цикла содержит безусловный переход, который передает управление обратно на заголовок. Очевидно, что цикл это с предусловием, такой как цикл while() в различных языках.

Интерес также представляет неосвещенный в [21] процесс поиска двух специальных конструкций в циклах - операторов continue и break.

Continue выглядит в коде как переход в начало цикла. Иногда это производится явно, в виде перехода, иногда код:

for (j=1; j<10; j+ + ) { printf("j = %d", j); if (j > 5)

continue; printf("some string");

}

преобразуется в:

for (j=1; j<10; j+ + ) { printf("j = %d", j); if (j <= 5) {

printf("some string");

}

}

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

iНе можете найти то, что вам нужно? Попробуйте сервис подбора литературы.

Break выглядит в коде как переход за границу цикла, на блок, непосредственно следующий за последним блоком цикла. Если же переход происходит на другой блок, его нужно считать простым неструктурированным оператором типа goto.

5.3. Структурирование условий

Рассмотрим примеры графов: 1-4 и 5-7 на рисунке 4. Оба они содержат в заголовке вершину с двумя ребрами, которые выбираются в зависимости от выполнения или не выполнения определенного условия. Но в обоих графах вне зависимости от выполнения условия, управление рано или поздно передается в вершину 4 или 7. Первый граф представляет собой структуру if-then-else, а второй if-then.

Рисунок 4.

Обобщенный алгоритм структурирования условий состоит в поиске двух вершин - заголовка условия и защелки условия. Тип условия (if-then-else или if-then) определяется путем проверки, не является ли одно из ребер, выходящих из заголовка переходом на защелку. Сначала исследуются внутренние вершины, а затем внешние. Это позволяет постепенно собирать базисные блоки в более крупные конструкции, так как в языках высокого уровня условия часто являются вложенными. Таким образом, перебор производится в порядке, обратном нумерации вершин. Для каждой вершины с двумя исходящими ребрами (потенциальный заголовок) вершина-защелка определяется как вершина, над которой вершина-заголовок непосредственно доминирует (определение 14). Кроме того, вершина-защелка должна иметь как минимум два входящих ребра, так как она должна достигаться как минимум двумя различными путями (определение 8) их вершины-заголовка. Как только найдены заголовок и защелка условия, можно определить тип условия и тела веток then и else.

5.4. Слияние последовательного кода

При построении графа потоков управления [9] происходит разбиение блока на два, если в середину блока производится переход. Однако после проведения структурирования циклов и условий, большинство переходов маскируется в структурные конструкции for/while и if, так что возможно появление так называемых pass-through блоков, то есть блоков с безусловной передачей управления в другой блок, который в свою очередь имеет только один вход. Такие блоки (2 или несколько) могут быть могут быть объединены в один.

Слияние блоков упрощает анализ кода, так как приходится работать с атомарными сущностями, а не с цепочками блоков. Кроме того, при этом алгоритмы структурирования хорошо укладываются в объектно-ориентированную структуру.

6. Заключение

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

Существует еще ряд интересных вопросов, таких как глобальное

распространение типов, обнаружение структур, массивов и классов;

структурирование вызовов виртуальных процедур и функций, а так же структурирование ветвлений типа case.

7. Список литературы

1 . Борковский А. Б. Англо-русский словарь по программированию и информатике (с толкованиями). - М.: Московская международная школа переводчиков, 1992.

2. M.H. Halstead. Machine-independent computer programming, chapter 11, pages 143-150. Spartan Books, 1962.

3. M.H. Halstead. Machine independence and third generation computers. In proceedings SJCC, pages 587-592, 1967.

4. B.C. Housel. A Study of decompilation machine language into high-level machine independent languages. 1973.

5. G.L. Hopwood. Decompilation. PhD dissertation, University of California, Irvine, Computer Science, 1978.

6. D.L. Brinkley. Intercomputer transportation of assembly language software through decompilation. Technical report, Naval underwater system center, 1981.

7. C.W. Yoo. An approach to the transportation of computer software. Information processing letters, 21:153-157, 1985.

8. http://www.cs.kun.nl/~hans/A2C/blurb.html A2C Software Renovation.

9. http://www.smltd.com Software Migrations Ltd.

10. http://www.source-recovery.com The Source Recovery Company.

11. http://www.jreveal.org/ On-line constructive Java class decompiler and obfuscator.

12. Ilfak Guilfanov. Fast Library Identification and Recognition Technology. -http://www.datarescue.com/idabase/flirt.htm, 1997.

1. Джеймс Николаи. Нарушители авторского права «в законе». http://www.osp.ru/cw/1999/36/22.htm

14. http://cie.ase.md/~sereda/legal_ru.htm О правовых аспектах исследования программ.

15. Крис Касперски, Хакеры - люди из группы риска? http://kr.tritec.ru/1999/289.html

16. Григорьев В.Л. Микропроцессор i486. Архитектура и программирование. - М.: ГРАНАЛ, 1993.

17. Злобин В.К., Григорьев В.Л. Программирование арифметических операций в микропроцессорах. - М.: Высш. шк., 1991.

18. Янг С. Алгоритмические языки реального времени: конструирование и разработка: Пер. с англ. - М.: Мир, 1985.

19. Лин В. PDP-11 и VAX-11. Архитектура ЭВМ и программирование на языке ассемблера: Пер с англ. - М.: - Радио и связь, 1989.

20. Ахо А., Ульман Д. Теория синтаксического анализа, перевода и компиляции. - М.:Мир, 1978.

21. C. Cifuentes. A structuring algorithm for decompilation. - Queensland University of technology, 1 994.

22. GNU. Writing a compiler front end. - http://cobolforgcc.sourceforge.net.

23. Keith V. Besaw, Robert J. Donovan, Edward C. Prosser, Robert R. Roediger, William J. Schmidt, Peter J. Steinmetz. The optimizing translator. - IBM, 1999.

24. Glenn Holloway, Michael D. Smith. The Machine-SUIF Control Flow Analysis Library. - http://www.eecs.harvard.edu/machsuif.

25. C. Cifuentes, K. Gough. A methodology of decompilation. - Queensland University of technology, 1 994.

26. S.T. Hood. Decompiling with definite clause grammars. Technical report ERL-0571-RR, Electronic Research Laboratory, DSTO Australia, 1991.

i Надоели баннеры? Вы всегда можете отключить рекламу.