Практический анализ двоичных файлов [Дэннис Эндриесс] (pdf) читать онлайн

Книга в формате pdf! Изображения и текст могут не отображаться!


 [Настройки текста]  [Cбросить фильтры]

Дэннис Эндриесс

Практический анализ
двоичных файлов

PRACTICAL
BINARY
ANALYSIS
Build Your Own Linux Tools
for Binary Instrumentation,
Analysis, and Disassembly

Dennis Andriesse

San Francisco

ПРАКТИЧЕСКИЙ
АНАЛИЗ
ДВОИЧНЫХ
ФАЙЛОВ
Как самому создать
в Linux инструментарий
для оснащения, анализа
и дизассемблирования
двоичных файлов
Дэннис Эндриесс

Москва, 2022

УДК 004.451.5
ББК 32.371
Э64

Э64

Эндриесс Д.
Практический анализ двоичных файлов / пер. с англ. А. А. Слинкина. – М.:
ДМК Пресс, 2021. – 460 с.: ил.
ISBN 978-5-97060-978-1
В книге представлено подробное описание методов и инструментов, необходимых для анализа двоичного кода, который позволяет убедиться, что откомпилированная программа работает так же, как исходная, написанная на языке
высокого уровня.
Наряду с базовыми понятиями рассматриваются такие темы, как оснащение
двоичной программы, динамический анализ заражения и символическое выполнение. В каждой главе приводится несколько примеров кода; к книге прилагается
сконфигурированная виртуальная машина, включающая все примеры.
Руководство адресовано специалистам по безопасности и тестированию на
проникновение, хакерам, аналитикам вредоносных программ и всем, кто интересуется вопросами защиты ПО.

УДК 004.451.5
ББК 32.371

Title of English-language original: Practical Binary Analysis: Build Your Own Linux Tools
for Binary Instrumentation, Analysis, and Disassembly Reversing Modern Malware and Next
Generation Threats, ISBN 9781593279127, published by No Starch Press Inc. 245 8th Street, San
Francisco, California United States 94103. The Russian-Language 1st edition Copyright © 2021
by DMK Press Publishing under license by No Starch Press Inc. All rights reserved.
Все права защищены. Любая часть этой книги не может быть воспроизведена в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения
владельцев авторских прав.

ISBN 978-1-59327-912-7 (англ.)
ISBN 978-5-97060-978-1 (рус.)

© Dennis Andriesse, 21921
© Перевод, оформление,
издание, ДМК Пресс, 2021

Посвящается Ноортье и Сиетсе

ОГЛАВЛЕНИЕ
https://t.me/it_boooks
Вступительное слово....................................................................................................... 17
Предисловие...................................................................................................................... 20
Благодарности. ................................................................................................................. 21
Введение............................................................................................................................. 22

ЧАСТЬ I. ФОРМАТЫ ДВОИЧНЫХ ФАЙЛОВ
Глава 1. Анатомия двоичного файла. .......................................................................... 32
Глава 2. Формат ELF...............................................................................................52
Глава 3. Формат PE: краткое введение.................................................................78
Глава 4. Создание двоичного загрузчика с применением libbfd.......................... 88

ЧАСТЬ II. ОСНОВЫ АНАЛИЗА ДВОИЧНЫХ ФАЙЛОВ
Глава 5. Основы анализа двоичных файлов в Linux...............................................109
Глава 6. Основы дизассемблирования и анализа двоичных файлов..................135
Глава 7. Простые методы внедрения кода для формата ELF.................................178

ЧАСТЬ III. ПРОДВИНУТЫЙ АНАЛИЗ ДВОИЧНЫХ ФАЙЛОВ
Глава 8. Настройка дизассемблирования..................................................................212
Глава 9. Оснащение двоичных файлов. .....................................................................244
Глава 10. Принципы динамического анализа заражения.....................................289
Глава 11. Практический динамический анализ заражения с по­мощью libdft....305
Глава 12. Принципы символического выполнения. ...............................................335
Глава 13. Практическое символическое выполнение с помощью Triton. .........361

ЧАСТЬ IV. ПРИЛОЖЕНИЯ
Приложение A. Краткий курс ассемблера x86..........................................................402
Приложение B. Реализация перезаписи PT_NOTE с помощью libelf..................422
Приложение C. Перечень инструментов анализа двоичных файлов.................443
Приложение D. Литература для дополнительного чтения. ..................................447
Предметный указатель..................................................................................................451

6

Глава 

СОДЕРЖАНИЕ

Вступительное слово.............................................................................................. 17
Предисловие................................................................................................................ 20
Благодарности. ........................................................................................................... 21
Введение........................................................................................................................ 22
ЧАСТЬ I. ФОРМАТЫ ДВОИЧНЫХ ФАЙЛОВ
Глава 1. Анатомия двоичного файла............................................................. 32
1.1

1.2
1.3
1.4
1.5

Процесс компиляции программы на C. .......................................................... 33
1.1.1
Этап препроцессирования................................................................... 33
1.1.2
Этап компиляции................................................................................... 35
1.1.3
Этап ассемблирования.......................................................................... 37
1.1.4
Этап компоновки................................................................................... 38
Символы и зачищенные двоичные файлы..................................................... 40
1.2.1
Просмотр информации о символах................................................... 40
1.2.2
Переход на темную сторону: зачистка двоичного файла. ........... 42
Дизассемблирование двоичного файла.......................................................... 42
1.3.1
Заглянем внутрь объектного файла................................................... 43
1.3.2
Изучение полного исполняемого двоичного файла. .................... 45
Загрузка и выполнение двоичного файла...................................................... 48
Резюме..................................................................................................................... 50

Глава 2. Формат ELF................................................................................................. 52
2.1

Заголовок исполняемого файла........................................................................ 54
2.1.1
Массив e_ident......................................................................................... 55
2.1.2
Поля e_type, e_machine и e_version..................................................... 56
Содержание

7

2.2

2.3

2.4

2.5

2.1.3
Поле e_entry............................................................................................. 57
2.1.4
Поля e_phoff и e_shoff............................................................................ 57
2.1.5
Поле e_flags. ............................................................................................. 57
2.1.6
Поле e_ehsize. .......................................................................................... 58
2.1.7
Поля e_*entsize и e_*num...................................................................... 58
2.1.8
Поле e_shstrndx....................................................................................... 58
Заголовки секций.................................................................................................. 59
2.2.1
Поле sh_name........................................................................................... 60
2.2.2
Поле sh_type............................................................................................. 60
2.2.3
Поле sh_flags............................................................................................ 61
2.2.4
Поля sh_addr, sh_offset и sh_size.......................................................... 61
2.2.5
Поле sh_link.............................................................................................. 62
2.2.6
Поле sh_info. ............................................................................................ 62
2.2.7
Поле sh_addralign.................................................................................... 62
2.2.8
Поле sh_entsize........................................................................................ 62
Секции..................................................................................................................... 62
2.3.1
Секции .init и .fini................................................................................... 64
2.3.2
Секция .text.............................................................................................. 64
2.3.3
Секции .bss, .data и .rodata. .................................................................. 66
2.3.4
Позднее связывание и секции .plt, .got, .got.plt.............................. 66
2.3.5
Секции .rel.* и .rela.*. ............................................................................. 70
2.3.6
Секция .dynamic...................................................................................... 71
2.3.7
Секции .init_array и .fini_array. ............................................................ 72
2.3.8
Секции .shstrtab, .symtab, .strtab, .dynsym и .dynstr........................ 73
Заголовки программы......................................................................................... 74
2.4.1
Поле p_type............................................................................................... 75
2.4.2
Поле p_flags.............................................................................................. 76
2.4.3
Поля p_offset, p_vaddr, p_paddr, p_filesz и p_memsz......................... 76
2.4.4
Поле p_align. ............................................................................................ 76
Резюме..................................................................................................................... 77

Глава 3. Формат PE: краткое введение. ...................................................... 78
3.1
3.2

3.3
3.4
3.5

Заголовок MS-DOS и заглушка MS-DOS. ......................................................... 79
Сигнатура PE, заголовок PE-файла и факультативный заголовок PE...... 79
3.2.1
Сигнатура PE........................................................................................... 82
3.2.2
Заголовок PE-файла............................................................................... 82
3.2.3
Факультативный заголовок PE............................................................ 83
Таблица заголовков секций................................................................................ 83
Секции..................................................................................................................... 84
3.4.1
Секции .edata и .idata. ........................................................................... 85
3.4.2
Заполнение в секциях кода PE............................................................ 86
Резюме..................................................................................................................... 86

Глава 4. Создание двоичного загрузчика с применением
libbfd. ................................................................................................................................ 88
4.1
4.2

8

Что такое libbfd?.................................................................................................... 89
Простой интерфейс загрузки двоичных файлов. ......................................... 89
Содержание

4.3

4.4
4.5

4.2.1
Класс Binary. ............................................................................................ 92
4.2.2
Класс Section............................................................................................ 92
4.2.3
Класс Symbol............................................................................................ 92
Реализация загрузчика двоичных файлов. .................................................... 93
4.3.1
Инициализация libbfd и открытие двоичного файла. .................. 94
4.3.2
Разбор основных свойств двоичного файла.................................... 96
4.3.3
Загрузка символов................................................................................. 99
4.3.4
Загрузка секций.....................................................................................102
Тестирование загрузчика двоичных файлов................................................104
Резюме....................................................................................................................106

ЧАСТЬ II. ОСНОВЫ АНАЛИЗА ДВОИЧНЫХ ФАЙЛОВ
Глава 5. Основы анализа двоичных файлов в Linux. ........................109
5.1
5.2
5.3
5.4
5.5
5.6
5.7

Разрешение кризиса самоопределения с помощью file. ...........................110
Использование ldd для изучения зависимостей..........................................113
Просмотр содержимого файла с помощью xxd............................................115
Разбор выделенного заголовка ELF с помощью readelf. ............................117
Разбор символов с по­мощью nm. ....................................................................119
Поиск зацепок с по­мощью strings...................................................................122
Трассировка системных и библиотечных вызовов с по­мощью strace
и ltrace.....................................................................................................................125
5.8 Изучение поведения на уровне команд с помощью objdump..................129
5.9 Получение буфера динамической строки с по­мощью gdb........................131
5.10 Резюме....................................................................................................................134

Глава 6. Основы дизассемблирования и анализа двоичных
файлов............................................................................................................................135
6.1
6.2
6.3

6.4

6.5
6.6

Статическое дизассемблирование...................................................................136
6.1.1
Линейное дизассемблирование.........................................................136
6.1.2
Рекурсивное дизассемблирование. ..................................................139
Динамическое дизассемблирование. .............................................................142
6.2.1
Пример: трассировка выполнения двоичного файла в gdb........143
6.2.2
Стратегии покрытия кода...................................................................146
Структурирование дизассемблированного кода и данных.......................150
6.3.1
Структурирование кода.......................................................................151
6.3.2
Структурирование данных. ................................................................158
6.3.3
Декомпиляция.......................................................................................160
6.3.4
Промежуточные представления........................................................162
Фундаментальные методы анализа................................................................164
6.4.1
Свойства двоичного анализа. ............................................................164
6.4.2
Анализ потока управления.................................................................169
6.4.3
Анализ потока данных.........................................................................171
Влияние настроек компилятора на результат дизассемблирования......175
Резюме....................................................................................................................177
Содержание

9

Глава 7. Простые методы внедрения кода для формата ELF.......178
7.1

7.2

7.3
7.4

7.5

Прямая модификация двоичного файла с помощью
шестнадцатеричного редактирования...........................................................178
7.1.1
Ошибка на единицу в действии. .......................................................179
7.1.2
Исправление ошибки на единицу.....................................................182
Модификация поведения разделяемой библиотеки с по­мощью
LD_PRELOAD..........................................................................................................186
7.2.1
Уязвимость, вызванная переполнением кучи...............................186
7.2.2
Обнаружение переполнения кучи....................................................189
Внедрение секции кода......................................................................................192
7.3.1
Внедрение секции в ELF-файл: общий обзор. ...............................192
7.3.2
Использование elfinject для внедрения секции в ELF-файл.......195
Вызов внедренного кода....................................................................................198
7.4.1
Модификация точки входа. ................................................................199
7.4.2
Перехват конструкторов и деструкторов........................................202
7.4.3
Перехват записей GOT.........................................................................205
7.4.4
Перехват записей PLT...........................................................................208
7.4.5
Перенаправление прямых и косвенных вызовов.........................209
Резюме....................................................................................................................210

ЧАСТЬ III. ПРОДВИНУТЫЙ АНАЛИЗ ДВОИЧНЫХ ФАЙЛОВ
Глава 8. Настройка дизассемблирования.................................................212
8.1

8.2

8.3

8.4

Зачем писать специальный проход дизассемблера?..................................213
8.1.1
Пример специального дизассемблирования:
обфусцированный код.........................................................................213
8.1.2
Другие причины для написания специального
дизассемблера........................................................................................216
Введение в Capstone............................................................................................217
8.2.1
Установка Capstone...............................................................................218
8.2.2
Линейное дизассемблирование с по­мощью Capstone.................219
8.2.3
Изучение Capstone C API.....................................................................224
8.2.4
Рекурсивное дизассемблирование с по­мощью Capstone............225
Реализация сканера ROP-гаджетов.................................................................234
8.3.1
Введение в возвратно-ориентированное
программирование...............................................................................234
8.3.2
Поиск ROP-гаджетов. ...........................................................................236
Резюме....................................................................................................................242

Глава 9. Оснащение двоичных файлов......................................................244
9.1
9.2

10

Что такое оснащение двоичного файла?. ......................................................244
9.1.1
API оснащения двоичных файлов.....................................................245
9.1.2
Статическое и динамическое оснащение двоичных файлов.....246
Статическое оснащение двоичных файлов...................................................248
9.2.1
Подход на основе int 3. ........................................................................248
9.2.2
Подход на основе трамплинов...........................................................250
Содержание

9.3
9.4

9.5

9.6

Динамическое оснащение двоичных файлов...............................................255
9.3.1
Архитектура DBI-системы..................................................................255
9.3.2
Введение в Pin........................................................................................257
Профилирование с по­мощью Pin. ...................................................................259
9.4.1
Структуры данных профилировщика и код инициализации....259
9.4.2
Разбор символов функций..................................................................262
9.4.3
Оснащение простых блоков. ..............................................................264
9.4.4
Оснащение команд управления потоком.......................................266
9.4.5
Подсчет команд, передач управления и системных вызовов....269
9.4.6
Тестирование профилировщика.......................................................270
Автоматическая распаковка двоичного файла с по­мощью Pin...............274
9.5.1
Введение в упаковщики исполняемых файлов.............................274
9.5.2
Структуры данных и код инициализации распаковщика. .........276
9.5.3
Оснащение команд записи в память. ..............................................278
9.5.4
Оснащение команд управления потоком.......................................280
9.5.5
Отслеживание операций записи в память. ....................................280
9.5.6
Обнаружение оригинальной точки входа
и запись распакованного двоичного файла...................................281
9.5.7
Тестирование распаковщика..............................................................283
Резюме....................................................................................................................287

Глава 10. Принципы динамического анализа заражения. .............289
10.1 Что такое DTA?......................................................................................................290
10.2 Три шага DTA: источники заражения, приемники заражения
и распространение заражения.........................................................................290
10.2.1 Определение источников заражения...............................................291
10.2.2 Определение приемников заражения.............................................291
10.2.3 Прослеживание распространения заражения. ..............................292
10.3 Использование DTA для обнаружения дефекта Heartbleed ......................292
10.3.1 Краткий обзор уязвимости Heartbleed.............................................292
10.3.2 Обнаружение Heartbleed с по­мощью заражения. .........................294
10.4 Факторы проектирования DTA: гранулярность, цвета политики
заражения..............................................................................................................296
10.4.1 Гранулярность заражения...................................................................296
10.4.2 Цвета заражения. ..................................................................................297
10.4.3 Политики распространения заражения. .........................................298
10.4.4 Сверхзаражение и недозаражение. ..................................................300
10.4.5 Зависимости по управлению. ............................................................300
10.4.6 Теневая память......................................................................................302
10.5 Резюме....................................................................................................................304

Глава 11. Практический динамический анализ заражения
с по­мощью libdft. .....................................................................................................305
11.1 Введение в libdft...................................................................................................305
11.1.1 Внутреннее устройство libdft.............................................................306
11.1.2 Политика заражения............................................................................309
Содержание

11

11.2 Использование DTA для обнаружения удаленного перехвата
управления............................................................................................................310
11.2.1 Проверка информации о заражении................................................313
11.2.2 Источники заражения: заражение принятых байтов..................315
11.2.3 Приемники заражения: проверка аргументов execve. ................317
11.2.4 Обнаружение попытки перехвата потока управления................318
11.3 Обход DTA с по­мощью неявных потоков.......................................................323
11.4 Детектор утечки данных на основе DTA........................................................324
11.4.1 Источники заражения: прослеживание заражения
для открытых файлов...........................................................................327
11.4.2 Приемники заражения: мониторинг отправки по сети
на предмет утечки данных. ................................................................330
11.4.3 Обнаружение потенциальной утечки данных...............................331
11.5 Резюме....................................................................................................................334

Глава 12. Принципы символического выполнения. ...........................335
12.1 Краткий обзор символического выполнения...............................................336
12.1.1 Символическое и конкретное выполнение....................................336
12.1.2 Варианты и ограничения символического выполнения.............340
12.1.3 Повышение масштабируемости символического
выполнения............................................................................................347
12.2 Удовлетворение ограничений с помощью Z3. .............................................349
12.2.1 Доказательство достижимости команды........................................350
12.2.2 Доказательство недостижимости команды....................................354
12.2.3 Доказательство общезначимости формулы...................................354
12.2.4 Упрощение выражений. ......................................................................356
12.2.5 Моделирование ограничений для машинного кода
с битовыми векторами. .......................................................................356
12.2.6 Решение непроницаемого предиката над битовыми
векторами. ..............................................................................................358
12.3 Резюме....................................................................................................................359

Глава 13. Практическое символическое выполнение
с помощью Triton. ....................................................................................................361
13.1 Введение в Triton.................................................................................................362
13.2 Представление символического состояния абстрактными
синтаксическими деревьями............................................................................363
13.3 Обратное нарезание с по­мощью Triton. ........................................................365
13.3.1 Заголовочные файлы и конфигурирование Triton.......................368
13.3.2 Конфигурационный файл символического выполнения............369
13.3.3 Эмуляция команд..................................................................................370
13.3.4 Инициализация архитектуры Triton................................................372
13.3.5 Вычисления обратного среза.............................................................373
13.4 Использование Triton для увеличения покрытия кода..............................374
13.4.1 Создание символических переменных. ..........................................376
13.4.2 Нахождение модели для нового пути..............................................377

12

Содержание

13.4.3 Тестирование инструмента покрытия кода. ..................................380
13.5 Автоматическая эксплуатация уязвимости..................................................384
13.5.1 Уязвимая программа............................................................................385
13.5.2 Нахождение адреса уязвимой команды вызова............................388
13.5.3 Построение генератора эксплойта...................................................390
13.5.4 Получение оболочки с правами root................................................397
13.6 Резюме....................................................................................................................400

ЧАСТЬ IV. ПРИЛОЖЕНИЯ
Приложение A. Краткий курс ассемблера x86......................................402
A.1

A.2

A.3

A.4

Структура ассемблерной программы. ............................................................403
A.1.1 Ассемблерные команды, директивы, метки и комментарии. ...404
A.1.2 Разделение данных и кода..................................................................405
A.1.3 Синтаксис AT&T и Intel.......................................................................405
Структура команды x86......................................................................................405
A.2.1 Ассемблерное представление команд x86......................................406
A.2.2 Структура команд x86 на машинном уровне.................................406
A.2.3 Регистровые операнды........................................................................407
A.2.4 Операнды в памяти..............................................................................409
A.2.5 Непосредственные операнды............................................................410
Употребительные команды x86........................................................................410
A.3.1 Сравнение операндов и установки флагов состояния.................411
A.3.2 Реализация системных вызовов........................................................412
A.3.3 Реализация условных переходов.......................................................412
A.3.4 Загрузка адресов памяти.....................................................................413
Представление типичных программных конструкций на языке
ассемблера.............................................................................................................413
A.4.1 Стек...........................................................................................................413
A.4.2 Вызовы функций и кадры функций.................................................414
A.4.3 Условные предложения. ......................................................................419
A.4.4 Циклы.......................................................................................................420

Приложение B. Реализация перезаписи PT_NOTE
с помощью libelf.......................................................................................................422
B.1
B.2
B.3
B.4
B.5
B.6
B.7
B.8
B.9
B.10
B.11

Обязательные заголовки....................................................................................423
Структуры данных, используемые в elfinject................................................423
Инициализация libelf..........................................................................................425
Получение заголовка исполняемого файла. .................................................428
Нахождение сегмента PT_NOTE.......................................................................429
Внедрение байтов кода. .....................................................................................430
Выравнивание адреса загрузки внедренной секции..................................431
Перезапись заголовка секции .note.ABI-tag..................................................432
Задание имени внедренной секции................................................................437
Перезапись заголовка программы PT_NOTE. ...............................................439
Модификация точки входа................................................................................441
Содержание

13

Приложение C. Перечень инструментов анализа двоичных
файлов............................................................................................................................443
C.1
C.2
C.3
C.4

Дизассемблеры.....................................................................................................443
Отладчики. ............................................................................................................445
Каркасы дизассемблирования..........................................................................445
Каркасы двоичного анализа..............................................................................446

Приложение D. Литература для дополнительного чтения. ...........447
D.1
D.2
D.3

Стандарты и справочные руководства...........................................................447
Статьи.....................................................................................................................448
Книги. .....................................................................................................................450

Предметный указатель.........................................................................................451

От издательства
Отзывы и пожелания
Мы всегда рады отзывам наших читателей. Расскажите нам, что вы
ду­маете об этой книге, – что понравилось или, может быть, не понравилось. Отзывы важны для нас, чтобы выпускать книги, которые
будут для вас максимально полезны.
Вы можете написать отзыв на нашем сайте www.dmkpress.com, зайдя­
на страницу книги и оставив комментарий в разделе «Отзывы и рецензии». Также можно послать письмо главному редактору по адресу
dmkpress@gmail.com; при этом укажите название книги в теме письма.
Если вы являетесь экспертом в какой-либо области и заинтересованы в написании новой книги, заполните форму на нашем сайте по
адресу http://dmkpress.com/authors/publish_book/ или напишите в издательство по адресу dmkpress@gmail.com.

Скачивание исходного кода примеров
Скачать файлы с дополнительной информацией для книг издательства «ДМК Пресс» можно на сайте www.dmkpress.com на странице с описанием соответствующей книги.

Список опечаток
Хотя мы приняли все возможные меры для того, чтобы обеспечить
высокое качество наших текстов, ошибки все равно случаются. Если
вы найдете ошибку в одной из наших книг, мы будем очень благодарны, если вы сообщите о ней главному редактору по адресу dmkpress@
gmail.com. Сделав это, вы избавите других читателей от недопонимания и поможете нам улучшить последующие издания этой книги.

Нарушение авторских прав
Пиратство в интернете по-прежнему остается насущной проблемой.
Издательства «ДМК Пресс» и No Starch Press очень серьезно относятся
к вопросам защиты авторских прав и лицензирования. Если вы столкнетесь в интернете с незаконной публикацией какой-либо из наших
книг, пожалуйста, пришлите нам ссылку на интернет-ресурс, чтобы
мы могли применить санкции.
Ссылку на подозрительные материалы можно прислать по адресу
элект­ронной почты dmkpress@gmail.com.
Мы высоко ценим любую помощь по защите наших авторов, благодаря которой мы можем предоставлять вам качественные материалы.

Об авторе
Дэннис Эндриесс имеет степень доктора по безопасности систем и сетей, и анализ двоичных файлов – неотъемлемая часть его исследований. Он один из основных соразработчиков системы целостности потока управления PathArmor, которая защищает от атак с перехватом
потока управления типа возвратно-ориентированного программирования (ROP). Также Эндриесс принимал участие в разработке атаки,
которая положила конец P2P-сети ботов GameOver Zeus.

О технических рецензентах
Торстен Хольц (Thorsten Holz) – профессор факультета электротехники и информационных технологий в Рурском университете в Бохуме, Германия. Он занимается исследованиями в области технических
аспектов безопасности систем. В настоящее время его интересуют обратная разработка, автоматизированное обнаружение уязвимостей
и изучение последних векторов атак.
Tim Vidas – хакер-ас. На протяжении многих лет он возглавлял инф­
раструктурную команду в соревновании DARPA CGC, внедрял инновации в компании Dell Secureworks и курировал исследовательскую
группу CERT в области компьютерно-технической экспертизы. Он
получил степень доктора в университете Карнеги-Меллона, является частым участником и докладчиком на конференциях и обладает
числом Эрдёша–Бейкона 4-3. Много времени уделяет обязанностям
отца и мужа.

ВСТ УПИТЕЛЬНОЕ
СЛОВО

В

наши дни нетрудно найти книги по языку ассемблера и даже
описания двоичных форматов ELF и PE. Растет количество статей, посвященных прослеживанию потока информации и символическому выполнению. Но нет еще ни одной книги, которая вела
бы читателя от основ ассемблера к выполнению сложного анализа
двоичного кода. Нет ни одной книги, которая научила бы читателя оснащать двоичные программы инструментальными средствами, применять динамический анализ заражения (taint analysis) для прослеживания путей прохождений интересных данных по программе или
использовать символическое выполнение в целях автоматизированного генерирования эксплойтов. Иными словами, нет книг, которые
учили бы методам, инструментам и образу мыслей, необходимым для
анализа двоичного кода. Точнее, до сих пор не было.
Трудность анализа двоичного кода состоит в том, что нужно разбираться в куче разных вещей. Да, конечно, нужно знать язык ассемблера, но, кроме того, понимать форматы двоичных файлов, механизмы
компоновки и загрузки, принципы статического и динамического
анализов, расположение программы в памяти, соглашения, поддерживаемые компиляторами, – и это только начало. Для конкретных
задач анализа или оснащения могут понадобиться и более специальные знания. Конечно, для всего этого нужны инструменты. Многих
эта перспектива настолько пугает, что они сдаются, даже не вступив
в борьбу. Так много всего предстоит учить. С чего же начать?
Ответ: с этой книги. Здесь все необходимое излагается последовательно, логично и доступно. И интересно к тому же! Даже если вы
ничего не знаете о том, как выглядит двоичная программа, как она
Вступительное слово

17

загружается и что происходит во время ее выполнения, в книге вы
найдете отлично продуманное введение во все эти понятия и инструменты, с по­мощью которых сможете не только быстро освоить теоретические основы, но и поэкспериментировать в реальных ситуациях.
На мой взгляд, это единственный способ приобрести глубокие знания, которые не забудутся на следующий день.
Но и в том случае, если у вас есть богатый опыт анализа двоичного
кода с по­мощью таких инструментов, как Capstone, Radare, IDA Pro,
OllyDbg или что там у вас стоит на первом месте, вы найдете здесь материал по душе. В поздних главах описываются продвинутые методики создания таких изощренных инструментов анализа и оснащения,
о существовании которых вы даже не подозревали.
Анализ и оснащение двоичного кода – увлекательные, но трудные
техники, которыми в совершенстве владеют лишь немногие опытные
хакеры. Но внимание к вопросам безопасности растет, а вместе с ним
и важность этих вопросов. Мы должны уметь анализировать вредоносные программы, чтобы понимать, что они делают и как им в этом
воспрепятствовать. Но поскольку все больше вредоносных программ
применяют методы обфускации и приемы противодействия анализу,
нам необходимы более хитроумные методы.
Мы также все чаще анализируем и оснащаем вполне благопристойные программы, например, чтобы затруднить атаки на них. Так, может возникнуть желание модифицировать существующий двоичный
код программы, написанной на C++, с целью гарантировать, что все
вызовы (виртуальных) функций обращаются только к допустимым
методам. Для этого мы должны сначала проанализировать двоичный
код и найти в нем методы и вызовы функций. Затем нужно добавить
оснащение, сохранив при этом семантику оригинальной программы.
Все это проще сказать, чем сделать.
Многие начинают изучать такие методы, столкнувшись с интересной задачкой, оказавшейся не по зубам. Это может быть что угодно:
как превратить игровую консоль в компьютер общего назначения,
как взломать какую-то программу или понять, как работает вредонос,
проникший в ваш компьютер.
К своему стыду, должен сознаться, что лично для меня все началось
с желания снять защиту от копирования с видеоигр, покупка которых была мне не по средствам. Поэтому я выучил язык ассемблера
и стал искать проверки в двоичных файлах. Тогда на рынке правил
бал 8-разрядный процессор 6510 с аккумулятором и двумя регистрами общего назначения. Хотя для того чтобы использовать все 64 КБ
системной памяти, требовалось исполнять танцы с бубнами, в целом
система была простой. Но поначалу все было непонятно. Со временем
я набирался ума от более опытных друзей, и постепенно туман стал
рассеиваться. Маршрут был, без сомнения, интересным, но долгим,
трудным и иногда заводил в тупик. Многое я бы отдал тогда за путеводитель! Современные 64-разрядные процессоры x86 не в пример
сложнее, как и компиляторы, которые генерируют двоичный код. Понять, что делает код, гораздо труднее, чем раньше. Специалист, кото-

18

Вступительное слово

рый покажет путь и прояснит сложные вопросы, которые вы могли
бы упустить из виду, сделает путешествие короче и интереснее, превратив его в истинное удовольствие.
Дэннис Эндриесс – эксперт по анализу двоичного кода и в доказательство может предъявить степень доктора в этой области – буквально. Однако он не академический ученый, публикующий статьи для
себе подобных. Его работы по большей части сугубо практические.
Например, он был в числе тех немногих людей, кто сумел разобраться
в коде печально известной сети ботов GameOver Zeus, ущерб от которой оценивается в 100 миллионов долларов. И более того, он вместе
с другими экспертами безопасности принимал участие в возглавляемой ФБР операции, положившей конец деятельности этой сети. Работая с вредоносными программами, он на практике имел возможность
оценить сильные стороны и ограничения имеющихся средств анализа двоичного кода и придумал, как их улучшить. Новаторские методы дизассемблирования, разработанные Дэннисом, теперь включены
в коммерческие продукты, в частности Binary Ninja.
Но быть экспертом еще недостаточно. Автор книги должен еще
уметь излагать свои мысли. Дэннис Эндриесс обладает этим редким
сочетанием талантов: он эксперт по анализу двоичного кода, способный объяснить самые сложные вещи простыми словами, не упуская
сути. У него приятный стиль, а примеры ясные и наглядные.
Лично я давно хотел иметь такую книгу. В течение многих лет я читаю курс по анализу вредоносного ПО в Амстердамском свободном
университете без учебника ввиду отсутствия такового. Мне приходилось использовать разнообразные онлайновые источники, пособия
и эклектичный набор слайдов. Когда студенты спрашивали, почему
бы не использовать печатный учебник (как они привыкли), я отвечал,
что хорошего учебника по анализу двоичного кода не существует, но
если у меня когда-нибудь выдастся свободное время, я его напишу.
Разумеется, этого не случилось.
Это как раз та книга, которую я надеялся написать, но так и не собрался. И она лучше, чем мог бы написать я сам.
Приятного путешествия.
Герберт Бос

ПРЕДИСЛОВИЕ

А

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

20

Предисловие

БЛАГОДАРНОСТИ

П

режде всего я хочу поблагодарить свою жену Ноортье и нашего
сына Сиетсе за поддержку во время работы над книгой. Это было
невероятно горячее время, но вы видели только мою спину.
Я также благодарен всем сотрудникам издательства No Starch Press,
которые помогли воплотить эту книгу в реальность, а особенно Биллу Поллоку и Тайлеру Ортману, предоставившим мне возможность
взяться за нее, и Анни Чой, Райли Хоффмана и Ким Уимспетт за отличную работу по редактированию и подготовке книги к печати. Спасибо
моим техническим рецензентам, Торстену Хольцу и Тиму Видасу, за
пространные отзывы, которые способствовали улучшению текста.
Спасибо Бену Грасу, который помог перенести библиотеку libdft
на современную версию Ubuntu, Джонатану Сэлуэну за замечания
о главах, посвященных символическому выполнению, а также Лоренцо Кавалларо, Эрику ван дер Коуве и всем остальным, кто готовил
слайды, легшие в основу приложения, касающегося языка ассемблера.
Наконец, я признателен Герберту Босу, Эйше Словинска и всем
моим коллегам, которые создали замечательную среду для научной
работы, благодаря чему у меня и появилась идея написать эту книгу.

Благодарности

21

ВВЕДЕНИЕ

П

одавляющее большинство компьютерных программ написаны
на языках высокого уровня типа C или C++, которые компьютер не может исполнять непосредственно. Такие программы
необходимо откомпилировать, в результате чего создаются двоичные
исполняемые файлы, содержащие машинный код, – его компьютер уже
может выполнить. Но откуда мы знаем, что откомпилированная программа имеет такую же семантику, как исходная? Ответ может разочаровать – а мы и не знаем!
Существует семантическая пропасть между языками высокого
уровня и двоичным машинным кодом, и как ее преодолеть, знают немногие. Даже программисты в большинстве своем плохо понимают,
как их программы работают на самом нижнем уровне, и просто верят, что откомпилированная программа делает то, что они задумали.
Поэтому многие дефекты компилятора, тонкие ошибки реализации,
потайные ходы на двоичном уровне и другие вредоносные паразиты
остаются незамеченными.
Хуже того, существует бесчисленное множество двоичных программ и библиотек – в промышленности, в банках, во встраиваемых
системах, – исходный код которых давно утерян или является коммерческой собственностью. Это означает, что такие программы и библио­
теки невозможно исправить или хотя бы оценить их безопас­ность на
уровне исходного кода с применением традиционных методов. Это
реальная проблема даже для крупных программных компаний, свидетельство чему – недавний выпуск компанией Microsoft созданного
с большим трудом двоичного исправления ошибки переполнения буфера в программе «Редактор формул», являющейся частью Microsoft
Office1.

1

22

https://0patch.blogspot.nl/2017/11/did-microsoft-just-manually-patch-their.html.

Введение

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

Что такое анализ двоичных файлов, и зачем
он вам нужен?
Анализ двоичных файлов, или просто двоичный анализ, – это наука и искусство анализа свойств двоичных компьютерных программ, а также машинного кода и данных, которые они содержат. Короче говоря,
цель анализа двоичных файлов – определить (и, возможно, модифицировать) истинные свойства двоичных программ, т. е. понять, что
они делают в действительности, а не доверяться тому, что они, по нашему мнению, должны делать.
Многие отождествляют двоичный анализ с обратной разработкой
и дизассемблированием, и отчасти они правы. Дизассемблирование –
важный первый шаг многих видов двоичного анализа, а обратная
разработка – типичное приложение двоичного анализа и зачастую
единственный способ документировать поведение проприетарного
или вредоносного программного обеспечения. Однако область двоичного анализа гораздо шире.
Методы анализа двоичных файлов можно отнести к одному из двух
классов, хотя возможны и комбинации.
Статический анализ В этом случае мы рассуждаем о программе,
не выполняя ее. У такого подхода несколько преимуществ: теоретически возможно проанализировать весь двоичный файл за один
присест, и для его выполнения не нужен процессор. Например,
можно статически проанализировать двоичный файл для процессора ARM на компьютере с процессором x86. Недостаток же в том,
что в процессе статического анализа мы ничего не знаем о состоя­
нии выполнения двоичной программы, что сильно затрудняет
анализ.
Динамический анализ Напротив, в случае динамического анализа
мы запускаем программу и анализируем ее во время выполнения.
Часто этот подход оказывается проще статического анализа, потому что нам известно все состояние программы, включая значения
переменных и выбор ветвей при условном выполнении. Однако мы
видим лишь тот код, который выполняется, поэтому можем про­
пус­тить интересные части программы.
И у статического, и у динамического анализов есть плюсы и минусы. В этой книге мы расскажем об обоих направлениях. Помимо
пассивного двоичного анализа, вы узнаете о методах оснащения двоВведение

23

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

В чем сложность анализа двоичных файлов?
Двоичный анализ – вещьгораздо более трудная, чем эквивалентный
анализ на уровне исходного кода. На самом деле многие задачи в этом
случае принципиально неразрешимы, т. е. невозможно сконструировать движок, который всегда возвращает правильный результат! Чтобы вы могли составить представление о том, с какими проблемами
предстоит столкнуться, ниже приведен список некоторых вещей, затрудняющих анализ двоичных файлов. Увы, этот список далеко не исчерпывающий.
Отсутствует символическая информация В исходном коде, написанном на языке высокого уровня типа C или C++, мы даем осмысленные имена переменным, функциям, классам и т. п. Эти имена
мы называем символической информацией, или просто символами.
Если придерживаться хороших соглашений об именовании, то понять исходный код будет гораздо проще, но на двоичном уровне
имена не имеют ни малейшего значения. Поэтому из двоичных
файлов информация о символах часто удаляется, из-за чего понять
код становится гораздо труднее.
Отсутствует информация о типах Еще одна особенность программ на языках высокого уровня – наличие у переменных четко
определенных типов, например int, float, string или более сложных структурных типов. На двоичном же уровне типы нигде явно
не упоминаются, поэтому понять структуру и назначение данных
нелегко.
Отсутствуют высокоуровневые абстракции Современные программы состоят из классов и функций, но компиляторы отбрасывают эти высокоуровневые конструкции. Это означает, что двоичный
файл выглядит как огромный «комок» кода и данных, а не хорошо
структурированный код, и восстановить высокоуровневую структуру трудно и чревато ошибками.

24

Введение

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

Кому адресована эта книга?
Целевой аудиторией этой книги являются инженеры по безопасности, ученые, занимающиеся исследованиями в области безопасности,
хакеры и специалисты по тестированию на проникновение, специа­
листы по обратной разработке, аналитики вредоносных программ
и студенты компьютерных специальностей, интересующиеся анализом двоичных файлов.
Поскольку в книге рассматриваются темы повышенного уровня,
предполагаются предварительные знания в области программирования и компьютерных систем. Для получения максимальной пользы от
прочтения книги необходимы:
достаточно свободное владение языками C и C++;
zz базовые знания о внутреннем устройстве операционных систем
(что такое процесс, что такое виртуальная память и т. д.);
zz умение работать с оболочкой Linux (предпочтительно bash);
zz рабочие знания о языке ассемблера x86/x86-64. Если вы вообще
ничего не знаете о языках ассемблера, прочитайте сначала приложение A.
zz

Если вы никогда раньше не программировали или не любите копаться в низкуровневых деталях компьютерных систем, то, вероятно,
эта книга не для вас.

1

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

25

Назначение и структура книги
Главная цель этой книги – сделать из вас разносторонне образованного аналитика двоичных файлов, знакомого как с основными вопросами, так и с такими продвинутыми темами, как оснащение двоичного
кода, анализ заражения и символическое выполнение. Эта книга не
претендует на роль единственного и исчерпывающего источника,
поскольку в области двоичного анализа и его инструментария изменения происходят настолько быстро, что любая исчерпывающая
книга устарела бы через год. Наша цель – снабдить вас достаточным
объемом знаний по всем важным темам, чтобы дальше вы могли двигаться самостоятельно.
С другой стороны, мы не пытаемся разобраться во всех тонкостях
обратной разработки кода для процессоров x86 и x86-64 (хотя основные сведения приведены в приложении A) и анализа вредоносных
программ на этих платформах. На эти темы уже написано много книг,
и дублировать их здесь не имеет смысла. Список книг, посвященных
ручной обратной разработке и анализу вредоносного ПО, приведен
в приложении D.
Книга состоит из четырех частей.
Часть I. Форматы двоичных файлов. В этой части мы познакомимся с форматами двоичных файлов, без чего невозможно понять последующий материал. Если вы уже знакомы с форматами ELF и PE,
а также с библиотекой libbfd, то можете пропустить одну или несколько глав в этой части.
Глава 1. Анатомия двоичного файла. Содержит общее введение
в анатомию двоичных программ.
Глава 2. Формат ELF. Введение в двоичный формат ELF, используемый в ОС Linux.
Глава 3. Формат PE: краткое введение. Содержит краткое введение в двоичный формат PE, используемый в Windows.
Глава 4. Создание двоичного загрузчика с применением libbfd.
Показано, как разбирать двоичные файлы с по­мощью библио­
теки libbfd. Строится двоичный загрузчик, используемый в ос­
тальной части книги.
Часть II. Основы анализа двоичных файлов. Содержит основополагающие методы двоичного анализа.
Глава 5. Основы анализа двоичных файлов в Linux. Введение
в основные инструменты двоичного анализа в Linux.
Глава 6. Основы дизассемблирования и анализа двоичных
файлов. Рассматриваются базовые методы дизассемблирования
и фундаментальные приемы анализа.
Глава 7. Простые методы внедрения кода для формата ELF.
Первые представления о том, как модифицировать двоичный
ELF-файл с по­мощью внедрения паразитного кода и шестнадцатеричного редактирования.

26

Введение

Часть III. Продвинутый анализ двоичных файлов. Целиком посвящена продвинутым методам двоичного анализа.
Глава 8. Настройка дизассемблирования. Показано, как создать
собственные инструменты дизассемблирования с по­мощью программы Capstone.
Глава 9. Оснащение двоичных файлов. Посвящена модификации двоичных файлов с по­мощью полнофункциональной платформы оснащения Pin.
Глава 10. Принципы динамического анализа заражения. Введение в принципы динамического анализа заражения – современного метода двоичного анализа, позволяющего прослеживать потоки данных в программах.
Глава 11. Практический динамический анализ заражения
с по­мощью libdft. Описывается, как построить собственные
инструменты динамического анализа заражения с применением библиотеки libdft.
Глава 12. Принципы символического выполнения. Посвящена
символическому выполнению, еще одному продвинутому ме­
тоду автоматических рассуждений о сложных свойствах программы.
Глава 13. Практическое символическое выполнение с по­
мощью Triton. Показано, как построить практически полезные
инструменты символического выполнения с по­мощью программы Triton.
Часть IV. Приложения. Включает полезные ресурсы.
Приложение A. Краткий курс ассемблера x86. Содержит краткое
введение в язык ассемблера x86 для читателей, которые с ним
еще незнакомы.
Приложение B. Реализация перезаписи PT_NOTE с по­мощью
libelf. Приведены детали реализации инструмента elfinject, используемого в главе 7. Может служить введением в библиотеку
libelf.
Приложение C. Перечень инструментов анализа двоичных
файлов. Содержит перечень инструментов, которые могут вам
пригодиться.
Приложение D. Литература для дополнительного чтения. Содержит перечень ссылок на статьи и книги по темам, обсуждаемым в этой книге.

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

27

Архитектура системы команд
Хотя многие методы, описанные в книге, можно перенести на другие архитектуры, я во всех примерах использую архитектуру системы
команд (Instruction Set Architecture – ISA) процессора Intel x86 и его
64-разрядной версии x86-64 (для краткости x64). Обе архитектуры будут обобщенно называться «x86 ISA». Как правило, в примерах приведен код для x64, если явно не оговорено противное.
Архитектура x86 ISA интересна, потому что доминирует на рынке
потребительской электроники, особенно настольных компьютеров
и ноутбуков, а также в исследованиях по анализу двоичных файлов
(отчасти из-за ее популярности на компьютерах конечных пользователей). Поэтому многие каркасы двоичного анализа ориентированы
на x86.
Кроме того, сложность x86 ISA позволит вам узнать о некоторых
проблемах двоичного анализа, которые не встречаются в более прос­
тых архитектурах. У архитектуры x86 долгая история обратной сов­
мес­тимости (начинающаяся с 1978 года), из-за чего система команд
получилась очень плотной в том смысле, что подавляющее большинство возможных байтовых значений представляет допустимый
код операции. Это порождает проблему различения кода и данных,
из-за которой дизассемблеры могут не понять, что интерпретируют
данные как код. Мало того, длины команд различаются, и допускается невыровненный доступ к памяти для слов любой корректной
длины. Таким образом, x86 допускает чрезвычайно сложные двоичные конст­рукции, в частности частичное перекрытие команд и невыровненные команды. Иными словами, поняв, как обращаться с такой
сложной системой команд, как у процессора x86, с другими системами (например, для ARM) вы разберетесь на раз!

Синтаксис языка ассемблера
Как объяснено в приложении A, существует два популярных формата записи машинных команд x86: синтаксис Intel и синтаксис AT&T.
Я буду использовать синтаксис Intel, потому что он лаконичнее. В синтаксисе Intel помещение константы в регистр edi записывается так:
mov edi,0x6

Заметим, что конечный операнд (edi) записывается первым. Если
вы плохо знакомы с различиями синтаксиса AT&T и Intel, обратитесь
к приложению A, где описаны основные особенности того и другого.

Формат двоичного файла и платформа разработки
Я разрабатывал все примеры кода, приведенные в этой книге, в ОС
Ubuntu Linux на языках C/C++ (за исключением очень немногочисленных примеров, написанных на Python). Это связано с тем, что многие

28

Введение

популярные библиотеки анализа двоичных файлов ориентированы
в основном на Linux и имеют удобные API, рассчитанные на C/C++
или Python. Однако все используемые в книге методы и большинство
библиотек применимы также к Windows, поэтому если вы предпочитаете платформу Windows, то без труда перенесете на нее все, чему
научились. Что касается форматов двоичных файлов, то в этой книге
рассматривается в основном формат ELF, подразумеваемый по умолчанию на платформах Linux, хотя многие инструменты поддерживают также двоичный формат Windows PE.

Примеры кода и виртуальная машина
В каждой главе этой книги имеется несколько примеров кода, а к книге прилагается уже сконфигурированная виртуальная машина (ВМ),
включающая все примеры. ВМ работает под управлением популярного дистрибутива Linux Ubuntu 16.04, на нее установлены все обсуждаемые инструменты двоичного анализа с открытым исходным кодом.
Вы можете использовать эту ВМ для экспериментов с примерами
кода и для решения упражнений в конце каждой главы. ВМ доступна на сайте книги по адресу https://practicalbinaryanalysis.com или https://
nostarch.com/binaryanalysis/.
На сайте книги имеется также архив с исходным кодом всех примеров и упражнений. Можете скачать его, если не хотите скачивать всю
ВМ, но имейте в виду, что для некоторых средств двоичного анализа
необходима сложная настройка, которую вам придется выполнить самостоятельно, если вы решите не использовать ВМ.
Чтобы воспользоваться ВМ, понадобится программа виртуализации. Данная ВМ рассчитана на работу под управлением программы
VirtualBox, которую можно скачать бесплатно с сайта https://www.virtualbox.org/. Версии VirtualBox имеются для всех популярных операционных систем, включая Windows, Linux и macOS.
После установки VirtualBox запустите ее, выберите из меню команду File → Import Appliance и выберите виртуальную машину, скачанную с сайта книги. После добавления запустите эту ВМ, щелкнув по
зеленой стрелке Start в главном окне VirtualBox. Когда ВМ загрузится,
войдите в систему, указав в качестве имени пользователя и пароля
слово «binary». Затем откройте терминал с по­мощью комбинации
клавиш Сtrl+Аlt+Т и можете делать все, что предлагается в книге.
В каталоге ~/code вы найдете подкаталоги, соответствующие главам; там находятся все примеры кода и прочие файлы, относящиеся
к главе. Например, весь код из главы 1 находится в каталоге ~/code/
chapter1. Есть также каталог ~/code/inc, в котором собран общий код,
используемый в программах из разных глав. Я использую расширение .cc для файлов с исходным кодом на C++, .c – для файлов с кодом
на чистом C, .h – для заголовочных файлов и .py – для скриптов на
Python.
Для сборки всех примеров в данной главе откройте терминал, перейдите в соответствующий этой главе каталог и выполните команду
Введение

29

make. Это работает во всех случаях, кроме тех, для которых явно указа-

на другая команда сборки.
Важные примеры кода по большей части подробно обсуждаются
в соответствующих главах. Если листингу обсуждаемого в книге кода
соответствует исходный файл на ВМ, то перед ним указывается имя
файла:
filename.c
int
main(int argc, char *argv[])
{
return 0;
}

В заголовке этого листинга указано, что приведенный код находится в файле filename.c. Если явно не оговорено противное, то файл
с указанным именем находится в каталоге той главы, где встретился пример. Иногда встречаются листинги, в заголовках которых указаны не имена файлов; это означает, что примеру не соответствует
никакой файл в ВМ. Совсем короткие листинги без соответствующих
файлов могут даже не иметь заголовков, как, например, приведенный
выше код, демонстрирующий синтаксис ассемблера.
В листингах, показывающих команды оболочки и их результаты,
используется символ $, обозначающий приглашение, а строки, содержащие данные, введенные пользователем, набраны полужирным
шрифтом. Такие строки являются командами, которые вы можете выполнить в виртуальной машине, а следующие за ними строки, не начинающиеся приглашением и не набранные полужирным шрифтом,
соответствуют выведенным командой результатам. Вот, например,
распечатка содержимого каталога ~/code на виртуальной машине:
$ cd ~/code && ls
chapter1 chapter2 chapter3 chapter4 chapter5 chapter6 chapter7
chapter8 chapter9 chapter10 chapter11 chapter12 chapter13 inc

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

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

ЧАСТЬ I
ФОРМАТЫ ДВОИЧНЫХ
ФАЙЛОВ

1

АНАТОМИЯ
ДВОИЧНОГО ФАЙЛА
https://t.me/it_boooks

С

мысл двоичного анализа – анализ двоичных файлов. Но что такое двоичный файл? В этой главе описывается общая структура
формата двоичного файла и жизненный цикл двоичного файла.
Прочитав ее, вы будете готовы к восприятию двух следующих глав,
посвященных двум наиболее широко распространенным форматам:
ELF и PE, соответственно в ОС Linux и Windows.
В современных компьютерах вычисления производятся в двоичной системе счисления, где числа записываются строками нулей
и единиц. Машинный код, выполняемый такими компьютерами, называется двоичным кодом. Любая программа состоит из совокупности
двоичного кода (машинных команд) и данных (переменных, констант
и т. п.). Чтобы различать программы, хранящиеся в данной системе,
необходим способ хранения всего кода и данных, принадлежащих
программе, в одном замкнутом файле. Поскольку такие файлы содержат исполняемые двоичные программы, они называются двоичными
исполняемыми файлами, или просто двоичными файлами (жарг. бинарники). Анализ двоичных файлов и является предметом данной книги.

32

Глава 1

Прежде чем переходить к специфике таких форматов двоичных
файлов, как ELF и PE, дадим краткий обзор процесса порождения исполняемых двоичных файлов из исходного кода. Затем я дизассемб­
лирую простой двоичный файл, чтобы вы составили представление
о находящихся в нем коде и данных. Этот материал пригодится нам
в главах 2 и 3, когда мы будем изучать форматы ELF и PE, и в главе 4,
где мы напишем собственный загрузчик, который умеет разбирать
двоичные файлы и открывать их для анализа.

1.1

Процесс компиляции программы на C
Двоичные файлы порождаются в процессе компиляции, т. е. трансляции понятного человеку исходного кода, например на языке C или
C++, в машинный код, исполняемый процессором1. На рис. 1.1 показаны шаги типичного процесса компиляции C-кода (шаги компиляции
кода, написанного на C++, аналогичны). Компиляция C-кода состоит
из четырех этапов, один из которых называется (не слишком удачно) компиляцией, как и весь процесс в целом. Это препроцессирование,
компиляция, ассемблирование и компоновка2. На практике современные компиляторы часто объединяют некоторые или даже все этапы,
но для демонстрации я буду рассматривать их по отдельности.

file-1.c
file-2.c
file-n.c
Исходные
файлы

header.h

file-1.о

Препроцессор
C

Компилятор
С/C++

file-2.о
file-n.о
Ассемблер

Объектные
файлы

library.a

Компоновщик

a.out
Двоичный
исполняемый
файл

Рис. 1.1. Процесс компиляции программы на C

1.1.1 Этап препроцессирования
Процесс компиляции начинается с обработки нескольких файлов,
которые вы хотите откомпилировать (на рис. 1.1 они обозначены
1

2

Существуют также языки, например Python или JavaScript, программы на
которых интерпретируются «на лету», а не компилируются как единое
целое. Иногда части интерпретируемого кода компилируются «своевре­
менно» (just in time – JIT), по мере выполнения программы. При этом порождается двоичный код в памяти, который можно проанализировать
с применением описанных в этой книге методов. Поскольку анализ интерпретируемых языков требует зависящих от языка специальных шагов,
я не стану подробно останавливаться на этом процессе.
Раньше этап компоновки (linking) по-русски назывался редактированием
связей, но сейчас этот термин вышел из употребления. – Прим. перев.
Анатомия двоичного файла

33

file-1.c, …, file-n.c). Исходный файл может быть всего один, но крупные
программы обычно состоят из большого числа файлов. Это не только
упрощает управление проектом, но и ускоряет компиляцию, потому
что если изменится один какой-то файл, то перекомпилировать придется только его, а не весь код.
Исходные C-файлы могут содержать макросы (директивы #define)
и директивы #include. Последние служат для включения заголовочных
файлов (с расширением .h), от которых зависит исходный файл. На
этапе препроцессирования все директивы #define и #include расширяются, так что остается только код на чистом C, подлежащий компиляции.
Проиллюстрируем сказанное на конкретном примере. Мы будем
использовать компилятор gcc, подразумеваемый по умолчанию во
многих дистрибутивах Linux (включая Ubuntu, операционную систему на нашей виртуальной машине). Результаты работы других
компиляторов, например clang или Visual Studio, похожи. Как уже
было сказано во введении, я компилирую все примеры (включая
и текущий) в машинный код x86-64, если явно не оговорено противное.
Пусть требуется откомпилировать исходный файл на C, показанный в листинге 1.1, который выводит на экран знаменитое сообщение «Hello, world!».
Листинг 1.1. compilation_example.c
#include
#define FORMAT_STRING "%s"
#define MESSAGE
"Hello, world!\n"
int
main(int argc, char *argv[]) {
printf(FORMAT_STRING, MESSAGE);
return 0;
}

Скоро мы увидим, что происходит с этим файлом на других этапах
процесса компиляции, но пока рассмотрим только результат этапа
препроцессирования. По умолчанию gcc автоматически выполняет
все этапы компиляции, так что если мы хотим остановиться после
препроцессирования и посмотреть на промежуточный результат, то
об этом нужно явно сказать. В случае gcc это делается командой gcc
‑E ‑P, где флаг –E требует остановиться после препроцессирования,
а –P заставляет компилятор опустить отладочную информацию, чтобы результат был немного понятнее. В листинге 1.2 показан результат этапа препроцессирования, для краткости отредактированный.
За­пус­тите ВМ и выполните предлагаемые команды.

34

Глава 1

Листинг 1.2. Результат работы препроцессора C для программы "Hello, world!"
$ gcc -E -P compilation_example.c
typedef
typedef
typedef
typedef
typedef

long unsigned int size_t;
unsigned char __u_char;
unsigned short int __u_short;
unsigned int __u_int;
unsigned long int __u_long;

/* ... */
extern
extern
extern
extern
extern
extern
extern
extern
extern
extern

int sys_nerr;
const char *const sys_errlist[];
int fileno (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) ;
int fileno_unlocked (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) ;
FILE *popen (const char *__command, const char *__modes) ;
int pclose (FILE *__stream);
char *ctermid (char *__s) __attribute__ ((__nothrow__ , __leaf__));
void flockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));
int ftrylockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) ;
void funlockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));

int
main(int argc, char *argv[]) {
printf("%s", "Hello, world!\n");
return 0;
}

Заголовочный файл stdio.h включен целиком, т. е. все содержащие­
ся в нем определения типов, глобальные переменные и прототипы
функций «скопированы» в исходный файл. Поскольку это делается
для каждой директивы #include, результат работы препроцессора
может оказаться очень длинным. Кроме того, препроцессор расширяет все макросы, определенные с по­мощью ключевого слова #define.
В данном примере это означает, что оба аргумента printf (FORMAT_
STRING и MESSAGE ) вычисляются и заменяются соответствующими
константными строками.

1.1.2 Этап компиляции
После завершения этапа препроцессирования исходный файл готов
к компиляции. На этапе компиляции обработанный препроцессором
код транслируется на язык ассемблера. (Большинство компиляторов
на этом этапе выполняют более или менее агрессивную оптимизацию, уровень которой задается флагами в командной строке; в случае
gcc это флаги от –O0 до –O3. В главе 6 мы увидим, что уровень оптимизации может оказывать значительное влияние на результат дизассемблирования.)
Почему на этапе компиляции порождается код на языке ассемблера, а не машинный код? Это проектное решение кажется бессмысленАнатомия двоичного файла

35

ным в контексте одного конкретного языка (в данном случае C), но
обретает смысл, если вспомнить о других языках. Из наиболее популярных компилируемых языков назовем C, C++, Objective-C, Common
Lisp, Delphi, Go и Haskell. Писать компилятор, который порождает машинный код для каждого из них, было бы чрезвычайно трудоемким
и долгим занятием. Проще генерировать код на языке ассемблера
(тоже, кстати, достаточно трудное дело) и обрабатывать его на последнем этапе процесса одним и те же ассемблером.
Таким образом, результатом этапа компиляции является ассемб­
лерный код, все еще понятный человеку, в котором вся символическая информация сохранена. Как уже было сказано, gcc обычно вызывает все этапы компиляции автоматически, поэтому чтобы увидеть
ассемблерный код, сгенерированный на этапе компиляции, нужно
попросить gcc остановиться после этого этапа и сохранить ассемблерные файлы на диске. Для этого служит флаг –S (расширение .s традиционно используется для файлов на языке ассемблера). Кроме того,
передадим gcc флаг ‑masm=intel, чтобы ассемблерные команды записывались в синтаксисе Intel, а не AT&T, подразумеваемом по умолчанию. В листинге 1.3 показан результат этапа компиляции для нашего
примера1.
Листинг 1.3. Ассемблерный код, сгенерированный на этапе компиляции
программы "Hello, world!"
$ gcc -S -masm=intel compilation_example.c
$ cat compilation_example.s
.file "compilation_example.c"
.intel_syntax noprefix
.section
.rodata
 .LC0:
.string
"Hello, world!"
.text
.globl main
.type main, @function
 main:
.LFB0:
.cfi_startproc
push
rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
mov
rbp, rsp
.cfi_def_cfa_register 6
Sub
rsp, 16
mov
DWORD PTR [rbp-4], edi
mov
QWORD PTR [rbp-16], rsi
mov
edi, OFFSET FLAT:.LC0
1

Обратите внимание, что в процессе оптимизации gcc заменил вызовы

printf обращениями к puts.

36

Глава 1

call
puts
mov
eax, 0
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size
main, .-main
.ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.4) 5.4.0 20160609"
.section .note.GNU-stack,"",@progbits

Пока что я не стану вдаваться в детали ассемблерного кода. Но
интересно отметить, что код в листинге 1.3 читается сравнительно просто, потому что имена символов и функций сохранены. Так,
конс­тантам и переменным соответствуют символические имена, а не
прос­то адреса (пусть даже имя было сгенерировано автоматически,
как в случае LC0 для безымянной строки "Hello, world!"), а функции
main  (единственной функции в этом примере) – явная метка. Все
ссылки на код и данные тоже символические, как, например, ссылка
на строку "Hello, world!" . Мы будем лишены такого удобства при работе с зачищенными двоичными файлами ниже в этой книге!

1.1.3 Этап ассемблирования
В конце этапа ассемблирования мы наконец получаем настоящий
машинный код! На вход этого этапа поступают ассемблерные файлы, сгенерированные на этапе компиляции, а на выходе имеем набор
объектных файлов, которые иногда называются модулями. Объектные
файлы содержат машинные команды, которые в принципе могут
быть выполнены процессором. Но, как я скоро объясню, прежде чем
появится готовый к запуску исполняемый двоичный файл, необходимо проделать еще кое-какую работу. Обычно одному исходному файлу соответствует один ассемблерный файл, а одному ассемблерному
файлу – один объектный. Чтобы сгенерировать объектный файл, нужно передать gcc флаг –c, как показано в листинге 1.4.
Листинг 1.4. Генерирование объектного файла с по­мощью gcc
$ gcc -c compilation_example.c
$ file compilation_example.o
compilation_example.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

Чтобы убедиться, что сгенерированный файл compilation_example.o
действительно объектный, можно воспользоваться утилитой file
(весьма полезной, я вернусь к ней в главе 5). Как показано в листинге 1.4, это и вправду так: видно, что это файл типа ELF 64–bit LSB re‑
locatable.
И что же это значит? Первая часть вывода file говорит, что файл
отвечает спецификации формата исполняемых двоичных файлов ELF
Анатомия двоичного файла

37

(мы подробно рассмотрим этот формат в главе 2). Точнее, это 64-разрядный ELF-файл (поскольку в этом примере мы генерировали код
для процессора x86-64), а буквы LSB означают, что при размещении
чисел в памяти первым располагается младший байт (Least Significant
Byte). Но самое главное здесь – слово relocatable (перемещаемый).
Перемещаемые файлы не привязаны к какому-то конкретному
адресу в памяти, их можно перемещать, не нарушая никаких принятых в коде предположений. Увидев в напечатанной file строке слово
relocatable, мы понимаем, что речь идет об объектном, а не исполняе­
мом файле1.
Объектные файлы компилируются независимо, поэтому, обрабатывая один файл, ассемблер не может знать, какие адреса упоминаются в других объектных файлах. Именно поэтому объектные файлы
должны быть перемещаемыми, тогда мы сможем скомпоновать их
в любом порядке и получить полный исполняемый двоичный файл.
Если бы объектные файлы не были перемещаемыми, то это было бы
невозможно.
Содержимое объектного файла мы увидим ниже в этой главе, когда
будем готовы дизассемблировать свой первый файл.

1.1.4 Этап компоновки
Компоновка – последний этап процесса компиляции. На этом этапе
все объектные файлы объединяются в один исполняемый двоичный
файл. В современных системах этап компоновки иногда включает
дополнительный проход, называемый оптимизацией на этапе компоновки (link-time optimization – LTO)2.
Неудивительно, что программа, выполняющая компоновку, называется компоновщиком. Обычно она отделена от компилятора, который выполняет все предыдущие этапы.
Как я уже говорил, объектные файлы перемещаемы, потому что
компилируются независимо друг от друга, и компилятор не может
делать никаких предположений о начальном адресе объектного файла в памяти. Кроме того, объектные файлы могут содержать ссылки
на функции и переменные, находящиеся в других объектных файлах
или внешних библиотеках. До этапа компоновки адреса, по которым
будут размещены код и данные, еще неизвестны, поэтому объектные
файлы содержат только перемещаемые символы, которые определяют,
как в конечном итоге будут разрешены ссылки на функции и переменные. В контексте компоновки ссылки, зависящие от перемещаемого символа, называются символическими ссылками. Если объектный
файл ссылается на одну из собственных функций или переменных по
абсолютному адресу, то такая ссылка тоже будет символической.
1

2

38

Глава 1

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

Задача компоновщика – взять все принадлежащие программе объектные файлы и объединить их в один исполняемый файл, который,
как правило, должен загружаться с конкретного адреса в памяти. Теперь, когда известно, из каких модулей состоит исполняемый файл,
компоновщик может разрешить большинство символических ссылок.
Но ссылки на библиотеки могут остаться неразрешенными – это зависит от типа библиотеки.
Статические библиотеки (в Linux они обычно имеют расширение
.a, как показано на рис. 1.1) включаются в исполняемый двоичный
файл, поэтому ссылки на них можно разрешить окончательно. Но
существуют также динамические (разделяемые) библиотеки, которые совместно используются всеми программами, работающими
в системе. Иными словами, динамическая библиотека не копируется в каждый использующий ее двоичный файл, а загружается
в память лишь один раз, и все нуждающиеся в ней двоичные файлы
пользуются этой разделяемой копией. На этапе компоновки адреса,
по которым будут размещаться динамические библиотеки, еще неизвестны, поэтому ссылки на них разрешить невозможно. Поэтому
компоновщик оставляет символические ссылки на такие библиотеки даже в окончательном исполняемом файле, и эти ссылки разрешаются, только когда двоичный файл будет загружен в память для
выполнения.
Большинство компиляторов, в т. ч. и gcc, автоматически вызывают
компоновщик в конце процесса компиляции. Поэтому для создания
полного двоичного исполняемого файла можно просто вызвать gcc
без специальных флагов, как показано в листинге 1.5.
Листинг 1.5. Генерирование двоичного исполняемого файла с по­мощью gcc
$ gcc compilation_example.c
$ file a.out
a.out: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically
linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32,
BuildID[sha1]=d0e23ea731bce9de65619cadd58b14ecd8c015c7, not stripped
$ ./a.out
Hello, world!

По умолчанию созданный исполняемый файл называется a.out, но
можно задать другое имя, предварив его флагом –o. Теперь утилита
file сообщает, что мы имеем файл типа ELF 64–bit LSB executable
, т. е. исполняемый, а не перемещаемый, как после этапа ассемблирования. Важно также, что файл динамически скомпонован , т. е.
в нем используются библиотеки, не включенные в его состав, а разделяемые с другими программами, работающими в системе. Наконец,
слова interpreter /lib64/ld–linux–x86–64.so.2  в выводе file говорят, какой динамический компоновщик будет использован для окончательного разрешения зависимостей от динамических библиотек на
этапе загрузки исполняемого файла в память. Запустив двоичный
Анатомия двоичного файла

Powered by TCPDF (www.tcpdf.org)

39

файл (командой ./a.out), вы увидите, что он делает то, что ожидалось (печатает строку "Hello, world!" на стандартный вывод), т. е. мы
действительно получили работоспособный двоичный файл. Но что
означают слова «not stripped»  в выводе file? Обсудим это в следующем разделе.

1.2

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

1.2.1 Просмотр информации о символах
Чтобы вы могли представить, как выглядит информация о символах,
в листинге 1.6 показаны некоторые символы в двоичном файле нашей демонстрационной программы.
Листинг 1.6. Символы в двоичном файле a.out, показанные программой readelf
$ readelf --syms a.out
Symbol
Num:
0:
1:
2:
3:
Symbol
Num:
...
56:
57:
58:
59:
60:
61:
62:
63:
64:
65:
66:

40

table '.dynsym' contains 4 entries:
Value
Size Type Bind
0000000000000000
0 NOTYPE LOCAL
0000000000000000
0 FUNC GLOBAL
0000000000000000
0 FUNC GLOBAL
0000000000000000
0 NOTYPE WEAK
table '.symtab' contains 67 entries:
Value
Size Type Bind
0000000000601030
00000000004005d0
0000000000400550
0000000000601040
0000000000400430
0000000000601038
0000000000400526
0000000000000000
0000000000601038
0000000000000000
00000000004003c8

Глава 1

0
4
101
0
42
0
32
0
0
0
0

OBJECT
OBJECT
FUNC
NOTYPE
FUNC
NOTYPE
FUNC
NOTYPE
OBJECT
NOTYPE
FUNC

GLOBAL
GLOBAL
GLOBAL
GLOBAL
GLOBAL
GLOBAL
GLOBAL
WEAK
GLOBAL
WEAK
GLOBAL

Vis
DEFAULT
DEFAULT
DEFAULT
DEFAULT

Ndx
UND
UND
UND
UND

Name

Vis

Ndx Name

HIDDEN
DEFAULT
DEFAULT
DEFAULT
DEFAULT
DEFAULT
DEFAULT
DEFAULT
HIDDEN
DEFAULT
DEFAULT

25
16
14
26
14
26
14
UND
25
UND
11

puts@GLIBC_2.2.5 (2)
__libc_start_main@GLIBC_2.2.5 (2)
__gmon_start__

__dso_handle
_IO_stdin_used
__libc_csu_init
_end
_start
__bss_start
main
_Jv_RegisterClasses
__TMC_END__
_ITM_registerTMCloneTable
_init

В листинге 1.6 для отображения символов использована утилита

readelf . Мы вернемся в ней в главе 5 и расскажем, как интерпрети-

ровать ее вывод. А пока просто заметим, что среди множества незнакомых символов имеется символ для функции main . Видно, что ему
соответствует адрес (0x400526), с которого будет начинаться main пос­
ле загрузки двоичного файла в память. Приведен также размер кода
main (32 байта) и указано, что это символ функции (тип FUNC).
Информация о символах может быть включена в состав двоичного
файла (как в примере выше) или выведена в виде отдельного файла
символов. Кроме того, она может быть представлена в разных форматах. Компоновщику нужны только «голые» символы, но для отладки
требуется гораздо более подробная информация. Отладочные символы содержат полное отображение между строками исходного кода
и двоичными командами, они даже описывают параметры функции,
кадр стека и т. д. Для двоичных файлов в формате ELF отладочные
символы обычно генерируются в формате DWARF1, тогда как для
файлов в формате PE используется проприетарный формат Microsoft
Portable Debugging (PDB)2. Данные в формате DWARF обычно встраиваются в сам двоичный файл, а в формате PDB записываются в отдельный файл символов.
Нетрудно представить, насколько полезной может быть информация о символах для анализа двоичных файлов. Приведу лишь один
пример: наличие полного набора символов функций намного упрощает дизассемблирование, потому что каждый символ можно использовать как начальную точку дизассемблирования. Поэтому гораздо меньше шансов, что дизассемблер случайно интерпретирует
данные как код (что привело бы к появлению бессмысленных команд
на выходе). Кроме того, при наличии информации о том, какая часть
двоичного кода какой функции принадлежит и какая функция вызывается, специалисту по обратной разработке будет гораздо проще разбить код на логические составляющие и понять, что он делает.
Даже «голые» символы для компоновщика (лишенные дополнительной отладочной информации) оказывают огромную помощь во многих приложениях двоичного анализа.
Символы можно разобрать с по­мощью readelf, как показано выше,
или программно с по­мощью библиотеки типа libbfd, как будет описано в главе 4. Имеются также библиотеки типа libdwarf, специально предназначенные для разбора отладочных символов в формате
DWARF, но в этой книге они не рассматриваются.
К сожалению, отладочная информация обычно не включается
в производственные двоичные файлы, и даже базовая информация
1

2

Для тех, кому интересно, скажу, что акроним DWARF (англ. гном) никак не
расшифровывается. Название было выбрано просто потому, что хорошо
сочетается с «ELF» (по крайней мере, если это вызывает у вас ассоциации
со сказочными существами).
Для любознательных в приложении D приведены ссылки на документацию по форматам DWARF и PDB.
Анатомия двоичного файла

41

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

1.2.2 Переход на темную сторону: зачистка двоичного
файла
Вы, конечно, помните, что наш демонстрационный двоичный файл
еще не зачищен (что показывает утилита file в листинге 1.5). Видимо, по умолчанию gcc не зачищает откомпилированные двоичные
файлы. А чтобы сделать это, нужно всего-то воспользоваться командой strip, как показано в листинге 1.7.
Листинг 1.7. Зачистка исполняемого файла
$ strip --strip-all a.out
$ file a.out
a.out: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically
linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32,
BuildID[sha1]=d0e23ea731bce9de65619cadd58b14ecd8c015c7, stripped
$ readelf --syms a.out
 Symbol table '.dynsym' contains 4 entries:

Num:
0:
1:
2:
3:

Value
Size Type Bind Vis
Ndx
0000000000000000
0 NOTYPE LOCAL DEFAULT UND
0000000000000000
0 FUNC GLOBAL DEFAULT UND
0000000000000000
0 FUNC GLOBAL DEFAULT UND
0000000000000000
0 NOTYPE WEAK DEFAULT UND

Name
puts@GLIBC_2.2.5 (2)
__libc_start_main@GLIBC_2.2.5 (2)
__gmon_start__

Теперь двоичный файл зачищен , что подтверждает выход file
. В таблице символов .dynsym осталось всего несколько символов .
Они нужны для разрешения динамических зависимостей (например,
ссылок на динамические библиотеки) при загрузке двоичного файла в память, но для дизассемблирования особой ценности не представляют. Символ, соответствующий функции main, исчез, как и все
остальные.

1.3

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

42

Глава 1

Это позволит нам лучше понять, что такое объектный файл и что
именно добавляется на этапе компоновки.

1.3.1 Заглянем внутрь объектного файла
Пока что для дизассемблирования я воспользуюсь утилитой objdump
(другие инструменты мы обсудим в главе 6). Это простой и легкий
в использовании дизассемблер, включенный в состав большинства
дистрибутивов Linux, его вполне достаточно, чтобы получить представление о коде и данных, содержащихся в двоичном файле. В лис­
тинге 1.8 приведен результат дизассемблирования объектного файла
compilation_example.o.
Листинг 1.8. Дизассемблирование объектного файла
$ objdump -sj .rodata compilation_example.o
compilation_example.o:

file format elf64-x86-64

Contents of section .rodata:
0000 48656c6c 6f2c2077 6f726c64 2100

Hello, world!.

$ objdump -M intel -d compilation_example.o
compilation_example.o:

file format elf64-x86-64

Disassembly of section .text:
0000000000000000 :
0: 55
push
1: 48 89 e5
mov
4: 48 83 ec 10
sub
8: 89 7d fc
mov
b: 48 89 75 f0
mov
f: bf 00 00 00 00
mov
call
14: e8 00 00 00 00
19: b8 00 00 00 00
mov
1e: c9
leave
1f: c3
ret

rbp
rbp,rsp
rsp,0x10
DWORD PTR [rbp-0x4],edi
QWORD PTR [rbp-0x10],rsi
edi,0x0
19
eax,0x0

Обратите внимание, что в листинге 1.8 утилита objdump вызывается
дважды. При первом вызове я попросил показать содержимое секции .rodata. Ее название означает «read-only data» (данные, предназначенные только для чтения); именно в этой части двоичного файла хранятся все константы, включая строку «Hello, world!». Я вернусь
к более подробному обсуждению .rodata и других секций ELF-файла
в главе 2, где рассматривается этот двоичный формат. А пока заметим, что в секции .rodata находится строка в кодировке ASCII, показанная в левой части вывода. В правой же части находится понятное
человеку представление тех же самых байтов.
При втором вызове objdump  дизассемблируется весь находящийся в объектном файле код, и результат представляется в синтаксисе
Анатомия двоичного файла

43

Intel. Мы видим только код функции main , потому что это единственная функция в исходном файле. По большей части, вывод очень
близок к ассемблерному коду, сгенерированному на этапе компиляции (плюс-минус несколько ассемблерных макросов). Интересно отметить, что указатель на строку «Hello, world!» () инициализируется нулем. Следующий вызов , который должен бы вывести строку
на экран с по­мощью функции puts, также содержит бессмысленный
адрес (смещение 19, где-то в середине main).
Почему вызов, который должен ссылаться на puts, вместо этого
указывает в середину main? Ранее я говорил, что ссылки на код и данные в объектных файлах еще не полностью разрешены, потому что
компилятор не знает, по какому базовому адресу будет в конечном
итоге загружена программа. Потому-то вызов puts и не разрешен.
Объектный файл ждет, когда компоновщик подставит правильное
значение вместо этой ссылки. Вы можете убедиться в этом, попросив
readelf показать все перемещаемые символы в объектном файле, как
показано в листинге 1.9.
Листинг 1.9. Перемещаемые символы, показанные readelf
$ readelf --relocs compilation_example.o
Relocation section '.rela.text' at offset 0x210 contains 2 entries:
Offset
Info
Type
Sym. Value
Sym. Name + Addend
 000000000010
00050000000a R_X86_64_32
0000000000000000 .rodata + 0
 000000000015
000a00000002 R_X86_64_PC32 0000000000000000 puts - 4
...

Перемещаемый символ в строке
говорит компоновщику, что
нужно разрешить ссылку на строку, так чтобы она указывала на ее
окончательный адрес в секции .rodata.
Заметьте, что из символа puts вычитается значение 4. Пока можете необращать на это внимания; способ вычисления смещений
компоновщиком довольно сложный, и вывод readelf может вызвать
замешательство, поэтому я пока опущу детали перемещения и представлю общую картину дизассемблирования двоичного файла. А о перемещаемых символах мы поговорим в главе 2.
В левом столбце каждой строки вывода readelf (на сером фоне) показано смещение того места в объектном файле, в которое нужно подставить разрешенную ссылку. Приглядевшись, вы увидите, что в обоих случаях оно равно смещению подлежащей исправлению команды
плюс 1. Например, обращение к puts в выводе objdump смещено от начала кода на величину 0x14, однако перемещаемый символ указывает
на смещение 0x15. Объясняется это тем, что нам нужно перезаписать
только операнд команды, но не код операции. Так уж случилось, что
в обеих нуждающихся в исправлении командах код операции занимает 1 байт, поэтому для указания на операнд перемещаемый символ
должен пропустить этот байт.

44

Глава 1

1.3.2 Изучение полного исполняемого двоичного файла
Познакомившись с содержимым объектного файла, перейдем к диз­
ассемблированию полного двоичного файла. Начнем с файла, содержащего символы, а затем займемся его зачищенной версией, чтобы
посмотреть, чем будут отличаться результаты дизассемблирования.
Между дизассемблированными объектным и исполняемым файлами
есть большая разница, в чем легко убедиться, взглянув на выход obj‑
dump в листинге 1.10.
Листинг 1.10. Дизассемблирование исполняемого файла
с по­мощью objdump
$ objdump -M intel -d a.out
a.out:

file format elf64-x86-64

Disassembly of section .init:
00000000004003c8 :
4003c8: 48 83 ec 08
sub rsp,0x8
4003cc: 48 8b 05 25 0c 20 00 mov rax,QWORD PTR [rip+0x200c25]
4003d3: 48 85 c0
test rax,rax
4003d6: 74 05
je
4003dd
4003d8: e8 43 00 00 00
call 400420
4003dd: 48 83 c4 08
add rsp,0x8
4003e1: c3 ret
Disassembly of section .plt:
00000000004003f0 :
4003f0: ff 35 12 0c 20 00
push QWORD PTR [rip+0x200c12]
4003f6: ff 25 14 0c 20 00
jmp QWORD PTR [rip+0x200c14]
4003fc: 0f 1f 40 00
nop DWORD PTR [rax+0x0]
0000000000400400 :
400400: ff 25 12 0c 20 00
400406: 68 00 00 00 00
40040b: e9 e0 ff ff ff

jmp QWORD PTR [rip+0x200c12]
push 0x0
jmp 4003f0

...
Disassembly of section .text:
0000000000400430 :
400430: 31 ed
400432: 49 89 d1
400435: 5e
400436: 48 89 e2
400439: 48 83 e4 f0
40043d: 50
40043e: 54
40043f: 49 c7 c0 c0 05 40 00
400446: 48 c7 c1 50 05 40 00

xor
mov
pop
mov
and
push
push
mov
mov

ebp,ebp
r9,rdx
rsi
rdx,rsp
rsp,0xfffffffffffffff0
rax
rsp
r8,0x4005c0
rcx,0x400550
Анатомия двоичного файла

45

40044d:
400454:
400459:
40045a:

48 c7 c7 26 05 40 00
e8 b7 ff ff ff
f4
66 0f 1f 44 00 00

mov rdi,0x400526
call 400410
hlt
nop WORD PTR [rax+rax*1+0x0]

0000000000400460 :
...
0000000000400526 :
400526: 55
400527: 48 89 e5
40052a: 48 83 ec 10
40052e: 89 7d fc
400531: 48 89 75 f0
400535: bf d4 05 40 00
40053a: e8 c1 fe ff ff
40053f: b8 00 00 00 00
400544: c9
400545: c3
400546: 66 2e 0f 1f 84 00 00
40054d: 00 00 00

push
mov
sub
mov
mov
mov
call
mov
leave
ret
nop

rbp
rbp,rsp
rsp,0x10
DWORD PTR [rbp-0x4],edi
QWORD PTR [rbp-0x10],rsi
edi,0x4005d4
400400 Î
eax,0x0

WORD PTR cs:[rax+rax*1+0x0]

0000000000400550 :
...
Disassembly of section .fini:
00000000004005c4 :
4005c4: 48 83 ec 08
4005c8: 48 83 c4 08
4005cc: c3

sub
add
ret

rsp,0x8
rsp,0x8

Как видите, в двоичном файле кода гораздо больше, чем в объектном. Теперь это не только функция main, да и секция отнюдь не единственная. Существуют секции с именами .init , .plt , .text  и др.
Все они содержат код, служащий разным целям, например предназначенный для инициализации программы или являющийся заглушкой для вызова функций из разделяемых библиотек.
Секция .text – это основная секция кода, она содержит функцию
main , а также ряд других функций, например _start, отвечающих,
в частности, за подготовку аргументов командной строки, настройку среды выполнения для main и очистку после завершения main. Это
стандартные функции, присутствующие в любом двоичном ELFфайле, сгенерированном gcc.
Видно также, что отсутствовавшие ранее ссылки на код и данные
теперь разрешены компоновщиком. Например, обращение к puts 
сейчас указывает на нужную заглушку (в секции .plt) для доступа
к разделяемой библиотеке, содержащей puts. (Как работают PLT-за­
глуш­ки, я объясню в главе 2.)
Итак, полный исполняемый двоичный файл содержит значительно больше кода (и данных, хотя я их не показал), чем соответствующий объектный файл. Но до сих пор интерпретировать вывод было

46

Глава 1

ненамного труднее. Все меняется, если двоичный файл зачищен. Это
видно в листинге 1.11, где показан результат дизассемблирования зачищенной версии демонстрационного файла утилитой objdump.
Листинг 1.11. Дизассемблирование зачищенного исполняемого файла
с по­мощью objdump
$ objdump -M intel -d ./a.out.stripped
./a.out.stripped:

file format elf64-x86-64

Disassembly of section .init:
00000000004003c8 :
4003c8: 48 83 ec 08
4003cc: 48 8b 05 25 0c 20 00
4003d3: 48 85 c0
4003d6: 74 05
4003d8: e8 43 00 00 00
4003dd: 48 83 c4 08
4003e1: c3

sub
mov
test
je
call
add
ret

rsp,0x8
rax,QWORD PTR [rip+0x200c25]
rax,rax
4003dd
400420
rsp,0x8

xor
mov
pop
mov
and
push
push
mov
mov
mov
call
hlt
nop
mov

ebp,ebp
r9,rdx
rsi
rdx,rsp
rsp,0xfffffffffffffff0
rax
rsp
r8,0x4005c0
rcx,0x400550
rdi,0x400526
400410

jmp
push
mov
sub
mov
mov
mov
call
mov

4004a0
rbp
rbp,rsp
rsp,0x10
DWORD PTR [rbp-0x4],edi
QWORD PTR [rbp-0x10],rsi
edi,0x4005d4
400400
eax,0x0

Disassembly of section .plt:
...
Disassembly of section .text:









0000000000400430 :
400430: 31 ed
400432: 49 89 d1
400435: 5e
400436: 48 89 e2
400439: 48 83 e4 f0
40043d: 50
40043e: 54
40043f: 49 c7 c0 c0 05 40 00
400446: 48 c7 c1 50 05 40 00
40044d: 48 c7 c7 26 05 40 00
400454: e8 b7 ff ff ff
400459: f4
40045a: 66 0f 1f 44 00 00
400460: b8 3f 10 60 00
...
400520: 5d pop rbp
400521: e9 7a ff ff ff
400526: 55
400527: 48 89 e5
40052a: 48 83 ec 10
40052e: 89 7d fc
400531: 48 89 75 f0
400535: bf d4 05 40 00
40053a: e8 c1 fe ff ff
40053f: b8 00 00 00 00

WORD PTR [rax+rax*1+0x0]
eax,0x60103f

Анатомия двоичного файла

47



400544:
400545:
400546:
40054d:
400550:
400552:
...

c9
c3
66
00
41
41

2e 0f 1f 84 00 00
00 00
57
56

leave
ret
nop WORD PTR cs:[rax+rax*1+0x0]
push r15
push r14

Disassembly of section .fini:
00000000004005c4 :
4005c4: 48 83 ec 08
4005c8: 48 83 c4 08
4005cc: c3

sub
add
ret

rsp,0x8
rsp,0x8

Какой урок мы можем вынести из листинга 11.1? Различные секции по-прежнему хорошо различимы (они помечены цифрами , 
и ), но функции – уже нет. Все функции слились в один большой
блок кода. Функция _start начинается в точке , а функция deregis‑
ter_tm_clones – в точке . Функция main начинается в точке  и заканчивается в точке , но ни в одном из этих случаев нет ничего, что
позволило бы сказать, что команды как-то связаны с началом функции. Единственные исключения – функции в секции .plt, которые
по-прежнему имеют имена (что видно на примере вызова функции
__libc_start_main в точке ). А в остальном мы должны сами попытаться извлечь смысл из результата дизассемблирования.
Даже в этом простом примере все запутано, а представьте, что было
бы в случае большого двоичного файла, содержащего сотни функций,
слипшихся в один ком! Именно поэтому во многих областях анализа
двоичных файлов так важно иметь точный механизм автоматизированного обнаружения функций, который мы подробно обсудим в главе 6.

1.4

Загрузка и выполнение двоичного файла
Теперь вы знаете, как работает компилятор и как устроены внутри
двоичные файлы. Вы также научились дизассемблировать двоичные
файлы с по­мощью утилиты objdump. Если вы прорабатывали примеры, то на вашем диске даже есть новенький, с пылу с жару двоичный
файл. Теперь посмотрим, что происходит во время загрузки и выполнения двоичного файла. Это будет полезно при обсуждении идей динамического анализа в последующих главах.
Детали зависят от платформы и формата двоичного файла, но процесс загрузки и выполнения двоичного файла, как правило, состоит
из нескольких шагов. На рис. 1.2 показано, как загруженный двоичный ELF-файл (например, откомпилированный выше) расположен
в памяти Linux-системы. На верхнем уровне загрузка двоичного PEфайла в Windows очень похожа.

48

Глава 1

Ядро
Окружение

Стек

Аргументы
Интерпретатор

Перемещения

Переход на точку входа

Область
отображения
памяти

lib1.so
lib2.so

Куча
Заголовок
Секция данных 1
Данные
Секция данных 2
Секция кода 1
Код
Секция кода 2

Адрес 0x00

Двоичный файл
Виртуальная память

Рис. 1.2. Загрузка ELF-файла в Linux

Загрузка двоичного файла – сложный процесс, требующий большой
работы от операционной системы. Важно также понимать, что представление двоичного файла в памяти необязательно один в один соответствует его представлению на диске. Например, большие участки
данных, инициализированных нулями, могут быть свернуты на диске
(для экономии места), но в памяти все эти нули будут присутствовать. Некоторые части двоичного файла на диске могут быть упорядочены в памяти по-другому или вообще отсутствовать. Поскольку
детали зависят от формата файла, я отложу вопрос о представлениях
двоичного файла в памяти и на диске до главы 2 (формат ELF) и главы 3 (формат PE). А пока рассмотрим в общих чертах, что происходит
в процессе загрузки.
Когда вы запускаете двоичный файл, операционная система первым делом подготавливает новый процесс, в котором программа
будет исполняться, и, в частности, виртуальное адресное пространство1. Затем операционная система отображает интерпретатор
1

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

49

в виртуальную память процесса. Эта программа работает в режиме
пользователя и знает, как загружать двоичный файл и выполнять
необходимые перемещения. В Linux в роли интерпретатора обычно
выступает разделяемая библиотека ld-linux.so. В Windows функциональность интерпретатора реализована в библиотеке ntdll.dll. После
загрузки интерпретатора ядро передает ему управление, и тот начинает работать.
В двоичных ELF-файлах в Linux имеется специальная секция .in‑
terp, где указан путь к интерпретатору, который будет загружать данный файл. Это видно из результата readelf, показанного в листинге 1.12.
Листинг 1.12. Содержимое секции .interp
$ readelf -p .interp a.out
String dump of section '.interp':
[
0] /lib64/ld-linux-x86-64.so.2

Как уже было сказано, интерпретатор загружает двоичный файл
в его виртуальное адресное пространство (то самое, в которое загружен он сам). Затем он разбирает двоичный файл и определяет (среди
прочего), какие динамические библиотеки тот использует. Эти биб­
лио­теки интерпретатор отображает в виртуальное адресное пространство (с помощью функции mmap или эквивалентной ей), после
чего выполняет оставшиеся перемещения в секциях кода, чтобы подставить правильные адреса вместо ссылок на динамические библиотеки. В действительности процесс разрешения ссылок на функции
в динамических библиотеках часто откладывается на потом. Иначе
говоря, вместо разрешения этих ссылок сразу в момент загрузки интерпретатор откладывает это на момент первого вызова. Это называется поздним связыванием и будет объяснено подробнее в главе 2.
Завершив перемещение, интерпретатор находит точку входа в двоичный файл и передает ей управление, после чего начинается собственно выполнение двоичного файла.

1.5

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

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

50

Глава 1

Упражнения
1. Нахождение функций
Напишите на C программу, содержащую несколько функций,
и откомпилируйте ее, получив ассемблерный файл, объектный
файл и исполняемый двоичный файл. Попытайтесь найти написанные вами функции во всех трех файлах. Видите ли вы соответствие между кодом на C и на ассемблере? Зачистите исполняемый файл и снова попробуйте идентифицировать функции.
2. Секции
Как вы видели, двоичные ELF-файлы (и файлы в других форматах) разбиты на секции. Одни секции содержат код, другие – данные. Зачем, на ваш взгляд, нужно разделение между секциями
кода и данных? Как вы думаете, чем различаются процессы загрузки кода и данных? Необходимо ли копировать все секции
в память, когда двоичный файл загружается для выполнения?

2

ФОРМАТ ELF
https://t.me/it_boooks

И

мея общее представление о том, как выглядят и как работают
двоичные файлы, мы можем перейти к деталям конкретного
двоичного формата. В этой главе мы рассмотрим формат Executable and Linkable Format (ELF), подразумеваемый по умолчанию
для двоичных файлов в Linux-системах. Именно с ним мы будем работать в этой книге.
Формат ELF используется для исполняемых файлов, объектных
файлов, разделяемых библиотек и дампов памяти. Здесь я остановлюсь только на исполняемых ELF-файлах, но все те же концепции
применимы и к другим файлам в этом формате. Поскольку в этой
книге мы будем иметь дело в основном с 64-разрядными двоичными файлами, в центре обсуждения будет 64-разрядный формат ELF.
Впрочем, 32-разрядный формат похож и отличается главным образом
размером и порядком следования некоторых полей заголовков и других структур данных. Вам не составит труда перенести обсуждаемые
здесь концепции на 32-разрядные двоичные ELF-файлы.
На рис. 2.1 показан формат и содержание типичного 64-разрядного исполняемого ELF-файла. Когда впервые начинаешь подробно
анализировать двоичный ELF-файл, эта сложность может показаться
ошеломляющей. Но по существу ELF-файл содержит компоненты всего четырех типов: заголовок исполняемого файла, несколько необязательных заголовков программы, несколько секций и несколько необязательных заголовков секций, по одному на каждую секцию. Далее мы
обсудим их по порядку.

52

Глава 2

Заголовок

Заголовки
программы

Заголовок исполняемого файла

Заголовок программы

Важные секции:

Секции
Секция

Заголовки
секций

Заголовок секции

Рис. 2.1. Структура 64-разрядного двоичного ELF-файла

Формат ELF

53

Как показано на рис. 2.1, заголовок исполняемого файла в стандартном ELF-файле расположен первым, за ним идут заголовки программы, секции и заголовки секций. Чтобы упростить изложение,
я буду обсуждать их немного в другом порядке: сначала секции и заголовки секций, а затем заголовки программы. Но начнем мы с заголовка исполняемого файла.

2.1

Заголовок исполняемого файла
Каждый ELF-файл начинается с заголовка исполняемого файла. Это всего лишь структурированная последовательность байтов, сообщающая
нам, что это ELF-файл определенного типа и где искать все остальное
содержимое. Формат заголовка исполняемого файла можно найти
в определении типа в файле /usr/include/elf.h (там же определены другие относящиеся к ELF типы и константы) или в спецификации ELF1.
В листинге 2.1 приведено определение типа для заголовка 64-разрядного исполняемого ELF-файла.

Листинг 2.1. Определение типа ELF64_Ehdr в файле /usr/include/elf.h
typedef struct {
unsigned char e_ident[16];
uint16_t e_type;
uint16_t e_machine;
uint32_t e_version;
uint64_t e_entry;
uint64_t e_phoff;
uint64_t e_shoff;
uint32_t e_flags;
uint16_t e_ehsize;
uint16_t e_phentsize;
uint16_t e_phnum;
uint16_t e_shentsize;
uint16_t e_shnum;
uint16_t e_shstrndx;
} Elf64_Ehdr;

/*
/*
/*
/*
/*
/*
/*
/*
/*
/*
/*
/*
/*
/*

Магическое число и другая информация */
Тип объектного файла */
Архитектура */
Версия объектного файла */
Виртуальный адрес точки входа */
Смещение таблицы заголовков программы в файле */
Смещение таблицы заголовков секций в файле */
Флаги, зависящие от процессора */
Размер заголовка ELF в байтах */
Размер записи таблицы заголовков программы */
Количество записей в таблице заголовков программы */
Размер записи таблицы заголовков секций */
Количество записей в таблице заголовков секций */
Индекс таблицы строк в заголовке секции */

Заголовок исполняемого файла представлен здесь в виде C-струк­
туры (struct) Elf64_Ehdr. Заглянув в файл /usr/include/elf.h, вы увидите, что в определении структуры на самом деле фигурируют типы
Elf64_Half и Elf64_Word. Это просто псевдонимы (typedef) целых типов uint16_t и uint32_t. Для простоты я раскрыл эти псевдонимы на
рис. 2.1 и в листинге 2.1.

1

54

Глава 2

Спецификация ELF находится по адресу http://refspecs.linuxbase.org/elf/elf.pdf,
а описание различий между 32- и 64-разрядными версиями ELF – по адресу https://uclibc.org/docs/elf-64-gen.pdf.

2.1.1 Массив e_ident
Заголовок исполняемого файла (и сам ELF-файл) начинается с 16-байтового массива e_ident. В начале этого массива всегда находится
«магическое значение», показывающее, что это двоичный ELF-файл,
а именно шестнадцатеричное число 0x7f, за которым следуют ASCIIко­ды букв E, L и F. Эти байты расположены в начале файла, чтобы
различные инструментальные средства, в т. ч. утилита file, а также двоичный загрузчик, могли быстро определить, что имеют дело
с ELF-файлом.
За магическим значением следует несколько байтов, содержащих
более подробную информацию о типе ELF-файла. В файле elf.h индексы этих байтов (от 4 до 15 в массиве e_ident) обозначаются символическими константами EI_CLASS, EI_DATA, EI_VERSION, EI_OSABI, EI_ABI‑
VERSION и EI_PAD соответственно. Они показаны на рис. 2.1.
Поле EI_PAD содержит байты с индексами от 9 до 15. Все они в настоящее время являются байтами заполнения, т. е. зарезервированы
для будущего использования, а сейчас равны 0.
Байт EI_CLASS обозначает то, что в спецификации ELF называется
«классом» двоичного файла. Название выбрано не вполне удачно, потому что слово класс слишком общее и может означать что угодно.
В действительности же этот байт сообщает архитектуру двоичного
файла: 32- или 64-разрядную. В первом случае байт EI_CLASS равен
константе ELFCLASS32 (1), а во втором – ELFCLASS64 (2).
Помимо разрядности, к архитектуре относится также порядок байтов. Многобайтовые значения (например, целые числа) могут размещаться в памяти, так что сначала идет младший байт (прямой порядок) или старший байт (обратный порядок). Байт EI_DATA описывает
порядок байтов в двоичном файле. Константа ELFDATA2LSB (1) означает прямой порядок, а ELFDATA2MSB (2) – обратный.
Следующий байт, EI_VERSION, обозначает версию спецификации
ELF, которой отвечает данный двоичный файл. В настоящее время
допустимо только значение EV_CURRENT, равное 1.
Наконец, байты EI_OSABI и EI_ABIVERSION описывают двоичный интерфейс приложения (ABI) и операционную систему (OS), для которой
откомпилирован файл. Если байт EI_OSABI отличен от нуля, значит,
в ELF-файле используются расширения, зависящие от ABI или OS; это
означает, что семантика некоторых полей в двоичном файле может
быть другой или что могут присутствовать нестандартные секции.
Значение по умолчанию, нуль, означает, что файл ориентирован на
ABI операционной системы UNIX System V. Байт EI_ABIVERSION содержит номер версии ABI, указанного байтом EI_OSABI. Обычно этот байт
равен 0, потому что при использовании значения EI_OSABI по умолчанию задавать номер версии необязательно.
Узнать, что находится в массиве e_ident любого ELF-файла, позволяет утилита readelf. Например, в листинге 2.2 показан результат для
файла compilation_example из главы 1 (я буду ссылаться на этот лис­
тинг и при обсуждении других полей заголовка исполняемого файла).
Формат ELF

55

Листинг 2.2. Заголовок исполняемого файла, отображаемый readelf















$ readelf -h a.out
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class:
ELF64
Data:
2's complement, little endian
Version:
1 (current)
OS/ABI:
UNIX - System V
ABI Version:
0
Type:
EXEC (Executable file)
Machine:
Advanced Micro Devices X86-64
Version:
0x1
Entry point address:
0x400430
Start of program headers:
64 (bytes into file)
Start of section headers:
6632 (bytes into file)
Flags:
0x0
Size of this header:
64 (bytes)
Size of program headers:
56 (bytes)
Number of program headers:
9
Size of section headers:
64 (bytes)
Number of section headers:
31
Section header string table index: 28

В листинге 2.2 массив e_ident показан в строке Magic . Он начинается с уже знакомых нам четырех магических байтов, за которыми
следует значение 2 (ELFCLASS64), затем 1 (ELFDATA2LSB) и снова 1 (EV_
CURRENT). Далее следуют нули, потому что байты EI_OSABI и EI_ABIVER‑
SION принимают значения по умолчанию, а байты заполнения всегда
нулевые. Информация, содержащаяся в этих байтах, явно повторена
в последующих строках, названных соответственно Class, Data, Ver‑
sion, OS/ABI и ABI Version .

2.1.2 Поля e_type, e_machine и e_version
После массива e_ident находятся многобайтовые целочисленные
поля. Первое из них, e_type, определяет тип двоичного файла. Наиболее распространенные значения: ET_REL (перемещаемый объектный
файл), ET_EXEC (исполняемый двоичный файл) и ET_DYN (динамическая библиотека, называемая также разделяемым объектным файлом). Результат readelf для нашего примера показывает, что мы имеем дело с исполняемым файлом (Type: EXEC  в листинге 2.2).
Далее идет поле e_machine, описывающее архитектуру, для которой
предназначен двоичный файл . В этой книге оно обычно равно EM_
X86_64 (как в результате readelf), т. к. мы по большей части работаем
с двоичными файлами для 64-разрядной архитектуры x86. Но можно
встретить и другие значения: EM_386 (32-разрядная x86) и EM_ARM (для
процессоров ARM).
Поле e_version играет ту же роль, что байт EI_VERSION в массиве
e_ident, а именно содержит версию спецификации ELF, согласно ко-

56

Глава 2

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

2.1.3 Поле e_entry
Поле e_entry описывает точку входа в двоичный файл; это виртуальный адрес, с которого должно начинаться выполнение (см. также раздел 1.4). В нашем примере выполнение начинается с адреса 0x400430
(строка  в листинге 2.2). Именно туда передает управление интерпретатор (обычно ld-linux.so) после завершения загрузки двоичного
файла в виртуальную память. Точка входа также является полезной
начальной точкой для рекурсивного дизассемблирования, о чем мы
будем говорить в главе 6.

2.1.4 Поля e_phoff и e_shoff
На рис. 2.1 показано, что двоичные ELF-файлы среди прочего содержат таблицы заголовков программы и секций. Я вернусь к этим типам
заголовков после обсуждения заголовка исполняемого файла, но уже
сейчас могу сказать, что смещение этих таблиц относительно начала
двоичного файла не фиксировано. Единственная структура данных,
которая должна находиться в точно определенном месте ELF-файла, –
его заголовок, и он всегда находится в начале.
Откуда же мы знаем, где искать заголовки программы и секций?
Для этой цели предназначены два поля в заголовке исполняемого
файла: e_phoff и e_shoff, в которых хранятся смещения таблиц заголовков программы и секций соответственно. Так, в нашем примере эти смещения равны 64 и 6632 (строки  в листинге 2.2). Любое
смещение может быть равно нулю, это означает, что в программе нет
таблицы заголовков программы или секций. Важно понимать, что
эти поля содержат смещения относительно начала файла – количество
байтов, которые нужно прочитать, чтобы добраться до заголовков. То
есть, в отличие от поля e_entry, поля e_phoff и e_shoff не являются
виртуальными адресами.

2.1.5 Поле e_flags
Поле e_flags содержит флаги, специфичные для той архитектуры, на
которую ориентирован данный двоичный файл. Например, в файлах
для архитектуры ARM, предназначенной для встраиваемых платформ, поле e_flags может содержать дополнительные детали об ожидаемом интерфейсе операционной системы (соглашения о формате
файла, об организации стека и т. д.). Для двоичных файлов на платформе x86 поле e_flags обычно равно 0 и потому не представляет
интереса.
Формат ELF

57

2.1.6 Поле e_ehsize
В поле e_ehsize находится размер заголовка исполняемого файла
в байтах. Для двоичных файлов на 64-разрядной платформе x86 размер заголовка всегда равен 64 байтам, как видно из распечатки re‑
adelf, а для файлов на 32-разрядной платформе x86 он равен 52 байтам (см. строку  в листинге 2.2).

2.1.7 Поля e_*entsize и e_*num
Как вы уже знаете, поля e_phoff и e_shoff содержат смещения таблиц
заголовков программы и секций от начала файла. Но компоновщику
и загрузчику (а также другим программам, работающим с двоичными
ELF-файлами) необходима дополнительная информация для обхода
этих таблиц. А именно им нужно знать размер одной записи таблицы, а также количество записей в ней. Эти сведения находятся в полях
e_phentsize и e_phnum для таблицы заголовков программы и в полях e_
shentsize и e_shnum для таблицы заголовков секций. В примере в лис­
тинге 2.2 имеется девять заголовков программы размером 56 байт
каждый и 31 заголовок секций размером 64 байта .

2.1.8 Поле e_shstrndx
Поле e_shstrndx содержит индекс (в таблице заголовков секций) заголовка специальной секции – таблицы строк, .shstrtab. Эта секция
содержит таблицу завершающихся нулем ASCII-строк, в которой хранятся имена всех секций в двоичном файле. Она используется такими инструментальными средствами, как readelf, для правильного
отобра­жения имен секций. Я опишу секцию .shstrtab (и другие) ниже
в этой главе.
В примере в листинге 2.2 заголовок секции .shstrtab имеет индекс 28 . Ее содержимое (в шестнадцатеричном виде) можно получить с по­мощью readelf, как показано в листинге 2.3.
Листинг 2.3. Секция .shstrtab, отображаемая readelf
$ readelf -x .shstrtab a.out
Hex dump of section '.shstrtab':
0x00000000 002e7379 6d746162 002e7374
0x00000010 002e7368 73747274 6162002e
0x00000020 7270002e 6e6f7465 2e414249
0x00000030 002e6e6f 74652e67 6e752e62
0x00000040 2d696400 2e676e75 2e686173
0x00000050 796e7379 6d002e64 796e7374
0x00000060 6e752e76 65727369 6f6e002e
0x00000070 76657273 696f6e5f 72002e72
0x00000080 64796e00 2e72656c 612e706c
0x00000090 6e697400 2e706c74 2e676f74

58

Глава 2

72746162
696e7465
2d746167
75696c64
68002e64
72002e67
676e752e
656c612e
74002e69
002e7465

..symtab..strtab

..shstrtab..inte
rp..note.ABI-tag
..note.gnu.build
-id..gnu.hash..d
ynsym..dynstr..g
nu.version..gnu.
version_r..rela.
dyn..rela.plt..i
nit..plt.got..te

0x000000a0
0x000000b0
0x000000c0
0x000000d0
0x000000e0
0x000000f0
0x00000100

7874002e
002e6568
65685f66
72726179
002e6a63
676f742e
7373002e

66696e69
5f667261
72616d65
002e6669
72002e64
706c7400
636f6d6d

002e726f
6d655f68
002e696e
6e695f61
796e616d
2e646174
656e7400

64617461
6472002e
69745f61
72726179
6963002e
61002e62

xt..fini..rodata
..eh_frame_hdr..
eh_frame..init_a
rray..fini_array
..jcr..dynamic..
got.plt..data..b
ss..comment.

Имена секций (например, .symtab, .strtab и т. д.), присутствующие
в таблице строк, можно разглядеть в правой части листинга 2.3 . Теперь, познакомившись с форматом и содержимым заголовка исполняемого ELF-файла, перейдем к заголовкам секций.

2.2

Заголовки секций
Код и данные в двоичном ELF-файле логически разделены на неперекрывающиеся смежные блоки, называемые секциями. У секций нет
общей предопределенной структуры, структура каждой секции зависит от ее содержимого. На самом деле у секции может не быть вообще
никакой структуры; зачастую секция представляет собой всего лишь
неструктурированный блок кода или данных. Каждая секция описывается своим заголовком, который перечисляет ее свойства и позволяет найти принадлежащие ей байты. Заголовки всех секций двоичного
файла хранятся в таблице заголовков секций.
Строго говоря, разделение на секции призвано обеспечить удобную организацию для работы компоновщика (хотя, конечно, другие
инструменты, например программы статического двоичного анализа, тоже могут разбирать секции). Это означает, что не каждая секция
необходима для подготовки процесса и виртуальной памяти к выполнению двоичного файла. Некоторые секции содержат данные, которые на этапе выполнения вообще не нужны, например информацию
о символах и перемещении.
Поскольку секции предназначены только для информирования
компоновщика, таблица заголовков секций является факультативной частью формата ELF. ELF-файлы, не нуждающиеся в компоновке,
могут не иметь такой таблицы. Если в файле нет таблицы заголовков
секций, то поле e_shoff в заголовке исполняемого файла равно нулю.
Чтобы загрузить и выполнить двоичный файл в процессе, данные
и код в нем должны быть организованы по-другому. Поэтому в исполняемых ELF-файлах имеется еще одна логическая организация –
сегменты, используемые на этапе выполнения (в отличие от секций,
которые используются на этапе компоновки). Я буду рассматривать
сегменты ниже в этой главе, когда дойду до заголовков программы.
А пока сконцентрируемся на секциях, но будем помнить, что обсуждаемая здесь логическая организация существует только на этапе
компоновки (и используется инструментами статического анализа),
но не во время выполнения.
Формат ELF

59

Начнем с обсуждения формата заголовков секций. А затем обратимся к содержимому секций. В листинге 2.4 показан формат заголовка секции, определенный в файле /usr/include/elf.h.
Листинг 2.4. Определение структуры Elf64_Shdr в файле /usr/include/elf.h
typedef struct {
uint32_t sh_name; /*
uint32_t sh_type; /*
uint64_t sh_flags; /*
uint64_t sh_addr; /*
uint64_t sh_offset; /*
uint64_t sh_size; /*
uint32_t sh_link; /*
uint32_t sh_info; /*
uint64_t sh_addralign; /*
uint64_t sh_entsize; /*
} Elf64_Shdr;

Имя секции (индекс в таблице строк) */
Тип секции */
Флаги секции */
Виртуальный адрес секции на этапе выполнения */
Смещение секции в файле */
Размер секции в байтах */
Ссылка на другую секцию */
Дополнительная информация о секции */
Выравнивание секции */
Размер записи, если секция содержит таблицу */

2.2.1 Поле sh_name
Как показано в листинге 2.4, первое поле заголовка секции называется sh_name. Если оно задано, то содержит индекс в таблице строк. Если
индекс равен нулю, то у секции нет имени.
В разделе 2.1 мы обсуждали специальную секцию .shstrtab, которая содержит массив завершаемых нулем строк, по одной для каждого имени секции. Индекс заголовка секции с этой таблицей строк
хранится в поле e_shstrndx заголовка исполняемого файла. Это позволяет таким инструментам, как readelf, легко находить секцию
.shstrtab, а затем – имя секции в ней, пользуясь полем sh_name, имею­
щимся в заголовке каждой секции (в т. ч. секции .shstrtab). Поэтому
человек, анализирующий файл, может легко понять назначение каждой секции1.

2.2.2 Поле sh_type
У каждой секции есть тип, обозначаемый целочисленным полем sh_
type, который сообщает компоновщику о структуре содержимого секции. На рис. 2.1 показаны типы наиболее интересных для нас секций.
Мы обсудим их поочередно.
Секции типа SHT_PROGBITS содержат данные программы, например машинные команды или константы. У таких секций нет никакой
структуры, которую нужно было бы разбирать компоновщику.
Имеются также специальные типы секций для таблиц символов
(SHT_SYMTAB для таблиц статических символов и SHT_DYNSYM для таблиц
1

60

Глава 2

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

символов, используемых динамическим компоновщиком) и таб­лицы
строк (SHT_STRTAB). В таблицах символов хранятся символы в точно определенном формате (struct Elf64_Sym в файле elf.h, если вам
интересно), который среди прочего содержит имя и тип символа по
конкретному смещению в файле или адресу. Таблица статических
символов может отсутствовать, например, если двоичный файл был
зачищен. Как мы уже говорили, таблицы строк – это просто массивы
завершающихся нулем строк, причем первый байт таблицы строк, по
соглашению, равен NULL.
Секции типа SHT_REL или SHT_RELA особенно важны для компоновщика, потому что содержат записи о перемещении в точно определенном формате (структуры struct Elf64_Rel и struct Elf64_Rela
в файле elf.h), который компоновщик может разобрать, чтобы произвести необходимые перемещения в других секциях. Каждая запись
о перемещении несет информацию об одном месте в двоичном файле, нуждающемся в перемещении, и о символе, разрешающем это
перемещение. Сам процесс перемещения весьма сложен, и я не буду
сейчас вдаваться в его подробности. Главное – запомните, что секции
SHT_REL и SHT_RELA используются при статической компоновке.
Секции типа SHT_DYNAMIC содержат информацию, необходимую
для динамической компоновки. Ее формат описывается структурой
Elf64_Dyn в файле elf.h.

2.2.3 Поле sh_flags
Флаги секции (определенные в поле sh_flags) содержат дополнительную информацию о секции. Наиболее интересны флаги SHF_WRITE,
SHF_ALLOC и SHF_EXECINSTR.
Флаг SHF_WRITE означает, что секция допускает запись во время выполнения. Это позволяет различить секции, содержащие статические
данные (например, константы) и переменные. Флаг SHF_ALLOC означает, что содержимое секции должно быть загружено в виртуальную
память при выполнении двоичного файла (хотя собственно загрузка
производится с применением сегментного, а не секционного представления файла). Наконец, флаг SHF_EXECINSTR означает, что секция
содержит исполняемые команды; это полезно знать при дизассемб­
лировании двоичного файла.

2.2.4 Поля sh_addr, sh_offset и sh_size
Поля sh_addr, sh_offset и sh_size описывают виртуальный адрес,
смещение в файле (в байтах от его начала) и размер секции в байтах
соответственно. На первый взгляд может показаться, что полю, описывающему виртуальный адрес секции, например sh_addr, здесь не
место; ведь я же говорил, что секции используются только для компоновки, а не для создания и выполнения процесса. Это действительно
так, но компоновщику иногда нужно знать, по каким адресам будут
размещены определенные части кода и данных на этапе выполнения,
Формат ELF

61

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

2.2.5 Поле sh_link
Иногда между секциями имеются связи, о которых нужно знать компоновщику. Например, с секциями SHT_SYMTAB, SHT_DYNSYM и SHT_DY‑
NAMIC ассоциирована секция таблицы строк, содержащая имена соответствующих символов. Аналогично с секциями перемещения (типа
SHT_REL и SHT_RELA) ассоциирована таблица символов, описывающая
символы, которые участвуют в перемещениях. Поле sh_link позволяет сделать такие связи явными, поскольку содержит индекс (в таблице
заголовков секций) связанной секции.

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

2.2.7 Поле sh_addralign
Некоторые секции должны быть выровнены в памяти для повышения
эффективности доступа к памяти. Например, бывает, что секцию необходимо загрузить с адреса, кратного 8 или 16 байтам. Требования
к выравниванию задаются в поле sh_addralign. Так, если это поле равно 16, значит, базовый адрес секции (выбранный компоновщиком)
должен быть кратен 16. Значения 0 и 1 зарезервированы и означают,
что требований к выравниванию нет.

2.2.8 Поле sh_entsize
Некоторые секции, например таблицы символов или перемещений,
содержат таблицу точно определенных структур данных (скажем,
Elf64_Sym или Elf64_Rela). Для таких секций поле sh_entsize содержит размер одной записи таблицы в байтах. Если поле не используется, оно равно нулю.

2.3

Секции
Познакомившись со структурой заголовка секции, обратимся к некоторым конкретным секциям двоичного ELF-файла. Типичные ELFфайлы, встречающиеся в системе GNU/Linux, организованы в виде
последовательности стандартных (официально или по факту) секций.

62

Глава 2

В листинге 2.5 показан результат распечатки секций утилитой readelf
для нашего демонстрационного файла.
Листинг 2.5. Перечень секций для примера двоичного файла
$ readelf --sections --wide a.out
There are 31 section headers, starting at offset 0x19e8:
Section Headers:
[Nr] Name
Type
Address
Off
Size ES Flg Lk Inf Al
[ 0]
NULL
0000000000000000 000000 000000 00
0 0 0
[ 1] .interp
PROGBITS
0000000000400238 000238 00001c 00
A 0 0 1
[ 2] .note.ABI-tag
NOTE
0000000000400254 000254 000020 00
A 0 0 4
[ 3] .note.gnu.build-id NOTE
0000000000400274 000274 000024 00
A 0 0 4
[ 4] .gnu.hash
GNU_HASH
0000000000400298 000298 00001c 00
A 5 0 8
[ 5] .dynsym
DYNSYM
00000000004002b8 0002b8 000060 18
A 6 1 8
[ 6] .dynstr
STRTAB
0000000000400318 000318 00003d 00
A 0 0 1
[ 7] .gnu.version
VERSYM
0000000000400356 000356 000008 02
A 5 0 2
[ 8] .gnu.version_r
VERNEED
0000000000400360 000360 000020 00
A 6 1 8
[ 9] .rela.dyn
RELA
0000000000400380 000380 000018 18
A 5 0 8
[10] .rela.plt
RELA
0000000000400398 000398 000030 18 AI 5 24 8
[11] .init
PROGBITS
00000000004003c8 0003c8 00001a 00 AX 0 0 4
[12] .plt
PROGBITS
00000000004003f0 0003f0 000030 10 AX 0 0 16
[13] .plt.got
PROGBITS
0000000000400420 000420 000008 00 AX 0 0 8
[14] .text
 PROGBITS
0000000000400430 000430 000192 00  AX 0 0 16
[15] .fini
PROGBITS
00000000004005c4 0005c4 000009 00 AX 0 0 4
[16] .rodata
PROGBITS
00000000004005d0 0005d0 000011 00
A 0 0 4
[17] .eh_frame_hdr
PROGBITS
00000000004005e4 0005e4 000034 00
A 0 0 4
[18] .eh_frame
PROGBITS
0000000000400618 000618 0000f4 00
A 0 0 8
[19] .init_array
INIT_ARRAY
0000000000600e10 000e10 000008 00 WA 0 0 8
[20] .fini_array
FINI_ARRAY
0000000000600e18 000e18 000008 00 WA 0 0 8
[21] .jcr
PROGBITS
0000000000600e20 000e20 000008 00 WA 0 0 8
[22] .dynamic
DYNAMIC
0000000000600e28 000e28 0001d0 10 WA 6 0 8
[23] .got
PROGBITS
0000000000600ff8 000ff8 000008 08 WA 0 0 8
[24] .got.plt
PROGBITS
0000000000601000 001000 000028 08 WA 0 0 8
[25] .data
PROGBITS
0000000000601028 001028 000010 00 WA 0 0 8
[26] .bss
NOBITS
0000000000601038 001038 000008 00 WA 0 0 1
[27] .comment
PROGBITS
0000000000000000 001038 000034 01 MS 0 0 1
[28] .shstrtab
STRTAB
0000000000000000 0018da 00010c 00
0 0 1
[29] .symtab
SYMTAB
0000000000000000 001070 000648 18
30 47 8
[30] .strtab
STRTAB
0000000000000000 0016b8 000222 00
0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), l (large)
I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)

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

63

размер одной записи таблицы. Наконец, readelf показывает флаги
каждой секции, индекс связанной секции (если таковая существует),
дополнительную информацию (зависящую от типа секции) и требования к выравниванию.
Как видим, распечатка повторяет структуру заголовка секции. Первая запись в таблице заголовков секций любого ELF-файла, согласно
стандарту ELF, должна содержать пустую запись. Тип этой записи равен SHT_NULL , а все поля заголовка секции равны нулю. Это означает,
что секция не имеет имени и не содержит никаких байтов (т. е. мы
имеем заголовок, которому не соответствует никакая секция). А теперь поговорим подробнее о содержимом и назначении наиболее
интересных секций, с которыми вы, скорее всего, встретитесь в ходе
своих подвигов на ниве двоичного анализа1.

2.3.1 Секции .init и .fini
Секция .init (имеющая индекс 11 в листинге 2.5) содержит код, который отвечает за инициализацию и должен быть выполнен раньше
любого другого кода в двоичном файле. О том, что секция содержит
исполняемый код, сообщает флаг SHF_EXECINSTR, который readelf выводит как X (в столбце Flg) . Система выполняет код в секции .init,
до того как передать управление главной точке входа в двоичный
файл. Если вы знакомы с объектно-ориентированным программированием, то можете уподобить эту секцию конструктору. Секция .fini
(с индексом 15) аналогична секции .init, но содержит код, выполняемый после завершения основной программы; таким образом, она
играет роль деструктора.

2.3.2 Секция .text
Секция .text (с индексом 14) содержит основной код программы,
именно она часто является главным объектом внимания в процессе
двоичного анализа или обратной разработки. Как показывает результат работы readelf в листинге 2.5, секция .text имеет тип SHT_PROG‑
BITS , т. к. содержит код, написанный пользователем. Обратите также
внимание на флаги секции, которые показывают, что она исполняемая, но не допускает записи . В общем случае исполняемые секции
почти никогда не допускают записи (и наоборот), поскольку это позволило бы противнику воспользоваться уязвимостью, чтобы модифицировать поведение программы путем прямой перезаписи ее кода.
Кроме зависящего от приложения кода, являющегося результатом
компиляции исходного кода программы, секция .text типичного
двоичного файла, откомпилированного gcc, содержит ряд стандартных функций, выполняющих инициализацию и очистку, например
_start, register_tm_clones и frame_dummy. На данный момент нам наи1

64

Глава 2

Общий обзор и описание всех стандартных секций ELF имеется в специ­
фикации ELF по адресу http://refspecs.linuxbase.org/elf/elf.pdf.

более интересна стандартная функция _start, и из листинга 2.6 ясно,
почему (не переживайте, если не до конца понимаете ассемблерный
код, важные части я объясню ниже).
Листинг 2.6. Результат дизассемблирования стандартной функции _start
$ objdump -M intel -d a.out
...
Disassembly of section .text:
 0000000000400430 :

400430:
400432:
400435:
400436:
400439:
40043d:
40043e:
40043f:
400446:
40044d:
400454:
400459:
40045a:
...

31
49
5e
48
48
50
54
49
48
48
e8
f4
66

ed
89 d1
89
83

c7
c7
c7
b7
0f

xor
ebp,ebp
mov
r9,rdx
pop
rsi
e2
mov
rdx,rsp
e4 f0
and
rsp,0xfffffffffffffff0
push rax
push rsp
c0 c0 05 40 00 mov
r8,0x4005c0
c1 50 05 40 00 mov
rcx,0x400550
c7 26 05 40 00 mov  di,0x400526
ff ff ff
call 400410 
hlt
1f 44 00 00
nop
WORD PTR [rax+rax*1+0x0]

 0000000000400526 :

400526:
400527:
40052a:
40052e:
400531:
400535:
40053a:
40053f:
400544:
400545:
400546:
40054d:
...

55
48
48
89
48
bf
e8
b8
c9
c3
66
00

89
83
7d
89
d4
c1
00

e5
ec
fc
75
05
fe
00

10
f0
40 00
ff ff
00 00

2e 0f 1f 84 00 00
00 00

push
mov
sub
mov
mov
mov
call
mov
leave
ret
nop

rbp
rbp,rsp
rsp,0x10
DWORD PTR [rbp-0x4],edi
QWORD PTR [rbp-0x10],rsi
edi,0x4005d4
400400
eax,0x0

WORD PTR cs:[rax+rax*1+0x0]

В программе, написанной на C, всегда имеется функция main, с которой начинается выполнение программы. Но если вы посмотрите
на точку входа в двоичный файл, то обнаружите, что она указывает
не на main по адресу 0x400526 , а на адрес 0x400430, где начинается
функция _start .
А как же программа доходит до main? Внимательно присмотревшись, вы увидите, что _start содержит команду по адресу 0x40044d,
которая помещает адрес main в регистр rdi  – один из регистров,
которые используются для передачи параметров функции на платформе x64. Затем _start вызывает функцию __libc_start_main . Эта
Формат ELF

65

функция находится в секции .plt, т. е. является частью разделяемой
библиотеки (мы поговорим об этом подробнее в разделе 2.3.4).
Как явствует из самого имени, __libc_start_main наконец-то вызывает main и начинает выполнение пользовательского кода.

2.3.3 Секции .bss, .data и .rodata
Поскольку секции кода в общем случае не допускают записи, переменные хранятся в одной или нескольких специальных секциях, в которые можно записывать. Константные данные обычно также хранятся в отдельной секции, что улучшает организацию двоичного файла,
хотя иногда компиляторы все же помещают константы в секции кода.
(Современные версии gcc и clang, как правило, не смешивают код
и данные, но Visual Studio иногда грешит этим.) В главе 6 мы увидим,
что это может сильно затруднить дизассемблирование, потому что не
всегда ясно, какие байты соответствуют командам, а какие – данным.
Секция .rodata (read-only data – постоянные данные) предназначена для хранения константных значений и, следовательно, не допус­
кает записи. Начальные значения инициализированных переменных
хранятся в секции .data, которая допускает запись, потому что значения таких переменных могут изменяться во время выполнения.
Наконец, секция .bss предназначена для неинициализированных
переменных. Название «bss» означает «block started by symbol», историческиподразумевалось резервирование блоков памяти для (символических) переменных.
В отличие от секций .rodata и .data, имеющих тип SHT_PROGBITS,
секция .bss имеет тип SHT_NOBITS. Это объясняется тем, что .bss не
занимает ни одного байта в двоичном файле на диске, это просто директива, требующая выделить блок памяти необходимого размера
для неинициализированных переменных на этапе подготовки окружения для выполнения файла. Обычно переменные, находящиеся
в .bss, инициализируются нулями, а сама секция помечена как допускающая запись.

2.3.4 Позднее связывание и секции .plt, .got, .got.plt
В главе 1 мы говорили, что когда двоичный файл загружается в процесс для выполнения, динамический компоновщик производит последние перемещения. Например, он разрешает ссылки на функции,
находящиеся в разделяемых библиотеках, адреса которых на этапе
компиляции неизвестны. Я также упомянул, что на практике многие
перемещения выполняются не в момент загрузки двоичного файла,
а позже – при первом обращении к неразрешенному адресу. Это называется поздним связыванием.

Позднее связывание и PLT
Позднее связывание гарантирует, что динамический компоновщик
не будет без нужды тратить время на перемещения; они производят-

66

Глава 2

ся лишь тогда, когда это действительно необходимо. В Linux режим
позднего связывания подразумевается динамическим компоновщиком по умолчанию. Можно заставить компоновщик выполнять
все перемещения немедленно, экспортировав переменную среду LD_
BIND_NOW1, но обычно так не делают – разве что приложение требует
гарантий производительности, характерных для режима реального
времени.
Позднее связывание в ELF-файлах в Linux реализуется с по­мощью
двух специальных секций: Procedure Linkage Table (.plt) (таблица связей процедур) и Global Offset Table (.got) (таблица глобальных смещений). Хотя далее мы обсуждаем лишь позднее связывание, таблица
GOT применяется не только для этой цели. В двоичных ELF-файлах
часто имеется отдельная секция .got.plt, которая используется в сочетании с .plt в процессе позднего связывания. Секция .got.plt аналогична обычной секции .got, и для рассматриваемых здесь целей
можно считать, что это одно и то же (исторически так и было)2. На
рис. 2.2 показаны процесс позднего связывания и роль таблицы PLT
и GOT.
Код
Данные

Рис. 2.2. Вызов функции в разделяемой библиотеке с по­мощью PLT

1
2

В оболочке bash для этого служит команда export LD_BIND_NOW=1.
Разница в том, что .got.plt допускает запись во время выполнения, а .got
не допускает, если включена защита от атак путем перезаписи GOT, называемая RELRO (постоянные перемещения). В этом режиме записи таблицы
GOT, которые должны допускать модификацию во время выполнения, чтобы было возможно позднее связывание, помещаются в секцию .got.plt,
а остальные хранятся в постоянной секции .got.
Формат ELF

67

Как видно по рисунку и распечатке readelf в листинге 2.5, секция
.plt содержит исполняемый код, как и секция .text, тогда как .got.
plt – секция данных1. Таблица PLT содержит только заглушки в точ-

но определенном формате, цель которых – перенаправить вызовы
из секции .text на соответствующую библиотечную функцию. Для
изучения формата PLT рассмотрим результат дизассемблирования
секции .plt для нашего примера, показанный в листинге 2.7 (коды
операций в командах для краткости опущены).
Листинг 2.7. Результат дизассемблирования секции .plt
$ objdump -M intel --section .plt -d a.out
a.out: file format elf64-x86-64
Disassembly of section .plt:
 00000000004003f0 :

4003f0: push QWORD PTR [rip+0x200c12] # 601008
4003f6: jmp QWORD PTR [rip+0x200c14] # 601010
4003fc: nop DWORD PTR [rax+0x0]
 0000000000400400 :

400400: jmp QWORD PTR [rip+0x200c12] # 601018
400406: push 0x0
40040b: jmp 4003f0
 0000000000400410 :

400410: jmp QWORD PTR [rip+0x200c0a] # 601020
400416: push 0x1
40041b: jmp 4003f0

Опишем формат таблицы PLT. Вначале располагается заглушка по
умолчанию , о которой я скажу чуть ниже. Затем идет последовательность заглушек функций , по одной на каждую библиотечную функцию; все они устроены одинаково. Заметим также, что для
каждой заглушки функции значение, помещаемое в стек, на единицу
больше, чем для предыдущей . Это значение является идентификатором, а как оно используется, я расскажу ниже. Теперь рассмот­
рим, как хранящиеся в PLT заглушки позволяют вызвать функцию
в разделяемой библиотеке (см. рис. 2.2) и как это помогает осущест­
вить позднее связывание.
1

Быть может, вы обратили внимание на еще одну исполняемую секцию –

.plt.got. Это альтернативная таблица PLT, в которой хранятся постоянные
записи .got, а не записи .got.plt. Она используется, если на этапе компиляции был задан флаг –z компоновщика ld, означающий, что требуется

«раннее связывание». Эффект такой же, как при задании переменной среды LD_BIND_NOW=1, но благодаря информированию ld на этапе компиляции
мы можем поместить записи GOT в секцию .got для пущей безопасности
и использовать 8-байтовые записи .plt.got вместо более длинных 16-байтовых записей .plt.

68

Глава 2

Динамическое разрешение библиотечной функции с по­мощью PLT
Допустим, требуется вызвать функцию puts, находящуюся в известной библиотеке libc. Вместо того чтобы вызывать ее непосредственно (что невозможно по вышеупомянутым причинам), мы можем вызвать соответствующую заглушку из PLT, puts@plt (шаг на рис. 2.2).
Находящаяся в PLT заглушка начинается командой косвенного перехода по адресу, хранящемуся в секции .got.plt (шаг  на рис. 2.2).
Вначале, до позднего связывания, это просто адрес следующей команды в заглушке функции, т. е. команды push. Таким образом, команда косвенного перехода просто передает управление следующей
за ней команде (шаг  на рис. 2.2)! Согласимся, это не самый очевидный способ перейти к следующей команде, но, как скоро станет ясно,
тому есть веские причины.
Команда push помещает целое число (в данном случае 0x0) в стек.
Как уже было сказано, это число играет роль идентификатора для рассматриваемой PLT-заглушки. Затем следующая команда осуществляет переход к заглушке по умолчанию, общей для всех PLT-заглушек
(шаг 4 на рис. 2.2). Заглушка по умолчанию помещает в стек еще один
идентификатор (взятый из таблицы GOT), который определяет сам
исполняемый файл, и переходит (косвенно, снова через GOT) к динамическому компоновщику (шаг  на рис. 2.2).
Благодаря идентификаторам, помещенным в стек PLT-заглушками,
динамический компоновщик устанавливает, что должен разрешить
адрес puts и сделать это от имени главного исполняемого файла, загруженного в процесс. Этот последний момент важен, потому что
в один и тот же процесс может быть загружено несколько библиотек,
каждая со своими таблицами PLT и GOT. Затем динамический компоновщик ищет адрес функции puts и вставляет его в запись GOT, ассоциированную с puts@plt. После этого запись GOT указывает уже не на
PLT-заглушку, как было вначале, а на реальный адрес puts. На этом
процедура позднего связывания завершается.
Наконец, динамический компоновщик передает управление функции puts, чего мы и добивались. При последующих обращениях
к puts@plt запись GOT уже содержит правильный (модифицированный) адрес puts, поэтому команда перехода в начале PLT-заглушки
ведет прямо на puts без посредничества динамического компоновщика (шаг  на рисунке).

Зачем нужна GOT?
Сейчас вы, наверное, недоумеваете, зачем вообще нужна таблица
GOT. Не проще было бы вставить разрешенный адрес библиотечной
функции прямо в код PLT-заглушек? Одна из главных причин отказа от такого решения – безопасность. Если где-то в двоичном файле
имеется уязвимость (а в любом нетривиальном файле она обязательно имеется), то атакующему было бы уж слишком просто модифицировать код, если бы исполняемые секции, в частности .text и .plt, допускали запись. Но поскольку GOT – секция данных, в которую можно
Формат ELF

69

записывать, то имеет смысл добавить дополнительный уровень косвенности через GOT, чтобы не создавать допускающие запись секции
кода. Атакующий все же может изменить адреса в GOT, но эта модель
атаки дает гораздо меньше возможностей, чем внедрение произвольного кода.
Еще одна причина связана с разделяемостью кода, находящегося
в разделяемых библиотеках. Как было отмечено выше, современные
операционные системы экономят физическую память, осуществляя
разделение библиотечного кода между процессами, использующими
библиотеки. Вместо того чтобы загружать отдельную копию каждой
библиотеки во все использующие ее процессы, операционная система должна загрузить только одну копию. Но хотя физически имеется
лишь одна копия библиотеки, в каждом процессе она отображается на различные виртуальные адреса. А это значит, что разрешенные адреса библиотечных функций нельзя модифицировать прямо
в коде, потому что такой адрес будет правилен только в контексте
одного процесса, а все остальные перестанут работать. Модификация же адресов в GOT работает, потому что у каждого процесса своя
копия GOT.
Как вы уже, наверное, догадались, ссылки из кода на перемещаемые символы данных (например, переменные и константы, экспортируемые из разделяемых библиотек) тоже должны быть перенаправлены с по­мощью GOT, чтобы избежать модификации адресов данных
непосредственно в коде. Разница в том, что ссылки на данные проходят только через GOT без посредничества PLT. Это также проясняет
различие между секциями .got и .got.plt: .got предназначена для
ссылок на элементы данных, а .got.plt – для хранения разрешенных
адресов библиотечных функций, доступ к которым осуществляется
с по­мощью PLT.

2.3.5 Секции .rel.* и .rela.*
В распечатке readelf заголовков секций нашего демонстрационного
файла есть несколько секций с именами вида rela.*. Все они имеют
тип SHT_RELA, т. е. содержат информацию, используемую компоновщиком для выполнения перемещений. По существу, каждая секция
типа SHT_RELA представляет собой таблицу записей о перемещениях,
а в каждой записи хранится адрес, к которому необходимо применить перемещение, и указание о том, как найти конкретное значение,
которое надлежит вставить по этому адресу. В листинге 2.8 показано
содержимое секций перемещения для нашего примера. Видно, что
остались только динамические перемещения (осуществляемые динамическим компоновщиком), поскольку все статические перемещения, которые присутствовали в объектном файле, уже разрешены
на этапе статической компоновки. В реальном двоичном файле (в отличие от нашего простого примера), конечно, будет гораздо больше
динамических перемещений.

70

Глава 2

Листинг 2.8. Секции перемещений в демонстрационном двоичном файле
$ readelf --relocs a.out
Relocation section '.rela.dyn' at offset 0x380 contains 1 entries:
Offset
Info
Type
Sym. Value Sym. Name + Addend
 0000600ff8 000300000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
Relocation section '.rela.plt' at offset 0x398 contains 2 entries:
Offset
Info
Type
Sym. Value Sym. Name + Addend
 0000601018 000100000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0
 0000601020 000200000007 R_X86_64_JUMP_SLO 0000000000000000 __libc_start_main@GLIBC_2.2.5 + 0

Здесь имеется два типа перемещений: R_X86_64_GLOB_DAT и R_X86_
64_JUMP_SLO. Хотя на практике можно встретить гораздо больше ти-

пов, именно эти два самые распространенные и важные. У всех типов
перемещений есть одна общая черта: они задают смещение, к которому нужно применить перемещение. Детали вычисления значения,
подставляемого по этому смещению, зависят от типа перемещения и иногда довольно сложны. Полную информацию можно найти
в спецификации ELF, но для анализа типичного двоичного файла она
не нужна.
Для первого перемещения в листинге 2.8, типа R_X86_64_GLOB_DAT,
смещение находится в секции .got – это легко установить, сравнив
смещение с базовым адресом .got, показанным в распечатке readelf
в листинге 2.5. Вообще говоря, этот тип перемещения используется
для вычисления адреса символа данных и вставки его по соответст­
вующему смещению в .got.
Записи типа R_X86_64_JUMP_SLO называются слотами перехода ;
их смещения находятся в секции .got.plt и представляют позиции,
в которые можно вставить адреса библиотечных функций. Взглянув
на распечатку таблицы PLT нашего примера в листинге 2.7, легко понять, что каждый слот используется в одной PLT-заглушке, чтобы получить конечный адрес косвенного перехода. Адреса слотов перехода
(вычисленные по смещению относительно регистра rip) показаны
в правой части листинга 2.7, сразу после символа #.

2.3.6 Секция .dynamic
Секция .dynamic исполняет роль «дорожной карты» для операционной системы и динамического компоновщика во время загрузки
и подготовки двоичного ELF-файла к выполнению. Если вы забыли,
как устроен процесс загрузки, обратитесь к разделу 1.4.
Секция .dynamic содержит таблицу структур типа Elf64_Dyn (см.
файл /usr/include/elf.h), называемых также тегами. Есть разные типы
тегов, и с каждым из них ассоциировано значение. Для примера рассмотрим содержимое секции .dynamic нашего демонстрационного
двоичного файла, показанное в листинге 2.9.
Формат ELF

71

Листинг 2.9. Содержимое секции .dynamic
$ readelf --dynamic a.out
Dynamic section at offset 0xe28 contains
Tag
Type
 0x0000000000000001 (NEEDED)
0x000000000000000c (INIT)
0x000000000000000d (FINI)
0x0000000000000019 (INIT_ARRAY)
0x000000000000001b (INIT_ARRAYSZ)
0x000000000000001a (FINI_ARRAY)
0x000000000000001c (FINI_ARRAYSZ)
0x000000006ffffef5 (GNU_HASH)
0x0000000000000005 (STRTAB)
0x0000000000000006 (SYMTAB)
0x000000000000000a (STRSZ)
0x000000000000000b (SYMENT)
0x0000000000000015 (DEBUG)
0x0000000000000003 (PLTGOT)
0x0000000000000002 (PLTRELSZ)
0x0000000000000014 (PLTREL)
0x0000000000000017 (JMPREL)
0x0000000000000007 (RELA)
0x0000000000000008 (RELASZ)
0x0000000000000009 (RELAENT)
 0x000000006ffffffe (VERNEED)
 0x000000006fffffff (VERNEEDNUM)
0x000000006ffffff0 (VERSYM)
0x0000000000000000 (NULL)

24 entries:
Name/Value
Shared library: [libc.so.6]
0x4003c8
0x4005c4
0x600e10
8 (bytes)
0x600e18
8 (bytes)
0x400298
0x400318
0x4002b8
61 (bytes)
24 (bytes)
0x0
0x601000
48 (bytes)
RELA
0x400398
0x400380
24 (bytes)
24 (bytes)
0x400360
1
0x400356
0x0

Типы тегов в секции .dynamic показаны во втором столбце. Теги
типа DT_NEEDED информируют динамический компоновщик о зависимостях исполняемого файла. Например, в этом двоичном файле используется функция puts из разделяемой библиотеки libc.so.6 , поэтому ее нужно загрузить при выполнении файла. Теги DT_VERNEED 
и DT_VERNEEDNUM  сообщают начальный адрес и количество записей
в таблице версий зависимостей, где хранятся ожидаемые номера версий различных зависимостей исполняемого файла.
Помимо списка зависимостей, секция .dynamic содержит еще
указатели на другие важные данные, необходимые динамическому
компоновщику (например, на динамическую таблицу строк, на динамическую таблицу символов, на секцию .got.plt и на секцию динамических перемещений, которым соответствуют теги типа DT_STRTAB,
DT_SYMTAB, DT_PLTGOT и DT_RELA).

2.3.7 Секции .init_array и .fini_array
Секция .init_array содержит массив указателей на функции, используемые как конструкторы. Они вызываются по очереди при инициализации двоичного файла, еще до вызова main. Если упомянутая выше

72

Глава 2

секция .init содержит одну функцию, которая выполняет инициализацию, критически важную для запуска исполняемого файла, то
.init_array – секция данных, которая может содержать сколько угодно
указателей на функции, в т. ч. на конструкторы, написанные вами. Компилятор gcc позволяет пометить функции в исходных C-файлах как
конструкторы, снабдив их атрибутом __attribute__((constructor)).
В нашем демонстрационном примере секция .init_array содержит
всего одну запись. Это указатель на еще одну полезную функцию инициализации, frame_dummy, что подтверждает результат objdump, показанный в листинге 2.10.
Листинг 2.10. Содержимое секции .init_array
 $ objdump -d --section .init_array a.out

a.out: file format elf64-x86-64
Disassembly of section .init_array:
0000000000600e10 :
600e10:  00 05 40 00 00 00 00 00
..@.....
 $ objdump -d a.out | grep ''

0000000000400500 :

Первый вызов objdump показывает содержимое секции .init_array
. Как видим, имеется единственный указатель на функцию (выделен
серым цветом), содержащий байты 00 05 40 00 00 00 00 00 . Это не
что иное, как запись адреса 0x400500 в прямом порядке (чтобы ее получить, нужно изменить порядок байтов на противоположный и отбросить начальные нули). Второй вызов objdump показывает, что это
действительно начальный адрес функции frame_dummy .
Вы, наверное, уже догадались, что секция .fini_array аналогична
.init_array, только содержит указатели на деструкторы, а не на конструкторы. Указатели, хранящиеся в .init_array и .fini_array, легко
изменить, поэтому эти секции – подходящее место для добавления
в двоичный файл кода инициализации или очистки, модифицирующего его поведение. Отметим, что двоичные файлы, созданные старыми версиями gcc, могут содержать секции .ctors и .dtors вместо
.init_array и .fini_array.

2.3.8 Секции .shstrtab, .symtab, .strtab, .dynsym и .dynstr
При обсуждении заголовков секций мы упоминали, что секция
.shstrtab – это просто массив завершаемых нулем строк, который содержит имена всех секций в двоичном файле. Этот массив позволяет
таким инструментам, как readelf, находить имена секций.
Секция .symtab содержит таблицу символов – структур типа Elf64_
Sym, которые ассоциируют символическое имя с кодом или данными
Формат ELF

73

в другом месте двоичного файла, например с функцией или переменной. Сами строки, образующие символические имена, находятся
в секции .strtab. На эти строки указывают структуры Elf64_Sym. На
практике анализируемые вами двоичные файлы часто будут зачищены, т. е. таблицы .symtab и .strtab удалены.
Секции .dynsym и .dynstr аналогичны .symtab и .strtab, но содержат
символы и строки, необходимые для динамической, а не статической
компоновки. Поскольку информация, хранящаяся в этих секциях, необходима на этапе динамической компоновки, зачистить их нельзя.
Заметим, что статическая таблица символов имеет тип SHT_SYMTAB,
а динамическая – SHT_DYNSYM. Это позволяет инструментам типа strip
понять, какие таблицы символов безопасно удалить во время зачистки, а какие – нет.

2.4

Заголовки программы
Таблица заголовков программы дает сегментное представление двоичного файла в противоположность секционному представлению, которое дает таблица заголовков секций. Как я уже говорил, секционное представление двоичного ELF-файла предназначено только для
целей статической компоновки. Напротив, обсуждаемое ниже сегментное представление используется операционной системой и динамическим компоновщиком при загрузке ELF-файла в процесс для
выполнения; оно помогает находить релевантный код и данные и решать, что следует загрузить в виртуальную память.
Сегмент ELF охватывает нуль или более секций, т. е. объединяет их
в один блок. Поскольку сегменты служат целям выполнения, они необходимы только для исполняемых ELF-файлов, а для всех остальных,
например перемещаемых объектных файлов, не нужны. Таблица заголовков программы описывает сегменты с по­мощью структур типа
struct Elf64_Phdr. Каждый заголовок программы содержит поля, показанные в листинге 2.11.
Листинг 2.11. Определение типа Elf64_Phdr в файле /usr/include/elf.h
typedef struct {
uint32_t p_type; /*
uint32_t p_flags; /*
uint64_t p_offset; /*
uint64_t p_vaddr; /*
uint64_t p_paddr; /*
uint64_t p_filesz; /*
uint64_t p_memsz; /*
uint64_t p_align; /*
} Elf64_Phdr;

Тип сегмента */
Флаги сегмента */
Смещение сегмента относительно начала файла */
Виртуальный адрес сегмента */
Физический адрес сегмента */
Размер сегмента в файле */
Размер сегмента в памяти */
Выравнивание сегмента */

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

74

Глава 2

Листинг 2.12. Типичный заголовок программы, отображаемый readelf
$ readelf --wide --segments a.out
Elf file type is EXEC (Executable file)
Entry point 0x400430
There are 9 program headers, starting at offset 64
Program Headers:
Type
Offset VirtAddr
PhysAddr
FileSiz MemSiz Flg Align
PHDR
0x000040 0x0000000000400040 0x0000000000400040 0x0001f8 0x0001f8 R E 0x8
INTERP
0x000238 0x0000000000400238 0x0000000000400238 0x00001c 0x00001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD
0x000000 0x0000000000400000 0x0000000000400000 0x00070c 0x00070c R E 0x200000
LOAD 0x000e10 0x0000000000600e10 0x0000000000600e10 0x000228 0x000230 RW 0x200000
DYNAMIC 0x000e28 0x0000000000600e28 0x0000000000600e28 0x0001d0 0x0001d0 RW 0x8
NOTE 0x000254 0x0000000000400254 0x0000000000400254 0x000044 0x000044 R 0x4
GNU_EH_FRAME 0x0005e4 0x00000000004005e4 0x00000000004005e4 0x000034 0x000034 R 0x4
GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10
GNU_RELRO 0x000e10 0x0000000000600e10 0x0000000000600e10 0x0001f0 0x0001f0 R 0x1
 Section to Segment mapping:
Segment Sections...
00
01
.interp
02
.interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version
.gnu.version_r .rela.dyn .rela.plt .init .plt .plt.got .text .fini .rodata
.eh_frame_hdr .eh_frame
03
.init_array .fini_array .jcr .dynamic .got .got.plt .data .bss
04
.dynamic
05
.note.ABI-tag .note.gnu.build-id
06
.eh_frame_hdr
07
08
.init_array .fini_array .jcr .dynamic .got

Обратите внимание на отображение секций на сегменты в нижней
части распечатки readelf, которое недвусмысленно показывает, что
сегменты – это просто блоки секций . Это конкретное отображение
секций на сегменты типично для большинства двоичных ELF-файлов,
которые вам встретятся на практике. Далее в этом разделе мы обсудим поля заголовка программы, показанные в листинге 2.11.

2.4.1 Поле p_type
Поле p_type определяет тип сегмента. Из допустимых значений этого
поля отметим наиболее важные: PT_LOAD, PT_DYNAMIC и PT_INTERP.
Сегменты типа PT_LOAD предназначены для загрузки в память на
этапе подготовки процесса. Размер загружаемого блока и адрес его
загрузки описаны в остальных полях заголовка программы. Как показывает распечатка readelf, обычно имеется по крайней мере два
сегмента типа PT_LOAD: в одном собраны секции, не допускающие
запи­си, а в другом – допускающие.
Формат ELF

75

Сегмент PT_INTERP содержит секцию .interp, в которой хранится
имя интерпретатора, используемого для загрузки двоичного файла.
А сегмент PT_DYNAMIC содержит секцию .dynamic, которая сообщает интерпретатору, как разбирать и подготавливать двоичный файл
к выполнению. Упомянем также сегмент PT_PHDR, который включает
таблицу заголовков программы.

2.4.2 Поле p_flags
Флаги определяют права доступа к сегменту во время выполнения. Есть
три важных флага: PF_X, PF_W и PF_R. Флаг PF_X говорит, что сегмент исполняемый и задается для сегментов кода (readelf отображает его как
E, а не X в столбце Flg в листинге 2.12). Флаг PF_W означает, что сегмент
допускает запись и обычно задается только для сегментов данных, но
не для сегментов кода. Наконец, флаг PF_R означает, что сегмент допус­
кает чтение – обычная ситуация для сегментов кода и данных.

2.4.3 Поля p_offset, p_vaddr, p_paddr, p_filesz и p_memsz
Поля p_offset, p_vaddr и p_filesz в листинге 2.11 аналогичны полям sh_
offset, sh_addr и sh_size в заголовке секции. Они определяют смещение
в файле, с которого начинается сегмент, виртуальный адрес, по которому его следует загрузить, и размер сегмента в файле соответственно.
Для загружаемых сегментов поле p_vaddr должно быть равно p_offset
по модулю размера страницы (который обычно равен 4096 байт).
В некоторых системах поле p_paddr используется для хранения
адреса в физической памяти, по которому загружается сегмент. В современных операционных системах, в частности в Linux, это поле не
используется и равно нулю, потому что все двоичные файлы выполняются в виртуальной памяти.
На первый взгляд, не очевидно, почему есть два поля размера сегмента: в файле (p_filesz) и в памяти (p_memsz). Чтобы разобраться
в этом, вспомним, что некоторые секции всего лишь указывают на необходимость выделить сколько-то байтов в памяти, но не занимают
столько байтов в двоичном файле. Например, секция .bss содержит
данные, инициализированные нулями. Поскольку заведомо известно, что все данные в этой секции равны нулю, нет необходимости хранить их в двоичном файле. Однако при загрузке в память сегмента,
содержащего .bss, место для всех этих байтов должно быть выделено.
Поэтому p_memsz может оказаться больше p_filesz. В таком случае загрузчик добавит дополнительные байты в конец сегмента и инициализирует их нулями.

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

76

Глава 2

ние 0 или 1 означает, что выравнивание не требуется. Если значение
p_align отлично от 0 и 1, то оно должно быть степенью 2, а p_vaddr
должно быть равно p_offset по модулю p_align.

2.5

Резюме
В этой главе было рассказано обо всех тонкостях формата ELF. Я описал форматы заголовка исполняемого файла, таблиц заголовков секций и программы, а также содержимое секций. Это было непросто!
Но стоило того, потому что теперь, понимая, как устроены двоичные
ELF-файлы, вы располагаете фундаментом, на котором можно возводить здание двоичного анализа. В следующей главе мы рассмотрим
формат PE, применяемый для двоичных файлов в системах на основе
Window. Если вас интересует только анализ двоичных ELF-файлов, то
можете пропустить следующую главу и сразу перейти к главе 4.

Упражнения
1. Ручное исследование заголовка
Воспользуйтесь шестнадцатеричным средством просмотра, например xxd, для изучения байтов двоичного ELF-файла в шестнадцатеричном формате. Например, команда xxd /bin/ls | head
–n 30 позволяет просмотреть первые 30 строк байтов программы
/bin/ls. Сможете ли вы идентифицировать байты, относящие­ся
к заголовку ELF? Попробуйте найти все поля заголовка ELF в распечатке xxd и оцените, насколько хорошо вы понимаете их содержимое.
2. Секции и сегменты
Воспользуйтесь readelf для просмотра секций и сегментов какого-нибудь двоичного ELF-файла. Как секции отображаются на
сегменты? Сравните представление файла на диске с представлением его же в памяти. Каковы основные различия?
3. Двоичные файлы программ на C и C++
Воспользуйтесь readelf для дизассемблирования двух двоичных
файлов, созданных на основе исходного кода, написанного на C
и на C++. Какие вы видите различия?
4. Позднее связывание
Воспользуйтесь objdump для дизассемблирования секции PLT
двоичного ELF-файла. Какие записи таблицы GOT используются
в PLT-заглушках? Теперь просмотрите содержимое этих записей GOT (снова с по­мощью objdump) и проанализируйте их связь
с PLT.

3

ФОРМАТ PE:
КРАТКОЕ ВВЕДЕНИЕ
https://t.me/it_boooks

Т

еперь, когда вы знаете все о формате ELF, бросим беглый взгляд
еще на один популярный формат двоичных файлов: Portable
Executable (PE). Поскольку PE – основной формат двоичных
файлов в Windows, знакомство с ним полезно при анализе файлов на
этой платформе, а необходимость в этом часто возникает в процессе
анализа вредоносных программ.
PE – это модифицированная версия формата Common Object File
Format (COFF), который использовался и в системах на основе Unix,
пока ему на смену не пришел ELF. В силу этой исторической причины PE иногда называют PE/COFF. Путаницу усугубляет тот факт, что
64-разрядная версия PE называется PE32+. Поскольку PE32+ лишь немногим отличается от оригинального формата PE, я буду использовать общее название «PE».
В обзоре формата PE я остановлюсь на основных отличиях от ELF на
случай, если вы захотите работать на платформе Windows. Я не стану
излагать материал так же подробно, как для ELF, поскольку PE не является основной темой данной книги. Но отмечу, что PE (как и боль-

78

Глава 3

шинство других форматов двоичных файлов) имеет много общего
с ELF. Так что после освоения ELF вам будет гораздо проще изучать
новые форматы!
Я построю обсуждение вокруг рис. 3.1. Показанные на нем структуры данных определены в файле WinNT.h, включенном в состав комп­
лекта средств разработчика Microsoft Windows Software Developer Kit.

3.1

Заголовок MS-DOS и заглушка MS-DOS
Глядя на рис. 3.1, мы видим большое сходство с форматом ELF, но
также несколько существенных отличий. Одно из главных – наличие
заголовка MS-DOS. Да-да, речь идет именно о MS-DOS, старой операционной системе Microsoft, написанной в 1981 году! В чем же причина включения этого заголовка в предположительно современный
формат двоичных файлов? Вы, наверное, уже догадались – обратная
совместимость.
Когда PE только появился, имел место переходный период, в течение которого использовались как старые файлы MS-DOS, так и новые
PE-файлы. Чтобы переход был менее болезненным, каждый PE-файл
начинается заголовком MS-DOS, что позволяет интерпретировать
его также как двоичный файл MS-DOS, пусть и ограниченно. Главная
функция заголовка MS-DOS – описать, как загрузить и выполнить следующую непосредственно за ним заглушку MS-DOS. Заглушка обычно представляет собой небольшую программу для MS-DOS, которая
выполняется вместо основной программы, если двоичный PE-файл
запускается в MS-DOS. Как правило, эта программа просто печатает
строку вида «This program cannot be run in DOS mode» (Эта программа
не может быть выполнена в режиме DOS) и завершается. Но в принципе это могла бы быть полноценная версия программы для MS-DOS!
Заголовок MS-DOS начинается с магического значения – двух
ASCII-символов «MZ»1. Поэтому его иногда называют заголовком MZ.
С точки зрения этой главы, в заголовке MS-DOS важно еще только
одно поле, e_lfanew. Оно содержит смещение, с которого начинается
настоящий двоичный PE-файл. Таким образом, когда поддерживающий PE загрузчик открывает двоичный файл, он может прочитать
заголовок MS-DOS, пропустить заглушку и перейти сразу к началу заголовков PE.

3.2 Сигнатура PE, заголовок PE-файла
и факультативный заголовок PE
Заголовки PE аналогичны заголовку исполняемого файла в ELF с тем
отличием, что в PE «заголовок исполняемого файла» разбит на три
1

MZ означает «Mark Zbikowski» – автор оригинального формата исполняемых файлов в MS-DOS.
Формат PE: краткое введение

Powered by TCPDF (www.tcpdf.org)

79

"MZ"
К заголовкам PE

Заголовки
MS-DOS

Заголовок MS-DOS
Заглушка MS-DOS
Сигнатура PE

Заголовки
PE/COFF

"PE\0\0"

Заголовок PE-файла
Факультативный заголовок PE

Заголовки
секций

Заголовок секции

Секции
Секция

Важные секции:

Рис. 3.1. Общая структура двоичного файла в формате PE32+

80

Глава 3

час­ти: 32-разрядная сигнатура, заголовок PE-файла и факультативный заголовок PE. Заглянув в файл WinNT.h, вы найдете структуру
struct IMAGE_NT_HEADERS64, которая включает в себя все три части.
Можно было бы сказать, что IMAGE_NT_HEADERS64 и есть вариант заголовка исполняемого файла, принятый в PE. Однако на практике сигнатура, заголовок файла и факультативный заголовок рассматриваются как три разные сущности.
В следующих разделах мы обсудим все эти компоненты заголовка.
А чтобы увидеть их в действии, рассмотрим программу hello.exe, аналог программы compilation_example из главы 1. В листинге 3.1 приведена распечатка наиболее важных элементов заголовка и каталог
данных DataDirectory файла hello.exe. Что такое DataDirectory, я объясню чуть ниже.
Листинг 3.1. Пример распечатки заголовков PE и DataDirectory
$ objdump -x hello.exe
 file format pei-x86-64
hello.exe:
hello.exe
architecture: i386:x86-64, flags 0x0000012f:
HAS_RELOC, EXEC_P, HAS_LINENO, HAS_DEBUG, HAS_LOCALS, D_PAGED
start address 0x0000000140001324
 Characteristics 0x22

executable
large address aware
Time/Date

Thu Mar 30 14:27:09 2017
020b (PE32+)
MajorLinkerVersion
14
MinorLinkerVersion
10
SizeOfCode
00000e00
SizeOfInitializedData 00001c00
SizeOfUninitializedData 00000000
 AddressOfEntryPoint
0000000000001324
 BaseOfCode
0000000000001000
 ImageBase
0000000140000000
SectionAlignment
0000000000001000
FileAlignment
0000000000000200
MajorOSystemVersion
6
MinorOSystemVersion
0
MajorImageVersion
0
MinorImageVersion
0
MajorSubsystemVersion 6
MinorSubsystemVersion 0
Win32Version
00000000
SizeOfImage
00007000
SizeOfHeaders
00000400
CheckSum
00000000
Subsystem
00000003 (Windows CUI)
DllCharacteristics
00008160
 Magic

Формат PE: краткое введение

81

SizeOfStackReserve
SizeOfStackCommit
SizeOfHeapReserve
SizeOfHeapCommit
LoaderFlags
NumberOfRvaAndSizes

0000000000100000
0000000000001000
0000000000100000
0000000000001000
00000000
00000010

 The Data Directory

Entry
Entry
Entry
Entry
Entry
Entry
Entry
Entry
Entry
Entry
Entry
Entry
Entry
Entry
Entry
Entry

0
1
2
3
4
5
6
7
8
9
a
b
c
d
e
f

0000000000000000
0000000000002724
0000000000005000
0000000000004000
0000000000000000
0000000000006000
0000000000002220
0000000000000000
0000000000000000
0000000000000000
0000000000002290
0000000000000000
0000000000002000
0000000000000000
0000000000000000
0000000000000000

00000000
000000a0
000001e0
00000168
00000000
0000001c
00000070
00000000
00000000
00000000
000000a0
00000000
00000188
00000000
00000000
00000000

Export Directory [.edata]
Import Directory [parts of .idata]
Resource Directory [.rsrc]
Exception Directory [.pdata]
Security Directory
Base Relocation Directory [.reloc]
Debug Directory
Description Directory
Special Directory
Thread Storage Directory [.tls]
Load Configuration Directory
Bound Import Directory
Import Address Table Directory
Delay Import Directory
CLR Runtime Header
Reserved

...

3.2.1 Сигнатура PE
Сигнатура PE – это строка, содержащая ASCII-символы «PE», за которыми следуют два символа NULL. Она аналогична магическим байтам
в поле e_ident заголовка исполняемого ELF-файла.

3.2.2 Заголовок PE-файла
Заголовок файла описывает его общие свойства. Наиболее важны
поля Machine, NumberOfSections, SizeOfOptionalHeader и Characteris‑
tics. Оба поля, описывающих таблицу символов, объявлены нерекомендуемыми, и PE-файлы больше не должны использовать внедрен­
ные символы и отладочную информацию. Вместо этого символы
могут запи­сываться в отдельный отладочный файл.
Как и поле e_machine в ELF, поле Machine описывает архитектуру
машины, для которой предназначен PE-файл. В данном случае это
x86-64 (определена как константа 0x8664) . В поле NumberOfSections
хранится число записей в таблице заголовков секций, а в поле Size­
OfOptionalHeader размер в байтах факультативного заголовка, следующего за заголовком файла. Поле Characteristics содержит флаги,
описывающие такие вещи, как порядок байтов, является ли двоичный файл DLL-библиотекой и был ли он зачищен. Как показывает
распечатка objdump, флаги Characteristics в нашем демонстраци-

82

Глава 3

онном файле говорят, что это исполняемый файл, поддерживающий
большое адресное пространство .

3.2.3 Факультативный заголовок PE
Несмотря на название, факультативный заголовок PE вовсе не является факультативным для исполняемых файлов (хотя может отсутствовать в объектных файлах). На самом деле факультативный заголовок, скорее всего, встретится вам в любом исполняемом PE-файле,
с которым вам столкнетесь. Он содержит много полей, здесь я рассмотрю наиболее важные.
Прежде всего имеется 16-разрядное магическое значение, равное
0x020b для 64-разрядных PE-файлов . Есть также несколько полей,
описывающих основную и дополнительную версии компоновщика,
который был использован для создания двоичного файла, и минимальную версию операционной системы, необходимую для его выполнения. В поле ImageBase  находится базовый адрес, с которого
нужно загружать двоичный файл (двоичные PE-файлы, по построению, загружаются с определенного виртуального адреса). Другие указательные поля содержат относительные виртуальные адреса (relative
virtual address – RVA), которые следует прибавить к базовому, чтобы
получить абсолютный виртуальный адрес. Например, в поле BaseOf‑
Code  хранится начальный адрес секций кода в виде RVA. Поэтому
абсолютный виртуальный адрес начала секций можно найти, вычислив ImageBase+BaseOfCode. Как вы, наверное, догадались, поле Addres‑
sOfEntryPoint  содержит адрес точки входа в двоичный файл, тоже
в виде RVA.
Пожалуй, больше всего нуждается в объяснении массив DataDirec‑
tory  в факультативном заголовке. Он содержит записи типа struct
IMAGE_DATA_DIRECTORY, в которых хранится RVA и размер. Каждая
запись­массива описывает начальный RVA и размер какой-то важной
части двоичного файла; точная интерпретация записи зависит от ее
индекса в массиве. Наиболее важны запись с индексом 0, которая описывает начальный RVA и размер каталога экспорта (по сути дела, это
таблица экспортируемых функций), запись с индексом 1, описывающая каталог импорта (таблица импортируемых функций), и запись
с индексом 5, описывающая таблицу перемещений. О таблицах импорта и экспорта я расскажу ниже при обсуждении секций PE-файла.
Поле DataDirectory служит справочником для загрузчика, позволяя
ему быстро находить нужные данные, не просматривая таблицу заголовков секций от начала до конца.

3.3

Таблица заголовков секций
Во многих отношениях таблица заголовков секций PE-файла аналогична одноименной таблице в формате ELF. Это массив структур
типа IMAGE_SECTION_HEADER, каждая из которых описывает одну секФормат PE: краткое введение

83

цию и содержит ее размер в файле и в памяти (SizeOfRawData и Vir‑
tualSize), смещение от начала файла и виртуальный адрес (Pointer‑
ToRawData и VirtualAddress), информацию о перемещении и флаги
(Characteristics). Поле флагов, в частности, говорит, допускает ли
секция исполнение, чтение, запись или какую-либо комбинацию этих
операций. Вместо ссылки на таблицу строк, как в заголовках секций
ELF, в заголовках секций PE имя секции записывается в виде простого массива символов, метко названного Name. Поскольку длина этого
массива всего 8 байт, имена секций в PE не могут быть длиннее 8 символов.
В отличие от ELF, в формате PE нет явного различия между секциями и сегментами. В PE-файлах наиболее близким к сегментному представлению ELF является каталог данных DataDirectory,
который предоставляет загрузчику указатели на некоторые участки двоичного файла, необходимые для подготовки к выполнению.
Помимо этого, никакой отдельной таблицы заголовков программы
нет; таблица заголовков секций используется и для компоновки,
и для загрузки.

3.4

Секции
Многие секции PE-файлов являются прямыми аналогами секций ELF
и зачастую даже называются почти так же. В листинге 3.2 показан перечень секций файла hello.exe.
Листинг 3.2. Перечень секций демонстрационного PE-файла
$ objdump -x hello.exe
...
Sections:
Idx Name
0 .text

Size
00000db8
CONTENTS,
1 .rdata 00000d72
CONTENTS,
2 .data 00000200
CONTENTS,
3 .pdata 00000168
CONTENTS,
4 .rsrc 000001e0
CONTENTS,
5 .reloc 0000001c
CONTENTS,
...

VMA
LMA
0000000140001000 0000000140001000
ALLOC, LOAD, READONLY, CODE
0000000140002000 0000000140002000
ALLOC, LOAD, READONLY, DATA
0000000140003000 0000000140003000
ALLOC, LOAD, DATA
0000000140004000 0000000140004000
ALLOC, LOAD, READONLY, DATA
0000000140005000 0000000140005000
ALLOC, LOAD, READONLY, DATA
0000000140006000 0000000140006000
ALLOC, LOAD, READONLY, DATA

File off Algn
00000400 2**4
00001200 2**4
00002000 2**4
00002200 2**2
00002400 2**2
00002600 2**2

Мы видим, что имеется секция .text, содержащая код, секция
.rdata, содержащая постоянные данные (приблизительный эквива-

84

Глава 3

лент секции .rodata в ELF), и секция .data, содержащая данные, допускающие чтение и запись. Обычно имеется также секция .bss для
данных, инициализированных нулями, хотя в этом простом примере она отсутствует. Присутствует также секция .reloc, содержащая
информацию о перемещениях. Важно отметить, что компиляторы,
создающие PE-файлы, например Visual Studio, иногда помещают постоянные данные в секцию .text (смешивая их с кодом), а не в .rdata.
Это может создавать проблемы дизассемблеру, потому что открывает
возможность случайно интерпретировать константные данные как
команды.

3.4.1 Секции .edata и .idata
Самыми важными секциями PE, не имеющими прямых эквивалентов
в ELF, являются .edata и .idata, которые содержат таблицы экспортируемых и импортируемых функций соответственно. Записи каталогов экспорта и импорта в массиве DataDirectory ссылаются именно
на эти секции. Секция .idata описывает, какие символы (функции
и данные) двоичный файл импортирует из разделяемых библиотек,
или, в терминологии Windows, DLL. В секции .edata перечислены
символы, экспортируемые двоичным файлом, и их адреса. Таким образом, чтобы разрешить внешние ссылки, загрузчик должен найти
требуемые импортируемые символы в таблице экспорта той DLL, где
эти символы находятся.
На практике вы можете встретить ситуацию, когда отдельных секций .idata и .edata нет. Собственно говоря, их нет и в нашем примере двоичного файла в листинге 3.2! Если эти секции отсутствуют, то
обычно они объединены с .rdata, но содержимое и семантика остаются неизменными.
В процессе разрешения зависимостей загрузчик записывает разрешенные адреса в таблицу импортированных адресов (Import Address
Table – IAT). Как и глобальная таблица смещений в ELF, IAT представляет собой просто таблицу разрешенных указателей, в которой под
каждый указатель отведена одна запись. IAT также является частью
секции .idata и первоначально содержит указатели на имена или
числовые идентификаторы импортируемых символов. Затем динамический загрузчик заменяет эти указатели указателями на фактически импортированные функции или переменные. После этого вызов библиотечной функции реализуется как обращение к переходнику
(thunk) для этой функции, который всего лишь выполняет косвенный
переход через соответствующую ей запись в IAT. В листинге 3.3 показано, как выглядят реальные переходники.
Листинг 3.3. Примеры переходников в PE
$ objdump -M intel -d hello.exe
...
Формат PE: краткое введение

85

140001cd0:
140001cd6:
140001cdc:
140001ce2:
140001ce8:
...

ff
ff
ff
ff
ff

25
25
25
25
25

b2
a4
06
f8
ca

03
03
04
03
03

00
00
00
00
00

00
00
00
00
00

jmp
jmp
jmp
jmp
jmp

QWORD
QWORD
QWORD
QWORD
QWORD

PTR
PTR
PTR
PTR
PTR

[rip+0x3b2]
[rip+0x3a4]
[rip+0x406]
[rip+0x3f8]
[rip+0x3ca]

#
#
#
#
#

0x140002088
0x140002080
0x1400020e8
0x1400020e0
0x1400020b8

Часто переходники группируются вместе, как показано в листинге 3.3. Отметим, что конечные адреса переходов – хранятся в каталоге импорта, находящемся в секции .rdata, которая начинается по
адресу 0x140002000. Это элементы таблицы IAT.

3.4.2 Заполнение в секциях кода PE
При дизассемблировании PE-файлов вы можете встретить много
команд int3. Visual Studio генерирует эти команды в качестве заполнения (вместо команд nop, как делает gcc), чтобы выровнять
функции и блоки кода на определенную границу в памяти и тем
самым обеспечить эффективный доступ1. Обычно команда int3
используется отладчиками для установки точек останова; она заставляет программу передать управление отладчику или аварийно
завершиться, если отладчика нет. Использовать их для заполнения
безопасно, потому что такие заполняющие команды вообще не
должны выполняться.

3.5

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

1

86

Глава 3

Байты заполнения int3 иногда играют и другую роль, связанную с флагом компиляции /hotpatch в Visual Studio, который позволяет динамически модифицировать код во время выполнения. Если флаг /hotpatch
задан, то Visual Studio вставляет 5 команд int3 перед каждой функцией,
а также двухбайтовую «ничего не делающую» команду (обычно mov edi,
edi) в точке входа в функцию. Чтобы «срочно залатать» функцию, нужно перезаписать 5 байт int3 командой дальнего перехода на измененную версию функции, а затем перезаписать 2-байтовую пустую команду
командой относительного перехода на эту команду дальнего перехода.
Тем самым мы перенаправляем точку входа на модифицированную
функцию.

Упражнения
1. Ручное исследование заголовка
Как и в главе 2 для ELF-файлов, воспользуйтесь шестнадцатеричным средством просмотра, например xxd, для изучения байтов
двоичного PE-файла. Можете использовать ту же программу, что
и раньше, xxd program.exe | head –n 30, где program.exe – двоичный
PE-файл. Сможете ли вы идентифицировать байты, относящиеся
к заголовку PE, и разобрать все поля заголовка?
2. Представление на диске и в памяти
Воспользуйтесь утилитой readelf для просмотра содержимого
двоичного PE-файла. Затем сравните представление файла на
диске с представлением его же в памяти. Каковы основные различия?
3. Сравнение PE и ELF
Воспользуйтесь утилитой objdump для дизассемблирования двоичных файлов в форматах ELF и PE. Используются ли в них одинаковые конструкции кода и данных? Можете ли вы опознать
какие-нибудь закономерности в коде или данных,характерные
для компиляторов, создающих ELF- и PE-файлы?

4

СОЗДАНИЕ ДВОИЧНОГО
ЗАГРУЗЧИКА
С ПРИМЕНЕНИЕМ LIBBFD
https://t.me/it_boooks

И

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

88

Глава 4

4.1

Что такое libbfd?
Библиотека Binary File Descriptor1 (libbfd) предлагает единый интерфейс для чтения и разбора всех популярных двоичных форматов откомпилированных файлов для широкого спектра архитектур, в т. ч.
форматов ELF и PE для машин с архитектурой x86 и x86-64. Если при
построении своего загрузчика двоичных файлов вы будете опираться
на библиотеку libbfd, то сможете автоматически поддержать все эти
форматы, не отвлекаясь на реализацию зависящей от формата поддержки.
Библиотека BFD является частью проекта GNU и используется
многими приложениями, входящими в состав комплекта binutils,
в т. ч. objdump, readelf и gdb. Она предоставляет абстракции для всех
общих компонентов форматов двоичных файлов, например заголовков, описывающих целевую архитектуру и свойства файла, списков
секций, множеств перемещений, таблиц символов и т. д. В системе
Ubuntu libbfd является частью пакета binutils–dev.
Основной API библиотеки libbfd описан в файле /usr/include/bfd.h2.
К сожалению, работать с libbfd не очень просто, поэтому вместо долгих объяснений будем исследовать API в процессе реализации каркаса загрузки двоичных файлов.

4.2 Простой интерфейс загрузки двоичных
файлов
Прежде чем приступать к реализации загрузчика, спроектируем интерфейс, которым было бы легко пользоваться. В конце концов, идея
загрузчика в том и состоит, чтобы максимально упростить процесс
загрузки двоичных файлов для всех инструментов двоичного анализа, которые мы будем создавать в этой книге. Его предполагается использовать для статического анализа. Заметим, что он кардинально
отличается от динамического загрузчика, предоставляемого ОС, задача которого – загрузить файл в память, чтобы его можно было выполнить (см. главу 1).
Мы сделаем интерфейс загрузчика независимым от лежащей в его
основе реализации, т. е. не будем раскрывать никакие функции или
структуры данных из библиотеки libbfd. Для простоты оставим интерфейс максимально общим, раскрывая лишь те части двоичного
файла, которые будут часто использоваться в последующих главах.
1

2

Первоначально акроним BFD расшифровывался как «big fucking deal», что
было ответом на скептическое отношение Ричарда Столлмена к возможности реализации такой библиотеки. Расшифровка «binary file descriptor»
была предложена позже.
Если вы предпочитаете писать инструменты двоичного анализа на Python,
то можете найти неофициальную обертку интерфейса BFD на Python по
адресу https://github.com/Groundworkstech/pybfd/.
Создание двоичного загрузчика с применением libbfd

89

Например, мы опустим компоненты, связанные с перемещением, потому что в наших инструментах анализа двоичных файлов они обычно не нужны.
В листинге 4.1 показан заголовочный файл на C++, в котором описан базовый API загрузчика двоичных файлов. Заметим, что он находится в каталоге inc виртуальной машины, а не в каталоге chapter4
вместе с другим кодом к данной главе. Так сделано, потому что загрузчик используется во всех главах книги.
Листинг 4.1. inc/loader.h
#ifndef LOADER_H
#define LOADER_H
#include
#include
#include
class Binary;
class Section;
class Symbol;
 class Symbol {

public:
enum SymbolType {
SYM_TYPE_UKN = 0,
SYM_TYPE_FUNC = 1
};
Symbol() : type(SYM_TYPE_UKN), name(), addr(0) {}
SymbolType type;
std::string name;
uint64_t
addr;
};
 class Section {

public:
enum SectionType {
SEC_TYPE_NONE = 0,
SEC_TYPE_CODE = 1,
SEC_TYPE_DATA = 2
};
Section() : binary(NULL), type(SEC_TYPE_NONE),
vma(0), size(0), bytes(NULL) {}
bool contains(uint64_t addr) { return (addr >= vma) && (addr-vma < size); }
Binary
std::string
SectionType
uint64_t

90

Глава 4

*binary;
name;
type;
vma;

uint64_t
uint8_t

size;
*bytes;

};
 class Binary {

public:
enum BinaryType {
BIN_TYPE_AUTO = 0,
BIN_TYPE_ELF = 1,
BIN_TYPE_PE = 2
};
enum BinaryArch {
ARCH_NONE = 0,
ARCH_X86 = 1
};
Binary() : type(BIN_TYPE_AUTO), arch(ARCH_NONE), bits(0), entry(0) {}
Section *get_text_section()
{ for(auto &s : sections) if(s.name == ".text") return &s; return NULL; }
std::string
BinaryType
std::string
BinaryArch
std::string
unsigned
uint64_t
std::vector
std::vector

filename;
type;
type_str;
arch;
arch_str;
bits;
entry;
sections;
symbols;

};
 int load_binary(std::string &fname, Binary *bin, Binary::BinaryType type);
 void unload_binary(Binary *bin);

#endif /* LOADER_H */

Здесь API состоит из нескольких классов, представляющих различные компоненты двоичного файла. «Корневой» класс Binary  –
абстракция двоичного файла в целом. Среди прочего он содержит
векторы объектов Section и Symbol. Класс Section  и класс Symbol
представляют соответственно секции и символы, содержащиеся
в двоичном файле.
Основу API составляют всего две функции. Первая, load_binary ,
принимает имя двоичного файла, подлежащего загрузке (fname), указатель на объект Binary, который будет содержать загруженный файл
(bin), и тип файла (type). Она загружает указанный файл в объект bin
и возвращает 0, если загрузка прошла успешно, и отрицательное значение в противном случае. Вторая функция, unload_binary , принимает указатель на ранее загруженный объект Binary и очищает его.
Познакомившись с API загрузчика двоичных файлов, посмотрим,
как он реализуется. Начнем с реализации класса Binary.
Создание двоичного загрузчика с применением libbfd

91

4.2.1 Класс Binary
Класс Binary представляет абстракцию двоичного файла в целом. Он
содержит имя файла, его тип, архитектуру, разрядность, адрес точки
входа, а также списки секций и символов. Тип двоичного файла имеет
двоякое представление: член type содержит числовой идентификатор типа, а type_str – его строковое представление. Такое же двоякое
представление предлагается для архитектуры.
Допустимые типы двоичных файлов определены в перечислении
enum BinaryType; это ELF (BIN_TYPE_ELF) и PE (BIN_TYPE_PE). Имеется
также тип BIN_TYPE_AUTO, который можно передать функции load_bi‑
nary, чтобы она автоматически определила, принадлежит ли файл
типу ELF или PE. Аналогично допустимые архитектуры определены
в перечислении enum BinaryArch. Нас интересует только одна архитектура, ARCH_X86. Она включает как x86, так и x86-64, отличить одну от
другой позволяет член bits класса Binary, который равен 32 для x86
и 64 для x86-64.
Обычно для доступа к секциям и символам класса Binary следует
обойти векторы sections и symbols соответственно. Поскольку в ходе
двоичного анализа нас часто интересует код в секции .text, имеется
также вспомогательная функция get_text_section, которая находит
и возвращает эту секцию.

4.2.2 Класс Section
Секции представлены объектами типа Section. Класс Section – прос­
тая обертка основных свойств секции, включая имя, тип, начальный
адрес (член vma), размер (в байтах) и истинное количество байтов
в секции. Для удобства имеется также обратный указатель на объект
Binary, содержащий данный объект Section. Тип секции определяется перечислением enum SectionType и сообщает, содержит секция код
(SEC_TYPE_CODE) или данные (SEC_TYPE_DATA).
В процессе анализа часто бывает нужно узнать, какой секции принадлежит конкретная команда или элемент данных. Для этого в классе Section предусмотрена функция contains, которая принимает адрес
кода или данных и возвращает булево значение, показывающее, принадлежит ли адрес этой секции.

4.2.3 Класс Symbol
Как вы знаете, двоичные файлы содержат символы для различных
компонентов, включая локальные и глобальные переменные, функции, выражения перемещений, объекты и т. д. Чтобы не усложнять
программу, интерфейс загрузчика раскрывает только один вид символов: функции. Они особенно полезны, потому что позволяют легко реализовать инструменты двоичного анализа на уровне функций,
если символы функций доступны.

92

Глава 4

4.3

Реализация загрузчика двоичных файлов
Итак, интерфейс загрузчика двоичных файлов определен, так давайте же реализуем его! Тут-то нам и понадобится библиотека libbfd.
Поскольку полный код загрузчика довольно длинный, я разобью его
на части и рассмотрю их поочередно. В приведенном ниже коде опо­
знать функции из libbfd легко, потому что все они начинаются префиксом bfd_ (имеются также функции, оканчивающиеся суффиксом
_bfd, но они являются частью загрузчика).
Прежде всего нужно, конечно, включить все необходимые заголовочные файлы. Не буду упоминать стандартные заголовки C/C++,
включаемые в код загрузчика, потому что нам они не интересны
(если хотите, можете посмотреть, какие заголовки включаются, в исходном коде загрузчика на ВМ). Важно отметить, что любая программа, пользующаяся библиотекой libbfd, должна включать файл bfd.h,
как показано в листинге 4.2. Кроме того, ее необходимо скомпоновать
с библиотекой libbfd, задав флаг компоновщика –lbfd. Помимо bfd.h,
загрузчик включает заголовочный файл, содержащий интерфейс,
описанный в предыдущем разделе.
Листинг 4.2. inc/loader.cc
#include
#include "loader.h"

Далее рассмотрим две функции, являющиеся точками входа в нашу
библиотеку: load_binary и unload_binary. В листинге 4.3 приведена их
реализация.
Листинг 4.3. inc/loader.cc (продолжение)
int
 load_binary(std::string &fname, Binary *bin, Binary::BinaryType type)

{
return load_binary_bfd(fname, bin, type);
}
void
 unload_binary(Binary *bin)

{
size_t i;
Section *sec;
 for(i = 0; i < bin->sections.size(); i++) {

sec = &bin->sections[i];
if(sec->bytes) {
 free(sec->bytes);
}
}
}
Создание двоичного загрузчика с применением libbfd

93

Задача load_binary – разобрать двоичный файл, заданный своим
именем, и загрузить его в предоставленный объект Binary. Это довольно утомительный процесс, поэтому load_binary мудро перепоручает его другой функции, load_binary_bfd , которую мы обсудим
чуть ниже.
Сначала рассмотрим функцию unload_binary . Как часто бывает,
уничтожить объект Binary гораздо проще, чем создать. Для этого загрузчик должен освободить (с помощью функции free) всю память,
динамически выделенную для компонентов Binary. По счастью, таких
компонентов не так много: динамически (с помощью malloc) выделялась память только для члена bytes в каждом объекте Section. Поэтому unload_binary просто перебирает все объекты Section  и для
каждого из них освобождает память, отведенную под массив bytes.
Разобравшись, как работает выгрузка, давайте вплотную займемся
реализацией процесса загрузки с по­мощью libbfd.

4.3.1 Инициализация libbfd и открытие двоичного
файла
В предыдущем разделе я обещал показать функцию load_binary_bfd,
которая пользуется библиотекой libbfd для выполнения всей работы, связанной с загрузкой двоичного файла. Но прежде нужно сделать еще одну вещь. Чтобы разобрать и загрузить двоичный файл, его
сначала нужно открыть. Код открытия двоичного файла находится
в функции open_bfd, показанной в листинге 4.4.
Листинг 4.4. inc/loader.cc (продолжение)
static bfd*
open_bfd(std::string &fname)
{
static int bfd_inited = 0;
bfd *bfd_h;
if(!bfd_inited) {
 bfd_init();

bfd_inited = 1;
}

94



bfd_h = bfd_openr(fname.c_str(), NULL);
if(!bfd_h) {
fprintf(stderr, "failed to open binary '%s' (%s)\n",

fname.c_str(), bfd_errmsg(bfd_get_error()));
return NULL;
}



if(!bfd_check_format(bfd_h, bfd_object)) {
fprintf(stderr, "file '%s' does not look like an executable (%s)\n",

fname.c_str(), bfd_errmsg(bfd_get_error()));
return NULL;

Глава 4

}
/* Некоторые версии bfd_check_format пессимистически выставляют ошибку
* wrong_format еще до определения формата, а затем забывают сбросить
* ее, после того как формат определен. Во избежание проблем сбросим
* ошибку вручную.
*/
bfd_set_error(bfd_error_no_error);




if(bfd_get_flavour(bfd_h) == bfd_target_unknown_flavour) {
fprintf(stderr, "unrecognized format for binary '%s' (%s)\n",

fname.c_str(), bfd_errmsg(bfd_get_error()));
return NULL;
}
return bfd_h;
}

Функция open_bfd использует libbfd, чтобы определить свойства
двоичного файла, заданного своим именем (параметр fname), открыть его, а затем вернуть описатель файла. Но прежде чем работать
с libbfd, необходимо вызвать функцию bfd_init , которая инициализирует внутреннее состояние библиотеки (или, как написано в документации, «инициализирует магические внутренние структуры
данных»). Поскольку это нужно сделать всего один раз, в open_bfd
используется статическая переменная, которая запоминает, что ини­
циа­лизация уже произведена.
После инициализации libbfd можно вызвать функцию bfd_openr,
которая открывает двоичный файл по имени . Второй параметр
bfd_openr позволяет указать целевую архитектуру (тип двоичного
файла), но в данном случае я оставил его равным NULL, чтобы libbfd
определила тип файла самостоятельно. Функция bfd_openr возвращает указатель на описатель файла типа bfd; это корневая структура
данных libbfd, которую можно передавать всем остальным библиотечным функциям для выполнения операций с двоичным файлом.
В случае ошибки bfd_openr возвращает NULL.
Тип последней ошибки можно получить, вызвав функцию bfd_get_
error. Она возвращает объект типа bfd_error_type, который можно
сравнить с предопределенными идентификаторами ошибок, например bfd_error_no_memory или bfd_error_invalid_target, и решить, как
обрабатывать ошибку. Часто нужно завершить программу, напечатав
сообщение об ошибке. Для этого имеется функция bfd_errmsg, преобразующая объект bfd_error_type в строку с описанием ошибки, которую можно вывести на экран .
Получив описатель двоичного файла, нужно проверить формат
файла с по­мощью функции bfd_check_format . Эта функция принимает описатель bfd и значение типа bfd_format, которое может
быть равно bfd_object, bfd_archive или bfd_core. В данном случае мы
передаем bfd_object, чтобы убедиться, что открытый файл действительно является объектным, что в терминологии libbfd означает исСоздание двоичного загрузчика с применением libbfd

95

полняемый файл, перемещаемый объектный файл или разделяемую
библиотеку.
Удостоверившись, что имеет дело с bfd_object, загрузчик сбрасывает состояние ошибки libbfd в bfd_error_no_error . Это нужно, чтобы
обойти дефект, имеющийся в некоторых версиях libbfd, где еще до
определения формата устанавливается ошибка bfd_error_wrong_for‑
mat, и даже если впоследствии формат успешно определен, эта ошибка не сбрасывается.
Наконец, загрузчик проверяет, что известен «вид» двоичного файла, для чего вызывает функцию bfd_get_flavour , которая возвращает объект типа bfd_flavour, описывающий формат файла (ELF, PE
и т. д.). Возможны, в частности, следующие значения bfd_flavour: bfd_
target_msdos_flavour, bfd_target_coff_flavour и bfd_target_elf_fla‑
vour. Если формат двоичного файла неизвестен или возникла ошибка, то get_bfd_flavour возвращает bfd_target_unknown_flavour, тогда
open_bfd печатает сообщение об ошибке и возвращает NULL.
Если все проверки успешно пройдены, значит, мы открыли допус­
тимый двоичный файл и готовы приступить к загрузке его содержимого. Функция open_bfd возвращает открытый описатель bfd, который можно использовать для вызова других функций из библиотеки
libbfd, как будет показано в следующих листингах.

4.3.2 Разбор основных свойств двоичного файла
Познакомившись с кодом открытия двоичного файла, перейдем
к функции load_binary_bfd, показанной в листинге 4.5. Напомним,
что она занимается собственно разбором и загрузкой по поручению
функции load_binary и помещает все интересные детали двоичного
файла в объект Binary, на который указывает параметр bin.
Листинг 4.5. inc/loader.cc (продолжение)
static int
load_binary_bfd(std::string &fname, Binary *bin, Binary::BinaryType type)
{
int ret;
bfd *bfd_h;
const bfd_arch_info_type *bfd_info;
bfd_h = NULL;
 bfd_h = open_bfd(fname);

if(!bfd_h) {
goto fail;
}
bin->filename = std::string(fname);
= bfd_get_start_address(bfd_h);

 bin->entry

 bin->type_str = std::string(bfd_h->xvec->name);
 switch(bfd_h->xvec->flavour) {

96

Глава 4

case bfd_target_elf_flavour:
bin->type = Binary::BIN_TYPE_ELF;
break;
case bfd_target_coff_flavour:
bin->type = Binary::BIN_TYPE_PE;
break;
case bfd_target_unknown_flavour:
default:
fprintf(stderr, "unsupported binary type (%s)\n", bfd_h->xvec->name);
goto fail;
}
 bfd_info = bfd_get_arch_info(bfd_h);
 bin->arch_str = std::string(bfd_info->printable_name);
 switch(bfd_info->mach) {

case bfd_mach_i386_i386:
bin->arch = Binary::ARCH_X86;
bin->bits = 32;
break;
case bfd_mach_x86_64:
bin->arch = Binary::ARCH_X86;
bin->bits = 64;
break;
default:
fprintf(stderr, "unsupported architecture (%s)\n",

bfd_info->printable_name);
goto fail;
}
/* Мы пытаемся сделать все возможное для обработки символов (но их может
* вообще не быть)
* /
 load_symbols_bfd(bfd_h, bin);
load_dynsym_bfd(bfd_h, bin);
 if(load_sections_bfd(bfd_h, bin) < 0) goto fail;

ret = 0;
goto cleanup;
fail:
ret = -1;
cleanup:
 if(bfd_h) bfd_close(bfd_h);

return ret;
}

В самом начале функция load_binary_bfd вызывает реализованную
ранее функцию open_bfd, чтобы открыть двоичный файл, заданный
параметром fname, и получить его описатель bfd . Затем load_bina‑
ry_bfd устанавливает основные свойства объекта bin. Прежде всего
Создание двоичного загрузчика с применением libbfd

97

она копирует имя двоичного файла и с по­мощью libbfd находит и сохраняет адрес точки входа .
Для получения адреса точки входа в двоичный файл вызывается
функция bfd_get_start_address, которая просто возвращает значение
поля start_address объекта bfd. Начальный адрес имеет тип bfd_vma,
а это не что иное, как 64-разрядное целое число без знака.
Затем загрузчик собирает информацию о типе двоичного файла:
это ELF, PE или еще какой-то неподдерживаемый формат? Эти сведения можно найти в структуре bfd_target. Чтобы получить указатель
на эту структуру, нужно обратиться к полю xvec описателя bfd. Иными
словами, bfd_h–>xvec дает указатель на структуру bfd_target.
Среди прочего в этой структуре хранится строка, содержащая имя
типа. Загрузчик копирует эту строку в объект Binary . Затем он анализирует поле bfd_h–>xvec–>flavour в предложении switch и соответственно устанавливает тип объекта Binary . Загрузчик поддерживает только форматы ELF и PE, поэтому печатает сообщение об ошибке,
если bfd_h–>xvec–>flavour указывает на какой-то другой тип двоичного файла.
Теперь мы знаем, что двоичный файл имеет формат ELF или PE,
но архитектура еще неизвестна. Чтобы узнать ее, мы обращаемся
к функции bfd_get_arch_info . Она возвращает указатель на структуру данных bfd_arch_info_type, содержащую информацию об архитектуре файла. В частности, в ней имеется строка с описанием архитектуры, которую загрузчик копирует в объект Binary .
В структуре данных bfd_arch_info_type имеется также поле mach
 – целое число, служащее идентификатором архитектуры (в терминологии libbfd – machine). Оно позволяет организовать предложение
switch для обработки деталей, зависящих от архитектуры. Если mach
равно bfd_mach_i386_i386, то мы имеем 32-разрядную архитектуру
x86, и загрузчик устанавливает поля объекта Binary соответственно.
Если же mach равно bfd_mach_x86_64, то речь идет о 64-разрядной архитектуре x86-64. Все остальные архитектуры не поддерживаются
и приводят к ошибке.
Разобрав основные свойства – тип и архитектуру двоичного файла, мы можем приступить к настоящей работе: загрузке символов
и секций из двоичного файла. Понятно, что это будет не так просто,
как все сделанное до сих пор, поэтому загрузчик делегирует работу
специализированным функциям, описанным в следующем разделе. Функции для загрузки символов называются load_symbols_bfd
и load_dynsym_bfd . Они загружают символы соответственно из таб­
лицы статических и динамических символов. Имеется также функция
load_sections_bfd для загрузки секций двоичного файла . Ее мы обсудим в разделе 4.3.4.
После загрузки символов и секций вся интересующая нас информация записана в объект Binary, так что больше нам от библиотеки
libbfd ничего не нужно. А раз так, то описатель bfd можно закрыть
с по­мощью функции bfd_close . Описатель закрывается и в случае,
если в процессе загрузки файла произошла ошибка.

98

Глава 4

4.3.3 Загрузка символов
В листинге 4.6 приведен код функции load_symbols_bfd, которая загружает таблицу статических символов.
Листинг 4.6. inc/loader.cc (продолжение)
static int
load_symbols_bfd(bfd *bfd_h, Binary *bin)
{
int ret;
long n, nsyms, i;

asymbol **bfd_symtab;
Symbol *sym;
bfd_symtab = NULL;
 n = bfd_get_symtab_upper_bound(bfd_h);

if(n < 0) {
fprintf(stderr, "failed to read symtab (%s)\n",

bfd_errmsg(bfd_get_error()));
goto fail;
} else if(n) {
 bfd_symtab = (asymbol**)malloc(n);
if(!bfd_symtab) {
fprintf(stderr, "out of memory\n");
goto fail;
}
 nsyms = bfd_canonicalize_symtab(bfd_h, bfd_symtab);
if(nsyms < 0) {
fprintf(stderr, "failed to read symtab (%s)\n",

bfd_errmsg(bfd_get_error()));
goto fail;
}
 for(i = 0; i < nsyms; i++) {
 if(bfd_symtab[i]->flags & BSF_FUNCTION) {
bin->symbols.push_back(Symbol());
sym = &bin->symbols.back();
 sym->type = Symbol::SYM_TYPE_FUNC;
 sym->name = std::string(bfd_symtab[i]->name);
 sym->addr = bfd_asymbol_value(bfd_symtab[i]);
}
}
}
ret = 0;
goto cleanup;
fail:
ret = -1;
cleanup:
 if(bfd_symtab) free(bfd_symtab);

Создание двоичного загрузчика с применением libbfd

99

return ret;
}

В libbfd символы представлены типом asymbol (это псевдоним
типа struct bfd_symbol). В свою очередь, таблица символов имеет тип
asymbol**, т. е. это массив указателей на символы. Таким образом, задача функции load_symbols_bfd состоит в том, чтобы заполнить массив указателей на asymbol, объявленный в строке , а затем скопировать интересующую информацию в объект Binary.
На входе функция load_symbols_bfd получает описатель bfd и объект Binary, в котором сохраняет информацию о символах. Прежде
чем загружать указатели на символы, необходимо выделить память,
достаточную для их хранения. Функция bfd_get_symtab_upper_bound
 сообщает, сколько байтов нужно выделить для этой цели. В случае
ошибки количество байтов отрицательно. Если же оно равно нулю,
значит, в файле нет таблицы символов. В таком случае функции load_
symbols_bfd больше нечего делать, и она просто возвращает управление.
Если все хорошо и таблица символов не пуста, то мы выделяем память, достаточную для размещения всех указателей . Если malloc завершается успешно, то мы готовы попросить libbfd заполнить таблицу символов. Для этого вызывается функция bfd_canonicalize_symtab
, принимающая описатель bfd и таблицу символов, которую нужно
заполнить (значение типа asymbol**). libbfd заполняет таблицу и возвращает количество помещенных в нее символов (если оно отрицательно, значит, что-то пошло не так).
Имея заполненную таблицу символов, мы можем перебрать все
символы . Напомним, что наш загрузчик интересуется только символами функций. Поэтому для каждого символа нужно проверить,
установлен ли флаг BSF_FUNCTION, означающий, что это символ функции . Если это так, то мы резервируем место для объекта Symbol
(напомним, что это класс нашего загрузчика, предназначенный для
хранения символов) в объекте Binary, добавляя элемент в вектор, содержащий все загруженные символы. Вновь созданный Symbol помечается как символ функции , в него копируются имя символа 
и адрес символа . Чтобы получить значение символа функции, т. е.
ее начальный адрес, мы вызываем библиотечную функцию bfd_asym‑
bol_value.
После того как все интересующие нас символы скопированы
в объекты Symbol, у загрузчика отпадает необходимость в представлении, сформированном libbfd. Поэтому в самом конце load_sym‑
bols_bfd освобождает память, выделенную для символов libbfd .
Затем она возвращает управление, и процесс загрузки символов завершается.
Мы описали, как с по­мощью libbfd загружаются символы из таблицы статических символов. А как обстоит дело с таблицей динамических символов? К счастью, процесс почти такой же, о чем свидетельствует листинг 4.7.

100

Глава 4

Листинг 4.7. inc/loader.cc (продолжение)
static int
load_dynsym_bfd(bfd *bfd_h, Binary *bin)
{
int ret;
long n, nsyms, i;
 asymbol **bfd_dynsym;
Symbol *sym;
bfd_dynsym = NULL;
 n = bfd_get_dynamic_symtab_upper_bound(bfd_h);

if(n < 0) {
fprintf(stderr, "failed to read dynamic symtab (%s)\n",

bfd_errmsg(bfd_get_error()));
goto fail;
} else if(n) {
bfd_dynsym = (asymbol**)malloc(n);
if(!bfd_dynsym) {
fprintf(stderr, "out of memory\n");
goto fail;
}
 nsyms = bfd_canonicalize_dynamic_symtab(bfd_h, bfd_dynsym);
if(nsyms < 0) {
fprintf(stderr, "failed to read dynamic symtab (%s)\n",

bfd_errmsg(bfd_get_error()));
goto fail;
}
for(i = 0; i < nsyms; i++) {
if(bfd_dynsym[i]->flags & BSF_FUNCTION) {
bin->symbols.push_back(Symbol());
sym = &bin->symbols.back();
sym->type = Symbol::SYM_TYPE_FUNC;
sym->name = std::string(bfd_dynsym[i]->name);
sym->addr = bfd_asymbol_value(bfd_dynsym[i]);
}
}
}
ret = 0;
goto cleanup;
fail:
ret = -1;
cleanup:
if(bfd_dynsym) free(bfd_dynsym);
return ret;
}

Функция, показанная в листинге 4.7, загружает символы из таблицы динамических символов, в связи с чем называется load_dynsym_
Создание двоичного загрузчика с применением libbfd

101

bfd. Как видим, в libbfd используется одна и та же структура данных
(asymbol) для представления как статических, так и динамических
символов . От ранее показанной функции load_symbols_bfd она от-

личается в двух отношениях. Во-первых, чтобы узнать, сколько памяти выделять для указателей на символы, мы вызываем функцию bfd_
get_dynamic_symtab_upper_bound , а не bfd_get_symtab_upper_bound.
Во-вторых, для заполнения таблицы символов используется функция
bfd_canonicalize_dynamic_symtab  вместо bfd_canonicalize_symtab.
И это всё! В остальном процесс загрузки динамических символов такой же, как статических.

4.3.4 Загрузка секций
После загрузки символов осталось сделать всего один, но, пожалуй,
самый важный шаг: загрузить секции двоичного файла. В листинге 4.8 показано, как функция load_sections_bfd реализует эту функциональность.
Листинг 4.8. inc/loader.cc (продолжение)
static int
load_sections_bfd(bfd *bfd_h, Binary *bin)
{
int bfd_flags;
uint64_t vma, size;
const char *secname;
 asection* bfd_sec;
Section *sec;
Section::SectionType sectype;
 for(bfd_sec = bfd_h->sections; bfd_sec; bfd_sec = bfd_sec->next) {
 bfd_flags = bfd_get_section_flags(bfd_h, bfd_sec);

sectype = Section::SEC_TYPE_NONE;
 if(bfd_flags & SEC_CODE) {

sectype = Section::SEC_TYPE_CODE;
} else if(bfd_flags & SEC_DATA) {
sectype = Section::SEC_TYPE_DATA;
} else {
continue;
}
 vma
= bfd_section_vma(bfd_h, bfd_sec);
 size
= bfd_section_size(bfd_h, bfd_sec);
 secname = bfd_section_name(bfd_h, bfd_sec);

if(!secname) secname = "";
 bin->sections.push_back(Section());

sec = &bin->sections.back();
sec->binary = bin;
sec->name = std::string(secname);

102

Глава 4

sec->type = sectype;
sec->vma
= vma;
sec->size = size;
 sec->bytes = (uint8_t*)malloc(size);
if(!sec->bytes) {
fprintf(stderr, "out of memory\n");
return -1;
}
 if(!bfd_get_section_contents(bfd_h, bfd_sec, sec->bytes, 0, size)) {

fprintf(stderr, "failed to read section '%s' (%s)\n",

secname, bfd_errmsg(bfd_get_error()));
return -1;
}
}
return 0;
}

Для хранения секций в библиотеке libbfd используется тип asec‑
tion, являющийся псевдонимом типа struct bfd_section. На внутрен-

нем уровне множество секций представлено связанным списком
структур asection. Для обхода этого списка в загрузчике заведена
переменная типа asection* .
Чтобы обойти секции, мы начинаем с первой (на нее указывает
поле bfd_h‑>sections), а затем следуем по указателю next, имеющемуся в каждом объекте asection . Когда указатель next становится
равным NULL, мы достигли конца списка.
Для каждой секции проверяется, нужно ли вообще загружать ее.
Нас интересуют только секции кода и данных, поэтому загрузчик по
флагам секции смотрит, какого она типа. Флаги секции возвращает
функция bfd_get_section_flags , и нам интересны только секции,
для которых поднят флаг SEC_CODE или SEC_DATA . Если ни один из
этих флагов не поднят, то мы пропускаем секцию и переходим к следующей. Если же один из флагов поднят, то загрузчик устанавливает
тип секции в соответствующем объекте Section и продолжает ее загрузку.
Помимо типа секции, загрузчик копирует виртуальный адрес, размер (в байтах), имя и фактические байты каждой секции кода или
данных. Для получения базового адреса секции служит библиотечная функция bfd_section_vma . Аналогично для получения размера
и имени секции предназначены функции bfd_section_size  и bfd_
section_name  соответственно. Секция может не иметь имени, тогда
bfd_section_name вернет NULL.
Далее загрузчик копирует содержимое секции в объект Section.
Для этого в объекте Binary резервируется место для объекта Section
, и в него копируются все уже прочитанные поля. Затем в члене
bytes объекта Section выделяется достаточно памяти для хранения
всех байтов секции . Если malloc завершается успешно, то все байты
секции копируются из объекта секции libbfd в Section, для чего выСоздание двоичного загрузчика с применением libbfd

103

зывается функция bfd_get_section_contents , которая принимает
описатель bfd, указатель на объект asection, массив, в который нужно
скопировать содержимое секции, смещение, с которого начинать копирование, и число подлежащих копированию байтов. Чтобы скопировать все байты, следует задать начальное смещение 0, а в качестве
числа копируемых байтов указать размер секции. Если копирование
завершилось успешно, то bfd_get_section_contents возвращает true,
в противном случае false. Если все прошло без ошибок, то процесс
загрузки на этом завершается!

4.4

Тестирование загрузчика двоичных файлов
Напишем простую программу для тестирования нашего загрузчика
двоичных файлов. Она принимает имя файла, загружает его и отобра­
жает сведения о том, что загрузила. Код тестовой программы приведен в листинге 4.9.
Листинг 4.9. loader_demo.cc
#include
#include
#include
#include




"../inc/loader.h"

int
main(int argc, char *argv[])
{
size_t i;
Binary bin;
Section *sec;
Symbol *sym;
std::string fname;
if(argc < 2) {
printf("Usage: %s \n", argv[0]);
return 1;
}
fname.assign(argv[1]);
 if(load_binary(fname, &bin, Binary::BIN_TYPE_AUTO) < 0) {

return 1;
}
 printf("loaded binary '%s' %s/%s (%u bits) entry@0x%016jx\n",

bin.filename.c_str(),
bin.type_str.c_str(), bin.arch_str.c_str(),
bin.bits, bin.entry);
 for(i = 0; i < bin.sections.size(); i++) {

sec = &bin.sections[i];
printf(" 0x%016jx %-8ju %-20s %s\n",

104

Глава 4



}

sec->vma, sec->size, sec->name.c_str(),
sec->type == Section::SEC_TYPE_CODE ? "CODE" : "DATA");

 if(bin.symbols.size() > 0) {

printf("scanned symbol tables\n");
for(i = 0; i < bin.symbols.size(); i++) {
sym = &bin.symbols[i];
printf(" %-40s 0x%016jx %s\n",

sym->name.c_str(), sym->addr,

(sym->type & Symbol::SYM_TYPE_FUNC) ? "FUNC" : "");
}
}
 unload_binary(&bin);

return 0;
}

Эта тестовая программа загружает двоичный файл, имя которого указано в первом аргументе , а затем отображает информацию
о нем, в частности имя файла, тип, архитектуру и адрес точки входа
. После этого печатается базовый адрес, размер, имя и тип каждой
секции  и, наконец, все найденные символы . Затем файл выгружается  и программа завершается. Попробуйте запустить программу loader_demo на своей ВМ! Должна получиться примерно такая распечатка, как в листинге 4.10.
Листинг 4.10. Пример вывода тестовой программы для загрузчика
$ loader_demo /bin/ls
loaded binary '/bin/ls' elf64-x86-64/i386:x86-64 (64 bits) entry@0x4049a0
0x0000000000400238 28
.interp
DATA
0x0000000000400254 32
.note.ABI-tag
DATA
0x0000000000400274 36
.note.gnu.build-id DATA
0x0000000000400298 192
.gnu.hash
DATA
0x0000000000400358 3288
.dynsym
DATA
0x0000000000401030 1500
.dynstr
DATA
0x000000000040160c 274
.gnu.version
DATA
0x0000000000401720 112
.gnu.version_r
DATA
0x0000000000401790 168
.rela.dyn
DATA
0x0000000000401838 2688
.rela.plt
DATA
0x00000000004022b8 26
.init
CODE
0x00000000004022e0 1808
.plt
CODE
0x00000000004029f0 8
.plt.got
CODE
0x0000000000402a00 70281
.text
CODE
0x0000000000413c8c 9
.fini
CODE
0x0000000000413ca0 27060
.rodata
DATA
0x000000000041a654 2060
.eh_frame_hdr
DATA
0x000000000041ae60 11396
.eh_frame
DATA
0x000000000061de00 8
.init_array
DATA
Создание двоичного загрузчика с применением libbfd

105

0x000000000061de08 8
0x000000000061de10 8
0x000000000061de18 480
0x000000000061dff8 8
0x000000000061e000 920
0x000000000061e3a0 608
scanned symbol tables
...
_fini
_init
free
_obstack_memory_used
_obstack_begin
_obstack_free
localtime_r
_obstack_allocated_p
_obstack_begin_1
_obstack_newchunk
malloc

4.5

.fini_array
.jcr
.dynamic
.got
.got.plt
.data

0x0000000000413c8c
0x00000000004022b8
0x0000000000402340
0x0000000000412960
0x0000000000412780
0x00000000004128f0
0x00000000004023a0
0x00000000004128c0
0x00000000004127a0
0x00000000004127c0
0x0000000000402790

DATA
DATA
DATA
DATA
DATA
DATA

FUNC
FUNC
FUNC
FUNC
FUNC
FUNC
FUNC
FUNC
FUNC
FUNC
FUNC

Резюме
В главах 1–3 мы узнали все о форматах двоичных файлов. В этой главе
мы научились загружать двоичные файлы для последующего анализа. По ходу дела мы узнали о библиотеке libbfd, которая часто применяется для загрузки двоичных файлов. Имея работающий загрузчик,
мы можем перейти к методам анализа двоичных файлов. В части II
мы познакомимся с фундаментальными приемами такого анализа,
а в части III воспользуемся загрузчиком для реализации собственных
инструментов анализа.

Упражнения
1. Распечатка содержимого секции
Для краткости приведенная выше версия программы loader_
demo не отображает содержимое секции. Добавьте возможность
передавать в командной строке имя секции и выведите ее содержимое на экран в шестнадцатеричном формате.
2. Переопределение слабых символов
Символ называется слабым, если его значение может быть переопределено другим, неслабым символом. В текущей версии загрузчик двоичных файлов не учитывает эту возможность и просто
сохраняет все символы. Измените загрузчик следующим образом: если слабый символ впоследствии переопределен другим,
то должна сохраняться только последняя версия. Откройте файл
/usr/include/bfd.h и посмотрите, какие флаги следует проверять.

106

Глава 4

3. Распечатка символов данных
Дополните загрузчик и программу loader_demo, так чтобы они
могли обрабатывать локальные и глобальные символы данных,
а не только символы функций. Необходимо будет добавить код
обработки символов данных, определить новый тип Symbol‑
Type в классе Symbol и включить в программу loader_demo код
распечатки символов данных. Тестируйте изменения на незачищенном двоичном файле, чтобы какие-то символы данных
присутствовали. Отметим, что в терминологии, относящейся
к символам, элементы данных называются объектами. Если вы
не уверены в правильности вывода, воспользуйтесь readelf для
проверки.

ЧАСТЬ II
ОСНОВЫ АНАЛИЗА
ДВОИЧНЫХ ФАЙЛОВ

5

ОСНОВЫ АНАЛИЗА
ДВОИЧНЫХ ФАЙЛОВ
В LINUX

Д

аже при анализе самых сложных двоичных файлов можно достичь поразительных результатов, применяя базовые инструменты в правильном сочетании. Так можно сэкономить много
часов, которые иначе были бы потрачены на самостоятельную реализацию эквивалентной функциональности. В этой главе мы познакомимся с основными инструментами двоичного анализа в Linux.
Вместо того чтобы просто привести перечень инструментов и объяснить, что они делают, я воспользуюсь для иллюстрации задачей из
конкурса «Захвати флаг» (Capture the Flag – CTF). В области компьютерной безопасности и хакинга задачи CTF часто играют роль состязания, в котором цель состоит в том, чтобы проанализировать или
эксплуатировать уязвимость в предложенном двоичном файле (или
работающем процессе либо сервере) и в конечном итоге захватить
скрытый в нем флаг. Флаг обычно представляет собой шестнадцатеричную строку, которая служит доказательством того, что задача решена, и, возможно, ключом к получению следующих задач.
Основы анализа двоичных файлов в Linux

109

В этом CTF мы начнем с таинственного файла payload, который находится на ВМ в каталоге этой главы. Цель – извлечь скрытый флаг из payload. В процессе анализа payload и поиска флага мы научимся применять различные базовые инструменты двоичного анализа, имеющиеся
практически в любой системе на основе Linux (многие из них входят
в состав комплекта GNU coreutils или binutils). Следуйте за мной.
У большинства инструментов, о которых мы будем говорить, имеется ряд полезных параметров, но их слишком много, чтобы описать
в одной главе. Поэтому рекомендую прочитать страницы руководства
для этих инструментов, выполнив команду man tool на ВМ. В конце
главы мы воспользуемся найденным флагом, чтобы получить следующую задачу, которую вам предстоит решить самостоятельно.

5.1 Разрешение кризиса самоопределения
с помощью file
Поскольку мы не получили абсолютно никаких подсказок о содержимом файла payload, то и не знаем, что с ним делать. Столкнувшись
с такой ситуацией (например, в процессе обратной разработки или
компьютерно-технической экспертизы), в качестве первого шага следует выяснить все возможное о типе файла и его содержимом. Для
этого предназначена утилита file; она принимает несколько имен
файлов и для каждого сообщает его тип. В главе 2 мы использовали
file для определения типа ELF-файла.
Утилиту file не обмануть поддельным расширением имени. Она
ищет в файле другие идентифицирующие признаки, например магические байты типа последовательности 0x7f ELF в начале ELF-файлов.
И в данном случае это очень хорошо, потому что у файла payload вообще нет расширения. Вот что file сообщает о payload:
$ file payload
payload: ASCII text

Как видим, файл payload содержит ASCII-текст. Чтобы исследовать
его, мы можем воспользоваться утилитой head, которая выводит первые несколько строк (по умолчанию 10) текстового файла на stdout.
Существует похожая утилита tail, которая показывает последние
строки файла. Вот что выводит head:
$ head payload
H4sIAKiT61gAA+xaD3RTVZq/Sf9TSKL8aflnn56ioNJJSiktDpqUlL5o0UpbYEVI0zRtI2naSV5K
YV0HTig21jqojH9mnRV35syZPWd35ZzZ00XHxWBHYJydXf4ckRldZRUxBRzxz2CFQvb77ru3ee81
AZdZZ92z+XrS733fu993v/v/vnt/bqmVfNNkBlq0cCFyy6KFZiUHKi1buMhMLAvMi0oXWSzlZYtA
v2hRWRkRzN94ZEChoOQKCAJp8fdcNt2V3v8fpe9X1y7T63Rjsp7cTlCKGq1UtjL9yPUJGyupIHnw
/zoym2SDnKVIZyVWFR9hrjnPZeky4JcJvwq9LFforSo+i6XjXKfgWaoSWFX8mclExQkRxuww1uOz
Ze3x2U0qfpDFcUyvttMzuxFmN8LSc054er26fJns18D0DaxcnNtZOrsiPVLdh1ILPudey/xda1Xx
MpauTGN3L9hlk69PJsZXsPxS1YvA4uect8N3fN7m8rLv+Frm+7z+UM/8nory+eVlJcHOklIak4ml

110

Глава 5

rbm7kabn9SiwmKcQuQ/g+3n/OJj/byfuqjv09uKVj8889O6TvxXM+G4qSbRbX1TQCZnWPNQVwG86
/F7+4IkHl1a/eebY91bPemngU8OpI58YNjrWD16u3P3wuzaJ3kh4i6vpuhT6g7rkfs6k0DtS6P8l
hf6NFPocfXL9yRTpS0ny+NtJ8vR3p0hfl8J/bgr9Vyn0b6bQkxTl+ixF+p+m0N+qx743k+wWmlT6

Текст, очевидно, нечитаемый. Внимательно присмотревшись, мы
заметим, что он содержит только буквы, цифры и знаки + и / и состоит из строк одинаковой длины. Видя такой файл, обычно можно без
опас­ки предположить, что он представлен в кодировке Base64.
Кодировка Base64 широко применяется для представления двоичных данных в виде ASCII-текста. В частности, она используется
в электронной почте и в вебе, чтобы гарантировать, что двоичные
данные, передаваемые по сети, не будут случайно искажены службами, умеющими обрабатывать только текст. На наше счастье, в состав
Linux-систем входит инструмент base64 (обычно он является частью
пакета GNU coreutils), который умеет кодировать и декодировать
Base64. По умолчанию base64 кодирует все переданные ей файлы или
поток stdin. Но если задать флаг –d, то base64 будет декодировать файлы. Давайте декодируем payload и посмотрим, что получится.
$ base64 -d payload > decoded_payload

Эта команда декодирует payload и сохраняет результат в файле de‑
coded_payload. Теперь применим утилиту file к декодированному

файлу.

$ file decoded_payload
decoded_payload: gzip compressed data, last modified: Tue Oct 22 15:46:43 2019, from Unix

Уже что-то! Оказывается, что под слоем Base64 скрывался архив,
сжатый программой gzip. Тут у нас есть возможность продемонстрировать еще одно полезное свойство file: умение заглядывать внутрь
сжатых файлов. Задав флаг –z, мы сможем узнать, что находится внут­
ри архива, не распаковывая его. И вот что мы увидим:
$ file -z decoded_payload
decoded_payload: POSIX tar archive (GNU) (gzip compressed data, last modified:
Tue Oct 22 19:08:12 2019, from Unix)

Видно, что мы имеем дело с несколькими уровнями: на внешнем
для сжатия использовалась gzip, а на внутреннем находится tarархив, который обычно содержит несколько файлов. Чтобы узнать,
какие файлы упакованы в архив, мы воспользуемся программой tar,
дабы распаковать файл decoded_payload и извлечь его содержимое:
$ tar xvzf decoded_payload
ctf
67b8601
Основы анализа двоичных файлов в Linux

111

Как видим, в архиве было два файла: ctf и 67b8601. Снова воспользуемся утилитой file, чтобы узнать, с чем имеем дело.
$ file ctf
ctf: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32,
BuildID[sha1]=29aeb60bcee44b50d1db3a56911bd1de93cd2030, stripped
Basic Binary Analysis

Первый файл, ctf , – это динамически скомпонованный 64-разрядный зачищенный исполняемый ELF-файл, а второй, 67b8601, – раст­
ровое (BMP) изображение размером 512×512 пикселей. В последнем
можно убедиться с по­мощью все той же утилиты file:
$ file 67b8601
67b8601: PC bitmap, Windows 3.x format, 512 x 512 x 24

Изображение в этом BMP-файле выглядит как черный квадрат
(рис. 5.1a). Но, внимательно присмотревшись, можно заметить нерегулярно окрашенные пиксели в нижней части. На рис. 5.1b показано
увеличенное изображение этих пикселей.

(a) Все изображение

(b) Увеличенная нижняя часть изображения с цветными пикселями

Рис. 5.1. Извлеченный BMP-файл 67b8601

112

Глава 5

Прежде чем выяснять, что все это значит, приглядимся к ELF-фай­
лу ctf.

5.2 Использование ldd для изучения
зависимостей
Запускать неизвестные двоичные файлы категорически не рекомендуется, но мы работаем на виртуальной машине, поэтому попробуем
выполнить извлеченный файл ctf. Впрочем, далеко мы таким образом
не уйдем.
$ ./ctf
./ctf: error while loading shared libraries: lib5ae9b7f.so:
cannot open shared object file: No such file or directory

Еще до выполнения кода приложения динамический компоновщик сообщает об отсутствующей библиотеке lib5ae9b7f.so. Имя какоето странное – обычно таких библиотек в системе не бывает. Но преж­
де чем искать ее, имеет смысл проверить, нет ли в ctf еще каких-то
неразрешенных зависимостей.
В состав Linux-систем входит программа ldd, позволяющая узнать, от каких разделяемых объектов зависит двоичный файл и где
эти объекты находятся в вашей системе (если они в ней вообще
имеются). Можно даже задать флаг –v, который покажет, каких версий библиотек ожидает двоичный файл, что полезно для отладки.
На странице руководства по ldd сказано, что эта программа может
запускать двоичный файл для определения зависимостей, поэтому ее небезопасно использовать для неизвестных двоичных файлов, и лучше для этой цели работать на виртуальной машине или
в каком-то другом изолированном окружении. Вот что ldd выводит
для двоичного файла ctf:
$ ldd ctf
linux-vdso.so.1 => (0x00007fff6edd4000)
lib5ae9b7f.so => not found
libstdc++.so.6 => /usr/lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f67c2cbe000)
libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f67c2aa7000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f67c26de000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f67c23d5000)
/lib64/ld-linux-x86-64.so.2 (0x0000561e62fe5000)

По счастью, никаких неразрешенных ссылок, кроме уже известной
нам библиотеки lib5ae9b7f.so, нет. Теперь можно сосредоточиться на
выяснении того, что это за таинственная библиотека и как ее получить, чтобы захватить флаг.
Из имени библиотеки ясно, что ни в каком стандартном репозитории ее не найти, значит, она должна быть где-то в предоставленных
Основы анализа двоичных файлов в Linux

113

нам файлах. Вспомним, что двоичные файлы и библиотеки в формате
ELF начинаются магической последовательностью 0x7f ELF. Вот и поищем эту строку: если библиотека не зашифрована, то таким образом
мы должны будем найти заголовок ELF. Попробуем просто воспользоваться grep.
$ grep 'ELF' *
Binary file 67b8601 matches
Binary file ctf matches

Как и следовало ожидать, строка 'ELF' встречается в ctf, что и неудивительно, поскольку, как нам уже известно, это двоичный ELFфайл. Но она встречается также в файле 67b8601, который, на первый взгляд, представляет собой ничем не примечательное растровое
изобра­жение. А не может ли разделяемая библиотека скрываться
внутри пикселей? Это объяснило бы появление странных цветных
пикселей на рис. 5.1b! Исследуем содержимое файла 67b8601 более
пристально.

Быстрое нахождение кодов ASCII
При интерпретации байтов как кодов ASCII часто возникает необходимость в таблице, которая дает соответствие между значениями байтов и ASCII-символами. Для доступа к такой таблице
можно воспользоваться специальной страницей руководства man
ascii. Ниже приведен фрагмент распечатки, выдаваемой командой man ascii:
Oct Dec Hex Char
Oct Dec
Hex Char
______________________________________________________________________
000 0
00 NUL '\0' (null character) 100 64
40 @
001 1
01 SOH (start of heading)
101 65
41 A
002 2
02 STX (start of text)
102 66
42 B
003 3
03 ETX (end of text)
103 67
43 C
004 4
04 EOT (end of transmission) 104 68
44 D
005 5
05 ENQ (enquiry)
105 69
45 E
006 6
06 ACK (acknowledge)
106 70
46 F
007 7
07 BEL '\a' (bell)
107 71
47 G
...

Как видим, с ее помощью легко найти ASCII-символ поего восьмеричному, десятичному или шестнадцатеричному коду. Намного быст­рее, чем гуглить таблицу ASCII!

114

Глава 5

5.3 Просмотр содержимого файла
с помощью xxd
Чтобы точно выяснить, что находится в файле, не полагаясь на стандартные допущения, мы должны проанализировать файл на байтовом уровне. Для этого можно вывести биты и байты на экран в любой
системе счисления. Например, если выбрать двоичную систему, то
будут показаны все нули и единицы. Но анализировать содержимое
в таком виде утомительно, поэтому лучше использовать шестнадцатеричную систему, в которой в качестве цифр используются символы
0– 9 (с обычной интерпретацией) и a–f (где a представляет значение
10, а f – значение 15). Поскольку любой байт может принимать 256 =
16 × 16 значений, он представляется ровно двумя шестнадцатеричными цифрами, так что эта система удобна для компактного отображения байтов.
Чтобы отобразить байты файла в шестнадцатеричном виде, нужна
программа шестнадцатеричной распечатки. Шестнадцатеричный редактор дополнительно позволяет изменять байты в файле. Я вернусь
к шестнадцатеричному редактированию в главе 7, а пока воспользуемся простой программой шестнадцатеричной распечатки xxd, которая установлена в большинстве Linux-систем по умолчанию.
Ниже показаны первые 15 строк, которые xxd печатает для анализируемого растрового файла:
$ xxd 67b8601 | head -n 15
00000000: 424d 3800 0c00 0000 0000 3600 0000 2800 BM8.......6...(.
00000010: 0000 0002 0000 0002 0000 0100 1800 0000 ................
00000020: 0000 0200 0c00 c01e 0000 c01e 0000 0000 ................
00000030: 0000 0000 7f45 4c46 0201 0100 0000 0000 .....ELF........
00000040: 0000 0000 0300 3e00 0100 0000 7009 0000 ......>.....p...
00000050: 0000 0000 4000 0000 0000 0000 7821 0000 ....@.......x!..
00000060: 0000 0000 0000 0000 4000 3800 0700 4000 ........@.8...@.
00000070: 1b00 1a00 0100 0000 0500 0000 0000 0000 ................
00000080: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000090: 0000 0000 f40e 0000 0000 0000 f40e 0000 ................
000000a0: 0000 0000 0000 2000 0000 0000 0100 0000 ...... .........
000000b0: 0600 0000 f01d 0000 0000 0000 f01d 2000 .............. .
000000c0: 0000 0000 f01d 2000 0000 0000 6802 0000 ...... .....h...
000000d0: 0000 0000 7002 0000 0000 0000 0000 2000 ....p......... .
000000e0: 0000 0000 0200 0000 0600 0000 081e 0000 ................

В первом столбце находится смещение от начала файла, в следующих восьми – шестнадцатеричные представления байтов файла,
а в последнем – ASCII-представление тех же байтов.
Количество байтов, отображаемых в одной строке, можно изменить с по­мощью флага –c. Так, команда xxd –c 32 выводит по 32 байта в строке. Флаг –b позволяет выводить байты в восьмеричном, а не
в шестнадцатеричном виде, а с по­мощью флага ‑i можно вывести
Основы анализа двоичных файлов в Linux

115

массив байтов в формате C и затем включить его непосредственно
в исходный код на C или C++. Если нужно вывести только часть байтов, воспользуйтесь флагом –s (seek), чтобы задать смещение начального байта в файле, и флагом –l (length), чтобы указать, сколько байтов выводить.
В распечатке xxd нашего растрового файла магические байты ELF
начинаются со смещения 0x34 (52 в десятичном виде). Теперь мы
знаем, с какого места файла, возможно, начинается ELF-библиотека.
К сожалению, найти, где она заканчивается, не так просто, потому
что нет никаких магических байтов, определяющих конец ELF-файла.
Поэтому прежде чем пытаться извлечь ELF-файл целиком, попробуем
извлечь только его заголовок. Это проще, т. к. мы знаем, что заголовок
64-разрядного ELF-файла содержит ровно 64 байта. Затем можно будет изучить заголовок и определить полный размер файла.
Чтобы выделить заголовок, воспользуемся программой dd и скопируем 64 байта из растрового файла, начиная со смещения 52, в новый
файл elf_header.
$ dd skip=52 count=64 if=67b8601 of=elf_header bs=1
64+0 records in
64+0 records out
64 bytes copied, 0.000404841 s, 158 kB/s

Здесь dd не является существенным инструментом, поэтому я не
стану останавливаться на ней подробно. Но отмечу, что это очень гибкое средство1, и если вы с ним незнакомы, то имеет смысл прочитать
страницу руководства.
Снова воспользуемся xxd и посмотрим, что получилось.
$ xxd elf_header
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............
00000010: 0300 3e00 0100 0000 7009 0000 0000 0000 ..>.....p.......
00000020: 4000 0000 0000 0000 7821 0000 0000 0000 @.......x!......
00000030: 0000 0000 4000 3800 0700 4000 1b00 1a00 ....@.8...@.....

Очень похоже на заголовок ELF! Видны магические байты , а также массив e_ident. Другие поля тоже выглядят разумно (см. описание
полей в главе 2).

1

116

И опасное! С помощью dd легко затереть критически важные файлы, поэтому иногда ее название расшифровывают как «destroy disk» (уничтожить
диск). Так что используйте ее очень осторожно.

Глава 5

5.4 Разбор выделенного заголовка ELF
с помощью readelf
Чтобы точно узнать, что находится в только что выделенном заголовке ELF, было бы здорово воспользоваться утилитой readelf, как
мы делали в главе 2. Но будет ли readelf работать для неполного ELFфайла, не содержащего ничего, кроме заголовка? Посмотрим.
Листинг 5.1. Результат работы readelf для извлеченного заголовка ELF
 $ readelf -h elf_header

ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class:
ELF64
Data:
2's complement, little endian
Version:
1 (current)
OS/ABI:
UNIX - System V
ABI Version:
0
Type:
DYN (Shared object file)
Machine:
Advanced Micro Devices X86-64
Version:
0x1
Entry point address:
0x970
Start of program headers:
64 (bytes into file)
8568 (bytes into file)
 Start of section headers:
Flags:
0x0
Size of this header:
64 (bytes)
Size of program headers:
56 (bytes)
Number of program headers:
7
64 (bytes)
 Size of section headers:
27
 Number of section headers:
Section header string table index: 26
readelf: Error: Reading 0x6c0 bytes extends past end of file for section headers
readelf: Error: Reading 0x188 bytes extends past end of file for program headers

Флаг –h просит readelf напечатать только заголовок исполняемого файла. Программа все же ругается на то, что смещения в таблицах
заголовков секций и программы указывают за пределы файла, но это
не страшно. Важно, что теперь у нас есть удобное представление извлеченного заголовка ELF.
Ну, и как же вычислить полный размер ELF-файла, не имея ничего,
кроме заголовка? На рис. 2.1 главы 2 показано, что в конце ELF-файла
обычно находится таблица заголовков секций и что ее смещение хранится в заголовке исполняемого файла . Заголовок исполняемого
файла также дает размер заголовка каждой секции  и количество заголовков секций в таблице . Поэтому мы можем вычислить полный размер ELF-библиотеки, скрытой в растровом файле, следующим образом:
size = e_shoff + (e_shnum × e_shentsize)
= 8568 + (27 × 64)
= 10 296.
Основы анализа двоичных файлов в Linux

117

В этой формуле size – полный размер библиотеки, e_shoff – смещение таблицы заголовков секций, а e_shentsize – размер одного заголовка секции.
Теперь, зная, что размер библиотеки равен 10 296 байт, мы можем
извлечь ее целиком с по­мощью dd:
$ dd skip=52 count=10296 if=67b8601 of=lib5ae9b7f.so bs=1
10296+0 records in
10296+0 records out
10296 bytes (10 kB, 10 KiB) copied, 0.0287996 s, 358 kB/s

Команда dd называет извлеченный файл lib5ae9b7f.so , потому что
именно так называется отсутствующая библиотека, которую требует
двоичный файл ctf. После выполнения этой команды мы имеем работоспособный разделяемый ELF-объект. Воспользуемся readelf, чтобы
понять, все ли получилось удачно (см. листинг 5.2). Чтобы сократить
объем вывода, напечатаем только заголовок исполняемого файла (‑h)
и таблицы символов (–s). Последняя должна дать нам представление
о функциональности библиотеки.
Листинг 5.2. Результат работы readelf для извлеченной библиотеки lib5ae9b7f.so
$ readelf -hs lib5ae9b7f.so
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class:
ELF64
Data:
2's complement, little endian
Version:
1 (current)
OS/ABI:
UNIX - System V
ABI Version:
0
Type:
DYN (Shared object file)
Machine:
Advanced Micro Devices X86-64
Version:
0x1
Entry point address:
0x970
Start of program headers:
64 (bytes into file)
Start of section headers:
8568 (bytes into file)
Flags:
0x0
Size of this header:
64 (bytes)
Size of program headers:
56 (bytes)
Number of program headers:
7
Size of section headers:
64 (bytes)
Number of section headers:
27
Section header string table index: 26
Symbol table '.dynsym' contains 22 entries:
Num:
Value
Size Type
Bind
0: 0000000000000000
0 NOTYPE LOCAL
1: 00000000000008c0
0 SECTION LOCAL
2: 0000000000000000
0 NOTYPE WEAK
3: 0000000000000000
0 NOTYPE WEAK

118

Глава 5

Vis
DEFAULT
DEFAULT
DEFAULT
DEFAULT

Ndx Name
UND
9
UND __gmon_start__
UND _Jv_RegisterClasses









4:
5:
6:
7:
8:
9:
10:
11:
12:
13:
14:
15:
16:
17:
18:
19:
20:
21:

0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000bc0
0000000000000cb0
0000000000202060
0000000000202058
0000000000000b40
0000000000000c60
0000000000202058
00000000000008c0
0000000000000c70
0000000000000d20

0
0
0
0
0
0
0
0
149
112
0
0
119
5
0
0
59
0

FUNC
FUNC
NOTYPE
NOTYPE
FUNC
FUNC
FUNC
FUNC
FUNC
FUNC
NOTYPE
NOTYPE
FUNC
FUNC
NOTYPE
FUNC
FUNC
FUNC

GLOBAL
GLOBAL
WEAK
WEAK
WEAK
GLOBAL
GLOBAL
GLOBAL
GLOBAL
GLOBAL
GLOBAL
GLOBAL
GLOBAL
GLOBAL
GLOBAL
GLOBAL
GLOBAL
GLOBAL

DEFAULT
DEFAULT
DEFAULT
DEFAULT
DEFAULT
DEFAULT
DEFAULT
DEFAULT
DEFAULT
DEFAULT
DEFAULT
DEFAULT
DEFAULT
DEFAULT
DEFAULT
DEFAULT
DEFAULT
DEFAULT

UND
UND
UND
UND
UND
UND
UND
UND
12
12
24
23
12
12
24
9
12
13

_ZNSt7__cxx1112basic_stri@GL(2)
malloc@GLIBC_2.2.5 (3)
_ITM_deregisterTMCloneTab
_ITM_registerTMCloneTable
__cxa_finalize@GLIBC_2.2.5 (3)
__stack_chk_fail@GLIBC_2.4 (4)
_ZSt19__throw_logic_error@ (5)
memcpy@GLIBC_2.14 (6)
_Z11rc4_encryptP11rc4_sta
_Z8rc4_initP11rc4_state_t
_end
_edata
_Z11rc4_encryptP11rc4_sta
_Z11rc4_decryptP11rc4_sta
__bss_start
_init
_Z11rc4_decryptP11rc4_sta
_fini

Как мы и надеялись, полная библиотека, похоже, извлечена правильно. И хотя она зачищена, таблица динамических символов показывает несколько интересных экспортируемых функций ( –). Но
имена какие-то странные, плохо читаемые. Посмотрим, можно ли это
исправить.

5.5

Разбор символов с по­мощью nm
В языке C++ функции можно перегружать, т. е. допускается существование нескольких функций с одинаковым именем, но разными сигнатурами. К сожалению, компоновщик ничего не знает о C++. И если
бы существовало несколько функций с именем foo, то компоновщик
понятия не имел бы, как разрешать ссылки на foo – какую именно
версию foo использовать. Чтобы устранить повторяющиеся имена,
компиляторы C++ генерируют декорированные имена функций. Декорированное имя – это комбинация оригинального имени функции
и закодированного представления ее параметров. Таким образом,
каждый вариант функции получает уникальное имя, и у компоновщика не возникает проблем с различением перегруженных функций.
Для аналитиков двоичных файлов декорированные имена функций – палка о двух концах. С одной стороны, декорированные имена
гораздо труднее воспринимаются, как мы видим на примере результата readelf для библиотеки lib5ae9b7f.so (листинг 5.2), написанной на
C++. С другой стороны, декорированное имя бесплатно предоставляет
информацию о типе параметров функции, что весьма полезно с точки зрения обратной разработки.
В целом преимущества декорированных имен перевешивают их
недостатки, потому что такие имена легко декодировать. С этой задачей справляются несколько стандартных инструментов. Один из
лучших – утилита nm, которая выводит список символов в данном исОсновы анализа двоичных файлов в Linux

Powered by TCPDF (www.tcpdf.org)

119

полняемом, объектном или разделяемом файле. Получив двоичный
файл, nm по умолчанию пытается разобрать таблицу статических символов.
$ nm lib5ae9b7f.so
nm: lib5ae9b7f.so: no symbols

Увы, как показывает этот пример, конфигурация nm по умолчанию для lib5ae9b7f.so бесполезна, потому что этот файл зачищен. Мы
должны явно попросить nm разобрать таблицу динамических символов, задав флаг –D, как показано в листинге 5.3. Здесь «...» означает,
что я обрезал строку и перенес ее на следующую (декорированные
имена бывают довольно длинными).
Листинг 5.3. Результат работы nm для lib5ae9b7f.so
$ nm -D lib5ae9b7f.so
w _ITM_deregisterTMCloneTable
w _ITM_registerTMCloneTable
w _Jv_RegisterClasses
0000000000000c60 T _Z11rc4_decryptP11rc4_state_tPhi
0000000000000c70 T _Z11rc4_decryptP11rc4_state_tRNSt7__cxx1112basic_...
...stringIcSt11char_traitsIcESaIcEEE
0000000000000b40 T _Z11rc4_encryptP11rc4_state_tPhi
0000000000000bc0 T _Z11rc4_encryptP11rc4_state_tRNSt7__cxx1112basic_...
...stringIcSt11char_traitsIcESaIcEEE
0000000000000cb0 T _Z8rc4_initP11rc4_state_tPhi
U _ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEE9_...
...M_createERmm
U _ZSt19__throw_logic_errorPKc
0000000000202058 B __bss_start
w __cxa_finalize
w __gmon_start__
U __stack_chk_fail
0000000000202058 D _edata
0000000000202060 B _end
0000000000000d20 T _fini
00000000000008c0 T _init
U malloc
U memcpy

Уже лучше, теперь мы видим хоть какие-то символы. Но имена
символов по-прежнему декорированы. Чтобы декодировать их, нужно указать флаг ––demangle.
Листинг 5.4. Результат работы nm с декодированнымми именами для файла
lib5ae9b7f.so
$ nm -D --demangle lib5ae9b7f.so
w _ITM_deregisterTMCloneTable

120

Глава 5

w
w
0000000000000c60 T
0000000000000c70 T

0000000000000b40 T
0000000000000bc0 T

0000000000000cb0 T
U
U
0000000000202058 B
w
w
U
0000000000202058 D
0000000000202060 B
0000000000000d20 T
00000000000008c0 T
U
U

_ITM_registerTMCloneTable
_Jv_RegisterClasses
rc4_decrypt(rc4_state_t*, unsigned char*, int)
rc4_decrypt(rc4_state_t*,
std::__cxx11::basic_string&)
rc4_encrypt(rc4_state_t*, unsigned char*, int)
rc4_encrypt(rc4_state_t*,
std::__cxx11::basic_string&)
rc4_init(rc4_state_t*, unsigned char*, int)
std::__cxx11::basic_string::_M_create(unsigned long&, unsigned long)
std::__throw_logic_error(char const*)
__bss_start
__cxa_finalize
__gmon_start__
__stack_chk_fail
_edata
_end
_fini
_init
malloc
memcpy

Наконец-то имена функций стали читаемыми. Мы видим пять интересных функций, и все они, похоже, являются криптографическими, реализующими хорошо известный алгоритм шифрования RC41.
Имеется функция rc4_init, которая принимает структуру данных
типа rc4_state_t, строку символов без знака и целое число . Первым параметром, вероятно, является структура данных для хранения
криптографического состояния, а следующие два – ключ и длина ключа соответственно. Есть также несколько функций шифрования и дешифрирования, все они принимают указатель на криптографическое
состояние, а также строки (в смысле C и C++), подлежащие шифрованию или дешифрированию ( –).
Для декодирования имен функций можно также воспользоваться
специальной утилитой c++filt, которая принимает декорированное
имя и выводит его декодированный эквивалент. Достоинство c++filt
заключается в том, что она поддерживает несколько форматов декорирования и автоматически определяет, в каком из них представлено
переданное имя. Ниже приведен пример использования c++filt для
декодирования имени _Z8rc4_initP11rc4_state_tPhi:

1

RC4 – популярный потоковый шифр, отличающийся простотой и высоким быстродействием. Интересующиеся могут прочитать о нем в статье
по адресу https://en.wikipedia.org/wiki/RC4. Отметим, что теперь RC4 считается взломанным, так что использовать его в новых реальных проектах не
рекомендуется!
Основы анализа двоичных файлов в Linux

121

$ c++filt _Z8rc4_initP11rc4_state_tPhi
rc4_init(rc4_state_t*, unsigned char*, int)

Подытожим, что мы уже сделали. Мы распаковали загадочный файл
payload и обнаружили двоичный файл ctf, зависящий от библиотеки
lib5ae9b7f.so. Мы выяснили, что файл lib5ae9b7f.so спрятан в растровом файле, и успешно извлекли его. Мы также примерно представляем, что он делает: это криптографическая библиотека. Теперь попробуем запустить ctf еще раз, уже без отсутствующих зависимостей.
В процессе выполнения двоичного файла компоновщик разрешает
его зависимости, для чего просматривает ряд стандартных каталогов
с разделяемыми библиотеками, например /lib. Поскольку мы извлекли lib5ae9b7f.so в нестандартный каталог, необходимо сказать компоновщику, что он должен искать и в нем тоже. Для этого мы установим
переменную окружения LD_LIBRARY_PATH. Зададим ее так, чтобы она
указывала на текущий рабочий каталог, и снова запустим ctf.
$ export LD_LIBRARY_PATH=`pwd`
$ ./ctf
$ echo $?
1

Получилось! Двоичный файл ctf по-прежнему не приносит никакой
видимой пользы, но работает, не жалуясь на отсутствие библиотек.
Код выхода программы ctf, доступный через переменную оболочки
$?, равен 1, что является признаком ошибки. Теперь, располагая всеми необходимыми зависимостями, мы можем продолжить расследование и как-то обойти ошибку в ctf, которая мешает добраться до
желанного флага.

5.6

Поиск зацепок с по­мощью strings
Чтобы понять, что делает двоичный файл и каких аргументов он ожидает, мы можем поискать в нем полезные строки, которые пролили
бы свет на его назначение. Например, если мы увидим строки, содержащие части HTTP-запросов или URL-адреса, можно предположить,
что файл делает что-то, связанное с вебом. Анализируя вредоносные
программы, например боты, мы, возможно, обнаружим строки, содержащие принимаемые ботом команды, если только они не обфусцированы. Можно даже встретить отладочные строки, которые программист забыл удалить, – такое действительно бывало в реальных
вредоносных программах!
Для поиска строк в двоичном (или любом другом) файле в системе
Linux можно воспользоваться утилитой strings. Она принимает один
или несколько файлов и печатает все найденные в них строки имеющих графическое представление символов. Отметим, что strings не

122

Глава 5

проверяет, действительно ли найденные строки рассчитаны на чтение человеком, поэтому применение ее к двоичным файлам может
давать посторонние строки, которые лишь случайно выглядят печатаемыми.
Поведение strings можно настроить с по­мощью флагов. Например,
флаг –d означает, что нужно печатать только строки, найденные в секциях данных, а не во всех секциях. По умолчанию strings печатает
лишь строки, содержащие не менее четырех символов, но минимальную длину можно задать с по­мощью флага –n. Для наших целей режима по умолчанию вполне достаточно; посмотрим, что strings сможет
найти в файле ctf.
Листинг 5.5. Строки символов, найденные в двоичном файле ctf
$ strings ctf
 /lib64/ld-linux-x86-64.so.2

lib5ae9b7f.so
 __gmon_start__

_Jv_RegisterClasses
_ITM_deregisterTMCloneTable
_ITM_registerTMCloneTable
_Z8rc4_initP11rc4_state_tPhi
...




DEBUG: argv[1] = %s
checking '%s'
show_me_the_flag
>CMb
-v@Pˆ:
flag = %s
guess again!



It's kinda like Louisiana. Or Dagobah. Dagobah - Where Yoda lives!

;*3$"
zPLR
GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.4) 5.4.0 20160609
 .shstrtab
.interp
.note.ABI-tag
.note.gnu.build-id
.gnu.hash
.dynsym
.dynstr
.gnu.version
.gnu.version_r
.rela.dyn
.rela.plt
.init
.plt.got
.text
.fini
.rodata
.eh_frame_hdr
Основы анализа двоичных файлов в Linux

123

.eh_frame
.gcc_except_table
.init_array
.fini_array
.jcr
.dynamic
.got.plt
.data
.bss
.comment

Некоторые из показанных здесь строк встречаются почти во всех
ELF-файлах. Таковы, например, имя интерпретатора программы ,
найденное в секции .interp, и некоторые имена символов, найденные в секции .dynstr . В конце распечатки мы видим все имена секций, найденные в секции .shstrtab . Но всё это нам не слишком
интересно.
По счастью, имеются и более полезные строки. Например, одна из
них похожа на отладочное сообщение и наводит на мысль, что программа ожидает флага в командной строке . Встречаются также
какие-то проверки, возможно, применяемые к входной строке . Мы
пока не знаем, каким должен быть параметр командной строки, но
можно было бы попробовать какую-то из других интересных строк,
например show_me_the_flag , – вдруг да сработает. Имеется также загадочная строка , содержащая сообщение непонятного назначения.
Сейчас мы не знаем, что означает это сообщение, но из исследования
lib5ae9b7f.so нам известно, что в этом двоичном файле используется
алгоритм шифрования RC4. Быть может, это сообщение является ключом шифрования?
Зная, что двоичный файл ожидает получить параметр в командной
строке, посмотрим, не приблизит ли нас к заветной цели задание произвольного параметра. За неимением лучшего зададим просто строку
foobar:
$ ./ctf foobar
checking 'foobar'
$ echo $?
1

Поведение двоичного файла изменилось. Он сообщает, что проверяет переданную ему строку. Но проверка оказалась неудачной, потому что код выхода все равно показывает ошибку. А давайте рискнем
и попробуем еще какую-нибудь из найденных интересных строк, например show_me_the_flag, которая выглядит особенно многообещающей:
$ ./ctf show_me_the_flag
checking 'show_me_the_flag'

124

Глава 5

ok
$ echo $?
1

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

5.7 Трассировка системных и библиотечных
вызовов с по­мощью strace и ltrace
Чтобы продвинуться дальше, выясним, по какой причине ctf завершается с кодом ошибки, для чего рассмотрим поведение программы перед самым выходом. Сделать это можно разными способами,
один из них – воспользоваться инструментами strace и ltrace. Они
показывают соответственно системные и библиотечные вызовы, совершаемые программой. Располагая этой информацией, часто можно
составить общее представление о том, что делает программа.
Для начала изучим с по­мощью strace, к каким системным вызовам
обращается ctf. Иногда желательно присоединить strace к работающему процессу. Для этого нужно задать флаг –p pid, где pid – идентификатор процесса. Но в данном случае достаточно просто запустить
ctf под управлением strace с самого начала. В листинге 5.6 показан
результат работы strace для двоичного файла ctf (строки, заканчивающиеся «…», обрезаны).
Листинг 5.6. Системные вызовы, выполняемые двоичным файлом ctf
$ strace ./ctf show_me_the_flag
 execve("./ctf", ["./ctf", "show_me_the_flag"], [/* 73 vars */]) = 0

brk(NULL) = 0x1053000
access("/etc/ld.so.nohwcap", F_OK)
= -1 ENOENT (No such file or directory)
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f703477e000
access("/etc/ld.so.preload", R_OK)
= -1 ENOENT (No such file or directory)
 open("/ch3/tls/x86_64/lib5ae9b7f.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or ...)
stat("/ch3/tls/x86_64", 0x7ffcc6987ab0) = -1 ENOENT (No such file or directory)
open("/ch3/tls/lib5ae9b7f.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
stat("/ch3/tls", 0x7ffcc6987ab0)
= -1 ENOENT (No such file or directory)
open("/ch3/x86_64/lib5ae9b7f.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
stat("/ch3/x86_64", 0x7ffcc6987ab0)
= -1 ENOENT (No such file or directory)
open("/ch3/lib5ae9b7f.so", O_RDONLY|O_CLOEXEC) = 3
 read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0p\t\0\0\0\0\0\0"..., 832) = 832
fstat(3, st_mode=S_IFREG|0775, st_size=10296, ...) = 0
mmap(NULL, 2105440, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f7034358000
mprotect(0x7f7034359000, 2097152, PROT_NONE) = 0
Основы анализа двоичных файлов в Linux

125

mmap(0x7f7034559000, 8192, PROT_READ|PROT_WRITE, ..., 3, 0x1000) = 0x7f7034559000
close(3)
= 0
open("/ch3/libstdc++.so.6", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, st_mode=S_IFREG|0644, st_size=150611, ...) = 0
mmap(NULL, 150611, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f7034759000
close(3)
= 0
access("/etc/ld.so.nohwcap", F_OK)
= -1 ENOENT (No such file or directory)
 open("/usr/lib/x86_64-linux-gnu/libstdc++.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0 \235\10\0\0\0\0\0"..., 832) = 832
fstat(3, st_mode=S_IFREG|0644, st_size=1566440, ...) = 0
mmap(NULL, 3675136, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f7033fd6000
mprotect(0x7f7034148000, 2097152, PROT_NONE) = 0
mmap(0x7f7034348000, 49152, PROT_READ|PROT_WRITE, ..., 3, 0x172000) = 0x7f7034348000
mmap(0x7f7034354000, 13312, PROT_READ|PROT_WRITE, ..., -1, 0) = 0x7f7034354000
close(3)
= 0
open("/ch3/libgcc_s.so.1", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
access("/etc/ld.so.nohwcap", F_OK)
= -1 ENOENT (No such file or directory)
open("/lib/x86_64-linux-gnu/libgcc_s.so.1", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0p*\0\0\0\0\0\0"..., 832) = 832
fstat(3, st_mode=S_IFREG|0644, st_size=89696, ...) = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f7034758000
mmap(NULL, 2185488, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f7033dc0000
mprotect(0x7f7033dd6000, 2093056, PROT_NONE) = 0
mmap(0x7f7033fd5000, 4096, PROT_READ|PROT_WRITE, ..., 3, 0x15000) = 0x7f7033fd5000
close(3)
= 0
open("/ch3/libc.so.6", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
access("/etc/ld.so.nohwcap", F_OK)
= -1 ENOENT (No such file or directory)
open("/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0P\t\2\0\0\0\0\0"..., 832) = 832
fstat(3, st_mode=S_IFREG|0755, st_size=1864888, ...) = 0
mmap(NULL, 3967392, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f70339f7000
mprotect(0x7f7033bb6000, 2097152, PROT_NONE) = 0
mmap(0x7f7033db6000, 24576, PROT_READ|PROT_WRITE, ..., 3, 0x1bf000) = 0x7f7033db6000
mmap(0x7f7033dbc000, 14752, PROT_READ|PROT_WRITE, ..., -1, 0) = 0x7f7033dbc000
close(3)
= 0
open("/ch3/libm.so.6", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
access("/etc/ld.so.nohwcap", F_OK)
= -1 ENOENT (No such file or directory)
open("/lib/x86_64-linux-gnu/libm.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\0V\0\0\0\0\0\0"..., 832) = 832
fstat(3, st_mode=S_IFREG|0644, st_size=1088952, ...) = 0
mmap(NULL, 3178744, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f70336ee000
mprotect(0x7f70337f6000, 2093056, PROT_NONE) = 0
mmap(0x7f70339f5000, 8192, PROT_READ|PROT_WRITE, ..., 3, 0x107000) = 0x7f70339f5000
close(3)
= 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f7034757000
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f7034756000
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f7034754000
arch_prctl(ARCH_SET_FS, 0x7f7034754740) = 0
mprotect(0x7f7033db6000, 16384, PROT_READ) = 0
mprotect(0x7f70339f5000, 4096, PROT_READ) = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f7034753000
mprotect(0x7f7034348000, 40960, PROT_READ) = 0

126

Глава 5

mprotect(0x7f7034559000, 4096, PROT_READ) = 0
mprotect(0x601000, 4096, PROT_READ)
= 0
mprotect(0x7f7034780000, 4096, PROT_READ) = 0
munmap(0x7f7034759000, 150611)
= 0
brk(NULL)
= 0x1053000
brk(0x1085000)
= 0x1085000
fstat(1, st_mode=S_IFCHR|0620, st_rdev=makedev(136, 1), ...) = 0
 write(1, "checking 'show_me_the_flag'\n", 28checking 'show_me_the_flag'
) = 28
 write(1, "ok\n", 3ok
) = 3
 exit_group(1) = ?
+++ exited with 1 +++

Если strace трассирует программу с самого начала, то включаются
все системные вызовы, выполняемые интерпретатором программы
для подготовки процесса, из-за чего распечатка оказывается довольно длинной. Первый системный вызов – execve, его делает оболочка,
чтобы запустить программу . Затем управление получает интерпретатор программы, который начинает подготавливать окружение,
в т. ч. выделять области памяти и задавать права доступа к ним с по­
мощью mprotect. Кроме того, мы видим системные вызовы, которые
производятся в процессе поиска и загрузки необходимых динамических библиотек.
Напомним, что в разделе 5.5 мы установили переменную среды LD_
LIBRARY_PATH, чтобы сообщить динамическому компоновщику о том,
что нужно добавить текущий рабочий каталог в список путей поиска.
И теперь мы видим, что компоновщик ищет файл lib5ae9b7f.so в ряде
стандартных подкаталогов нашего текущего рабочего каталога, пока,
наконец, не находит его в корне . Когда библиотека найдена, динамический компоновщик читает ее и отображает в память . Этот
процесс повторяется для всех требуемых библиотек, в частности libstdc++.so.6 , и занимает большую часть вывода strace.
К самому приложению относятся лишь три последних системных вызова. Первый из них – write, он нужен для вывода сообщения
checking 'show_me_the_flag' на экран . Еще один вызов write печатает строку ok , и, наконец, мы видим вызов exit_group, который
завершает программу с кодом 1 .
Все это, конечно, интересно, но поможет ли заполучить от ctf флаг?
Не поможет! В данном случае strace не дала никакой полезной информации, но я все равно хотел показать, как она работает, потому
что иногда она позволяет лучше понять поведение программы. Наблюдение за системными вызовами бывает полезно не только для
двоичного анализа, но и для отладки.
Знание системных вызовов ctf нам не особенно помогло, но попробуем библиотечные вызовы. Чтобы узнать, к каким библиотечным
функциям обращалась ctf, мы воспользуемся программой ltrace. Поскольку ltrace – близкая родственница strace, она принимает многие
из тех же параметров командной строки, в т. ч. –p для присоединения
Основы анализа двоичных файлов в Linux

127

к работающему процессу. В данном случае мы зададим флаг –i, чтобы
вывести счетчик программы в точке каждого библиотечного вызова
(это окажется полезным впоследствии). Еще зададим флаг –C, чтобы
автоматически декодировать имена функций C++. Запустим ctf под
управлением ltrace, как показано в листинге 5.7.
Листинг 5.7. Библиотечные вызовы, выполняемые двоичным файлом ctf













$ ltrace -i
[0x400fe9]
[0x400c44]
[0x400c51]
[0x400cf0]
[0x400d07]

-C ./ctf show_me_the_flag
__libc_start_main (0x400bc0, 2, 0x7ffc22f441e8, 0x4010c0
__printf_chk (1, 0x401158, 0x7ffc22f4447f, 160checking 'show_me_the_flag') = 28
strcmp ("show_me_the_flag", "show_me_the_flag") = 0
puts ("ok"ok) = 3
rc4_init (rc4_state_t*, unsigned char*, int)
(0x7ffc22f43fb0, 0x4011c0, 66, 0x7fe979b0d6e0) = 0
[0x400d14] std::__cxx11::basic_string:: assign (char const*)
(0x7ffc22f43ef0, 0x40117b, 58, 3) = 0x7ffc22f43ef0
[0x400d29] rc4_decrypt (rc4_state_t*, std::__cxx11::basic_string&)
(0x7ffc22f43f50, 0x7ffc22f43fb0, 0x7ffc22f43ef0, 0x7e889f91) = 0x7ffc22f43f50
[0x400d36] std::__cxx11::basic_string:: _M_assign (std::__cxx11::basic_string const&)
(0x7ffc22f43ef0, 0x7ffc22f43f50, 0x7ffc22f43f60, 0) = 0
[0x400d53] getenv ("GUESSME") = nil
[0xffffffffffffffff] +++ exited (status 1) +++

Как видим, распечатку ltrace читать гораздо проще, потому что
она не замусорена кодом подготовки процесса. Первая библиотечная
функция __libc_start_main вызывается из функции _start, чтобы
передать управление функции main программы. Функция main первым делом вызывает библиотечную функцию для печати уже знакомой нам строки checking ... на экран . Сама проверка осуществляется путем сравнения строк, реализованного функцией strcmp, ее
цель – убедиться, что переданный ctf аргумент равен show_me_the_flag
. Если это так, то на экран выводится сообщение ok .
Пока что мы не узнали ничего нового. Но дальше начинается интересное: криптографический алгоритм RC4 инициализируется путем
вызова функции rc4_init из ранее извлеченной библиотеки . Затем
мы с по­мощью функции assign присваиваем значение строке C++,
быть может, записывая в нее зашифрованное сообщение . Далее это
сообщение дешифрируется путем обращения к функции rc4_decrypt
, и расшифрованное сообщение присваивается новой строке C++ .
Наконец, имеется вызов стандартной библиотечной функции
getenv, которая ищет переменные окружения . Мы видим, что ctf
ожидает найти переменную среды GUESSME! И это вполне может быть
ранее дешифрированная строка. Посмотрим, изменится ли поведение ctf, если присвоить какое-нибудь значение переменной окружения GUESSME:

128

Глава 5

$ GUESSME='foobar' ./ctf show_me_the_flag
checking 'show_me_the_flag'
ok
guess again!

Стоило задать GUESSME, как появилась дополнительная строка guess
again!. Похоже, что ctf ожидает определенного значения GUESSME. Быть
может, еще один прогон ltrace, показанный в листинге 5.8, прольет
свет на эту тайну.

Листинг 5.8. Библиотечные вызовы, выполняемые ctf после задания переменной
окружения GUESSME
$ GUESSME='foobar' ltrace -i -C ./ctf show_me_the_flag
...
[0x400d53] getenv ("GUESSME") = "foobar"
 [0x400d6e] std::__cxx11::basic_string:: assign (char const*)
(0x7fffc7af2b00, 0x401183, 5, 3) = 0x7fffc7af2b00
 [0x400d88] rc4_decrypt (rc4_state_t*, std::__cxx11::basic_string&)
(0x7fffc7af2b60, 0x7fffc7af2ba0, 0x7fffc7af2b00, 0x401183) = 0x7fffc7af2b60
[0x400d9a] std::__cxx11::basic_string:: _M_assign (std::__cxx11::basic_string const&)
(0x7fffc7af2b00, 0x7fffc7af2b60, 0x7700a0, 0) = 0
[0x400db4] operator delete (void*)(0x7700a0, 0x7700a0, 21, 0) = 0
 [0x400dd7] puts ("guess again!"guess again!) = 13
[0x400c8d] operator delete (void*)(0x770050, 0x76fc20, 0x7f70f99b3780, 0x7f70f96e46e0) = 0
[0xffffffffffffffff] +++ exited (status 1) +++

После обращения к getenv ctf присваивает и дешифрирует  еще
одну строку C++. К сожалению, между дешифрированием и моментом,
когда на экран выводится guess again , нет никаких намеков на то,
каким могло бы быть ожидаемое значение GUESSME. Это означает, что
сравнение GUESSME с ожидаемым значением реализовано без использования библиотечных функций. Нам нужен какой-то другой подход.

5.8 Изучение поведения на уровне команд
с помощью objdump
Поскольку мы знаем, что значение переменной окружения GUESSME
проверяется без использования хорошо известных библиотечных
функций, следующий логический шаг – применить утилиту objdump
для изучения ctf на уровне команд, чтобы понять, что происходит1.
1

Напомним (см. главу 1), что objdump – это простой дизассемблер, входящий
в состав большинства дистрибутивов Linux.
Основы анализа двоичных файлов в Linux

129

Из распечатки ltrace в листинге 5.8 мы знаем, что строка guess
again печатается на экран в результате вызова puts по адресу 0x400dd7.
При работе с objdump нас будет интересовать код в окрестности этого адреса. Полезно было бы знать адрес строки, чтобы найти первую
команду, которая ее загружает. Для нахождения адреса мы можем посмотреть на секцию .rodata двоичного файла ctf, распечатав ее целиком командой objdump ‑s (листинг 5.9).

Листинг 5.9. Содержимое секции .rodata файла ctf, показанное objdump
$ objdump -s --section .rodata ctf
ctf: file format elf64-x86-64
Contents
401140
401150
401160
401170
401180
401190
4011a0
4011b0
4011c0
4011d0
4011e0
4011f0
401200

of section .rodata:
01000200 44454255 473a2061
315d203d 20257300 63686563
20272573 270a0073 686f775f
68655f66 6c616700 6f6b004f
887e009a 5b38babe 27ac0e3e
55868954 3848a34d 00192d76
00726200 666c6167 203d2025
75657373 20616761 696e2100
49742773 206b696e 6461206c
4c6f7569 7369616e 612e204f
676f6261 682e2044 61676f62
20576865 72652059 6f646120
73210000 00000000

7267765b ....DEBUG: argv[
6b696e67 1] = %s.checking
6d655f74 '%s'..show_me_t
89df919f he_flag.ok.O....
434d6285 .~..[8..'..>CMb.
40505e3a U..T8H.M..-v@Pˆ:
730a0067 .rb.flag = %s..g
00000000 uess again!.....
696b6520 It's kinda like
72204461 Louisiana. Or Da
6168202d gobah. Dagobah 6c697665 Where Yoda live
s!......

Итак, objdump показывает, что строка guess again начинается по
адресу 0x4011af . Теперь взглянем на листинг 5.10, где показаны команды вокруг вызова puts, и попытаемся понять, какого значения
переменной окружения GUESSME ожидает ctf.
Листинг 5.10. Команды, проверяющие значение GUESSME
$ objdump
...
 400dc0:
400dc4:
 400dc6:
 400dc8:
400dcb:
 400dcd:
 400dd2:
400dd7:
400ddc:
 400de0:
 400de4:
 400de8:
...

130

Глава 5

-d ctf
0f
84
74
3a
74
bf
e8
e9
0f
48
48
75

b6
d2
05
14
13
af
d9
84
1f
83
83
d6

14 03

movzx
test
je
01
cmp
je
11 40 00 mov
fc ff ff call
fe ff ff jmp
40 00
nop
c0 01
add
f8 15
cmp
jne

edx,BYTE PTR [rbx+rax*1]
dl,dl
400dcd
dl,BYTE PTR [rcx+rax*1]
400de0
edi,0x4011af
400ab0
400c60
DWORD PTR [rax+0x0]
rax,0x1
rax,0x15
400dc0

Строка guess again загружается командой по адресу 0x400dcd ,
а затем печатается функцией puts . Это происходит при неудачном
сравнении, а мы поднимемся по коду выше.
На случай несовпадения мы попадаем из цикла, начинающегося по
адресу 0x400dc0. На каждой итерации цикла байт из массива (вероятно, строки) загружается в регистр edx . Регистр rbx указывает на базу
этого массива, а регистр rax индексирует его. Если загруженный байт
равен NULL, то команда je по адресу 0x400dc6 переходит на случай несовпадения . Это сравнение с NULL – проверка конца строки. Если мы
дошли до конца строки, значит, она слишком короткая. Если же байт
не равен NULL, то выполняется следующая команда по адресу 0x400dc8,
которая сравнивает байт в регистре edx с байтом в другой строке с базой rcx и индексируемой rax .
Если байты совпадают, то программа переходит по адресу 0x400de0,
где увеличивает индекс строки , и проверяет, равен ли он 0x15, длине строке . Если это так, то сравнение строк завершилось, в противном случае начинается новая итерация цикла .
Из этого анализа следует, что строка, база которой хранится в регистре rcx, является искомой. С ней программа сравнивает строку,
взятую из переменной окружения GUESSME. Таким образом, если мы
сможем получить искомую строку, то найдем ожидаемое значение
GUESSME! Поскольку строка дешифрируется во время выполнения
и в статическом виде не существует, нам придется воспользоваться
динамическим анализом, одной objdump недостаточно.

5.9 Получение буфера динамической строки
с по­мощью gdb
Пожалуй, чаще всего для динамического анализа на платформе GNU/
Linux используется отладчик GNU gdb. Его основное назначение – отладка, но никто не мешает применять его для решения различных задач динамического анализа. На самом деле это исключительно гибкий
инструмент, и мы не сможем рассмотреть всю его функциональность
в этой главе. Однако я остановлюсь на наиболее часто используемых
возможностях gdb, которые позволят нам раскрыть ожидаемое значение GUESSME. Искать информацию о gdb лучше всего не на странице
руководства, а на сайте документации http://www.gnu.org/software/gdb/
documentation/, где имеется подробное руководство с описанием всех
поддерживаемых команд gdb.
Подобно strace и ltrace, gdb умеет присоединяться к работающему процессу. Но поскольку ctf работает недолго, мы можем прос­
то запустить его под управлением gdb с самого начала. Так как gdb –
интерактивный инструмент, двоичный файл, запущенный под его
управлением, не начинает выполняться немедленно. Напечатав вступительное сообщение с краткими инструкциями, gdb приостанавливается и ждет команду. Понять, что gdb ожидает команду, можно по
приглашению (gdb).
Основы анализа двоичных файлов в Linux

131

В листинге 5.11 показана последовательность команд gdb, которые позволяют найти ожидаемое значение переменной окружения
GUESSME. Я объясню их в процессе обсуждения листинга.
Листинг 5.11. Нахождение ожидаемого значения GUESSME с по­мощью
gdb













$ gdb ./ctf
GNU gdb (Ubuntu 7.11.1-0ubuntu1~16.04) 7.11.1
Copyright (C) 2016 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
.
Find the GDB manual and other documentation resources online at:
.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from ./ctf...(no debugging symbols found)...done.
(gdb) b *0x400dc8
Breakpoint 1 at 0x400dc8
(gdb) set env GUESSME=foobar
(gdb) run show_me_the_flag
Starting program: /home/binary/code/chapter3/ctf show_me_the_flag
checking 'show_me_the_flag'
ok
Breakpoint 1, 0x0000000000400dc8 in ?? ()
(gdb) display/i $pc
1: x/i $pc
=> 0x400dc8:
cmp
(%rcx,%rax,1),%dl
(gdb) info registers rcx
rcx
0x615050 6377552
(gdb) info registers rax
rax
0x0
0
(gdb) x/s 0x615050
0x615050: "Crackers Don't Matter"
(gdb) quit

Одна из основных функций любого отладчика – установка точки
останова, т. е. адреса или имени функции, в которой отладчик должен приостановить выполнение. Достигнув точки останова, отладчик
возвращает управление пользователю и ожидает команды. Чтобы
получить «магическую» строку, с которой сравнивается переменная
окружения GUESSME, мы поставим точку останова по адресу 0x400dc8
, где производится сравнение. В gdb точка останова по некоторому
адресу ставится командой b *address (b – сокращение от break). Если
символы доступны (в данном случае это не так), то можно поставить

132

Глава 5

точку останова на место входа в функцию, указав имя этой функции.
Например, чтобы поставить точку останова в начало main, следовало
бы выполнить команду b main.
Поставив точку останова, нужно сделать еще одну вещь до начала
выполнения ctf. Нам все равно нужно задать значение переменной
окружения GUESSME, чтобы ctf не завершилась раньше времени. В gdb
для задания переменной окружения GUESSME можно воспользоваться
командой set env GUESSME=foobar . Теперь можно начинать выполнение ctf, для этого служит команда run show_me_the_flag . Как видим,
команде run можно передать аргументы, которые она автоматически
передаст анализируемому двоичному файлу (в данном случае – ctf).
Программа ctf начинает выполняться, как обычно, и так будет продолжаться, пока не встретится точка останова.
Дойдя до точки останова, gdb приостановит выполнение ctf и вернет управление нам, сообщив, что встретилась точка останова .
В этот момент мы можем воспользоваться командой display/i $pc,
чтобы показать текущую команду (с адресом, равным счетчику команд $pc), – просто чтобы убедиться, что находимся там, где ожидаем
. Естественно, gdb сообщит, что следующая подлежащая выполнению команда – cmp (%rcx,%rax,1),%dl, а это и есть интересующая нас
команда сравнения (в формате AT&T).
Дойдя при выполнении ctf до точки, где GUESSME сравнивается
с ожидаемой строкой, мы должны найти базовый адрес строки, чтобы можно было ее распечатать. Чтобы просмотреть базовый адрес,
хранящийся в регистре rcx, воспользуемся командой info registers
rcx . Можно также просмотреть содержимое регистра rax – просто
чтобы убедиться, что счетчик цикла равен 0, как и должно быть .
Команду info registers можно использовать без указания имени регистра, тогда gdb покажет содержимое всех регистров общего назначения.
Теперь мы знаем базовый адрес нужной нам строки, она начинается по адресу 0x615050. Осталось только распечатать строку по этому
адресу. В gdb для распечатки памяти служит команда x, которая умеет
показывать содержимое памяти в разных единицах и кодировках. Например, x/d выводит один байт в десятичном представлении, x/x –
один байт в шестнадцатеричном представлении, а x/4xw – четыре
шестнадцатеричных слова (4-байтовых целых числа). В данном случае полезнее всего будет команда x/s, которая выводит строку в стиле
C, т. е. все байты до первого байта NULL. Команда x/s 0x615050 выводит
интересующую нас строку , и оказывается, что ожидаемое значение
GUESSME равно Crackers Don't Matter. Выйдем из gdb командой quit 
и попробуем!
$ GUESSME="Crackers Don't Matter" ./ctf show_me_the_flag
checking 'show_me_the_flag'
ok
flag = 84b34c124b2ba5ca224af8e33b077e9e
Основы анализа двоичных файлов в Linux

133

Как показывает листинг, мы наконец выполнили все шаги и заставили ctf выдать нам секретный флаг! На ВМ в каталоге этой главы
вы найдете программу oracle. Запустите ее, передав найденный флаг:
./oracle 84b34c124b2ba5ca224af8e33b077e9e. Так вы получите доступ
к следующей задаче, которую можете решить самостоятельно, воспользовавшись вновь обретенными навыками.

5.10

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

Упражнение
1. Новая задача CTF
Решите новую задачу CTF, к которой дала доступ программа oracle! Сделать это можно, пользуясь только инструментами, рассмотренными в этой главе, и знаниями, полученными в главе 2.
По завершении не забудьте передать найденный флаг оракулу
для разблокировки следующей задачи.

6

ОСНОВЫ
ДИЗАССЕМБЛИРОВАНИЯ
И АНАЛИЗА ДВОИЧНЫХ
ФАЙЛОВ

Т

еперь, когда вы знаете, как структурированы двоичные файлы,
и знакомы с основными инструментами двоичного анализа,
настало время приступить к дизассемблированию! В этой главе
вы узнаете о плюсах и минусах основных подходов к дизассемблированию и соответствующих инструментов. Мы также обсудим продвинутые методы анализа потоков данных и управления в дизасс­
емблированном коде.
Отметим, что эта глава – не руководство по обратной разработке,
для этой цели я рекомендую книгу Chris Eagle «The IDA Pro Book» (No
Starch Press, 2011)1. Наша цель – познакомиться с основными алго-

1

См. также: Игл К., Нэнс К. Ghidra. Полное руководство. М.: ДМК Пресс,
2021. – Прим. перев.
Основы дизассемблирования и анализа двоичных файлов

135

ритмами, на которых зиждется дизассемблирование, и понять, что
дизассемблеры могут, а чего не могут делать. Это знание поможет
лучше разобраться в продвинутых методах, изучаемых в последующих главах, поскольку все они опираются на дизассемблирование.
В большинстве примеров в этой главе я буду пользоваться программами objdump и IDA Pro. Иногда, чтобы упростить обсуждение, я буду
использовать псевдокод. В приложении C приведен перечень хорошо
известных дизассемблеров, с которыми можно поэкспериментировать, если ни IDA Pro, ни objdump вам не нравятся.

6.1

Статическое дизассемблирование
Методы двоичного анализа можно классифицировать как статические, динамические или смешанные. Говоря «дизассемблирование»,
мы обычно имеем в виду статическое дизассемблирование, которое
подразумевает извлечение команд из двоичного файла без его выполнения. Напротив, при динамическом дизассемблировании, которое еще называют трассировкой выполнения, исполняемые команды
проявляются в процессе работы двоичного файла.
Цель любого статического дизассемблера – преобразовать весь код
в двоичном файле в форму, понятную человеку или допускающую
машинную обработку (для последующего анализа). Для достижения
этой цели статические дизассемблеры должны выполнить следующие
шаги:
1) загрузить двоичный файл для обработки, пользуясь загрузчиком,
таким как был реализован в главе 4;
2) найти в двоичном файле все машинные команды;
3) представить эти команды в виде, понятном человеку или машине.
К сожалению, на практике шаг 2 зачастую очень труден, и на нем
возникают ошибки. Существует два основных подхода к статическому дизассемблированию, и оба пытаются по-своему избежать ошибок: линейное и рекурсивное дизассемблирование. Увы, ни один подход
не идеален во всех случаях. Обсудим компромиссы обоих методов
статического дизассемблирования. А к динамическому дизассемблированию я вернусь ниже в этой главе.
На рис. 6.1 показаны основные принципы линейного и рекурсивного дизассемблирования. Здесь же иллюстрируются некоторые типы
ошибок, свойственных каждомуподходу.

6.1.1 Линейное дизассемблирование
Начнем с линейного дизассемблирования, поскольку концептуально этот подход проще. Дизассемблер перебирает все сегменты кода
в двоичном файле, последовательно декодирует байты и преобразует их в список команд. Так работают многие простые дизассемблеры,
в т. ч. утилита objdump, рассмотренная в главе 1.

136

Глава 6

Линейное

Рекурсивное

BB0 f0
cmp ecx, edx
jl BB2
jmp BB1

BB0 f0
cmp ecx, edx
jl BB2
jmp BB1

Встроенные данные

Встроенные данные

BB1
mov eax,[fptr+ecx]
call eax

BB1
mov eax,[fptr+ecx]
call eax

BB2
mov eax,[fptr+edx]
call eax

BB2
mov eax,[fptr+edx]
call eax

f1

f1

f2

f2

Рис. 6.1. Линейное и рекурсивное дизассемблирование. Стрелками показан поток
дизассемблирования. Серые блоки – пропущенный или искаженный код

Риск заключается в том, что не все байты обязательно должны быть
командами. Например, некоторые компиляторы, в частности Visual
Studio, включают прямо в код данные, например таблицы переходов,
не оставляя никаких указаний, где данные начинаются и заканчиваются. Если дизассемблер натыкается на такие встроенные данные
в коде, то может генерировать некорректные команды. Это особенно
вероятно в случае архитектур с плотными системами команд, например x86, когда большинство значений байтов представляют допустимый код операции.
Кроме того, если коды операций могут иметь разную длину, как
в x86, то встроенные данные могут даже привести к рассинхронизации дизассемблера с потоком команд. В конечном итоге дизассемб­
лер обычно ресинхронизируется, но первые несколько команд после
встроенных данных могут быть пропущены, как показано на рис. 6.2.
На рисунке показана рассинхронизация дизассемблера в части секции кода. Мы видим несколько встроенных байтов данных (0x8e 0x20
0x5c 0x00), за которыми следуют команды (push rbp, mov rbp,rsp и т. д.).
Если бы дизассемблер был идеально синхронизирован, то декодировал бы все байты, как показано в левой части рисунка (столбец «Синхронизирован»). Но вместо этого наивный линейный дизассемблер
интерпретирует встроенные данные как код и декодирует байты, как
показано в столбце «Смещение на –4 байта». Как видим, встроенные данные были декодированы как последовательность команд mov
fs,[rax], pop rsp и add [rbp+0x48],dl. Последняя команда особенно
неприятна, потому что она пересекает область встроенных данных
и залезает в область настоящих команд! При этом команда «съедаОсновы дизассемблирования и анализа двоичных файлов

137

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

Смещение на –4 байта

Смещение на –3 байта

Встроенные данные

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

По счастью, на платформе x86 поток дизассемблированных команд
обычно автоматически ресинхронизируется после всего нескольких
команд. Но даже одна пропущенная команда может стать причиной
проблем, если вы занимаетесь автоматизированным анализом или
хотите модифицировать двоичный код, исходя из дизассемблированного. В главе 8 мы увидим, что вредоносные программы иногда
намеренно включают байты, призванные вызвать рассинхронизацию
дизассемблера и скрыть свое истинное поведение.
На практике такие линейные дизассемблеры, как objdump, можно
безопасно использовать для дизассемблирования двоичных ELF-фай­
лов, созданных недавними версиями компиляторов типа gcc или
clang. Версии этих компиляторов для x86 обычно не генерируют
встроенные данные. С другой стороны, Visual Studio делает это, поэтому рекомендуется внимательно следить за ошибками дизассемб­
лирования при использовании objdump для PE-файлов. То же самое

138

Глава 6

относится к анализу ELF-файлов для архитектур, отличных от x86,
например ARM. А если вы анализируете вредоносный код с по­мощью
линейного дизассемблера, то гарантий вообще никаких нет, поскольку автор может запутать код так, что встроенные данные покажутся
милой шалостью!

6.1.2 Рекурсивное дизассемблирование
В отличие от линейного, рекурсивное дизассемблирование учитывает
поток управления. Начинается оно с известных точек входа в двоичный файл (например, с главной точки входа и экспортируемых функций), а оттуда рекурсивно следует за потоком управления (например,
по командам перехода и вызова), обнаруживая таким образом код.
Это позволяет рекурсивному дизассемблеру обходить байты данных
во всех случаях, кроме самых экзотических1. Недостаток данного
подхода заключается в том, что не всякий поток управления легко
проследить. Например, часто трудно, а то и невозможно, статически
определить возможные конечные адреса косвенных переходов или
вызовов. Поэтому дизассемблер может пропускать участки кода (или
даже целые функции, такие как f1 и f2 на рис. 6.1), на который ведут
команды косвенного перехода или вызова, если только не использовать специальные (зависящие от компилятора и чреватые ошибками)
эвристики для распознавания потока управления.
Рекурсивное дизассемблирование – стандарт де-факто во многих
приложениях обратной разработки, например для анализа вредоносного ПО. IDA Pro (показана на рис. 6.3) – один из самых продвинутых
и широко используемых рекурсивных дизассемблеров. Эта программа предназначена для интерактивного использования (IDA расшифровывается как Interactive DisAssembler) и предлагает многочисленные
возможности, в т. ч. визуализацию кода, исследование кода, написание скриптов (на Python) и даже декомпиляцию2, которые не найдешь
в таких простых инструментах, как objdump. Конечно, все это обходится не даром: на момент написания книги стоимость лицензии на IDA
Starter (упрощенная версия IDA Pro) начиналась от 739 долларов, а на
полнофункциональную IDA Professional – от 1409 долларов и выше.
Но не переживайте – для чтения этой книги покупать IDA Pro не придется. Нас будет интересовать не столько интерактивная обратная
разработка, сколько создание собственных инструментов двоичного
анализа, основанных на бесплатных каркасах.
1

2

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

139

Рис. 6.3. Графовое представление в IDA Pro

На рис. 6.4 показаны некоторые проблемы, с которыми на практике
сталкиваются рекурсивные дизассемблеры типа IDA Pro. Конкретно:
мы видим, как gcc версии 5.1.1 откомпилировал простую написанную
на C функцию из программы opensshd v7.1p2 на платформе x64.
В левой части рисунка, занятой исходным кодом функции на C,
видно, что функция не делает ничего особенного. Она в цикле for
обходит массив, выполняя на каждой итерации предложение switch,
чтобы решить, что делать с текущим элементом: пропустить неинтересные элементы, вернуть индекс элемента, удовлетворяющего определенным критериям, или напечатать сообщение об ошибке и завершиться, если происходит что-то неожиданное. Несмотря на простоту
C-кода, правильно дизассемблировать откомпилированную версию
(показана справа) далеко не тривиально.
На рис. 6.4 видно, что реализация предложения switch на платформе x64 основана на таблице переходов – конструкции, которую современные компиляторы генерируют очень часто. Таблица переходов
позволяет избежать сложного нагромождения команд условного перехода. Вместо этого команда по адресу 0x4438f9 пользуется входным
значением переключателя, чтобы вычислить (в регистре rax) индекс
той записи в таблице, в которой хранится адрес соответствующей
ветви case. Таким образом, для передачи управления любой ветви,
определенной в таблице, достаточно одной команды косвенного перехода, расположенной по адресу 0x443901.
Этот подход эффективен, но косвенный поток управления создает
трудности рекурсивному дизассемблеру. Отсутствие явного конечно-

140

Глава 6

Рис. 6.4. Пример дизассемблированного предложения switch из (opensshd
v7.1p2 откомпилированной gcc 5.1.1 для x64, исходный код для краткости
отредактирован). Интересные строки выделены серым цветом
Основы дизассемблирования и анализа двоичных файлов

141

го адреса в команде косвенного перехода мешает дизассемблеру проследить поток команд после этой точки. В результате все команды,
на которые производится косвенный переход, могут остаться необнаруженными, если только дизассемблер не реализует специальные
(зависящие от компилятора) эвристики для распознавания и разбора
таблиц перехода1. В нашем примере это означает, что рекурсивный
дизассемблер, не реализующий эвристику распознавания switch, вообще не сможет найти команды по адресам 0x443903–0x443925.
Ситуация осложняется еще и наличием нескольких команд ret
внутри switch, а также вызовов функции fatal, которая печатает сообщение об ошибке и никогда не возвращается. В общем случае небезопасно предполагать, что после команды ret или команды call,
не возвращающей управление, есть какие-то команды; вполне может статься, что за ними следуют данные или байты заполнения,
которые не должны разбираться как код. Однако противоположное
предположение – что за этими командами нет кода – может привес­
ти к пропус­ку команд дизассемблером и, следовательно, неполному
дизассемб­лированию.
И это еще не все трудности, с которыми сталкиваются рекурсивные дизассемблеры; бывают куда более трудные ситуации, особенно
в функциях посложнее, чем та, что показана в примере. Так что ни
линейное, ни рекурсивное дизассемблирование не совершенно. Для
«честных» двоичных ELF-файлов на платформе x86 линейное дизассемблирование – хороший выбор, потому что дает полный и точный
результат: в таких файлах обычно нет встроенных данных, сбивающих дизассемблер с толку, и не происходит пропуска кода из-за косвенного потока управления. С другой стороны, если мы имеем дело со
встроенными данными или вредоносным кодом, то, наверное, разум­
нее будет воспользоваться рекурсивным дизассемблером, который не
так просто сбить с пути, как линейный.
В тех случаях, когда правильность дизассемблирования имеет первостепенное значение, даже в ущерб полноте, можно воспользоваться динамическим дизассемблированием. Посмотрим, чем этот подход отличает от только что рассмотренных статических методов.

6.2

Динамическое дизассемблирование
В предыдущих разделах мы видели, с какими трудностями сталкиваются статические дизассемблеры: различение данных и кода, разрешение косвенных вызовов и т. д. Динамический анализ решает мно1

142

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

Глава 6

гие из этих проблем, потому что располагает значительным объемом
информации, доступной во время выполнения, в частности содержимым регистров и памяти. Если выполнение доходит до некоторого
адреса, мы можем быть абсолютно уверены, что по этому адресу есть
команда, поэтому динамический дизассемблер не страдает от проб­
лем неточной интерпретации, характерной для статического. Это
позволяет динамическим дизассемблерам, которые иначе называют
трассировщиками выполнения или трассировщиками команд, просто
печатать команды (и, возможно, содержимое регистров и памяти)
в процессе выполнения программы. Основной недостаток этого подхода – проблема покрытия кода: динамический дизассемблер видит
не все команды, а только те, которые выполняет. Я вернусь к проблеме
покрытия кода ниже в этом разделе. А пока рассмотрим конкретный
пример трассировки выполнения.

6.2.1 Пример: трассировка выполнения двоичного
файла в gdb
Удивительно, но в Linux нет широко распространенного стандартного инструмента, который выполнял бы трассировку типа «выстрелил
и забыл» (в отличие от Windows, где имеются великолепные инструменты наподобие OllyDbg1). Если ограничиться стандартными инструментами, то проще всего воспользоваться несколькими командами gdb, как показано в листинге 6.1.
Листинг 6.1. Динамическое дизассемблирование с по­мощью gdb
$ gdb /bin/ls
GNU gdb (Ubuntu 7.11.1-0ubuntu1~16.04) 7.11.1
...
Reading symbols from /bin/ls...(no debugging symbols found)...done.
 (gdb) info files
Symbols from "/bin/ls".
Local exec file:
`/bin/ls', file type elf64-x86-64.
 Entry point: 0x4049a0
0x0000000000400238 - 0x0000000000400254 is .interp
0x0000000000400254 - 0x0000000000400274 is .note.ABI-tag
0x0000000000400274 - 0x0000000000400298 is .note.gnu.build-id
0x0000000000400298 - 0x0000000000400358 is .gnu.hash
0x0000000000400358 - 0x0000000000401030 is .dynsym
0x0000000000401030 - 0x000000000040160c is .dynstr
0x000000000040160c - 0x000000000040171e is .gnu.version
0x0000000000401720 - 0x0000000000401790 is .gnu.version_r
0x0000000000401790 - 0x0000000000401838 is .rela.dyn
0x0000000000401838 - 0x00000000004022b8 is .rela.plt
0x00000000004022b8 - 0x00000000004022d2 is .init
0x00000000004022e0 - 0x00000000004029f0 is .plt
1

См. http://www.ollydbg.de/.
Основы дизассемблирования и анализа двоичных файлов

143










0x00000000004029f0 - 0x00000000004029f8
0x0000000000402a00 - 0x0000000000413c89
0x0000000000413c8c - 0x0000000000413c95
0x0000000000413ca0 - 0x000000000041a654
0x000000000041a654 - 0x000000000041ae60
0x000000000041ae60 - 0x000000000041dae4
0x000000000061de00 - 0x000000000061de08
0x000000000061de08 - 0x000000000061de10
0x000000000061de10 - 0x000000000061de18
0x000000000061de18 - 0x000000000061dff8
0x000000000061dff8 - 0x000000000061e000
0x000000000061e000 - 0x000000000061e398
0x000000000061e3a0 - 0x000000000061e600
0x000000000061e600 - 0x000000000061f368
(gdb) b *0x4049a0
Breakpoint 1 at 0x4049a0
(gdb) set pagination off
(gdb) set logging on
Copying output to gdb.txt.
(gdb) set logging redirect on
Redirecting output to gdb.txt.
(gdb) run
(gdb) display/i $pc
(gdb) while 1
>si
>end
chapter1 chapter2 chapter3 chapter4 chapter5
chapter6 chapter7 chapter8 chapter9 chapter10
chapter11 chapter12 chapter13 inc
(gdb)

is
is
is
is
is
is
is
is
is
is
is
is
is
is

.plt.got
.text
.fini
.rodata
.eh_frame_hdr
.eh_frame
.init_array
.fini_array
.jcr
.dynamic
.got
.got.plt
.data
.bss

Здесь мы загружаем в gdb файл /bin/ls и получаем трассу всех команд,
выполняемых в процессе распечатки содержимого текущего каталога.
После запуска gdb можно запросить информацию о загруженных в него
файлах (в данном случае загружен только выполняемый файл /bin/ls)
. В ответ мы получим адрес точки входа в программу, так чтобы
можно было поставить точку остановка, срабатывающую сразу после
начала работы двоичного файла . Затем мы отключаем разбие­ние на
страницы  и конфигурируем gdb, так чтобы он отправлял все в файл,
а не на стандартный вывод . По умолчанию файл журнала называется gdb.txt. В режиме разбиения на страницы gdb приостанавливается
после вывода определенного числа строк, давая пользователю возможность прочитать напечатанное на экране, прежде чем двигаться
дальше. Этот режим по умолчанию включен. Поскольку мы выводим
в файл, такие паузы не нужны – нам просто пришлось бы раз за разом
нажимать клавишу для продолжения, что быстро наскучило бы.
Настроив все, что нужно, мы запускаем двоичный файл . Выполнение приостанавливается сразу, как встретится точка входа. Это дает
нам шанс попросить gdb вывести в файл первую команду , а затем
войти в цикл while , который выполняет по одной команде за раз
 (это называется пошаговый режим), пока еще остаются команды.

144

Глава 6

Каждая команда, выполненная в пошаговом режиме, автоматически выводится в файл журнала в том же формате, что и выше. После
того как программа завершится, мы получим файл, содержащий все
выполненные команды. Разумеется, файл будет довольно длинным;
даже при простом прогоне небольшой программы процессор выполняет десятки, а то и сотни тысяч команд, как видно из листинга 6.2.
Листинг 6.2. Результат динамического дизассемблирования с по­мощью gdb
 $ wc -l gdb.txt

614390 gdb.txt
 $ head -n 20 gdb.txt

Starting program: /bin/ls
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Breakpoint 1, 0x00000000004049a0 in ?? ()
 1: x/i $pc

=> 0x4049a0:
0x00000000004049a2
1: x/i $pc
=> 0x4049a2:
0x00000000004049a5
1: x/i $pc
=> 0x4049a5:
0x00000000004049a6
1: x/i $pc
=> 0x4049a6:
0x00000000004049a9
1: x/i $pc
=> 0x4049a9:
0x00000000004049ad

xor
%ebp,%ebp
in ?? ()
mov
%rdx,%r9
in ?? ()
pop
%rsi
in ?? ()
mov
%rsp,%rdx
in ?? ()
and
$0xfffffffffffffff0,%rsp
in ?? ()

Утилита wc показывает, что файл журнала содержит 614 390 строк,
гораздо больше, чем мы можем показать в тексте книги . Чтобы составить представление о том, как выглядит вывод, можно воспользоваться утилитой head и напечатать первые 20 строк файла . Собственно выполнение начинается в точке . Для каждой выполненной
команды gdb печатает инструкцию вывода этой команды в файл, затем саму команду и, наконец, контекст, описывающий местонахождение команды (неизвестный, поскольку двоичный файл зачищен).
С помощью grep можно отфильтровать всё, кроме строк, содержащих
выполненные команды, поскольку только они нас и интересуют. Результат показан в листинге 6.3.
Листинг 6.3. Профильтрованный результат динамического
дизассемблирования с по­мощью gdb
$ egrep 'ˆ=> 0x[0-9a-f]+:' gdb.txt | head -n 20
=> 0x4049a0:
xor
%ebp,%ebp
Основы дизассемблирования и анализа двоичных файлов

145

=>
=>
=>
=>
=>
=>
=>
=>
=>
=>
=>
=>
=>
=>
=>
=>
=>
=>
=>

0x4049a2:
0x4049a5:
0x4049a6:
0x4049a9:
0x4049ad:
0x4049ae:
0x4049af:
0x4049b6:
0x4049bd:
0x4049c4:
0x4022e0:
0x4022e6:
0x413be0:
0x413be2:
0x413be4:
0x413be7:
0x413be9:
0x413beb:
0x413bf2:

mov
pop
mov
and
push
push
mov
mov
mov
callq
pushq
jmpq
push
push
mov
push
push
lea
push

%rdx,%r9
%rsi
%rsp,%rdx
$0xfffffffffffffff0,%rsp
%rax
%rsp
$0x413c50,%r8
$0x413be0,%rcx
$0x402a00,%rdi
0x402640
0x21bd22(%rip) # 0x61e008
*0x21bd24(%rip) # 0x61e010
%r15
%r14
%edi,%r15d
%r13
%r12
0x20a20e(%rip),%r12 # 0x61de00
%rbp

В таком виде воспринимать результат гораздо удобнее.

6.2.2 Стратегии покрытия кода
Основной недостаток любого динамического анализа, а не только динамического дизассемблирования – проблема покрытия кода: анализ
видит только те команды, которые были выполнены во время прогона. Так что если важная информация скрыта в других командах,
то анализ о ней никогда не узнает. Например, если вы динамически
анализируете программу, содержащую логическую бомбу (например,
активацию вредоносного поведения в какой-то момент в будущем),
то ничего не узнаете о ней, пока не станет слишком поздно. С другой
стороны, внимательное изучение программы методами статического
анализа могло бы обнаружить проблему. Другой пример – динамически тестируя программу в поисках дефектов, вы никогда не можете
быть уверены, что в каком-то редко посещаемом уголке не притаилась ошибка, ускользнувшая от тестов.
Многие вредоносные программы даже активно пытаются спрятаться от инструментов динамического анализа или отладчиков типа gdb.
Практически все такие инструменты порождают в окружении нечто,
допускающее обнаружение; даже если анализ больше ничем себя не
выдает, он неизбежно замедляет выполнение, и этого достаточно для
обнаружения. Вредоносные программы обнаруживают такие вещи
и скрывают свое истинное поведение, если знают, что подвергаются
анализу. Чтобы выполнить динамический анализ в такой ситуации,
необходимо произвести обратную разработку, а затем подавить все
препятствующие анализу проверки (например, перезаписав байты
кода другими). Из-за таких приемов препятствования анализу обычно имеет смысл дополнять динамический анализ вредоносных программ статическим.

146

Глава 6

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

Комплекты тестов
Один из самых простых и популярных способов увеличить покрытие кода – прогон анализируемого двоичного файла с известными
тестовыми данными. Разработчики часто вручную создают тесты
для своих программ, стараясь подобрать входные данные, так чтобы
покрыть как можно большую часть функциональности. Такие комплекты тестов идеальны для динамического анализа. Чтобы добиться
хорошего покрытия, просто выполните анализ программы с каждым
набором тестовых данных. Конечно, у подобного подхода есть недостаток – готовые комплекты тестов не всегда удается добыть, например для коммерческих или вредоносных программ.
Как именно использовать комплекты тестов для покрытия кода
приложения, зависит от структуры комплекта. Как правило, в файле
Makefile существует специальная цель test, которой можно воспользоваться для прогона комплекта тестов, выполнив команду make test.
Цель test нередко устроена, как показано в листинге 6.4.
Листинг 6.4. Структура цели test в файле Makefile
PROGRAM := foo
test: test1 test2 test3 # ...
test1:
$(PROGRAM) < input > output
diff correct output
# ...

Переменная PROGRAM содержит имя тестируемого приложения,
в данном случае foo. Цель test зависит от ряда тестов (test1, test2
и т. д.), каждый из которых вызывается при выполнении make test.
Каждый тест заключается в выполнении PROGRAM с некоторыми входными данными, запоминании выхода и сравнении его с правильным
выходом посредством diff.
Существует много других (более лаконичных) способов реализовать каркас тестирования такого типа, но суть дела в том, что мы можем прогнать свой инструмент динамического анализа для каждого
теста, просто подменив переменную PROGRAM. Предположим, к примеру, что мы хотим прогнать каждый тест foo с по­мощью gdb. (На пракОсновы дизассемблирования и анализа двоичных файлов

147

тике вместо gdb вы, вероятно, воспользуетесь каким-либо полностью
автоматизированным средством динамического анализа, которые
научитесь создавать в главе 9.) Это можно было бы сделать следующим образом:
make test PROGRAM="gdb foo"

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

Фаззинг
Существуют инструменты, называемые фаззерами, которые автоматически генерируют входные данные, стремясь покрыть новые пути
в коде данного двоичного файла. Из хорошо известных фаззеров упомянем AFL, проект Microsoft Springfield и Google OSS-Fuzz. Можно выделить две широкие категории фаззеров, различающиеся способом
генерирования входных данных:
1) генерирующие фаззеры: генерируют входные данные с чистого
лис­та (возможно, даже не зная их ожидаемого формата);
2) мутирующие фаззеры: генерируют новые входные данные, изменяя каким-то образом допустимые входные данные, например отправляясь от имеющегося набора тестов.
Успешность и качество работы фаззеров сильно зависят от доступной фаззеру информации. Например, очень полезно иметь исходный
код или информацию об ожидаемом формате входных данных. Если
ни то, ни другое недоступно (и даже если все известно), фаззинг может занять много времени и так и не добраться до кода, скрытого за
сложными последовательностями условий if/else, о которых фаззер
не «догадался». Фаззеры обычно применяются для поиска дефектов
в программе путем подачи разных входных данных, пока программа
не «грохнется».
В этой книге я не буду вдаваться в детали фаззинга, но призываю
вас поэкспериментировать с каким-нибудь бесплатным инструментом. Методы работы с фаззерами различаются. Хорошим кандидатом
для экспериментирования является программа AFL, она бесплатна,
и для нее есть хорошая онлайновая документация1. Кроме того, в главе 10 мы поговорим, как можно дополнить фаззинг динамическим
анализом заражения.
1

148

См. http://lcamtuf.coredump.cx/afl/.

Глава 6

Символическое выполнение
Символическое выполнение – продвинутая техника, которую мы по­
дробно обсудим в главах 12 и 13. Помимо покрытия кода, у нее много
других применений. Здесь я расскажу лишь об общей идее символического выполнения в контексте покрытия кода, опуская многочисленные детали, поэтому не расстраивайтесь, если не все будет понятно.
Обычно при выполнении приложения используются конкретные
значения всех переменных. В каждый момент выполнения все регист­
ры процессора и области памяти содержат определенные значения,
и эти значения изменяются по ходу вычислений. Символическое выполнение устроено иначе.
Если в двух словах, то приложение выполняется не с конкретными, а с символическими значениями. Символические значения можно
представлять себе как математические символы. Символическое выполнение – это по существу эмуляция программы, когда все или некоторые переменные (или состояния регистров и памяти) представлены такими символами1. Чтобы лучше понять, что имеется в виду,
рассмотрим псевдокод в листинге 6.5.
Листинг 6.5. Пример псевдокода для иллюстрации символического
выполнения
 x = int(argv[0])

y = int(argv[1])
 z = x + y
 if(x < 5)

foo(x, y, z)
 else

bar(x, y, z)

Программа принимает два аргумента в командной строке, преобразует их в числа и сохраняет в двух переменных, x и y . В начале
символического выполнения можно было бы сказать, что переменная
x содержит символическое значение α1, а y – α2. И α1, и α2 – символы,
способные представлять любое числовое значение. Затем в процессе
эмуляции программа вычисляет формулы с этими символами. Например, операция z = x + y приводит к тому, что z принимает символическое выражение α1 + α2 .
Одновременно в процессе символического выполнения вычисляются путевые ограничения, т. е. ограничения на конкретные значения,
которые могут принимать символы с учетом тех ветвей, по которым
они прошли. Например, если была выбрана ветвь if(x < 5), то в символическое выполнение добавляется путевое ограничение α1 < 5 . Это
ограничение выражает тот факт, что если выбрана данная ветвь if, то
1

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

149

α1 (символическое значение, хранящееся в x) должно быть меньше 5.
В противном случае эта ветвь не была бы выбрана. Для каждой ветви
символическое выполнение соответственно расширяет список путевых ограничений.
Какое отношение всё это имеет к покрытию кода? Идея в том, что,
имея список путевых ограничений, мы можем проверить, существуют
ли конкретные входные данные, удовлетворяющие всем ограничениям.
Существуют специальные программы, называемые решателями задач удовлетворения ограничений, которые по заданному списку ограничений проверяют, имеется ли какой-то способ удовлетворить их
все. Например, если единственное ограничение имеет вид α1 < 5, то
решатель может выдать решение α1 = 4 ⋀ α2 = 0. Заметим, что путевые ограничения ничего не говорят об α2, поэтому подойдет любое
значение. Это означает, что если в начале конкретного выполнения
программы задать (с помощью входных аргументов) значение 4 для x
и значение 0 для y, то будет выбрана та же последовательность ветвей,
что при символическом выполнении. Если решения не существует, то
решатель сообщит об этом.
Теперь, чтобы увеличить покрытие кода, мы можем изменить путевые ограничения и спросить у решателя, существует ли способ удовлетворить их. Например, можно «перещелкнуть» ограничение α1 < 5,
заменив его на α1 ≥ 5, и запросить у решателя решение. Решатель в ответ выдаст возможное решение, скажем α1 = 5 ⋀ α2 = 0, которое можно подать на вход конкретному выполнению программы, заставив
ее выбрать ветвь else и тем самым увеличив покрытие кода . Если
решатель сообщит, что решения не существует, то мы понимаем, что
«перещелкнуть» эту ветвь невозможно, и должны искать новые пути,
изменяя другие путевые ограничения.
Из этого обсуждения вы, наверное, вынесли, что символическое выполнение (и даже его применение к покрытию кода) – сложный предмет. Даже обладая возможностью «перещелкивать» путевые ограничения, все равно немыслимо покрыть все пути в программе, потому
что их количество экспоненциально возрастает с увеличением числа
команд ветвления. К тому же решение системы путевых ограничений требует много вычислительных ресурсов; если не принять меры,
то подход на основе символического выполнения легко может стать
немасштабируемым. На практике применять символическое выполнение нужно очень осторожно, чтобы не пострадали ни масштабируемость, ни эффективность. Пока что я изложил лишь основные идеи,
лежащие в основе символического выполнения, но, надеюсь, подготовил вас к тому, чего ожидать от глав 12 и 13.

6.3 Структурирование дизассемблированного
кода и данных
До сих пор я описывал, как статические и динамические дизассемб­
леры находят команды в двоичном файле, но на этом дизассембли-

150

Глава 6

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

6.3.1 Структурирование кода
Для начала рассмотрим различные способы структурирования дизассемблированного кода. Те структуры, которые я покажу, упрощают
анализ кода двумя способами.
Изоляция: разбиение кода на логически связанные блоки упрощает анализ назначения каждого блока и межблочных связей.
zz Выявление потока управления: некоторые из обсуждаемых ниже
структур кода явно описывают не только сам код, но и передачи
управления между блоками кода. Эти структуры можно представить визуально, что позволяет легко и быстро понять, как устрое­
ны потоки управления в коде и что примерно код делает.
zz

Следующие структуры кода полезны как для автоматизированного,
так и для ручного анализа.

Функции
В большинстве языков программирования высокого уровня (включая C, C++, Java, Python и т. д.) функции являются фундаментальными
строительными блоками, образующими логически связанные части
кода. Любой программист знает, что программы, которые хорошо
структурированы и правильно разбиты на функции, гораздо проще
понять, чем плохо структурированные программы, в которых код
напоминает «блюдо спагетти». Поэтому большинство дизассемб­
ле­ров стараются восстановить первоначальную структуру функций
в программе и с ее помощью сгруппировать дизассемблированные
команды в функции. Это называется обнаружением функций. Это не
только делает код проще для инженеров, занимающихся обратной
разработкой, но и помогает выполнять автоматизированный анализ.
Например, одной из целей автоматизированного анализа двоичного
файла может стать поиск ошибок на уровне функций или модификация кода, так чтобы некоторая связанная с безопасностью проверка
производилась в начале и в конце каждой функции.
Если двоичный файл содержит символическую информацию, то
обнаружить функции тривиально; в таблице символов определено
множество функций, включающее имена, начальные адреса и размеры. Увы, как отмечалось в главе 1, двоичные файлы часто зачищаются, и тогда обнаружение функций оказывается куда более сложным
делом. На двоичном уровне пропадают все характерные признаки
функций в исходном коде, поэтому их границы во время компиляции
Основы дизассемблирования и анализа двоичных файлов

151

размываются. Код, принадлежащий определенной функции, может
даже не занимать непрерывный участок в двоичном файле. Части
функции могут быть разбросаны по всей секции кода, а некоторые
участки кода могут даже разделяться несколькими функциями (это
называется перекрытием блоков кода). На практике большинство диз­
ассемблеров предполагают, что функции занимают непрерывный
участок файла и код не разделяется; это действительно так во многих,
но не во всех случаях, а особенно заметные исключения представляют
прошивки и код встраиваемых систем.
Чаще всего стратегия обнаружения функций основана на сигнатурах функций, т. е. характерных последовательностях команд в начале
и в конце функции. Эта стратегия применяется во всех популярных
рекурсивных дизассемблерах, включая IDA Pro. Линейные дизассемб­
леры, в частности objdump, обнаруживают функции, только когда имеются символы. Обычно алгоритмы обнаружения на основе сигнатур
сначала выполняют проход по дизассемблированному двоичному
файлу с целью обнаружения функций, адресуемых непосредственно
командой call. Это просто, гораздо сложнее найти функции, адресуемые только косвенно, а также хвостовые вызовы1. Чтобы разобраться
с такими сложными случаями, детекторы на основе сигнатур обращаются к базам данных о сигнатурах хорошо известных функций.
К числу характерных сигнатур функций относятся хорошо известные прологи функций (команды, инициализирующие кадр стека
функции) и эпилоги функций (команды, уничтожающие кадр стека).
Например, неоптимизированный код функций, генерируемый компиляторами на платформе x86, часто начинается прологом push ebp;
mov ebp,esp и заканчивается эпилогом leave; ret. Многие детекторы
функций ищут такие сигнатуры в двоичном файле и считают их признаками начала и конца функции.
Хотя функции – важный и полезный способ структурирования
дизассемблированного кода, следует всегда помнить о возможности ошибок. На практике паттерны функций зависят от платформы,
от компилятора и от уровня оптимизации, заданного при создании
двоичного файла. В оптимизированных функциях может вообще не
быть прологов и эпилогов, из-за чего детектор на основе сигнатур
не сможет их распознать. В результате ошибки при обнаружении
функций встречаются регулярно. Например, считается нормальным, если в 20 или более процентах случаев дизассемблер неверно
определяет начальный адрес функции или даже сообщает о функции там, где ее нет.
1

152

Если функция F1 завершается вызовом другой функции F2, то говорят, что
имеет место хвостовой вызов. Компиляторы часто оптимизируют хвостовые вызовы: вместо команды call для вызова F2 используют команду jmp.
В этом случае F2 после завершения возвращается прямо в ту точку, откуда
была вызвана F1. Это означает, что F1 не должна возвращать управление
явно, т. е. мы экономим одну команду ret. Из-за использования обычной
команды jmp хвостовой вызов лишает детекторы функций возможности
распознать F2 как функцию.

Глава 6

В недавних исследованиях применяются другие методы обнаружения функций – основанные не на сигнатурах, а на структуре кода1.
Потенциально этот подход точнее, чем основанные на сигнатурах, но
ошибки обнаружения все равно возникают. Эта идея интегрирована
в программу Binary Ninja, исследовательский инструмент, который
может работать с IDA Pro, так что при желании можете попробовать.

Обнаружение функций с по­мощью секции .eh_frame
Интересный альтернативный подход к обнаружению функций в двоичных ELF-файлах основан на использовании секции
.eh_frame, которая позволяет решить проблему обнаружения на
корню. В секции .eh_frame находится информация, относящаяся
к отладочным средствам на основе DWARF, в частности раскрутке стека. Она включает информацию о границах всех функций
в двоичном файле. Эта информация остается даже в зачищенных файлах, если только файл не был откомпилирован gcc с флагом –fno–asynchronous–unwind–tables. Используется она главным
образом для обработки исключений C++, но также и для других
целей, например в функции backtrace() и во внутренних функциях gcc типа __attribute__((__cleanup__(f))) и __builtin_re‑
turn_address(n). Из-за столь многочисленных применений секция .eh_frame по умолчанию сохраняется не только в двоичных
файлах программ, написанных на C++, где нужна для обработки
исключений, но и во всех двоичных файлах, порожденных gcc,
в т. ч. написанных на простом C.
Насколько мне известно, этот метод впервые был описан Райа­
ном О’Нилом (Ryan O’Neill) (псевдоним ElfMaster). На своем
сайте по адресу http://www.bitlackeys.org/projects/eh_frame.tgz он выложил код разбора секции .eh_frame, который находит адреса
и размеры функций.

Графы потоков управления
Разбиение дизассемблированного кода на функции – вещь хорошая,
но некоторые функции весьма велики, поэтому анализ даже одной
функции может оказаться трудным делом. Для организации кода
каждой функции дизассемблеры и каркасы двоичного анализа применяют еще одну структуру данных – граф потока управления (control-flow graph – CFG). CFG полезны как для автоматизированного, так
и для ручного анализа. Кроме того, они дают удобное графическое
представление структуры кода, что позволяет составить первое впечатление о назначении функции. На рис. 6.5 приведен пример CFG
функции в IDA Pro.
1

Прототип такого инструмента имеется по адресу https://www.vusec.net/projects/function-detection/.
Основы дизассемблирования и анализа двоичных файлов

153

Рис. 6.5. CFG, построенный IDA Pro

Как видим, CFG представляет код внутри функции в виде множест­
ва простых блоков кода, соединенных ребрами ветвлений, которые
изображены стрелками. Простой блок – это последовательность
команд­, в которой первая команда является единственной точкой
входа (только на нее могут ссылаться команды перехода в двоичном
файле), а последняя – единственной точкой выхода (только эта команда может осуществлять переход на другой простой блок). Иначе

154

Глава 6

говоря, стрелки могут входить только в первую команду, а исходить
только из последней команды простого блока.
Если простой блок B соединен с простым блоком C ребром в CFG,
значит, последняя команда B может быть переходом на начало C. Если
из B исходит только одно ребро, значит, он точно передает управление блоку на другом конце этого ребра. Например, такую картину мы
увидим для команды косвенного перехода или вызова. С другой стороны, если B заканчивается командой условного перехода, то из него
будет исходить два ребра, и какое из них будет выбрано на этапе выполнения, зависит от результата вычисления условия.
Ребра вызова не являются частью CFG, потому что ведут за пределы функции. Вместо них в CFG показаны только «сквозные» ребра,
ведущие на команду, которая получит управление после возврата
из функции. Существует еще одна структура кода – граф вызовов, –
предназначенная для представления ребер между командами вызова
и функциями. О графах вызовов я расскажу в следующем разделе.
На практике дизассемблеры часто опускают косвенные ребра в CFG,
потому что статически трудно разрешить потенциальные конечные
точки таких ребер. Иногда дизассемблеры определяют глобальный
CFG, а не CFG для отдельных функций. Такой глобальный CFG называется межпроцедурным (ICFG), поскольку по существу представляет
собой объединение CFG всех функций (процедура – альтернативное
название функции). ICFG устраняют необходимость в чреватом ошибками обнаружении функций, но лишены тех преимуществ изоляции,
которыми обладают CFG уровня функций.

Графы вызовов
Графы вызовов похожи на CFG, но показывают связи между точками
вызова и функциями, а не между простыми блоками. Иными словами, CFG показывает поток управления внутри функции, а графы вызовов – как функции могут вызывать друг друга. Как и в случае CFG,
в графах вызовов часто опускают косвенные ребра, потому что невозможно точно определить, какая функция будет вызвана в точке
косвенного вызова.
Слева на рис. 6.6 показан набор функций (с метками от f1 до f4)
и связи между ними по вызовам. Каждая функция состоит из простых
блоков (серые кружки) и ребер ветвлений (стрелки). Справа показан
соответствующий граф вызовов. Как видим, граф вызовов содержит
по одной вершине для каждой функции, а его ребра показывают, что
функция f1 может вызывать f2 и f3, а функция f3 может вызывать f1.
Хвос­товые вызовы, реализованные командами перехода, в графе вызовов показаны как обычные вызовы. Отметим, однако, что косвенный вызов функции f4 из f2 в графе вызовов не показан.
IDA Pro умеет также показывать частичные графы вызовов, на которых изображены только функции, потенциально способные вызвать
указанную вами функцию. Для ручного анализа такие графы зачастую
полезнее полных графов вызовов, которые содержат слишком много
Основы дизассемблирования и анализа двоичных файлов

155

информации. На рис. 6.7 приведен пример частичного графа вызовов,
на котором показаны ссылки на функцию sub_404610. Из графа видно,
откуда вызывается эта функция; например, sub_404610 вызывается из
sub_4e1bd0, которая сама вызывается из sub_4e2fa0.
CFG f1

CFG f2

ов

Вызо
в

Выз

f1

Хвостовой вызов
CFG f3

Косвенный вызов

f2

f3

CFG f4
f4
Граф вызовов

CFG и (косвенные) ребра вызовов

Рис. 6.6. CFG и связи между функциями (слева) и соответствующий граф вызовов
(справа)

Рис. 6.7. Граф вызовов функции sub_404610, построенный в IDA Pro

Дополнительно графы вызовов, порождаемые IDA Pro, показывают команды, которые где-то сохраняют адрес функции. Например,
по адресу 0x4e072c в секции .text находится команда, сохраняющая

156

Глава 6

адрес функции sub_4e2fa0 в памяти. Это называется «взятием адреса»
функции sub_4e2fa0. Функции, адрес которых берется где-то в коде,
называются функциями с сохраняемым адресом (address-taken function).
Знать, адреса каких функций хранятся, полезно, потому что это
значит, что функция может быть вызвана косвенно, даже если мы не
знаем точно, откуда. Если адрес функции нигде не берется и не встречается ни в какой секции данных, то мы знаем, что эта функция никогда не будет вызванакосвенно1. Это полезно для некоторых видов
двоичного анализа и в целях безопасности, например если вы стремитесь обезопасить двоичный файл, ограничив косвенные вызовы
только допустимыми целями.

Объектно-ориентированный код
Многие инструменты двоичного анализа, в т. ч. полнофункциональные дизассемблеры типа IDA Pro, ориентированы на программы,
написанные на процедурных языках, например C. Поскольку в таких
языках основной структурной единицей является функция, то инструменты и дизассемблеры предоставляют такие средства, как детекторы функций, имеющие целью восстановить структуру программы, и графы вызовов для изучения связей между функциями.
В объектно-ориентированных языках типа C++ основной структурной единицей является класс, группирующий логически связанные
функции и данные. Обычно такие языки предлагают сложные механизмы обработки исключений, позволяющие возбуждать из любого
места программы исключения, которые затем перехватываются и обрабатываются специальным блоком кода. К сожалению, современные
инструменты двоичного анализа не умеют восстанавливать иерархии
классов и структуры обработки исключений.
Хуже того, программы на C++ часто содержат много указателей на
функции, поскольку с их помощью обычно реализуются виртуальные
методы. Виртуальными называются методы (функции) класса, которые разрешено переопределять в производном классе. Классический
пример – класс геометрической фигуры Shape и его подкласс Circle,
описывающий круг. В классе Shape определен виртуальный метод
area, который вычисляет площади фигуры, а в Circle этот метод переопределен, так что вычисляет площадь круга.
Компилятор программы на C++ может не знать, на что указывает
указатель во время выполнения: на базовый объект Shape или на производный объект Circle, поэтому у него нет возможности статически определить, какая реализация метода area должна быть на этапе
выполнения. Для решения этой проблемы компиляторы генерируют
так называемые v-таблицы, содержащие указатели на все виртуальные функции одного класса. Обычно v-таблицы хранятся в постоян1

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

157

ной памяти, а в каждом полиморфном объекте имеется указатель
(называемый v-указателем) на v-таблицу данного типа. Для вызова
виртуального метода компилятор генерирует код, который косвенно
вызывает функцию по значению v-указателя во время выполнения.
К сожалению, такие косвенные вызовы сильно усложняют поток выполнения программы.
Отсутствие поддержки объектно-ориентированных программ
в инструментах двоичного анализа и дизассемблерах означает, что
при двоичном анализе программ с классами вы предоставлены сами
себе. В процессе ручной обратной разработки программы на C++ час­
то удается объединить функции и структуры данных, принадлежащие
одному классу, но это требует значительных усилий. Я не стану вдаваться в детали этого предмета, а сосредоточусь на (полу)автоматизированных методах двоичного анализа. Интересующимся ручной
обратной разработкой кода на C++ я рекомендую книгу Eldad Eilam
«Reversing: Secrets of Reverse Engineering» (Wiley, 2005).
В случае автоматизированного анализа вы можете (как делает
большинство инструментов) притвориться, что никаких классов не
существует, и работать с объектно-ориентированными программами
так же, как с процедурными. На самом деле такое «решение» вполне
приемлемо для многих видов анализа и избавляет от необходимости реализовывать специальную поддержку C++, если без нее можно
обойтись.

6.3.2 Структурирование данных
Как мы видели, дизассемблеры автоматически выявляют различные
типы структур в коде, чтобы нам было проще анализировать двоичный файл. К сожалению, этого нельзя сказать о структурах данных.
Автоматическое обнаружение структуры данных в зачищенном двоичном файле – чрезвычайно трудная задача, и, если не считать исследовательских работ1, дизассемблеры даже не пытаются ее решать.
Но есть и исключения. Например, если ссылка на объект данных
передается хорошо известной библиотечной функции, то дизассемб­
леры и IDA Pro, в частности, могут автоматически вывести тип данных из спецификации функции. На рис. 6.8 приведен пример.
В конце простого блока находится обращение к хорошо известной
функции send, служащей для передачи сообщения по сети. Поскольку IDA Pro знает о параметрах send, он может сопоставить им имена
(flags, len, buf, s) и вывести типы данных, хранящихся в регистрах
и в ячейках памяти, из которых эти параметры загружались.
Кроме того, примитивные типы иногда можно вывести из регист­
ров, в которых они хранятся, или из команд, которые ими манипу1

158

В исследованиях по автоматическому обнаружению структур данных
обычно используется динамический анализ для выведения типов объектов в памяти из способа доступа к ним в коде. Дополнительные сведения
можно найти в работе https://www.isoc.org/isoc/conferences/ndss/11/pdf/5_1.pdf.

Глава 6

лируют. Например, если используется регистр или команда с плавающей точкой, то можно сделать вывод, что соответствующие данные
имеют тип с плавающей точкой. Увидев команду lodsb (load string
byte) или stosb (store string byte), можно предположить, что программа
работает со строкой.

Рис. 6.8. IDA Pro автоматически выводит типы данных
из использования в функции send

Для составных типов, например структур struct или массивов, никакие правила не действуют, и вы целиком предоставлены сами себе.
Чтобы понять, почему автоматическая идентификация составного
типа – такая трудная задача, рассмотрим следующую строку кода на
C и результат ее компиляции в машинный код:
ccf->user = pwd->pw_uid;

Эта строка взята из исходного кода nginx v1.8.0, где целое поле копируется из одной структуры в другую. Компилятор gcc v5.1 при заданном уровне оптимизации –O2 генерирует такой машинный код:
mov
mov

eax,DWORD PTR [rax+0x10]
DWORD PTR [rbx+0x60],eax
Основы дизассемблирования и анализа двоичных файлов

Powered by TCPDF (www.tcpdf.org)

159

Теперь рассмотрим следующую строчку кода на C, которая копирует целое число из массива b, выделенного в куче, в другой массив a:
a[24] = b[4];

Вот что генерирует для нее компилятор gcc v5.1 с тем же уровнем
оптимизации ‑O2:
mov
mov

eax,DWORD PTR [rsi+0x10]
DWORD PTR [rdi+0x60],eax

Как видим, код точно такой же, как для присваивания полю структуры, с точностью до имен регистров! Отсюда следует, что никакой
автоматизированный метод анализа не сможет по этой последовательности команд сказать, представляют ли они поиск в массиве,
доступ к структуре или что-то совсем иное. Такого рода проблемы
делают точное обнаружение составных типов данных трудной, а то
и нерешаемой задачей в общем случае. Имейте в виду, что это еще
очень простой пример; а представьте, что вы занимаетесь обратной
разработкой программы, которая содержит массив структур или вложенные структуры, и нужно определить, какие команды какую структуру индексируют! Очевидно, что это сложная задача, для решения
которой необходим углубленный анализ кода. Принимая во внимание сложность правильного распознавания нетривиальных типов
данных, вы теперь понимаете, почему дизассемблеры даже не пытаются автоматически обнаруживать структуры данных.
Чтобы облегчить ручное структурирование данных, IDA Pro позволяет определить собственные составные типы (для чего необходима
обратная разработка) и назначать их элементам данных. Книга Chris
Eagle «The IDA Pro Book» (No Starch Press, 2011) – ценный ресурс по
ручной обратной разработке структур данных в IDA Pro.

6.3.3 Декомпиляция
Как следует из самого названия, декомпилятор стремится «обратить
процесс компиляции». Обычно он начинает работу с дизассемблированного кода и транслирует его на язык более высокого уровня, как
правило, на псевдокод, напоминающий C. Декомпиляторы полезны
для обратной разработки больших программ, потому что декомпилированный код проще читать, чем кучу ассемблерных команд. Но
сфера применимости декомпиляторов ограничена ручной обратной
разработкой, т. к. этот процесс слишком подвержен ошибкам, чтобы
положить его в основу автоматизированного анализа. В этой книге
мы не будем использовать декомпиляцию, но все же взгляните на
лис­тинг 6.6, чтобы получить представление о том, как может выглядеть декомпилированный код.

160

Глава 6

Самым распространенным декомпилятором является Hex-Rays –
плагин, поставляемый вместе с IDA Pro1. В листинге 6.6 показан результат работы Hex-Rays для функции из листинга 6.5.
Листинг 6.6. Функция, декомпилированная Hex-Rays
 void **__usercall sub_4047D4(int a1)

{
 int v1; // eax@1

int
int
int
int

v2; // ebp@1
v3; // ecx@4
v5; // ST10_4@6
i; // [sp+0h] [bp-10h]@3

 v2 = a1 + 12;

v1 = *(_DWORD *)(v2 - 524);
*(_DWORD *)(v2 - 540) = *(_DWORD *)(v2 - 520);
 if ( v1 == 1 )
goto LABEL_5;
if ( v1 != 2 )
 for ( i = v2 - 472; ; i = v2 - 472 )
{
*(_DWORD *)(v2 - 524) = 0;
 sub_7A5950(i);
v3 = *(_DWORD *)(v2 - 540);
*(_DWORD *)(v2 - 524) = -1;
sub_9DD410(v3);
LABEL_5:
;
}
}
*(_DWORD *)(v2 - 472) = &off_B98EC8;
*(_DWORD *)(v2 - 56) = off_B991E4;
*(_DWORD *)(v2 - 524) = 2;
sub_58CB80(v2 - 56);
*(_DWORD *)(v2 - 524) = 0;
sub_7A5950(v2 - 472);
v5 = *(_DWORD *)(v2 - 540);
*(_DWORD *)(v2 - 524) = -1;
sub_9DD410(v5);
 return &off_AE1854;
}

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

Как сам декомпилятор, так и компания, разрабатывающая IDA, называются Hex-Rays.
Основы дизассемблирования и анализа двоичных файлов

161

пользованием обычных операторов C . Декомпилятор также пытается реконструировать управляющие конструкции: ветви if/else ,
циклы  и вызовы функций . Имеется также предложение возврата
в стиле C, благодаря которому становится яснее конечный результат
функции .
Но хотя все это замечательно, помните, что декомпиляция – не что
иное, как инструмент, помогающий понять, что делает программа.
Декомпилированный код может быть совершенно не похож на оригинальный код на C, может содержать явные ошибки и страдает не
только от неточностей самого процесса декомпиляции, но и от неточного дизассемблированного кода, лежащего в его основе. Поэтому
в общем случае не рекомендуется надстраивать поверх декомпиляции следующие уровни анализа.

6.3.4 Промежуточные представления
Системы команд процессоров x86 и ARM содержат много команд со
сложной семантикой. Например, в x86 даже кажущиеся простыми
команды типа add имеют побочные эффекты, в частности установку
флагов состояния в регистре eflags. Из-за большого числа команд
и их побочных эффектов трудно строить автоматизированные рассуждения о двоичных программах. Например, как мы увидим в главах 10–13, динамический анализ потенциальных брешей и движки
символического выполнения должны реализовывать явные обработчики, которые улавливают семантику потоков данных всех анализируемых команд. Аккуратно реализовать все эти обработчики – задача
не из легких.
Промежуточные представления (intermediate representation – IR),
или промежуточные языки, проектируются, чтобы устранить эту обу­
зу. IR – простой язык, который абстрагирует сложности низкоуровневых машинных языков типа x86 или ARM. К числу популярных IR
относятся Reverse Engineering Intermediate Language (REIL) и VEX IR
(используется в каркасе оснащения valgrind1). Существует даже инструмент McSema, который транслирует двоичные файлы в биткод
LLVM (известен также под названием LLVM IR)2.
Идея IR-языков заключается в том, чтобы автоматически транслировать реальный машинный код, например для x86, в промежуточное
представление, улавливающее всю семантику машинного кода, но существенно более простое для анализа. Для сравнения: REIL содержит
всего 17 команд, в отличие от сотен команд в x86. Более того, языки
типа REIL, VEX и LLVM IR явно выражают все операции без неочевидных побочных эффектов.
Реализация шага трансляции с низкоуровневого машинного кода
на IR все равно требует значительных усилий, но после того как эта
работа сделана, реализовать новые виды анализа оттранслированно1
2

162

http://www.valgrind.org/.
https://github.com/trailofbits/mcsema/.

Глава 6

го кода гораздо легче. Вместо написания зависящих от команд обработчиков для каждого акта двоичного анализа требуется только один
раз реализовать шаг трансляции на IR. Более того, трансляторы можно написать для различных архитектур, будь то x86, ARM или MIPS,
и отобразить их все на один и тот же IR. Таким образом, инструмент
двоичного анализа, работающий для этого IR, автоматически наследует поддержку всех архитектур систем команд, поддерживаемых IR.
Трансляция сложной системы команд типа x86 на простой IR типа
REIL, VEX или LLVM, естественно, приводит к гораздо более длинному
коду – это цена, которую приходится платить за удобство анализа. Да
и как может быть иначе, если сложные операции, включающие побочные эффекты, требуется выразить с по­мощью ограниченного числа
простых команд? Для автоматизированного анализа это не проблема,
но человеку читать промежуточные представления нелегко. Чтобы
почувствовать, как выглядит IR, взгляните на листинг 6.7, где показана трансляция команды x86-64 add rax,rdx на язык VEX IR1.
Листинг 6.7. Трансляция команды x86-64 add rax,rdx на язык VEX IR
 IRSB {

t0:Ity_I64 t1:Ity_I64 t2:Ity_I64 t3:Ity_I64
 00 | ------ IMark(0x40339f, 3, 0) ----- 01 | t2 = GET:I64(rax)

02 | t1 = GET:I64(rdx)
 03 | t0 = Add64(t2,t1)
 04 | PUT(cc_op) = 0x0000000000000004

05 | PUT(cc_dep1) = t2
06 | PUT(cc_dep2) = t1
 07 | PUT(rax) = t0
 08 | PUT(pc) = 0x00000000004033a2
09 | t3 = GET:I64(pc)
 NEXT: PUT(rip) = t3; Ijk_Boring
}

Как видим, всего одна команда add транслируется в 10 команд VEX
плюс метаданные. Во-первых, имеются метаданные, сообщающие,
что это суперблок IR (IRSB) , соответствующий одной машинной
коман­де. IRSB содержит четыре временных значения с метками t0–t3,
все типа Ity_I64 (64-разрядное целое) . Затем идут метаданные
IMark , в которых среди прочего указываются адрес и длина машинной команды.
Далее следуют сами команды IR, моделирующие add. Сначала мы
видим две команды GET, которые выбирают 64-разрядные значения
из rax и rdx во временные ячейки t2 и t1 соответственно . Заметим,
что rax и rdx – просто символические имена частей состояния VEX,
1

Этот пример был сгенерирован с по­мощью PyVex: https://github.com/angr/
pyvex. Сам язык VEX документирован в заголовочном файле https://github.
com/angr/vex/blob/dev/pub/libvex_ir.h.
Основы дизассемблирования и анализа двоичных файлов

163

используемых для моделирования этих регистров, – команды VEX
выбирают данные не из настоящих регистров rax и rdx, а из отражающего их состояния VEX. Для выполнения сложения в IR служит команда VEX Add64, которая вычисляет сумму двух 64-разрядных целых
t2 и t1 и помещает результат в t0 .
Вслед за сложением находятся команды PUT, которые моделируют
побочные эффекты команды add, в т. ч. изменение флагов состояния
x86 . Затем еще одна команда PUT сохраняет результат сложения
в части состояния VEX, представляющей rax . Наконец, VEX IR моделирует изменение счетчика программы, перемещая его к следующей команде . Ijk_Boring (Jump Kind Boring)  – это аннотация потока управления, означающая, что команда add никаким значимым
образом не влияет на поток управления, т. к. не является переходом,
а просто «проваливается» на следующую команду в памяти. С другой
стороны, команды перехода могут быть помечены аннотациями вида
Ijk_Call или Ijk_Ret, которые информируют инструменты анализа
о том, что имеет место вызов или возврат.
При реализации инструментов поверх существующего каркаса
двоичного анализа обычно не приходится иметь дело с IR. Каркас обрабатывает все взаимодействие с IR внутри себя. Однако о промежуточных представлениях полезно знать, если вы планируете реализовать собственный каркас или модифицировать уже имеющийся.

6.4

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

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

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

164

Глава 6

анализировать код на уровне функций. Еще одна причина использования функций – масштабируемость: некоторые виды анализа попросту невозможно применить к программе целиком.
Количество возможных путей выполнения программы экспоненциально увеличивается с ростом числа передач управления (например,
переходов и вызовов). Если в программе имеется всего 10 ветвлений
if/else, то количество возможных путей выполнения кода равно 210 =
1024. В программе, где таких ветвлений сотня, количество возможных путей равно 1,27 × 1030, а тысяча ветвлений дает 1,07 × 10301 путей!
Во многих программах ветвлений гораздо больше, поэтому не хватит
никаких вычислительных ресурсов, чтобы проанализировать все возможные пути выполнения нетривиальной программы.
Именно поэтому вычислительно затратные виды двоичного анализа часто являются внутрипроцедурными: рассматривается только
код внутри одной функции. Обычно в ходе внутрипроцедурного анализа по очереди анализируются CFG всех функций. Напротив, в случае межпроцедурного анализа рассматривается вся программа целиком, т. е. CFG всех функций объединяются вместе посредством графа
вызовов.
Поскольку большинство функций содержат лишь несколько десят­ков команд передачи управления, сложные виды анализа вычислительно реализуемы на уровне функций. Если по отдельности анализировать 10 функций, в каждой из которых 1024 возможных пути выполнения, то всего придется проанализировать 10 × 1024 = 10 240 путей;
это гораздо лучше, чем 102410 ≈ 1,27×1030 путей, которые пришлось бы
рассматривать, если бы программа сразу анализировалась целиком.
Недостаток внутрипроцедурного анализа – его неполнота. Например, если программа содержит ошибку, которая проявляется только
в результате очень специфической комбинации вызовов функций,
то внутрипроцедурный инструмент поиска ошибок ее не найдет. Он
просто будет рассматривать каждую функцию в отдельности и придет к выводу, что все в порядке. С другой стороны, межпроцедурный
инструмент нашел бы ошибку, но на это у него может уйти столько
времени, что результат уже и не важен.
Другой пример – рассмотрим, как компилятор мог бы оптимизировать код в листинге 6.8 при использовании внутрипроцедурной
и межпроцедурной оптимизаций.
Листинг 6.8. Программа, содержащая функцию dead
#include
static void
 dead(int x)

{
 if(x == 5) {

printf("Never reached\n");
}
}
Основы дизассемблирования и анализа двоичных файлов

165

int
main(int argc, char *argv[])
{
 dead(4);
return 0;
}

В этом примере функция dead принимает один целый параметр x
и ничего не возвращает . Внутри функции имеется ветвь, которая
печатает сообщение, только если x равно 5 . Но dead вызывается
лишь в одном месте, и в качестве аргумента ей передается константа
4 . Таким образом, ветвь  никогда не выполняется, и сообщение
не печатается.
Компиляторы применяют оптимизацию, называемую устранением
мертвого кода, чтобы найти участки кода, которые никогда не достигаются, и исключить такой бесполезный код из откомпилированного
двоичного файла. Но в данном случае внутрипроцедурное исключение мертвого кода не сможет исключить бесполезную ветвь . Действительно, когда на этапе оптимизации рассматривается функция
dead, о коде других функций ничего неизвестно, поэтому оптимизатор не знает, где и как вызывается dead. Точно так же во время обработки main оптимизатор не может заглянуть внутрь dead и заметить,
что для конкретного аргумента, переданного в точке , эта функция
ничего не делает.
Требуется межпроцедурный анализ, чтобы прийти к выводу, что
dead вызывается из main только с аргументом 4, а значит, ветвь  никогда не будет выполнена. Таким образом, внутрипроцедурное устранение мертвого кода оставило бы в двоичном файле всю функцию
dead целиком (и все ее вызовы), хотя это не имеет ни малейшего смысла, тогда как межпроцедурное исключило бы бесполезную функцию.

Чувствительность к потоку
Двоичный анализ может быть чувствительным или нечувствительным к потоку1. Чувствительность к потоку означает, что в процессе
анализа принимается во внимание порядок команд. Чтобы стало понятнее, рассмотрим следующий пример на псевдокоде.
x = unsigned_int(argv[0]) # x ∊ [0,∞]
x = x + 5
# x ∊ [5,∞]
x = x + 10
# x ∊ [15,∞]

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

166

Эти термины заимствованы из теории компиляторов.

Глава 6

решила бы, что x может содержать любое значение, потому что получена от пользователя. И хотя в общем случае x действительно могла
бы принимать любое значение в какой-то точке программы, это неверно для всех точек программы. Поэтому информация, предоставленная нечувствительным к потоку анализом, не очень точна, но этот
анализ сравнительно дешево обходится в терминах вычислительной
сложности.
Чувствительная к потоку версия дала бы более точные результаты.
В отличие от нечувствительной к потоку версии, она дает оценку возможного множества значений x в каждой точке программы, принимая
во внимание предыдущие команды. В точке анализ приходит к выводу, что x может иметь любое значение без знака, поскольку оно было
получено от пользователя и пока что никакие команды не наложили
ограничений на значение x. Однако в точке  оценку можно улучшить: так как к x прибавлено 5, мы знаем, что начиная с этого мес­та x
может принимать только значения, не меньшие 5. Аналогично после
команды в точке  известно, что x должно быть не меньше 15.
Разумеется, в реальности все не так просто, поскольку приходится иметь дело с гораздо более сложными конструкциями, например
ветв­лениями, циклами и вызовами функций (возможно, рекурсивными), а не только с простым линейным кодом. В результате анализ,
чувствительный к потоку, оказывается намного сложнее нечувствительного и требует больше вычислительных ресурсов.

Контекстная зависимость
Если чувствительность к потоку учитывает порядок команд, то контекстная зависимость принимает во внимание порядок вызова
функций. Контекстная зависимость имеет смысл только для межпроцедурных видов анализа. В случае контекстно-независимого межпроцедурного анализа вычисляется один глобальный результат. С другой стороны, в случае контекстно-зависимого анализа вычисляется
отдельный результат для каждого возможного пути в графе вызовов
(иными словами, для каждого возможного порядка появления функций в стеке вызовов). Отсюда, кстати, следует, что точность контекстно-зависимого анализа ограничена верностью графа вызовов. Контекстом анализа является состояние, накопленное в процессе обхода
графа. Я буду представлять это состояние в виде списка ранее посещенных функций и обозначать < f1, f2, …, fn >.
На практике контекст обычно ограничен, потому что при очень объемном контексте чувствительный к потоку анализ требует слишком
много вычислительных ресурсов. Например, анализ может вычислять
только результаты для контекстов из пяти (или иного произвольного числа) соседних функций, а не для полных путей неопределенной
длины. В качестве примера преимуществ, которые дает контекстнозависимый анализ, рассмотрим рис. 6.9.
На рисунке показано, как контекстная зависимость влияет на результат анализа косвенных вызовов в opensshd v3.5. Цель аналиОсновы дизассемблирования и анализа двоичных файлов

167

за – определить возможные цели косвенных вызовов из функции
channel_handler (точнее, из строки (*ftab[c–>type])(c, readset,
writeset);). В точке косвенного вызова цель берется из таблицы указателей на функции, переданной channel_handler в аргументе ftab.
Функция channel_handler вызывается из двух других функций: chan‑
nel_prepare_select и channel_after_select. Обе передают в ftab свою
таблицу указателей.
 Целевое множество,
найденное контекстно-независимым анализом

 Целевое множество,
найденное контекстно-зависимым анализом
(контекст – )

 Целевое множество,
найденное контекстно-зависимым анализом
(контекст – )

Рис. 6.9. Контекстно-зависимый и контекстно-независимый анализ косвенных
вызовов в opensshd

Контекстно-независимый анализатор косвенных вызовов приходит к выводу, что целью косвенного вызова в channel_handler может
быть любой указатель на функцию в таблице channel_pre (передаваемой из channel_prepare_select) или channel_post (передаваемой из
channel_after_select). То есть он заключает, что множество возможных целей является объединением всех возможных множеств на любом пути выполнения программы .
С другой стороны, контекстно-зависимый анализатор определяет
разные множества целей для каждого возможного контекста предыдущих вызовов. Если channel_handler была вызвана из функции chan‑
nel_prepare_select, то допустимыми целями являются только те, что
находятся в таблице channel_pre, которую та передает channel_han‑
dler . Если же channel_handler была вызвана из channel_after_se‑
lect, то допустимы только цели из таблицы channel_post . В этом
примере я рассматривал лишь контекст длины 1, но в общем случае
он может быть сколь угодно длинным (но не более самого длинного
из возможных путей в графе вызовов).
Как и в случае чувствительности к потоку, плюсом контекстной зависимости является повышенная точность, а минусом – дополнитель-

168

Глава 6

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

6.4.2 Анализ потока управления
Цель любого двоичного анализа – узнать о свойствах потока управления в программе, о свойствах потока данных или о том и другом сразу.
Если изучаются свойства потока управления, то мы, не мудрствуя лукаво, говорим об анализе потока управления, ну а анализ, направленный на изучение потока данных, так и называется анализом потока
данных. Различие обусловлено только предметом изучения; является
ли анализ внутрипроцедурным или межпроцедурным, чувствительным или нечувствительным к потоку, контекстно-зависимым или
контекстно-независимым, не оговаривается. Начнем со знакомства
с одним из типичных видов анализа потока управления – обнаружения циклов. В следующем разделе мы рассмотрим некоторые распространенные виды анализа потока данных.

Обнаружение циклов
Как следует из самого названия, нашей целью является нахождение
циклов в коде. На уровне исходного кода все просто: достаточно найти такие ключевые слова, как while или for. На двоичном уровне это
немного труднее, потому что циклы реализуются теми же командами
(условного или безусловного) перехода, что ветвления или переключатели.
Умение находить циклы полезно по многим причинам. Например,
с точки зрения компилятора, циклы интересны, потому что внутри
них тратится много времени (часто упоминают цифру 90 %). Поэтому циклы представляют собой важную цель оптимизации. С точки
зрения безопасности, анализ циклов полезен, поскольку некоторые
уязвимости, в частности переполнение буфера, часто встречаются
именно в циклах.
В алгоритмах обнаружения циклов, применяемых в компиляторах, используется не такое определение цикла, какого можно было
бы ожидать. Эти алгоритмы ищут естественные цепи (natural loop),
т. е. циклы, обладающие некоторыми свойствами формальной пра1

Я не буду излагать детали этих методов в этой книге, поскольку нам они не
понадобятся. Но интересующимся порекомендую книгу Aho et al. «Compi­
lers: Principles, Techniques & Tools» (Addison-Wesley, 2014), где этот предмет
излагается во всей полноте.
Основы дизассемблирования и анализа двоичных файлов

169

вильности, благодаря которым их проще анализировать и оптимизировать. Существуют также алгоритмы, обнаруживающие любой цикл
(cycle) в CFG, даже если он не отвечает более строгому определению
естественной цепи. На рис. 6.10 приведен пример CFG, содержащего
естественную цепь, а также цикл, не являющийся естественной цепью­.
Сначала я опишу типичный алгоритм обнаружения естественных
цепей. А затем станет понятно, почему не каждый цикл отвечает этому определению. Чтобы понять, что такое естественная цепь, нам
понадобится понятие дерева доминирования. На рис. 6.10 справа приведен пример дерева доминирования, соответствующего CFG, показанному на том же рисунке слева.
CFG

Дерево доминирования

ВВ1

ВВ2

ВВ1

ВВ3

ВВ4

ВВ5

ВВ6

ВВ2

ВВ3

ВВ4

ВВ6

ВВ7

ВВ5

ВВ7

Рис. 6.10. CFG и соответствующее дерево доминирования

Говорят, что простой блок A доминирует над простым блоком B,
если дойти до B из точки входа в CFG можно, только пройдя сначала
через A. Например, на рис. 6.10 блок BB3 доминирует над BB5, но не
над BB6, поскольку до BB6 можно также дойти через BB4. Однако над
BB6 доминирует BB1, являющийся последним узлом, через который
должен пройти любой путь из точки входа в BB6. Дерево доминирования кодирует все отношения доминирования в CFG.
При таком определении естественная цепь индуцируется обратным ребром из простого блока B в блок A, доминирующий над B. Эта
цепь содержит все простые блоки, над которыми доминирует A и из
которых имеется путь в B. По соглашению, сам блок B исключается из
этого множества. Интуитивно понятно, что это определение означает, что в естественную цепь нельзя войти в какой-то промежуточной
точке, а только в точно определенной головной вершине. Это упрощает
анализ естественных цепей.
Например, на рис. 6.10 имеется естественная цепь, содержащая
простые блоки BB3 и BB5, потому что существует обратное ребро из BB5
в BB3 и BB3 доминирует над BB5. В этом случае BB3 является головной
вершиной цепи, BB5 – «возвратной» вершиной, а «тело» цепи (которое, по определению, не включает головную вершину и возвратные
вершины) не содержит ни одной вершины.

170

Глава 6

Обнаружение циклов
Вы, вероятно, обратили внимание еще на одно обратное ребро в графе, ведущее из BB7 в BB4. Это обратное ребро индуцирует цикл, но
не естественную цепь, поскольку в него можно войти «в середине» –
в BB6 или BB7. Из-за этого BB4 не доминирует над BB7, поэтому цикл не
удовлетворяет условию естественности.
Для нахождения подобных циклов, включая и все естественные
цепи, нужен только CFG, но не дерево доминирования. Достаточно
просто начать поиск в глубину (depth-first search – DFS) из входной
вершины CFG и поддерживать стек, в который посещенные простые
блоки помещаются на прямом проходе DFS и извлекаются при возврате. Если DFS обнаруживает простой блок, который уже находится
в стеке, то найден цикл.
Например, предположим, что выполняется поиск в глубину в CFG
на рис. 6.10. DFS начинается в точке входа BB1. В листинге 6.9 показано, как изменяется состояние DFS и как обнаруживаются оба цикла
в CFG (для краткости я не стал показывать, как продолжается DFS пос­
ле нахождения обоих циклов).
Листинг 6.9. Обнаружение цикла с по­мощью DFS
0: [BB1]
1: [BB1,BB2]
2: [BB1]
3: [BB1,BB3]
4: [BB1,BB3,BB5]
 5: [BB1,BB3,BB5,BB3] *цикл найден*
6: [BB1,BB3,BB5]
7: [BB1,BB3,BB5,BB7]
8: [BB1,BB3,BB5,BB7,BB4]
9: [BB1,BB3,BB5,BB7,BB4,BB6]
 10: [BB1,BB3,BB5,BB7,BB4,BB6,BB7] *цикл найден*
...

Сначала поиск в глубину исследует самую левую ветвь BB1, но быст­
ро возвращается, упершись в тупик. Затем он входит в среднюю ветвь,
ведущую из BB1 в BB3, заходит в BB5, после чего снова встречает BB3 –
это означает, что найден цикл, включающий BB3 и BB5 . Далее поиск
возвращается в BB5, спускается по пути, ведущему в BB7, посещает
BB4, BB6, пока наконец снова не встретит BB7, – так обнаруживается
второй цикл .

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

171

Анализ достигающих определений
Анализ достигающих определений дает ответ на вопрос: «Какие
определения данных достигают данной точки программы?» Говоря,
что определение данных «достигает» некоторой точки, я имею в виду,
что значение, присвоенное переменной (или на нижнем уровне – регистру или ячейке памяти), может достичь данной точки, не будучи
перезаписано по дороге другим оператором присваивания. Анализ
достигающих определений обычно применяется на уровне CFG, хотя
может быть и межпроцедурным.
Анализ начинается с рассмотрения для каждого простого блока
определений, которые блок генерирует и которые он уничтожает.
Обычно это называют вычисление gen- и kill-множеств для каждого
простого блока. На рис. 6.11 приведен пример gen- и kill-множеств
простого блока.
Gen-множество для BB3 содержит предложения 6 и 8, потому что
эти определения данных в BB3 доживают до конца простого блока.
Предложение 7 не доживает, потому что z перезаписывается в предложении 8. Kill-множество содержит предложения 1, 3 и 4 из BB1 и BB2,
потому что эти присваивания перезаписываются далее в BB3.
ВВ1

ВВ2

ВВ3

gen[BB3] = {6,8}
kill[BB3] = {1,3,4}

Рис. 6.11. Пример gen- и kill-множеств для простого блока

После вычисления gen- и kill-множеств каждого простого блока мы
имеем локальное решение, которое говорит, какие определения данных генерирует и уничтожает каждый простой блок. На этой основе
можно вычислить глобальное решение, которое говорит, какие определения (в любом месте CFG) могут достичь начала простого блока
и какие из них останутся живы после этого блока. Глобальное мно­
жест­во определений, способных достичь простого блока B, обозначается in[B] и определяется следующим образом:

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

172

Глава 6

ний, покидающих простой блок B, обозначается out[B] и определяется
следующим образом:
out[B] = gen[B] ∪ (in[B] – kill[B]).

Иными словами, определения, покидающие B, – это те определения, которые B либо генерирует сам, либо получает от своих пред­
шественников (как часть своего множества in) и не уничтожает. Обратите внимание на взаимозависимость между определениями
множеств in и out: in определяется в терминах out, и наоборот. Это
означает, что на практике недостаточно вычислить множества in и out
для каждого простого блока только один раз. На самом деле анализ
должен быть итеративным: на каждой итерации вычисляются множества для каждого простого блока, и итерации продолжаются, пока
множества не перестанут изменяться. Только после того как мно­
жества in и out стабилизировались, анализ завершается.
Анализ достигающих определений лежит в основе многих видов
анализа потока данных, в т. ч. анализа использования-определения,
о котором я расскажу далее.

Цепочки использования-определения
Цепочки использования-определения для каждой точки программы,
где используется некоторая переменная, говорят, где эта переменная
могла бы быть определена. Например, на рис. 6.12 цепочка использования-определения для переменной y в B2 содержит предложения 2
и 7, поскольку в этой точке CFG y могла бы получить значение как
в результате первоначального присваивания в предложении 2, так
и (после одной итерации цикла) в предложении 7. Заметим, что для
переменной z в B2 нет цепочки использования-определения, потому
что в этом простом блоке ей только присваивается значение, но сама
она не используется.
В1

В2

ud[x] = {1,6}
ud[y] = {2,7}

В4

В3
ud[y] = {2,7}

ud[x] = {1,6}
ud[y] = {2,7}
ud[z] = {3}

Рис. 6.12. Пример цепочек использования-определения
Основы дизассемблирования и анализа двоичных файлов

173

Одним из примеров полезности цепочек использования-определения является декомпиляция: они позволяют декомпилятору следить, где имело место сравнение со значением, использованным
в команде условного перехода. Таким образом, декомпилятор может
взять команды cmp x,5 и je (перейти, если равно) и объединить их
в выражение более высокого уровня if(x == 5). Цепочки использования-определения применяются также компилятором в оптимизациях типа распространения констант, когда переменная заменяется
константой, если в данной точке программы это единственно возможное значение. Полезны они и еще во многих и многих сценариях
двоичного анализа.
На первый взгляд, вычисление цепочек использования-определения может показаться трудным делом. Но с учетом результатов
анализа достигающих определений совсем несложно вычислить цепочку использования-определения для переменной в простом блоке
с использованием множества in для нахождения определений этой
переменной, которые могут достичь данного блока. Помимо цепочек
использования-определения, можно вычислить также цепочки определения-использования, которые говорят, где в программе может использоваться заданное определение данных.

Нарезание программы
Нарезанием (slicing) называется анализ потока данных, цель которого – выделить все команды (или, в случае анализа исходного кода,
строки кода), которые вносят вклад в значения выбранного множества переменных в некоторой точке программы (это множество
называется критерием нарезания). Это полезно для отладки, когда
мы хотим найти, какие части кода могут быть виноваты в ошибке,
а также при обратной разработке. Вычисление срезов может оказаться довольно утомительным занятием и до сих пор является скорее
областью активных исследований, нежели методом, используемым
в производственных инструментах. Но техника интересная, поэтому
о ней стоит знать. Здесь я опишу лишь общую идею, но желающим
поэкспериментировать рекомендую присмотреться к каркасу обратной разработки angr1, который предлагает встроенную функцио­
нальность нарезания. В главе 13 мы также увидим, как реализовать
практический инструмент нарезания с по­мощью символического
выполнения.
Срезы вычисляются путем прослеживания потоков управления
и данных, чтобы понять, какие части кода не имеют отношения к срезу, и затем эти части удалить. Конечный срез – это то, что остается
после удаления всего ненужного кода. Например, допустим, что требуется узнать, какие строки в листинге 6.10 вносят вклад в значение
y в строке 14.
1

174

http://angr.io/.

Глава 6

Листинг 6.10. Использование нарезания для нахождения строк, дающих
вклад в значение y в строке 14
1: x = int(argv[0])
2: y = int(argv[1])
3:
4: z = x + y
5: while(x < 5) {
6: x = x + 1
7: y = y + 2
8: z = z + x
9: z = z + y
10: z = z * 5
11: }
12:
13: print(x)
14: print(y)
15: print(z)

Срез содержит строки, выделенные серым цветом. Заметим, что
все присваивания z не имеют никакого отношения к срезу, потому
что не сказываются на конечном значении y. Происходящее с x важно,
поскольку определяет количество итераций цикла в строке 5, а это,
в свою очередь, влияет на значение y. Если откомпилировать программу, которая содержит только строки, включенные в срез, то предложение print(y) напечатает точно такой же результат, как в полной
программе.
Первоначально нарезание было предложено как метод статического анализа, но теперь оно чаще применяется к трассам динамического выполнения. У динамического нарезания есть то преимущество,
что оно обычно порождает меньшие (и потому более простые для
восприятия) срезы, чем статическое.
То, что мы только что видели, известно под названием обратное
нарезание (backward slicing), потому что строки, влияющие на выбранный критерий нарезания, ищутся в обратном направлении. Но
существует также прямое нарезание, когда мы начинаем с некоторой
точки программы и производим прямой поиск, чтобы определить,
какие части кода затронуты командой и переменной в выбранном
критерии нарезания. Среди прочего это позволяет предсказать, на
что повлияет изменение кода в данной точке.

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

175

Оптимизированный код не так близок к исходному, поэтому интуи­
тивно менее понятен человеку. Например, при оптимизации арифметического кода компиляторы всеми силами стараются избежать
очень медленных команд mul и div, заменяя операции умножения
и деления последовательностями поразрядных сдвигов и сложений.
При обратной разработке кода понять, что имелось в виду, нелегко.
Кроме того, компиляторы часто включают небольшие функции
в более крупные, из которых те вызываются, чтобы избежать накладных расходов на выполнение команды call; это называется встраиванием. Таким образом, не все функции, встречающиеся в исходном
коде, непременно присутствуют и в двоичном – по крайней мере, не
в виде отдельной функции. Мало того, такие распространенные оптимизации, как хвостовые вызовы и оптимизированные соглашения
о вызове, сильно затрудняют надежное распознавание функций.
При более высоких уровнях оптимизации компиляторы часто размещают байты заполнения между функциями и простыми блоками,
чтобы те и другие начинались с определенных адресов в памяти, где
доступк ним максимально эффективен. Интерпретация байтов заполнения как кода может привести к ошибкам дизассемблирования,
если эти байты не являются допустимыми командами. Еще компиляторы могут «раскручивать» циклы, чтобы избежать накладных расходов на переход к следующей итерации. Это сбивает с толку алгоритмы
обнаружения циклов и декомпиляторы, которые пытаются выявить
в коде такие высокоуровневые конструкции, как циклы while и for.
Оптимизации могут также затруднять обнаружение структур данных, а не только распознавание структуры кода. Например, в оптимизированном коде один и тот же базовый регистр может использоваться для одновременного индексирования нескольких массивов,
что мешает распознать их как разные структуры данных.
В настоящее время набирает популярность оптимизация на этапе компоновки (link-time optimization – LTO). Это означает, что оптимизации, которые традиционно применялись к отдельным модулям,
теперь можно применять к программе в целом. Во многих случаях
это увеличивает поверхность оптимизации, позволяя достигать более
впечатляющих результатов.
При написании и тестировании собственных инструментов двоичного анализа всегда помните, что на их точность могут негативно
влиять эффекты оптимизации.
В дополнение ко всем перечисленным выше оптимизациям двоичные файлы все чаще компилируются как позиционно-независимый
код (position-independent code –PIC), чтобы сделать возможными
такие средства защиты, как рандомизация распределения адресного
пространства (address-space layout randomization – ASLR), для которой необходимо перемещать код и данные, не нарушая работоспособности двоичного файла1. Двоичные файлы, откомпилированные
1

176

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

Глава 6

в режиме PIC, называются позиционно-независимыми исполняемыми
файлами (position-independent executable – PIE). В отличие от позиционно-зависимых двоичных файлов, в PIE-файлах для ссылки на
код и данные не используются абсолютные адреса. Вместо этого все
ссылки производятся относительно текущего счетчика программы.
Это также означает, что традиционные структуры, в частности PLT
в двоичных ELF-файлах, в PIE-файлах выглядят по-другому. Таким
образом, инструменты двоичного анализа, при построении которых
возможность PIC не учитывалась, для таких файлов могут работать
неправильно.

6.6

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

Упражнения
1. Сбить с толку objdump
Напишите программу, которая сбивает с толку утилиту objdump,
заставляя ее интерпретировать данные как код и наоборот. Для
этого вам, вероятно, придется прибегнуть к встроенному коду на
ассемблере (например, воспользовавшись ключевым словом asm
в gcc).
2. Сбить с толку рекурсивный дизассемблер
Напишите еще одну программу, на этот раз сбивающую с толку
алгоритм обнаружения функций в вашем любимом рекурсивном дизассемблере. Добиться этого можно разными способами.
Например, можно написать функцию, для обращения к которой
применяется хвостовой вызов, или функцию, в которой есть
предложение switch с несколькими ветвями case, содержащими
return. Интересно, как далеко вы сможете зайти в обмане дизассемблера.
3. Улучшение обнаружения функций
Напишите плагин для какого-нибудь рекурсивного дизассемб­
лера, который будет лучше обнаруживать функции, запутавшие
дизассемблер в предыдущем упражнении. Понадобится дизассемблер, допускающий написание плагинов, например IDA Pro,
Hopper или Medusa.

7

ПРОСТЫЕ МЕТОДЫ
ВНЕДРЕНИЯ КОДА
Д ЛЯ ФОРМАТА ELF

В

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

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

178

Глава 7

ра ищутся байты кода или данных, подлежащие изменению, а затем
с по­мощью шестнадцатеричного редактора вносятся изменения.
Этот подход хорош своей простотой и низкими требованиями
к инструментарию. А недостаток его в том, что редактировать можно
только на месте: вы сможете изменить байты кода или данных, но не
сможете добавить ничего нового. Вставка нового байта сдвинула бы
все следующие за ним байты – их адреса изменились бы, и все ссылки на них стали бы недействительными. Трудно (а то и невозможно)
найти и исправить все «битые» ссылки, поскольку необходимая для
этого информация о перемещениях после этапа компоновки обычно отбрасывается. Если двоичный файл содержит байты заполнения,
мертвый код (например, неиспользуемые функции) или неиспользуемые данные, то можно перезаписать эти части чем-то другим. Но
такой подход ограничен, потому что в большинстве своем двоичные
файлы содержат немного байтов, которые можно было бы безопасно
перезаписать.
Тем не менее в некоторых случаях ничего, кроме шестнадцатеричного редактирования, и не нужно. Например, во вредоносных программах применяются методы противодействия отладке, которые
ищут признаки аналитических программ в среде выполнения. Заподозрив, что подвергается анализу, вредоносная программа отказывается работать или проводить атаку на среду. Если во время анализа
вредоносной программы вы обнаруживаете, что она содержит антиотладочные проверки, то можете подавить их, заменив в шестнадцатеричном редакторе команды проверки командами nop (ничего
не делать). Иногда так можно даже исправить простые ошибки. Для
демонстрации я воспользуюсь шестнадцатеричным редактором для
Linux hexedit с открытым исходным кодом, который уже установлен
на виртуальной машине. С его помощью я исправлю ошибку на единицу в простой программе.

Нахождение подходящего кода операции
При редактировании кода в двоичном файле нужно знать, какие
значения вставлять, а для этого нужно знать формат и шестнадцатеричное представление машинных команд. В сети имеются
удобные справочные материалы по кодам операций и форматам
операндов для команд x86, например на сайте http://ref.x86asm.net.
Если хотите более подробно узнать о том, как работает конкретная команда x86, обратитесь к официальному руководству Intel по адресу https://software.intel.com/sites/default/files/managed/39/
c5/325462-sdm-vol-1-2abcd-3abcd.pdf.

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

179

записывается на один байт больше или меньше, чем нужно. В лис­
тинге 7.1 приведен пример программы, которая шифрует файл, но
из-за ошибки на единицу оставляет последний байт незашифрованным. Чтобы исправить эту ошибку, я сначала воспользуюсь objdump
для дизассемблирования двоичного файла и поиска ошибки в коде.
Затем я с по­мощью hexedit отредактирую код и исправлю ошибку на
единицу.
Листинг 7.1. xor_encrypt.c
#include
#include
#include
#include






void
die(char const *fmt, ...)
{
va_list args;
va_start(args, fmt);
vfprintf(stderr, fmt, args);
va_end(args);
exit(1);
}
int
main(int argc, char *argv[])
{
FILE *f;
char *infile, *outfile;
unsigned char *key, *buf;
size_t i, j, n;
if(argc != 4)
die("Usage: %s \n", argv[0]);
infile = argv[1];
outfile = argv[2];
key
= (unsigned char*)argv[3];
 f = fopen(infile, "rb");

if(!f) die("Failed to open file '%s'\n", infile);
 fseek(f, 0, SEEK_END);

n = ftell(f);
fseek(f, 0, SEEK_SET);
 buf = malloc(n);

if(!buf) die("Out of memory\n");
 if(fread(buf, 1, n, f) != n)

die("Failed to read file '%s'\n", infile);

180

Глава 7

 fclose(f);



j = 0;
for(i = 0; i < n-1; i++) { /* Вот она! Ошибка на единицу! */
buf[i] ^= key[j];
j = (j+1) % strlen(key);
}

 f = fopen(outfile, "wb");

if(!f) die("Failed to open file '%s'\n", outfile);


if(fwrite(buf, 1, n, f) != n)
die("Failed to write file '%s'\n", outfile);

 fclose(f);

return 0;
}

После разбора аргументов командной строки программа открывает подлежащий шифрованию входной файл , находит его размер
и сохраняет его в переменной n , выделяет память для буфера ,
в который будет прочитан файл, читает весь файл в буфер  и закрывает файл . Если возникает какая-то проблема, программа вызывает
функцию die, которая печатает соответствующее сообщение и завершает работу.
Ошибка находится в следующей части программы, где байты файла
шифруются простым алгоритмом на основе xor. Программа входит
в цикл for, где перебирает все байты, хранящиеся в буфере, и шифрует каждый из них, вычисляя результат применения к нему операции
xor с предоставленным ключом . Обратите внимание на условие
в цикле for: цикл начинается при i = 0, но продолжается только до тех
пор, пока i < n–1. Это означает, что индекс последнего зашифрованного байта в буфере равен n–2, т. е. один байт (с индексом n–1) остался
незашифрованным! Это ошибка на единицу, которую мы исправим
с по­мощью шестнадцатеричного редактора.
Зашифровав буфер, программа открывает выходной файл , записывает в него зашифрованные байты  и закрывает файл . В лис­
тинге 7.2 показан пример прогона программы (откомпилированной
с по­мощью файла Makefile, имеющегося на виртуальной машине), так
что вы можете наблюдать ошибку на единицу в действии.
Листинг 7.2. Ошибка на единицу в программе xor_encrypt
 $ ./xor_encrypt xor_encrypt.c encrypted foobar
 $ xxd xor_encrypt.c | tail

000003c0:
000003d0:
000003e0:
000003f0:
00000400:

6420
2573
3b0a
7566
6e29

746f
275c
0a20
2c20
0a20

206f
6e22
2069
312c
2020

7065
2c20
6628
206e
2064

6e20
6f75
6677
2c20
6965

6669
7466
7269
6629
2822

6c65
696c
7465
2021
4661

2027
6529
2862
3d20
696c

d to open file '
%s'\n", outfile)
;.. if(fwrite(b
uf, 1, n, f) !=
n).
die("Fail

Простые методы внедрения кода для формата ELF

181

00000410: 6564 2074 6f20
00000420: 2027 2573 275c
00000430: 6529 3b0a 0a20
00000440: 3b0a 0a20 2072
00000450: 0a0a
 $ xxd encrypted | tail
000003c0: 024f 1b0d 411d
000003d0: 4401 4133 0140
000003e0: 5468 6b52 4606
000003f0: 1309 4342 505e
00000400: 0f5b 6c4f 4f42
00000410: 0a06 4106 094f
00000420: 4648 4a11 462e
00000430: 045b 5d65 6542
00000440: 5468 6b52 461d
00000450: 6c0a

7772
6e22
2066
6574

6974
2c20
636c
7572

6520
6f75
6f73
6e20

6669
7466
6528
303b

6c65
696c
6629
0a7d

ed to write file
'%s'\n", outfil
e);.. fclose(f)
;.. return 0;.}
..

160a
4d52
094a
4601
4116
1810
084d
4114
0a16

0142
091a
0705
4342
0f0a
0806
4342
0503
1400

071b
1b04
1406
075b
4740
034f
0e07
0011
084f

0a0a
081e
1b07
464e
2713
090b
1209
045a
5f59

4f45
0346
4910
5242
0f03
0d17
060e
0046
6b0f

.O..A....B....OE
D.A3.@MR.......F
ThkRF..J......I.
..CBPˆF.CB.[FNRB
.[lOOBA...G@'...
..A..O.....O....
FHJ.F..MCB......
.[]eeBA......Z.F
ThkRF......O_Yk.
l.

В этом примере я воспользовался программой xor_encrypt, чтобы
зашифровать ее собственный исходный файл ключом foobar и записать результат в файл encrypted . Программа xxd для просмотра содержимого исходного файла  показывает, что файл заканчивается
байтом 0x0a . В зашифрованном файле изменены все байты , кроме последнего . Это произошло из-за ошибки на единицу.

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

Нахождение байтов, приведших к ошибке
Чтобы исправить ошибку, нужно изменить условие цикла, так чтобы количество итераций увеличилось на единицу, – тогда последний
байт будет зашифрован. Следовательно, сначала нужно дизассемблировать двоичный файл и найти команды, отвечающие за условие цикла. В листинге 7.3 показаны соответствующие команды, дизассемблированные objdump.
Листинг 7.3. Дизассемблированный код, содержащий ошибку на единицу
$ objdump -M intel -d xor_encrypt
...
4007c2: 49 8d 45 ff lea rax,[r13-0x1]
4007c6: 31 d2 xor edx,edx
4007c8: 48 85 c0 test rax,rax

182

Глава 7

4007cb:
4007cf:
4007d1:
 4007d8:
4007dd:
4007e1:
4007e4:
4007e6:
4007ea:
4007ef:
4007f1:
4007f4:
4007f7:
4007fa:
4007fd:
4007ff:
400804:

4d
74
0f
41
48
4c
30
48
e8
31
48
48
48
49
75
48
be

8d 24 06 lea r12,[r14+rax*1]
2e je 4007ff
1f 80 00 00 00 00 nop DWORD PTR [rax+0x0]
0f b6 04 17 movzx eax,BYTE PTR [r15+rdx*1]
8d 6a 01 lea rbp,[rdx+0x1]
89 ff mov rdi,r15
03 xor BYTE PTR [rbx],al
83 c3 01  add rbx,0x1
a1 fe ff ff call 400690
d2 xor edx,edx
89 c1 mov rcx,rax
89 e8 mov rax,rbp
f7 f1 div rcx
39 dc  cmp r12,rbx
d9  jne 4007d8
8b 7c 24 08 mov rdi,QWORD PTR [rsp+0x8]
66 0b 40 00 mov esi,0x400b66

Цикл начинается по адресу 0x4007d8 , и счетчик цикла (i) находится в регистре rbx. Видно, что счетчик цикла увеличивается на каждой
итерации цикла . Видно также, что команда cmp  проверяет, нужна
ли еще одна итерация цикла. Эта команда сравнивает i (хранящееся
в rbx) со значением n–1 (хранящимся в r12). Если нужна еще одна итерация, то команда jne  возвращается в начало цикла. В противном
случае управление передается следующей команде, и цикл завершается.
Команда jne означает «jump if not equal» (перейти, если не равно)1:
она переходит на начало цикла, если i не равно n–1 (этот факт проверяет cmp). Иными словами, поскольку i увеличивается на 1 на каждой итерации, цикл будет работать, пока i < n–1. Но чтобы исправить
ошибку на единицу, цикл должен работать, пока i = i,
что эквивалентно i > import capstone
 >>> help(capstone)

Help on package capstone:
NAME
capstone - # Capstone Python bindings, by Nguyen Anh
# Quynnh
FILE

1

218

http://www.capstone-engine.org/.

Глава 8

/usr/local/lib/python2.7/dist-packages/capstone/__init__.py
[...]
CLASSES
__builtin__.object
Cs
CsInsn
_ctypes.PyCFuncPtr(_ctypes._CData)
ctypes.CFunctionType
exceptions.Exception(exceptions.BaseException)
CsError
 class Cs(__builtin__.object)
| Methods defined here:
|
| __del__(self)
|
# destructor to be called automatically when
|
# object is destroyed.
|
| __init__(self, arch, mode)
|
| disasm(self, code, offset, count=0)
|
# Disassemble binary & return disassembled
|
# instructions in CsInsn objects
[...]

Здесь мы импортируем пакет capstone и используем встроенную
в Python команду help, чтобы получить справку о Capstone . Основную функциональность предоставляет класс capstone.Cs . И прежде
всего он дает доступ к функции disasm, которая дизассемблирует код,
находящийся в буфере, и возвращает результат дизассемблирования.
Для исследования прочей функциональности привязок Capstone к Python пользуйтесь встроенными командами help и dir! Далее в этой
главе мы будем заниматься созданием инструментов на C/C++, но API
очень близок к Capstone Python API.

8.2.2 Линейное дизассемблирование с по­мощью Capstone
На верхнем уровне Capstone принимает буфер, содержащий блок байтов кода, и выводит команды, являющиеся результатом дизассемб­
лирования этих байтов. Проще всего загрузить в буфер все байты из
секции .text заданного двоичного файла, а затем линейно дизассемб­
лировать их в форму, понятную человеку. Если не считать инициализации и кода разбора вывода, то Capstone позволяет реализовать
такой режим использования с по­мощью всего одного вызова API –
функции cs_disasm. В листинге 8.4 реализован простой инструмент,
напоминающий objdump. Чтобы загрузить двоичный файл в блок байтов, который сможет использовать Capstone, мы воспользуемся написанным в главе 4 загрузчиком двоичных файлов на основе libbfd
(loader.h).
Настройка дизассемблирования

219

Листинг 8.4. basic_capstone_linear.cc
#include
#include
#include
#include




"../inc/loader.h"

int disasm(Binary *bin);
int
main(int argc, char *argv[])
{
Binary bin;
std::string fname;
if(argc < 2) {
printf("Usage: %s \n", argv[0]);
return 1;
}
fname.assign(argv[1]);
 if(load_binary(fname, &bin, Binary::BIN_TYPE_AUTO) < 0) {
return 1;
}
 if(disasm(&bin) < 0) {

return 1;
}
unload_binary(&bin);
return 0;
}
int
disasm(Binary *bin)
{
csh dis;
cs_insn *insns;
Section *text;
size_t n;
text = bin->get_text_section();
if(!text) {
fprintf(stderr, "Nothing to disassemble\n");
return 0;
}
 if(cs_open(CS_ARCH_X86, CS_MODE_64, &dis) != CS_ERR_OK) {

fprintf(stderr, "Failed to open Capstone\n");
return -1;
}
 n = cs_disasm(dis, text->bytes, text->size, text->vma, 0, &insns);

if(n get_text_section(), чтобы получить
указатель на объект Section, представляющий секцию .text. Пока что
ничего нового по сравнению с главой 4. Но мы как раз подобрались
к коду из самой библиотеки Capstone!
Первая функция Capstone, вызываемая disasm, встречается в любой
программе, где эта библиотека используется. Она называется cs_open
и открывает надлежащим образом сконфигурированный экземпляр
Capstone . В данном случае этот экземпляр подготовлен для дизассемблирования кода x86-64. Первый параметр cs_open – константа
CS_ARCH_X86, сообщающая Capstone, что мы собираемся дизассемблировать код для архитектуры x86. Точнее, мы говорим Capstone, что это
64-разрядный код, поскольку вторым параметром передана констанНастройка дизассемблирования

221

та CS_MODE_64. Наконец, третий параметр – указатель dis на объект
типа csh (сокращение от «Capstone handle»). После успешного завершения cs_open этот указатель представляет полностью сконфигурированный экземпляр Capstone, который понадобится для вызова всех
остальных функций из Capstone API. Если инициализация успешна,
то cs_open возвращает CS_ERR_OK.

Дизассемблирование буфера кода
Имея описатель Capstone и загруженную секцию кода, мы можем
приступить к дизассемблированию! Для этого нужен всего лишь один
вызов функции cs_disasm .
Первым параметром функции является dis, описатель Capstone.
Далее cs_disasm ожидает буфер (типа const uint8_t*), который содержит подлежащий дизассемблированию код, целое число типа size_t,
равное количеству байтов кода в буфере, и параметр типа uint64_t,
равный виртуальному адресу первого байта в буфере. Буфер кода
и связанные с ним значения уже загружены в объект Section, представляющий секцию .text загруженного двоичного файла.
Последние два параметра cs_disasm – size_t, задающий количест­
во подлежащих дизассемблированию команд (0 означает «столько,
сколько возможно»), и указатель на буфер команд Capstone (cs_insn**).
Этот последний параметр заслуживает особого внимания, потому что
тип cs_insn играет центральную роль в приложениях на основе Capstone.

Структура cs_insn
Как видно из примера кода, функция disasm содержит локальную
переменную insns типа cs_insn*. Адрес insns передается последним
параметром функции cs_disasm в точке . В процессе дизассемблирования буфера кода cs_disasm строит массив дизассемблированных
команд. В самом конце она возвращает этот массив в insns, чтобы мы
могли обойти все дизассемблированные команды и обработать их так,
как нужно приложению. В нашем примере мы просто распечатываем
команды. Каждая команда представляется структурой типа cs_insn,
которая определена в файле capstone.h, как показано в листинге 8.5.
Листинг 8.5. Определение структуры cs_insn в файле capstone.h
typedef struct
unsigned int
uint64_t
uint16_t
uint8_t
char
char
cs_detail
} cs_insn;

222

Глава 8

cs_insn {
id;
address;
size;
bytes[16];
mnemonic[32];
op_str[160];
*detail;

Поле id – уникальный (зависящий от архитектуры) идентификатор
типа команды, позволяющий проверить вид команды, не прибегая
к сравнению со строковым мнемоническим кодом. Например, можно
было бы реализовать зависящую от команды обработку дизассемблированных команд, как показано в листинге 8.6.
Листинг 8.6. Зависящая от команды обработка в Capstone
switch(insn->id) {
case X86_INS_NOP:
/* обработать команду NOP */
break;
case X86_INS_CALL:
/* обработать команду call */
break;
default:
break;
}

Здесь insn – указатель на объект cs_insn. Отметим, что значения
id уникальны только в рамках одной конкретной архитектуры, а не

среди всех архитектур. Возможные значения определены в архитектурно-зависимом заголовочном файле, который будет показан в разделе 8.2.3.
Поля address, size и bytes объекта cs_insn содержат соответственно адрес, количество байтов и сами байты команды. Поле mnemonic –
понятная человеку строка, описывающая команду (без операндов),
а op_str – понятное человеку представление ее операндов. Наконец,
detail – указатель на (зависящую от архитектуры) структуру данных,
содержащую более подробную информацию о дизассемблированной
команде, например о регистрах, которые она читает и изменяет. Отметим, что указатель detail отличен от нуля, только если вы явно задали режим детального дизассемблирования перед началом работы,
что в данном примере не сделано. Пример детального режима дизассемблирования приведен в разделе 8.2.4.

Интерпретация дизассемблированного кода и очистка
Если все пройдет хорошо, то cs_disasm вернет количество дизассемб­
лированных команд. В случае ошибки возвращается 0, и, чтобы узнать­,
в чем ошибка, нужно вызвать функцию cs_errno. Она возвращает
элемент перечисления типа cs_err. Как правило, мы хотим напечатать сообщение об ошибке и выйти. Поэтому Capstone предоставляет
вспомогательную функцию cs_strerror, которая преобразует cs_err
в строку, описывающую ошибку.
Если ошибок не было, то функция disasm в цикле перебирает все
дизассемблированные команды, возвращенные cs_disasm  (см. лис­
тинг 8.4). Для каждой команды печатается строка, содержащая различные поля описанной выше структуры cs_insn. По завершении
Настройка дизассемблирования

223

цикла disasm вызывает cs_free(insns, n), чтобы освободить память,
выделенную Capstone для каждой из n команд в буфере insns , после
чего закрывает объект Capstone, вызывая cs_close.
Теперь вы знаете большинство важных функций и структур данных
Capstone, которые понадобятся для выполнения простых задач дизассемблирования и анализа. Ели хотите, можете откомпилировать и запустить пример basic_capstone_linear. Он должен напечатать список
команд в секции .text дизассемблированного двоичного файла, как
показано в листинге 8.7.
Листинг 8.7. Пример вывода линейного дизассемблера
$ ./basic_capstone_linear /bin/ls | head
0x402a00: 41 57
push
0x402a02: 41 56
push
0x402a04: 41 55
push
0x402a06: 41 54
push
0x402a08: 55
push
0x402a09: 53
push
0x402a0a: 89 fb
mov
0x402a0c: 48 89 f5
mov
0x402a0f: 48 81 ec 88 03 00 00 sub
0x402a16: 48 8b 3e
mov

-n 10
r15
r14
r13
r12
rbp
rbx
ebx, edi
rbp, rsi
rsp, 0x388
rdi, qword ptr [rsi]

Далее в этой главе мы рассмотрим более сложные примеры дизассемблирования с по­мощью Capstone. Сложность в основном сводится
к разбору некоторых более детальных структур данных. Принципиально это не труднее уже показанных примеров.

8.2.3 Изучение Capstone C API
Познакомившись с базовыми функциями и структурами данных Capstone, вы, наверное, хотите знать, где документирована остальная
часть Capstone API. К сожалению, полной документации по Capstone
API в настоящее время не существует. Лучшее, на что можно рассчитывать, – заголовочные файлы Capstone. Зато они снабжены хорошими комментариями и не слишком сложны, так что, получив несколько
простых подсказок, вы сможете без труда разобраться в них и найти
то, что нужно для конкретного проекта. Заголовочные файлы Capstone – это все заголовочные C-файлы, включенные в версию Capstone
v3.0.5. В листинге 8.8 я выделил наиболее важные для наших целей.
Листинг 8.8. Заголовочные C-файлы Capstone
$ ls /usr/include/capstone/
arm.h arm64.h capstone.h
sparc.h systemz.h

224

Глава 8

x86.h

mips.h platform.h ppc.h
xcore.h

Мы уже видели, что capstone.h – основной заголовочный файл Capstone. Он содержит снабженные комментариями определения всех
функций Capstone API, а также архитектурно-независимые структуры
данных, в частности cs_insn и cs_err. Здесь же определены все возможные значения перечислений cs_arch, cs_mode и cs_err. Например,
если вам потребуется модифицировать линейный дизассемблер, так
чтобы он поддерживал код для ARM, то нужно будет найти в capstone.h
соответствующую архитектуру (CS_ARCH_ARM) и режим (CS_MODE_ARM)
и передать их в качестве параметров функции cs_open1.
Зависящие от архитектуры структуры данных и константы определены в отдельных заголовочных файлах, например x86.h для архитектур x86 и x86-64. В этих файлах содержатся возможные значения поля
id структуры cs_insn – для x86 все они являются значениями перечисления x86_insn. По большей части к архитектурно-зависимым заголовкам вы будете обращаться, чтобы найти, какие дополнительные
сведения доступны через поле detail типа cs_insn. Если включен режим детального дизассемблирования, то это поле указывает на структуру cs_detail.
Структура cs_detail содержит объединение архитектурно-зависимых структур, содержащих подробную информацию о команде.
С архитектурой x86 ассоциирован тип cs_x86, определенный в файле
x86.h. Для иллюстрации построим рекурсивный дизассемблер, который использует режим детального дизассемблирования, чтобы получить архитектурно-зависимую информацию о командах x86.

8.2.4 Рекурсивное дизассемблирование с по­мощью
Capstone
Без детального дизассемблирования Capstone позволял бы получить
только базовую информацию о командах, например адрес, байты команды и мнемоническое представление. Для линейного дизассемб­
лера этого достаточно, как мы видели в примере выше. Но в более
развитых инструментах двоичного анализа часто приходится принимать решения в зависимости от таких свойств команды, как регистры,
к которым она обращается, типы и значения операндов, тип самой
команды (арифметическая, управления потоком и т. д.) или конечные
адреса команд управления потоком. Такого рода детальная информация предоставляется только в режиме детального дизассемблирования. Для ее разбора необходимы дополнительные усилия со стороны
Capstone, поэтому дизассемблирование в этом режиме производится
медленнее. Так что используйте детальный режим только тогда, когда
это действительно необходимо, в частности для рекурсивного дизас1

Если уж заниматься обобщением дизассемблера, то следовало бы определить тип загруженного двоичного файла по полям arch и bits в классе
Binary, предоставляемом загрузчиком. А затем нужно задать параметры
Capstone, исходя из типа. Но чтобы не усложнять, этот пример поддерживает только одну архитектуру, которая зашита в код.
Настройка дизассемблирования

225

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

Настройка режима детального дизассемблирования
В листинге 8.9 показана базовая реализация рекурсивного дизассемб­
лера. В отличие от большинства рекурсивных дизассемблеров, здесь
не предполагается, что байты могут принадлежать только одной команде, поэтому поддерживается дизассемблирование перекрывающихся блоков кода.
Листинг 8.9. basic_capstone_recursive.cc
#include
#include
#include
#include
#include
#include






"../inc/loader.h"

int disasm(Binary *bin);
void print_ins(cs_insn *ins);
bool is_cs_cflow_group(uint8_t g);
bool is_cs_cflow_ins(cs_insn *ins);
bool is_cs_unconditional_cflow_ins(cs_insn *ins);
uint64_t get_cs_ins_immediate_target(cs_insn *ins);
int
main(int argc, char *argv[])
{
Binary bin;
std::string fname;
if(argc < 2) {
printf("Usage: %s \n", argv[0]);
return 1;
}
fname.assign(argv[1]);
if(load_binary(fname, &bin, Binary::BIN_TYPE_AUTO) < 0) {
return 1;
}

226

Глава 8

if(disasm(&bin) < 0) {
return 1;
}
unload_binary(&bin);
return 0;
}
int
disasm(Binary *bin)
{
csh dis;
cs_insn *cs_ins;
Section *text;
size_t n;
const uint8_t *pc;
uint64_t addr, offset, target;
std::queue Q;
std::map seen;
text = bin->get_text_section();
if(!text) {
fprintf(stderr, "Nothing to disassemble\n");
return 0;
}
if(cs_open(CS_ARCH_X86, CS_MODE_64, &dis) != CS_ERR_OK) {
fprintf(stderr, "Failed to open Capstone\n");
return -1;
}
 cs_option(dis, CS_OPT_DETAIL, CS_OPT_ON);
 cs_ins = cs_malloc(dis);

if(!cs_ins) {
fprintf(stderr, "Out of memory\n");
cs_close(&dis);
return -1;
}
addr = bin->entry;
 if(text->contains(addr)) Q.push(addr);

printf("entry point: 0x%016jx\n", addr);
 for(auto &sym: bin->symbols) {

if(sym.type == Symbol::SYM_TYPE_FUNC

&& text->contains(sym.addr)) {
Q.push(sym.addr);
printf("function symbol: 0x%016jx\n", sym.addr);
}
}
 while(!Q.empty()) {

addr = Q.front();
Настройка дизассемблирования

227

Q.pop();
if(seen[addr]) continue;
offset = addr - text->vma;
pc
= text->bytes + offset;
n
= text->size - offset;
 while(cs_disasm_iter(dis, &pc, &n, &addr, cs_ins)) {
if(cs_ins->id == X86_INS_INVALID || cs_ins->size == 0) {
break;
}
seen[cs_ins->address] = true;
print_ins(cs_ins);
 if(is_cs_cflow_ins(cs_ins)) {
 target = get_cs_ins_immediate_target(cs_ins);

if(target && !seen[target] && text->contains(target)) {
Q.push(target);
printf(" -> new target: 0x%016jx\n", target);
}
 if(is_cs_unconditional_cflow_ins(cs_ins)) {
break;
}
} else if(cs_ins->id == X86_INS_HLT) break;
}
printf("----------\n");
}
cs_free(cs_ins, 1);
cs_close(&dis);
return 0;
}
void
print_ins(cs_insn *ins)
{
printf("0x%016jx: ", ins->address);
for(size_t i = 0; i < 16; i++) {
if(i < ins->size) printf("%02x ", ins->bytes[i]);
else printf(" ");
}
printf("%-12s %s\n", ins->mnemonic, ins->op_str);
}
bool
is_cs_cflow_group(uint8_t g)
{
return (g == CS_GRP_JUMP) || (g == CS_GRP_CALL)
|| (g == CS_GRP_RET) || (g == CS_GRP_IRET);
}
bool
is_cs_cflow_ins(cs_insn *ins)
{

228

Глава 8

for(size_t i = 0; i < ins->detail->groups_count; i++) {
if(is_cs_cflow_group(ins->detail->groups[i])) {
return true;
}
}
return false;
}
bool
is_cs_unconditional_cflow_ins(cs_insn *ins)
{
switch(ins->id) {
case X86_INS_JMP:
case X86_INS_LJMP:
case X86_INS_RET:
case X86_INS_RETF:
case X86_INS_RETFQ:
return true;
default:
return false;
}
}
uint64_t
get_cs_ins_immediate_target(cs_insn *ins)
{
cs_x86_op *cs_op;
for(size_t i = 0; i < ins->detail->groups_count; i++) {
if(is_cs_cflow_group(ins->detail->groups[i])) {
for(size_t j = 0; j < ins->detail->x86.op_count; j++) {
cs_op = &ins->detail->x86.operands[j];
if(cs_op->type == X86_OP_IMM) {
return cs_op->imm;
}
}
}
}
return 0;
}

Как видно из листинга 8.9, функция main точно такая же, как в линейном дизассемблере. И код инициализации вначале disasm тоже
похож. Он начинается с загрузки секции .text и получения описателя Capstone. Однако же есть важное дополнение . Эта новая строка
включает режим детального дизассемблирования, активируя опцию
CS_OPT_DETAIL. Для рекурсивного дизассемблера это необходимо, потому что нам нужна информация о потоке выполнения, которая доступна только в режиме детального дизассемблирования.
Далее мы явно выделяем память для буфера команд . Для линейного дизассемблера это было не нужно, но здесь мы это делаем, поНастройка дизассемблирования

229

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

Цикл по точкам входа
После инициализации Capstone начинается логика самого рекурсивного дизассемблера. Основной структурой данных в нем является
очередь, содержащая начальные точки дизассемблирования. Первый
шаг – поместить в очередь начальные точки входа: главную точку входа в двоичный файл  и все известные символы функций . Затем
код входит в главный цикл дизассемблирования .
Пока в очереди есть начальные точки, программа выбирает очередную точку и следует вдоль начинающегося в ней потока управления,
дизассемблируя столько команд, сколько получится. По сути дела, это
линейное дизассемблирование из каждой начальной точки, но при
этом все вновь обнаруженные адреса назначения помещаются в очередь. Новое место назначения будет дизассемблировано на одной из
следующих итераций цикла. Каждый линейный проход завершается,
когда встречается команда hlt или безусловный переход, поскольку
за этими командами могут находиться не другие команды, а данные,
так что продолжать дизассемблирование было бы неосмотрительно.
В цикле используется несколько функций Capstone, которые раньше нам не встречались. Прежде всего собственно дизассемблирование
выполняется функцией cs_disasm_iter . Кроме того, существуют
функции для получения детальной информации, например конечных
адресов в командах управления потоком, а также о том, является ли
вообще некоторая команда командой управления потоком. Начнем
с обсуждения того, почему в этом примере используется cs_disasm_
iter, а не просто cs_disasm.

Применение итеративного дизассемблирования для разбора
команд в реальном времени
Как явствует из названия, cs_disasm_iter – итеративный вариант cs_
disasm. Вместо того чтобы дизассемблировать сразу весь буфер кода,
функция cs_disasm_iter дизассемблирует только одну команду и возвращает true или false. True означает, что команда была успешно
дизассемблирована, а false – что ничего не было дизассемблировано. Нетрудно написать цикл while по типу показанного в листинге ,
в котором cs_disasm_iter вызывается, пока еще имеется код для диз­
ассемблирования.
Параметры cs_disasm_iter – итеративные варианты параметров
cs_disasm, применяемой в линейном дизассемблере. Как и прежде,
первым передается описатель Capstone. Второй параметр – указатель
на подлежащий дизассемблированию код. Но теперь вместо uint8_t*

230

Глава 8

мы передаем двойной указатель (т. е. uint8_t**). Это позволяет cs_
disasm_iter автоматически обновлять указатель при каждом вызове,
устанавливая его так, чтобы он указывал на место сразу после только
что дизассемблированных байтов. Поскольку так ведет себя и счетчик программы, этот параметр называется pc. Как видим, для каждой
начальной точки в очереди нужно только один раз правильно установить pc на точку в секции .text. После этого мы просто вызываем
в цикле cs_disasm_iter, а та уже автоматически увеличивает pc.
Третий параметр – сколько байтов осталось дизассемблировать, это
число автоматически уменьшается функцией cs_disasm_iter. В данном случае оно всегда равно размеру секции .text минус количество
уже дизассемблированных байтов.
Имеется также автоматически увеличивающийся параметр addr,
который сообщает Capstone виртуальный адрес кода, на который
указывает pc (как text–>vma в линейном дизассемблере). Последний
параметр – указатель на объект cs_insn, играющий роль буфера последней дизассемблированной команды.
У функции cs_disasm_iter есть несколько преимуществ перед cs_
disasm. Главная причина ее использования – итеративное поведение,
позволяющее инспектировать каждую команду сразу после ее дизассемблирования и тем самым видеть поток управления и рекурсивно
следовать за ним. Кроме того, cs_disasm_iter быстрее и потребляет
меньше памяти, чем cs_disasm, т. к. она не требует заранее выделять
большой буфер для хранения всех дизассемблированных команд.

Разбор команд управления потоком
Как мы видели, в цикле дизассемблирования используется несколько
вспомогательных функций, которые определяют, имеем ли мы команду управления потоком, и если да, то каков ее целевой адрес. Например, функция is_cs_cflow_ins (вызываемая в точке ) смотрит,
передана ли ей какая-то команда управления потоком (условного или
безусловного). Для этого она обращается к детальной информации,
предоставленной Capstone. В частности, структура ins–>detail содержит массив «групп», которым принадлежит команда (ins–>detail–
>groups). Имея эту информацию, мы легко можем принимать решения на основе групп команды. Например, можно сказать, что это
команда перехода, не сравнивая явно поле ins–>id с кодами всех возможных команд перехода: jmp, ja, je, jnz и т. д. Функция is_cs_cflow_
ins проверяет, является ли команда переходом, вызовом, возвратом
или возвратом из прерывания (сама проверка производится в другой
вспомогательной функции, is_cs_cflow_group). Команда, относящаяся к одному из этих четырех типов, считается командой управления
потоком.
Если дизассемблированная команда оказывается командой управления потоком, то хотелось бы по возможности получить ее конечный
адрес и поместить его в очередь, если раньше он не встречался, чтобы
впоследствии начать дизассемблирование с этого адреса. Код опреНастройка дизассемблирования

231

деления конечных адресов находится во вспомогательной функции
get_cs_insn_immediate_target. В нашем примере эта функция вызывается в точке . Как следует из названия, функция умеет определять
только «непосредственные» конечные адреса, т. е. зашитые в код команды управления потоком. Иными словами, она не пытается разрешать косвенные вызовы, что в любом случае трудно сделать статически (см. главу 6).
Выделение конечных адресов – первый пример архитектурно-зависимой обработки команд в этой программе. Для этого необходимо
исследовать операнды команды, а поскольку в каждой архитектуре
имеются свои типы операндов, разобрать их единообразно не получится. В данном случае мы работаем с кодом для x86, поэтому необходим доступ к соответствующему массиву операндов, предоставляемому Capstone в составе детальной информации (ins–>detail–>x86.
operands). Этот массив содержит операнды, представленные структурой cs_x86_op, которая включает анонимное объединение union всех
возможных типов операндов: регистровый (reg), непосредственный
(imm), с плавающей точкой (fp) или хранимый в памяти (mem). Какое из этих полей в действительности установлено, зависит от типа
операнда, а тип определяется полем type структуры cs_x86_op. Наш
дизассемблер разбирает только непосредственные конечные адреса
в командах управления потоком, поэтому проверяет операнды типа
X86_OP_IMM и для них возвращает непосредственное значение адреса.
Если код по этому адресу еще не был дизассемблирован, то disasm
добавляет его в конец очереди.
Наконец, встретив команду hlt или команду безусловного управления потоком, disasm прекращает дизассемблирование, потому что за
ней могут оказаться байты, не являющиеся кодом. Для определения
такого типа команды вызывается еще одна вспомогательная функция, is_cs_unconditional_cflow_ins . Она просто сравнивает поле
ins–>id со всеми командами такого типа, поскольку их немного. Проверка на команду hlt производится отдельно в точке . После выхода
из цикла дизассемблирования функция disasm очищает буфер команд
и закрывает описатель Capstone.

Выполнение рекурсивного дизассемблера
Только что рассмотренный алгоритм рекурсивного дизассемблирования лежит в основе многих специальных инструментов дизассемб­
лирования, а также полнофункциональных продуктов типа Hopper
или IDA Pro. Конечно, по сравнению с нашим примером они содержат гораздо больше эвристик для определения точек входа в функции и установления других полезных свойств кода, даже в отсутствие
символов функций. Попробуйте откомпилировать и выполнить наш
дизассемблер! Лучше всего он работает для двоичных файлов с незачищенной символической информацией. Распечатка призвана помочь вам проследить, что делает процесс рекурсивного дизассемблирования. Например, в листинге 8.10 приведен фрагмент результата

232

Глава 8

для обфусцированного двоичного файла с перекрывающимися прос­
тыми блоками, который был показан в начале этой главы.
Листинг 8.10. Результат работы рекурсивного дизассемблера
$ ./basic_capstone_recursive overlapping_bb
entry point: 0x400500
function symbol: 0x400530
function symbol: 0x400570
function symbol: 0x4005b0
function symbol: 0x4005d0
function symbol: 0x4006f0
function symbol: 0x400680
function symbol: 0x400500
function symbol: 0x40061d
function symbol: 0x4005f6
0x400500: 31 ed
xor ebp, ebp
0x400502: 49 89 d1
mov r9, rdx
0x400505: 5e
pop rsi
0x400506: 48 89 e2
mov rdx, rsp
0x400509: 48 83 e4 f0
and rsp, 0xfffffffffffffff0
0x40050d: 50
push rax
0x40050e: 54
push rsp
0x40050f: 49 c7 c0 f0 06 40 00 mov r8, 0x4006f0
0x400516: 48 c7 c1 80 06 40 00 mov rcx, 0x400680
0x40051d: 48 c7 c7 1d 06 40 00 mov rdi, 0x40061d
0x400524: e8 87 ff ff f
call 0x4004b0
0x400529: f4
hlt
---------0x400530: b8 57 10 60 00
mov eax, 0x601057
0x400535: 55
push rbp
0x400536: 48 2d 50 10 60 00
sub rax, 0x601050
0x40053c: 48 83 f8 0e
cmp rax, 0xe
0x400540: 48 89 e5
mov rbp, rsp
0x400543: 76 1b
jbe 0x400560
-> new target: 0x400560
0x400545: b8 00 00 00 00
mov eax, 0
0x40054a: 48 85 c0
test rax, rax
0x40054d: 74 11
je
0x400560
-> new target: 0x400560
0x40054f: 5d
pop rbp
0x400550: bf 50 10 60 00
mov edi, 0x601050
0x400555: ff e0
jmp rax
---------...
0x4005f6: 55
push rbp
0x4005f7: 48 89 e5
mov rbp, rsp
0x4005fa: 89 7d ec
mov dword ptr [rbp - 0x14], edi
0x4005fd: c7 45 fc 00 00 00 00 mov dword ptr [rbp - 4], 0
0x400604: 8b 45 ec
mov eax, dword ptr [rbp - 0x14]
0x400607: 83 f8 00
cmp eax, 0
0x40060a: 0f 85 02 00 00 00
jne 0x400612
Настройка дизассемблирования

233

-> new target: 0x400612
 0x400610: 83 f0 04

0x400613: 04
0x400615: 89
0x400618: 8b
0x40061b: 5d
0x40061c: c3
---------...
 0x400612: 04
0x400614: 90
0x400615: 89
0x400618: 8b
0x40061b: 5d
0x40061c: c3
----------

90
45 fc
45 fc

04
45 fc
45 fc

xor
add
mov
mov
pop
ret

eax, 4
al, 0x90
dword ptr [rbp - 4], eax
eax, dword ptr [rbp - 4]
rbp

add
nop
mov
mov
pop
ret

al, 4
dword ptr [rbp - 4], eax
eax, dword ptr [rbp - 4]
rbp

Как видно из листинга 8.10, дизассемблер начинает с того, что помещает в очередь точки входа: сначала главную точку входа в двоичный файл, затем все известные символы функций. Затем он дизассемблирует столько кода, сколько можно, начиная с каждого адреса
в очереди (строкой дефисов обозначены точки, в которых дизассемб­
лер решает остановиться и продолжить со следующего адреса в очереди). Попутно дизассемблер находит новые, ранее не известные
адреса и помещает их в очередь. Например, при обработке команды
jbe по адресу 0x400543 определяется новый конечный адрес 0x400560
. Дизассемблер успешно находит оба перекрывающихся блока в обфусцированном файле: по адресу 0x400610  и по адресу 0x400612 .

8.3

Реализация сканера ROP-гаджетов
Все примеры, которые мы видели до сих пор, – написанные «на коленке» реализации хорошо известных методов дизассемблирования. Но
Capstone позволяет гораздо больше! В этом разделе мы познакомимся со специализированным инструментом, потребности которого не
покрываются стандартным линейным или рекурсивным дизассемб­
лированием. Речь идет об инструменте, незаменимом для написания
современных эксплойтов: сканере, который ищет гаджеты, пригодные для ROP-эксплойтов. Но сначала объясним, что все это значит.

8.3.1 Введение в возвратно-ориентированное
программирование
Чуть ли не в любом введении в написание эксплойтов упоминается
классическая статья Aleph One «Smashing the Stack for Fun and Profit»,
в которой объясняются основы эксплуатации переполнения стека.
В 1996 году, когда была опубликована эта статья, эксплуатация была
довольно прямолинейным занятием: найти уязвимость, загрузить
вредоносный шелл-код в буфер (обычно размещенный в стеке) при-

234

Глава 8

ложения-жертвы и воспользоваться уязвимостью, чтобы передать
управление шелл-коду.
С тех пор много чего произошло в области безопасности, и написать эксплойт стало куда труднее. Одна из самых распространенных
мер защиты от классических эксплойтов такого рода – предотвращение выполнения данных (data execution prevention – DEP), известное
также под названиями W⊕X или NX. Эта технология, появившаяся
в Windows XP в 2004 году, предотвращает внедрение шелл-кода прямолинейным образом. DEP гарантирует, что никакая область памяти
не является одновременнодопускающей запись и выполнение. Так
что если противник сумел внедрить шелл-код в буфер, он не сможет
его выполнить.
К сожалению, очень скоро хакеры научились обходить DEP. Были
придуманы новые меры защиты от внедрения шелл-кода, но и они
не смогли помешать хакерам использовать уязвимость, чтобы перенаправить поток управления на код, уже существущий в эксплуатируемых двоичных файлах или библиотеках. Эта слабость сначала нашла
применение в классе атак типа «возврат в libc» (ret2libc), когда поток
управления перенаправлялся функциям в широко распространенной
библиотеке libc, например execve, которую можно было использовать
для запуска нового процесса по усмотрению атакующего.
В 2007 году появился обобщенный вариант ret2libc, известный под
названием возвратно-ориентированное программирование (returnori­ented programming – ROP). Вместо того чтобы ограничиваться уже
имеющимися функциями, ROP позволяет атакующему реализовать
произвольную вредоносную функциональность, соединяя в цепочку
существующие участки кода в памяти программы-жертвы. Эти короткие последовательности команд в ROP называются гаджетами.
Каждый гаджет заканчивается командой возврата и выполняет
простую операцию, например сложение или логическое сравнение1.
Тщательно подбирая гаджеты с точно определенной семантикой,
противник может создать так называемый специальный набор команд для реализации произвольной функциональности, называемой
ROP-программой, не внедряя вообще никакого нового кода. Гаджеты могут состоять из обычных команд программы-жертвы, но могут
быть и не выровненными последовательностями команд такого вида,
как в примере обфусцированного кода в листингах 8.1. и 8.2.
ROP-программа состоит из серии адресов гаджетов, организованной в стеке таким образом, что команда возврата, заканчивающая
один гаджет, передает управление следующему гаджету в цепочке.
Чтобы запустить ROP-программу, нужно выполнить начальную команду возврата (например, активировав ее с по­мощью эксплойта),
которая перейдет по адресу первого гаджета. На рис. 8.1 показан пример ROP-цепочки.
1

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

235

&gn

&g2
constant
&g1

Рис. 8.1. Пример ROP-цепочки. Гаджет g1 загружает константу в регистр eax,
который затем складывается с esi гаджетом g2

Как видим, указатель стека (регистр esp) первоначально указывает
на адрес первого гаджета g1 в цепочке. Первая команда возврата извлекает этот адрес из стека и передает ему управление, что приводит
к выполнению g1. Гаджет g1 выполняет команду pop, которая загружает помещенную в стек константу в регистр eax и увеличивает esp,
так чтобы он указывал на гаджет g2. Затем команда ret g1 передает
управление на g2, который прибавляет константу, находящуюся в eax,
к регистру esi. После этого гаджет g2 возвращается на гаджет g3 и т. д.,
пока не будут выполнены все гаджеты g1, …, gn.
Как вы уже поняли, чтобы создать ROP-эксплойт, противник должен сначала набрать подходящий набор ROP-гаджетов. В следующем
разделе мы напишем инструмент, который ищет в двоичном файле
пригодные для использования гаджеты и печатает их сводку, чтобы
помочь в создании ROP-эксплойтов.

8.3.2 Поиск ROP-гаджетов
В следующем листинге показан код программы поиска ROPгаджетов. Она выводит список гаджетов, найденных в заданном
двоичном файле. Путем комбинирования гаджетов из этого списка
можно построить эксплойт. Как уже было сказано, нас интересуют
гаджеты, заканчивающиеся командой возврата. Но они могут быть
как выровнены, так и не выровнены с обычным потоком команд
в файле. Пригодные к использованию гаджеты должны иметь четкую и простую семантику, поэтому длина каждого гаджета не должна быть слишком велика. Мы введем (произвольное) ограничение –
не больше пяти команд.
Для поиска выровненных и невыровненных гаджетов можно, например, дизассемблировать файл с каждого возможного начального
байта и посмотреть, для каких байтов получается полезный гаджет.
Но можно поступить более эффективно: сначала найти в файле места
всех команд возврата (выровненных или невыровненных), а затем
идти от них назад, строя по пути гаджеты возрастающей длины. Тогда
запускать дизассемблирование нужно будет не с каждого возможного
адреса, а только с адресов в окрестности команд возврата. Чтобы прояснить, что мы имеем в виду, обратимся к коду в листинге 8.11.

236

Глава 8

Листинг 8.11. capstone_gadget_finder.cc
#include
#include
#include
#include
#include
#include






"../inc/loader.h"

int find_gadgets(Binary *bin);
int find_gadgets_at_root(Section *text, uint64_t root,
std::map *gadgets,
csh dis);
bool is_cs_cflow_group(uint8_t g);
bool is_cs_cflow_ins(cs_insn *ins);
bool is_cs_ret_ins(cs_insn *ins);
int
main(int argc, char *argv[])
{
Binary bin;
std::string fname;
if(argc < 2) {
printf("Usage: %s \n", argv[0]);
return 1;
}
fname.assign(argv[1]);
if(load_binary(fname, &bin, Binary::BIN_TYPE_AUTO) < 0) {
return 1;
}
if(find_gadgets(&bin) < 0) {
return 1;
}
unload_binary(&bin);
return 0;
}
int
find_gadgets(Binary *bin)
{
csh dis;
Section *text;
std::map gadgets;
const uint8_t x86_opc_ret = 0xc3;
text = bin->get_text_section();
if(!text) {
fprintf(stderr, "Nothing to disassemble\n");
return 0;
}
Настройка дизассемблирования

237

if(cs_open(CS_ARCH_X86, CS_MODE_64, &dis) != CS_ERR_OK) {
fprintf(stderr, "Failed to open Capstone\n");
return -1;
}
cs_option(dis, CS_OPT_DETAIL, CS_OPT_ON);
for(size_t i = 0; i < text->size; i++) {
 if(text->bytes[i] == x86_opc_ret) {
 if(find_gadgets_at_root(text, text->vma+i, &gadgets, dis) < 0) {

break;
}
}
}
 for(auto &kv: gadgets) {

printf("%s\t[ ", kv.first.c_str());
for(auto addr: kv.second) {
printf("0x%jx ", addr);
}
printf("]\n");
}
cs_close(&dis);
return 0;
}
int
find_gadgets_at_root(Section *text, uint64_t root,
std::map *gadgets,
csh dis)
{
size_t n, len;
const uint8_t *pc;
uint64_t offset, addr;
std::string gadget_str;
cs_insn *cs_ins;
const size_t max_gadget_len = 5; /* instructions */
const size_t x86_max_ins_bytes = 15;
const uint64_t root_offset = max_gadget_len*x86_max_ins_bytes;
cs_ins = cs_malloc(dis);
if(!cs_ins) {
fprintf(stderr, "Out of memory\n");
return -1;
}
 for(uint64_t a = root-1;

addr
offset
pc
n

238

Глава 8

=
=
=
=

a >= root-root_offset && a >= 0;
a--) {
a;
addr - text->vma;
text->bytes + offset;
text->size - offset;

len
= 0;
gadget_str = "";
 while(cs_disasm_iter(dis, &pc, &n, &addr, cs_ins)) {
if(cs_ins->id == X86_INS_INVALID || cs_ins->size == 0) {
break;
} else if(cs_ins->address > root) {
break;
} else if(is_cs_cflow_ins(cs_ins) && !is_cs_ret_ins(cs_ins)) {
break;
} else if(++len > max_gadget_len) {
break;
}
 gadget_str += std::string(cs_ins->mnemonic)



+ " " + std::string(cs_ins->op_str);

 if(cs_ins->address == root) {

(*gadgets)[gadget_str].push_back(a);
break;
}
gadget_str += "; ";
}
}
cs_free(cs_ins, 1);
return 0;
}
bool
is_cs_cflow_group(uint8_t g)
{
return (g == CS_GRP_JUMP) || (g == CS_GRP_CALL)
|| (g == CS_GRP_RET) || (g == CS_GRP_IRET);
}
bool
is_cs_cflow_ins(cs_insn *ins)
{
for(size_t i = 0; i < ins->detail->groups_count; i++) {
if(is_cs_cflow_group(ins->detail->groups[i])) {
return true;
}
}
return false;
}
bool
is_cs_ret_ins(cs_insn *ins)
{
switch(ins->id) {
case X86_INS_RET:
return true;
Настройка дизассемблирования

Powered by TCPDF (www.tcpdf.org)

239

default:
return false;
}
}

В программе в листинге 8.11 нет никаких новых концепций Capstone. Функция main такая же, как в линейном и рекурсивном диз­
ассемблерах, и вспомогательные функции (is_cs_cflow_group, is_
cs_cflow_ins и is_cs_ret_ins) похожи на те, что мы видели раньше.
Применяется та же функция дизассемблирования Capstone, cs_dis‑
asm_iter, что и в примере выше. А интересно здесь то, что программа
поиска гаджетов использует Capstone для анализа файла так, как не
умеет делать стандартный линейный или рекурсивный дизассемблер.
Вся функциональность поиска гаджетов сосредоточена в функциях
find_gadgets и find_gadgets_at_root, поэтому на них мы и остановимся.

Поиск корней и отображение гаджетов
Функция find_gadgets вызывается из main, ее начало нам уже знакомо. Сначала загружается секция .text и устанавливается режим
детального дизассемблирования Capstone. После инициализации
find_gadgets входит в цикл перебора байтов в .text и ищет значение
0xc3, код операции команды x86 ret 1. Концептуально каждая такая
команда – потенциальный «корень» одного или нескольких гаджетов, которые можно искать, двигаясь от корня в обратном направлении. Можно считать, что все гаджеты, заканчивающиеся некоторой
командой ret, образуют дерево с корнем в этой команде. Для поиска всех гаджетов, связанных с конкретным корнем, служит функция
find_gadgets_at_root (вызываемая в точке ), которую мы обсудим
чуть ниже.
Все гаджеты добавляются в структуру данных C++ map, которая
отобра­жает каждый уникальный гаджет (представленный строкой
string) на множество адресов, в которых этот гаджет можно найти.
Само добавление производится в функции find_gadgets_at_root.
Завершив поиск гаджетов, find_gadgets печатает все построенное
отобра­жение , выполняет очистку и возвращает управление.

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

240

Для простоты я игнорирую коды операций 0xc2, 0xca и 0xcb, соответствующие другим, менее распространенным формам команды возврата.

Глава 8

адрес начала поиска уменьшается, пока не окажется на расстоянии
15 × 5 байт от корня . Почему именно 15 × 5? Потому что нас интересуют гаджеты не длиннее пяти команд, а в x86 максимальная длина
команды равна 15 байтам, так что отдаляться от корня на расстояние
больше 15 × 5 не имеет смысла.
Для каждой точки начала поиска программа выполняет линейное
дизассемблирование . В отличие от нашего первого примера линейного дизассемблирования, здесь используется функция Capstone
cs_disasm_iter. Причина в том, что вместо дизассемблирования всего
буфера целиком программа поиска гаджетов должна проверять ряд
условий остановки после каждой команды. Прежде всего она прекращает дизассемблирование, встретив недопустимую команду; при
этом она отбрасывает гаджет, переходит к следующему адресу поиска
и начинает новый проход оттуда.
Проход дизассемблирования прекращается также, если очередная
команда начинается дальше корня . Вы, наверное, недоумеваете,
как дизассемблер мог дойти до команды за корнем, не наткнувшись
сначала на сам корень? Чтобы понять, как такое может быть, вспомните, что некоторые адреса, с которых начинается дизассемблирование, не выровнены относительно нормального потока команд. Встретив многобайтовую невыровненную команду, дизассемблер может
захватить корневую команду, интерпретировав ее как код операции
или операнды невыровненной команды, поэтому сам корень в потоке команд так и не появится.
Наконец, дизассемблирование гаджета прекращается, если обнаружены команда управления потоком, отличная от возврата . Ведь
гаджеты проще использовать, если они не содержат никакого управления потоком, кроме возврата1. Программа также отбрасывает гаджеты, оказавшиеся длиннее максимального размера .
Если ни одно из условий остановки не выполнено, то вновь дизассемблированная команда (cs_ins) добавляется в строку, содержащую
текущий гаджет . Когда мы дойдем до корневой команды, гаджет
считается завершенным и добавляется в отображение map . Рассмот­
рев все возможные начальные точки в окрестности корня, find_gad‑
gets_at_root возвращает управление функции find_gadgets, которая
переходит к анализу следующей корневой команды, если таковая
осталась.

Выполнение программы поиска гаджетов
Программа поиска гаджетов запускается так же, как инструменты
диз­ассемблирования. В листинге 8.12 показано, что она выводит.

1

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

241

Листинг 8.12. Пример результата работы сканера ROP-гаджетов
$ ./capstone_gadget_finder /bin/ls | head -n 10
adc byte ptr [r8], r8b; ret
[
adc byte ptr [rax - 0x77], cl; ret
[
adc byte ptr [rax], al; ret
[
adc byte ptr [rbp - 0x14], dh; xor eax, eax; ret [
adc byte ptr [rcx + 0x39], cl; ret
[
adc eax, 0x5c415d5b; ret
[ 0x4096d7
add al, 0x5b; ret
[
add al, 0xf3; ret
[
add al, ch; ret
[
add bl, dh; ret ; xor eax, eax; ret
[

0x40b5ac
0x40eb10
0x40b5ad
0x412f42
0x40eb8c
0x409747
0x41254b
0x404d8b
0x406697
0x40b4cf

]
]
]
]
]
]
]
]
]
]

В каждой строке находится строка гаджета и адреса, по которым
этот гаджет найден. Например, по адресу 0x406697 начинается гаджет
add al, ch; ret, который можно было бы использовать в полезной нагрузке ROP, чтобы сложить регистры al и ch. Наличие такого списка
помогает подобрать подходящие ROP-гаджеты при конструировании
полезной нагрузки ROP для эксплойта.

8.4

Резюме
Теперь вы знаете, как использовать Capstone при построении собственных специальных дизассемблеров. Все приведенные в этой главе примеры имеются на виртуальной машине, прилагаемой к книге.
Эксперименты с ними – хорошая отправная точка, чтобы набить руку
в использовании Capstone API. Проверьте свои навыки, выполнив
следующие упражнения и задачи!

Упражнения
1. Обобщение дизассемблера
Все инструменты в этой главе конфигурировали Capstone для
дизассемблирования только кода x64. Для этого мы передавали
cs_open архитектуру CS_ARCH_X86 и режим CS_MODE_64. Обобщите эти инструменты, так чтобы они автоматически выбирали
подходящие параметры Capstone для других архитектур, проверяя тип загруженного двоичного файла с по­мощью полей arch
и bits объекта Binary, предоставленного загрузчиком. Чтобы узнать, какие параметры архитектуры и режима нужно передать
Capstone, обратитесь к файлу /usr/include/capstone/capstone.h, где
перечислены все возможные значения.
2. Явное обнаружение перекрывающихся блоков
Хотя наш рекурсивный дизассемблер может справиться с перекрывающимися простыми блоками, он не выводит явных преду-

242

Глава 8

преждений о наличии такого кода. Дополните дизассемблер возможностью информировать пользователя о перекрывающихся
блоках.
3. Кросс-вариантная программа поиска гаджетов
Двоичный файл, полученный от компилирования программы из
исходного кода, может существенно зависеть от таких факторов,
как версия и параметры компилятора или целевая архитектура.
Кроме того, процесс создания эксплойта затрудняют стратегии
рандомизации, которые изменяют распределение регистров или
переставляют участки кода. Поэтому автор эксплойта (в частности, на основе ROP) не всегда знает, какой «вариант» программы
работает на целевой машине. Например, откомпилирован ли целевой сервер gcc или llvm? Работает ли он на 32- или 64-разрядной машине? Если ваше предположение неверно, то эксплойт,
скорее всего, работать не будет.
В этом упражнении ваша цель – модифицировать средство поиска ROP, так чтобы оно принимало два или более двоичных
файлов, представляющих разные варианты одной и той же программы. На выходе должен быть напечатан список виртуальных
адресов, пригодных для использования гаджетов во всех вариантах. Новая программа должна уметь сканировать каждый из поданных на вход файлов в поисках гаджетов, но выводить только
те адреса, по которым гаджет есть во всех, а не в некоторых двоичных файлах. Гаджеты для каждого выведенного виртуального
адреса должны реализовывать похожие операции. Например,
все должны содержать команду add или mov. Реализация полезного понятия «сходства» – часть задачи. В итоге должна получиться
кросс-вариантная программа поиска гаджетов, которую можно
использовать для разработки эксплойтов, способных работать
сразу в нескольких вариантах одной и той же программы!
Для тестирования программы можете создать несколько вариантов любой программы по своему усмотрению, откомпилировав
ее с разными параметрами или разными компиляторами.

9

ОСНАЩЕНИЕ
ДВОИЧНЫХ ФАЙЛОВ

В

главе 7 мы изучали методы модификации и расширения функциональности двоичных программ. Будучи относительно прос­
ты в применении, эти методы ограничивают как объем нового кода, который можно вставить в двоичный файл, так и место его
вставки. В этой главе мы рассмотрим метод оснащения двоичного файла (binary instrumentation), который позволяет вставить практически
неограниченный объем кода в любое место файла, чтобы наблюдать
или даже модифицировать его поведение.
После краткого обзора оснащения двоичных файлов я расскажу
о реализации статического оснащения (static binary instrumentation –
SBI) и динамического оснащения (dynamic instrumentation – DBI), двух
методов с различными свойствами. Наконец, мы научимся создавать
собственные инструменты оснащения двоичных файлов с по­мощью
популярной DBI-системы Pin, разработанной компанией Intel.

9.1

Что такое оснащение двоичного файла?
Вставка в любую точку существующего двоичного файла нового кода
для наблюдения или модификации его поведения тем или иным спо-

244

Глава 9

собом называется оснащением. Точка, в которую добавляется новый
код, называется точкой оснащения, а сам добавленный код – кодом
оснащения.
Например, предположим, что требуется узнать, какие функции
в двоичном файле вызываются чаще всего, чтобы можно было сосредоточить усилия на оптимизации этих функций. Для решения этой
задачи можно оснастить все команды call1, добавив код оснащения,
который регистрирует конечный адрес вызова, чтобы модифицированный таким образом двоичный файл при выполнении выдавал
список вызываемых функций.
В этом примере мы только наблюдаем за поведением двоичного
файла, но его можно и модифицировать. Например, можно улучшить
защиту файла от атак перехватом потока управления, оснастив все
косвенные передачи управления (например, call rax и ret) кодом, который проверяет, принадлежит ли конечный адрес множеству ожидаемых адресов. Если нет, выполнение аварийно останавливается, и отправляется уведомление2.

9.1.1 API оснащения двоичных файлов
В общем случае правильно реализовать метод оснащения двоичных
файлов, который добавлял бы новый код в произвольное место файла, гораздо труднее, чем простые способы модификации, описанные
в главе 7. Напомним, что нельзя просто вставить новый код в имеющуюся секцию кода, потому что при этом существующий код сдвинулся бы в другие адреса, так что все ссылки на него оказались бы
недействительными. Практически невозможно найти и исправить
все существующие ссылки после перемещения кода, т. к. в двоичном
файле нет никакой информации о местоположении этих ссылок и не
существует надежных способов отличить адреса ссылок от констант,
которые выглядят как адреса, но таковыми не являются.
По счастью, имеются общие платформы оснащения двоичных файлов, которые берут на себя все сложности реализации и предлагают
сравнительно простые API для создания инструментов оснащения.
Эти API обычно позволяют устанавливать обратные вызовы в выбранных вами точках оснащения.
Ниже в этой главе мы увидим два практических примера оснащения двоичного файла с по­мощью Pin, популярной платформы оснащения. Мы напишем профилировщик, который собирает статистику
о выполнении двоичного файла с целью последующей оптимизации.
Мы также воспользуемся Pin для реализации автоматического распа1

2

Для простоты мы игнорируем хвостовые вызовы, когда вместо call используется jmp.
Этот метод защиты от перехвата потока управления называется целостностью потока управления (control-flow integrity – CFI). Существует немало
исследований на тему того, как реализовать CFI эффективно и сделать
множество ожидаемых конечных адресов максимально точным.
Оснащение двоичных файлов

245

ковщика, предназначенного для деобфускации упакованных двоичных
файлов1.
Различают два класса платформ оснащения двоичных файлов: статические и динамические. Сначала обсудим различия между ними,
затем – как они работают на нижнем уровне.

9.1.2 Статическое и динамическое оснащение
двоичных файлов
Статическое и динамическое оснащение двоичных файлов по-раз­
но­му разрешают проблемы, связанные с вставкой и перемещением
кода. В SBI применяется метод перезаписи двоичного файла на диске, при котором в него вносятся постоянные изменения. О различных
подходах к перезаписи двоичных файлов на платформах SBI мы поговорим в разделе 9.2.
С другой стороны, DBI вообще не модифицирует файлы на диске,
а следит за ними во время выполнения и вставляет новые команды
«на лету». Этот подход хорош тем, что проблемы перемещения не возникают вовсе. Код оснащения вставляется только в поток команд, а не
в секцию двоичного файла в памяти, поэтому никакие ссылки не нарушаются. Но за это приходится расплачиваться большим объемом
вычислений и, следовательно, более сильным замедлением работы
оснащенного файла по сравнению с SBI.
В табл. 9.1 перечислены достоинства и недостатки SBI и DBI, первые отмечены символом +, а вторые – символом –.
Таблица 9.1. Достоинства и недостатки динамического и статического
оснащения двоичных файлов
Динамическое оснащение
– Относительно медленно (в 4 и более раз)
– Зависит от библиотеки и инструмента DBI
+ Прозрачно оснащает библиотеки
+ Может работать с динамически
генерируемым кодом
+ Может динамически присоединяться
и отсоединяться
+ Нет необходимости в дизассемблировании
+ Прозрачно, не требуется модифицировать
двоичный файл
+ Не нужны символы

Статическое оснащение
+ Относительно быстро (от 10 % до 2 раз)
+ Автономный двоичный файл
– Библиотеки должны оснащаться явно
– Динамически генерируемый код
не поддерживается
– Оснащается на протяжении всего
выполнения
– Чувствительно к ошибкам
дизассемблирования
– Чреватая ошибками перезапись
двоичного файла
– Наличие символов желательно для
уменьшения вероятности ошибок

Как видим, из-за необходимости в анализе во время выполнения
и затрат на оснащение DBI замедляет работу в четыре и более раз,
тогда как SBI – только от 10 % до двух раз. Отметим, что это лишь при1

246

Упаковка – популярный способ обфускации, я объясню, в чем его суть,
ниже в этой главе.

Глава 9

мерные цифры, а фактическое замедление сильно зависит от того,
что именно вам нужно, и от качества инструментария. Добавим еще,
что двоичные файлы, оснащенные методом DBI, труднее распространять: нужно поставлять не только сам файл, но и платформу и инструментарий DBI, которые содержат код оснащения. С другой стороны, двоичные файлы, оснащенные методом SBI, автономны, поэтому
после оснащения их можно распространять как обычно.
Основное преимущество DBI заключается в том, что использовать
эту технологию гораздо проще, чем SBI. Поскольку DBI применяется
к файлу во время выполнения, она автоматически учитывает все выполняемые команды, будь то части самого файла или используемых
им библиотек. С другой стороны, при работе с SBI приходится явно
оснащать и распространять все используемые двоичным файлом
биб­лиотеки, если только вы не хотите оставить их неоснащенными.
Тот факт, что DBI воздействует на поток исполняемых команд, означает также, что поддерживается динамически сгенерированный код,
например генерируемый JIT-компилятором или в результате самомодификации; SBI этого не умеет.
Кроме того, DBI-платформы обычно присоединяются к процессам
и отсоединяются от них динамически, как отладчики. Это удобно,
например, когда требуется наблюдать только за частью выполнения
долго работающего процесса. Мы просто присоединяемся к процессу, собираем нужную информацию, а затем отсоединяемся, позволяя
процессу работать дальше, как обычно. SBI такого не позволяет – либо
выполнение оснащается целиком, либо не оснащается вовсе.
Наконец, технология DBI в гораздо меньшей степени подвержена
ошибкам. Чтобы оснастить файл с по­мощью SBI, его нужно сначала
дизассемблировать, а потом внести необходимые изменения. Следовательно, ошибки дизассемблирования могут стать причиной ошибок оснащения и потенциально привести к неверным результатам
или вообще нарушить работу программы. У DBI такой проблемы нет,
потому что никакого дизассемблирования не требуется; за командами просто ведется наблюдение во время выполнения, поэтому мы
гарантированно видим правильный поток команд1. Чтобы минимизировать вероятность ошибок дизассемблирования, многие платформы SBI требуют наличия символов, тогда как платформы DBI такого
требования не предъявляют2.
Как я уже отмечал, есть разные способы реализовать перезапись
двоичного файла в SBI и оснастить файл во время выполнения в DBI.
В следующих двух разделах мы рассмотрим самые популярные способы реализации SBI и DBI соответственно.
1

2

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

247

9.2

Статическое оснащение двоичных файлов
Для статического оснащения двоичный файл нужно сначала диз­
ассемблировать, а потом добавить в нужные места код оснащения
и сохранить измененную версию на диске. К числу хорошо известных платформ SBI относятся PEBIL1 и Dyninst2 (поддерживает одновременно DBI и SBI). PEBIL требует наличия символов, Dyninst – нет.
Отметим, что и PEBIL, и Dyninst – исследовательские инструменты,
поэтому они не так хорошо документированы, как продукты производственного уровня.
Основная проблема при реализации SBI – придумать, как добавить
код оснащения и перезаписать двоичный файл, не «поломав» сущест­
вующий код и ссылки на данные. Рассмотрим два популярных решения этой проблемы, которые я назову подходами на основе int 3 и на основе трамплина. Отметим, что на практике движки SBI могут включать
элементы обоих подходов или использовать совсем другую технику.

9.2.1 Подход на основе int 3
Название этого подхода происходит от команды int 3 процессора
x86, которая используется отладчиками для задания точек останова.
Чтобы понять, почему int 3 необходима, рассмотрим сначала подход
к SBI, который не работает в общем случае.

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

Оригинальный код

Оснащенный код

Код оснащения

Рис. 9.1. Недостаточно общий подход к SBI с использованием jmp для подключения
точек оснащения
1

2

248

PEBIL доступна по адресу https://github.com/mlaurenzano/PEBIL/, а соответствующая научная статья находится по адресу https://www.sdsc.edu/pmac/
publications/laurenzano2010pebil.pdf.
Саму Dyninst и относящиеся к ней статьи можно найти по адресу http://
www.dyninst.org/.

Глава 9

В левом столбце на рис. 9.1 показан фрагмент оригинального, неоснащенного кода. Предположим, что мы хотим оснастить команду
mov edx,0x1 , добавив код оснащения, который будет работать до
и после нее. Для добавления нового кода прямо в нужную точку не
хватает места, и, чтобы выкрутиться из ситуации, мы перезаписываем mov edx,0x1 командой перехода на свой код оснащения , хранящийся в отдельной секции кода или в библиотеке. Код оснащения
сначала выполняет весь добавленный нами код предоснащения , т. е.
код, работающий до оригинальной команды. Затем выполняется оригинальная команда mov edx,0x1  и после нее код постоснащения .
Наконец, код оснащения переходит назад на команду, следующую за
точкой оснащения , и возобновляет нормальное выполнение.
Заметим, что если код предоснащения или постоснащения изменяет содержимое регистров, то это может негативно отразиться на других частях программы. Поэтому платформы SBI сохраняют состоя­ние
регистров до начала выполнения добавленного кода и восстанавливают его после выполнения, если только мы явно не сообщаем платформе, что хотим изменить состояние регистров.
Как видим, подход, показанный на рис. 9.1, – простой и элегантный способ выполнить произвольный объем кода до или после любой
команды. Так в чем же проблема? В том, что команды jmp занимают несколько байтов; для перехода к коду оснащения обычно нужна
5-байтовая команда jmp, состоящая из одного байта кода операции
и 32-разрядного смещения.
Если оснащается короткая команда, то команда перехода на код
оснащения может оказаться длиннее заменяемой. Например, команда xor esi,esi в левом верхнем углу рис. 9.1 занимает всего 2 байта,
поэтому если бы мы заменили ее 5-байтовой командой jmp, то была
бы затерта часть следующей команды. И решить эту проблему, сделав
следующую затертую команду частью кода оснащения, нельзя, потому что на нее может вести переход. И тогда любая команда перехода,
ведущая на эту команду, «приземлилась» был в середине вставленной
команды jmp, сделав двоичный файл неработоспособным.
Это возвращает нас к команде int 3. Ее можно использовать для
оснащения коротких команд, для чего многобайтовые команды перехода не годятся. Посмотрим, как это делается.

Решение проблемы многобайтовой команды перехода
с по­мощью int 3
Команда x86 int 3 генерирует программное прерывание в форме сигнала SIGTRAP (в Linux), доставляемого операционной системой. Этот
сигнал могут перехватить программы пользовательского уровня, например библиотеки SBI или отладчики. Важно, что длина int 3 всего
1 байт, поэтому ей можно перезаписать любую команду, не опасаясь
затереть соседнюю. Код операции int 3 равен 0xcc.
С точки зрения SBI, для оснащения команды с по­мощью int3 мы
просто заменяем первый байт команды на 0xcc. Получив сигнал SIG‑
Оснащение двоичных файлов

249

TRAP, мы можем воспользоваться API ptrace в Linux, чтобы узнать, по
какому адресу произошло прерывание, т. е. получить адрес точки оснащения. Затем можно вызвать соответствующий код оснащения, как
было показано на рис. 9.1.
Если встать на чисто функциональную точку зрения, то int 3 – идеальный способ реализации SBI, потому что он прост в использовании
и не требует перемещать код. К сожалению, программные прерывания и int 3, в частности, работают медленно и тормозят оснащенное
приложение. Кроме того, подход на основе int 3 не совместим с программами, которые уже работают под управлением отладчика и используют int 3 для точек остановки. Поэтому на практике многие SBIплатформы применяют более сложные, но и более быстрые методы
перезаписи, например подход на основе трамплинов.

9.2.2 Подход на основе трамплинов
В отличие от подхода на основе int 3, в подходе на основе трамплинов
не делается попыток оснастить оригинальный код напрямую. Вместо
этого создается копия всего оригинального кода, которая и оснащается. Идея в том, что так мы не порушим ссылки на код или данные,
потому что они по-прежнему ведут на оригинальные, неизмененные
места. Чтобы двоичный файл исполнял оснащенный, а не оригинальный код, используются команды jmp, называемые трамплинами, которые перенаправляют оригинальный код на оснащенный. Всякий
раз, как команда вызова или перехода передает управление в некоторую точку оригинального кода, находящийся в этой точке трамплин
перебрасывает поток на соответствующий оснащенный код.
Чтобы было понятнее, рассмотрим пример на рис. 9.2. Слева показан неоснащенный двоичный файл, а справа – как этот файл преобразуется в ходе оснащения.
Предположим, что оригинальный неоснащенный двоичный файл
содержит две функции, f1 и f2. На рис. 9.2 показан код f1. Код f2 нам
сейчас не важен.
:
test edi,edi
jne _ret
xor eax,eax
call f2
_ret:
ret

Оснащая двоичный файл методом трамплинов, движок SBI создает
копии всех функций, помещает их в новую секцию кода (на рис. 9.2
она названа .text.instrum) и перезаписывает первую команду каждой оригинальной функции трамплином jmp, который переходит на
соответствующую копию. Например, оригинальная функция f1 следующим образом перенаправляется на f1_copy:

250

Глава 9

:
jmp f1_copy
; мусорные байты

Заголовок исполняемого файла

Заголовок исполняемого файла

Заголовки программы

Заголовки программы

.text

.text

.data

.data

Заголовки секций

Заголовки секций

Оригинальный двоичный файл

Оснащенный двоичный файл

Код оснащения
(разделяемая
библиотека)

Рис. 9.2. Статическое оснащение двоичного файла с по­мощью трамплинов

Трамплином является 5-байтовая команда jmp, которая может час­
тично затирать несколько команд, порождая «мусорные байты» сразу
после трамплина. Но обычно это не проблема, потому что затертые
команды никогда не выполняютя. Впрочем, в конце этого раздела мы
увидим несколько случаев, когда проблема все-таки есть.

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

251

дит на f1_copy , оснащенную версию f1. За трамплином может располагаться несколько мусорных байтов , но они не выполняются.
Движок SBI вставляет несколько команд nop в каждую возможную
точку оснащения в f1_copy . Таким образом, чтобы оснастить команду, движок SBI может просто перезаписать команды nop в этой
точке оснащения командой jmp или call, передающей управление
коду оснащения. Заметим, что и вставка nop, и оснащение производятся статически, а не во время выполнения. На рис. 9.2 из всех участков nop используется только последний, непосредственно перед командой ret; я объясню этот момент чуть ниже.
Чтобы сохранить правильность относительных переходов, несмот­
ря на сдвиг кода из-за вновь вставленных команд, движок SBI изменяет смещения во всех командах jmp относительного перехода. Кроме
того, движок заменяет все 2-байтовые команды jmp относительного перехода, имеющие 8-разрядное смещение, соответствующими
5-байтовыми командами с 32-разрядным смещением . Это необходимо, потому что при сдвиге кода в f1_copy смещение от команды
jmp до ее конечного адреса может увеличиться, так что 8 разрядов не
хватит.
Аналогично движок SBI переписывает команды прямого вызова,
например call f2, так что они ведут на оснащенную функцию вмес­
то оригинальной . Из-за такой перезаписи прямых вызовов может
возникнуть вопрос, зачем вообще нужны трамплины в начале каждой
оригинальной функции. Как я скоро объясню, их задача – обеспечить
правильность косвенных вызовов.
Теперь предположим, что мы попросили движок SBI оснастить все
команды ret. Для этого движок перезаписывает зарезервированные
для этой цели команды nop командами jmp или call, передающими
управление коду оснащения . В примере на рис. 9.2 код оснащения – это функция hook_ret, помещенная в разделяемую библиотеку,
для доступа к которой служит команда call, поставленная движком
в точке оснащения.
Функция hook_ret первым делом сохраняет состояние , в частности содержимое регистров, а затем выполняет заданный нами код
оснащения. В конце она восстанавливает сохраненное состояние 
и возобновляет нормальное выполнение, возвращая управление команде, следующей за точкой оснащения.
Теперь, познакомившись с тем, как в подходе на основе трамплина
переписываются команды прямого управления потоком, посмотрим,
что происходит с командами косвенного управления.

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

252

Глава 9

и перенаправить поток обратно на оснащенный код. На рис. 9.3 показано, как обрабатываются два типа косвенного потока управления:
косвенные вызовы функций и косвенные переходы для реализации
предложений switch в C/C++.

.text

.data

.text

.text.instrum
.text.instrum

(a) Косвенный вызов

(b) Косвенный вызов через
неподправленную таблицу переходов
(реализующую switch)

Рис. 9.3. Косвенные передачи управления в статически оснащенном файле

На рис. 9.3a показано, как трамплины позволяют обработать косвенные вызовы. Движок SBI не изменяет код вычисления адресов,
поэтому конечные адреса в косвенных вызовах указывают на оригинальную функцию . Поскольку в начале каждой оригинальной функции находится трамплин, поток управления немедленно возвращается на оснащенную версию функции .
Для косвенных переходов дело обстоит чуть сложнее – см. рис. 9.3b.
Мы предположили, что косвенный переход является частью предложения switch в C/C++. На двоичном уровне предложения switch часто
реализуются с по­мощью таблицы переходов, содержащей адреса всех
возможных ветвей case. Для перехода на конкретную ветвь switch вычисляет соответствующий ей индекс в таблице переходов и использует команду jmp для косвенного перехода по хранящемуся там адресу .
По умолчанию все адреса, хранящиеся в таблице переходов, указывают на оригинальный код . Поэтому конечный адрес косвенной
команды jmp находится в середине оригинальной функции, где никакого трамплина нет, и выполнение продолжается оттуда . Чтобы
решить эту проблему, движок SBI должен либо исправить таблицу
переходов, заменив оригинальные адреса новыми, либо поместить
трамплин в каждую ветвь case в оригинальном коде.
К сожалению, в составе базовой информации о символах (в отличие от расширенной информации в формате DWARF) нет никаких
сведений о структуре предложений switch, поэтому трудно понять,
Оснащение двоичных файлов

253

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

Трамплины в позиционно-независимом коде
Движки SBI, основанные на трамплинах, нуждаются в специальной поддержке команд косвенного управления потоком в позиционно-независимых исполняемых файлах (PIE), которые не
зависят от адреса загрузки. В PIE-файлах для вычисления адреса используется текущий счетчик программы. На 32-разрядной
платформе x86 PIE-файл получает счетчик программы, выполнив команду call, а затем прочитав адрес возврата из стека.
Например, gcc 5.4.0 генерирует следующую функцию, которую
можно вызвать для чтения адреса команды, следующей за call:
:
mov ebx,DWORD PTR [esp]
ret

Эта функция копирует адрес возврата в ebx и возвращается. На
x64 прочитать счетчик программы можно непосредственно (из
регистра rip).
Опасность заключается в том, что PIE-файл может прочитать
счетчик программы во время выполнения оснащенного кода
и использовать его при вычислении адреса. Это, скорее всего, приведет к неверным результатам, потому что размещение
в памяти оснащенного кода отличается от оригинального размещения, предполагаемого при вычислении адреса. Для решения проблемы движки SBI оснащают конструкции, читающие
счетчик программы, так чтобы они возвращали то значение
счетчика, которое было бы получено в оригинальном коде. Тогда последующее вычисление адреса даст то же местоположение,
что и в неоснащенном коде, поэтому движок SBI перехватит там
управление с по­мощью трамплина.

О надежности подхода на основе трамплинов
Описание проблем, связанных с обработкой предложений switch, говорит о том, что подход на основе трамплинов подвержен ошибкам.
Помимо ветвей switch, слишком маленьких для размещения нормального трамплина, в программах могут встречаться (хотя это и маловероятно) такие коротенькие функции, что в них не хватит мес­
та для 5-байтовой команды jmp, что заставит движок SBI прибегнуть

254

Глава 9

к альтернативному решению, например на основе int 3. Хуже того,
если двоичный файл содержит встроенные данные, перемешанные
с кодом, – тогда трамплин может ненамеренно затереть часть данных,
что приведет к ошибкам при их использовании программой. И все это
в предположении, что дизассемблирование было изначально выполнено правильно, в противном случае любые изменения, внесенные
движком SBI, могут «поломать» двоичный файл.
К сожалению, неизвестен метод SBI, который был бы одновременно
надежен и эффективен в применении к реальным двоичным файлам.
Во многих случаях решения на базе DBI предпочтительнее, поскольку
они не подвержены ошибкам, терзающим SBI. Хотя они работают не
так быстро, как SBI, производительности современных DBI-платформ
достаточно для многих практических ситуаций. Далее в этой главе мы
будем говорить только о DBI, а конкретно о популярной платформе
Pin. Сначала рассмотрим некоторые детали реализации DBI, а затем
перейдем к практическим примерам.

9.3

Динамическое оснащение двоичных файлов
Поскольку движки DBI наблюдают за двоичными файлами (точнее,
процессами) во время выполнения и оснащают поток команд, им не
требуется ни дизассемблирование, ни перезапись двоичных файлов,
как в случае SBI, что делает их менее уязвимыми для ошибок.
На рис. 9.4 показана архитектура современных DBI-систем типа Pin
и DynamoRIO. На верхнем уровне все они похожи, хотя детали реализации и уровень оптимизации различаются. Далее в этой главе я буду
говорить о «чистых» DBI-системах, показанных на рисунке, а не о гиб­
ридных платформах, поддерживающих SBI и DBI с использованием
таких приемов модификации кода, как трамплины.

9.3.1 Архитектура DBI-системы
Движки DBI динамически оснащают процессы путем наблюдения
и управления всеми выполняемыми командами. Движок раскрывает
API, позволяющий пользователям писать свои инструменты (часто
в форме разделяемой библиотеки, загружаемой движком), которые
описывают, какой код следует оснастить и как именно. Например, инструмент DBI, показанный справа на рис. 9.4, реализует (на псевдокоде) несложный профилировщик, подсчитывающий, сколько было
выполнено простых блоков. Для этого с по­мощью API движка DBI последняя команда каждого простого блокаоснащается обратным вызовом функции, увеличивающей счетчик на единицу.
Прежде чем запустить главный процесс приложения (или возобновить его, если вы присоединились к существующему процессу),
движок DBI дает инструменту возможность инициализироваться.
На рис. 9.4 функция инициализации инструмента DBI регистрирует
функцию instrument_bb в движке . Эта функция говорит движку, как
Оснащение двоичных файлов

255

оснащать каждый простой блок; в данном случае она добавляет обратный вызов bb_callback за последней командой простого блока.
Затем функция инициализации информирует движок DBI, что она
завершила работу и можно запускать приложение .
Процесс
Данные
Код
Виртуальная машина

Блок выборки кода

Инструмент DBI
Движок оснащения
Выбрать
новый код

JIT-компилятор

API

Диспетчер
Кеш кода
Выполнение
Эмулятор
Запуск
Операционная система
Оборудование

Рис. 9.4. Архитектура DBI-системы

Движок DBI никогда не выполняет прикладной процесс непосредственно, а только в кеше кода, содержащем весь оснащенный код. Первоначально кеш кода пуст, поэтому движок DBI выбирает блок кода
из процесса  и оснащает его , как того требует инструмент DBI .
Заметим, что движки DBI необязательно выбирают и оснащают код
простыми блоками, как будет объяснено ниже в разделе 9.4. Однако

256

Глава 9

в этом примере я предполагаю, что в результате вызова instrument_bb
движок оснащает код на уровне простых блоков.
После оснащения кода движок DBI компилирует его с по­мощью
своевременного (JIT) компилятора , который заново оптимизирует оснащенный код, и сохраняет откомпилированный код в кеше
. JIT-компилятор также перезаписывает команды управления потоком, чтобы движок DBI гарантированно сохранил управление;
идея в том, чтобы воспрепятствовать передаче управления из непрерывно выполняемого участка кода в неоснащенную часть процесса
приложения. Отметим, что в отличие от большинства компиляторов
JIT-компилятор в движке DBI не транслирует код на другой язык,
а переводит с машинного языка на него же. Это необходимо только
для оснащения кода при первом выполнении. Затем код сохраняется
в кеше и используется многократно.
Оснащенный и JIT-откомпилированный код теперь выполняется
в кеше кода, пока не встретится команда управления потоком, которая потребует выбрать новый код или найти другой блок кода в кеше
. Движки DBI типа Pin и DynamoRIO уменьшают издержки времени
выполнения, переписывая всюду, где возможно, команды управления
потоком, так чтобы они переходили непосредственно к следующему
блоку в кеше, не прибегая к посредничеству движка. Когда же это невозможно (например, для косвенных вызовов), переписанные команды возвращают управление движку DBI, чтобы он мог подготовить
и запустить следующий блок кода.
Хотя большинство команд работают в кеше кода естественным
образом, движок может эмулировать некоторые команды вместо их
прямого выполнения. Например, Pin поступает так для системных
вызовов типа execve, требующих специальной обработки.
Оснащенный код содержит обратные вызовы функций в инструменте DBI, которые наблюдают за поведением кода или модифицируют его . Например, на рис. 9.4 функция instrument_bb добавляет
обратный вызов в конец каждого простого блока, который вызывает
функцию bb_callback, увеличивающую счетчик выполненных прос­
тых блоков. Движок DBI автоматически сохраняет и восстанавливает
состояние регистров при передаче управления функции обратного
вызова и возврате из нее.
Познакомившись с внутренним устройством движков DBI, обсудим
Pin – движок, который я буду использовать далее в примерах.

9.3.2 Введение в Pin
Одна из самых популярных DBI-платформ, Intel Pin, активно разрабатывается, бесплатна для использования (хотя ее исходный код закрыт), хорошо документирована и предлагает сравнительно простой
API1. На виртуальную машину уже установлена версия Pin v3.6, ~/pin/
1

Скачать Pin и найти документацию можно по адресу https://software.intel.
com/en-us/articles/pin-a-binary-instrumentation-tool-downloads/.
Оснащение двоичных файлов

257

pin-3.6-97554-g31f0a167d-gcc-linux. В комплект поставки Pin включено
много примеров инструментов, они находятся в подкаталоге source/
tools главного каталога Pin.

Внутреннее устройство Pin
В настоящее время Pin поддерживает архитектуры процессоров Intel, включая x86 и x64, и доступен для Linux, Windows и macOS. Его
архитектура похожа на изображенную на рис. 9.4. Pin выбирает и JITкомпилирует код с гранулярностью трассы. Трасса похожа на простой
блок абстракции, в нее можно войти только через верхнюю точку, но
выйти из разных точек – в отличие от настоящих простых блоков1.
В Pin трасса определяется как прямолинейная последовательность
команд, заканчивающаяся безусловной передачей управления либо
по достижении максимальной длины или максимального числа команд условного управления потоком.
Хотя Pin всегда JIT-компилирует код на уровне трасс, оснащать его
он позволяет на разных уровнях, включая одну команду, простой блок,
трассу, функцию и образ (полный исполняемый файл или библиотека). И движок DBI, и Pin-инструменты работают в пространстве пользователя, так что с по­мощью Pin можно оснащать только процессы
в пространстве пользователя.

Реализация Pin-инструментов
Инструменты DBI, реализуемые вами с по­мощью Pin, называются Pinинст­рументами и являются разделяемыми библиотеками, написанными на C/C++ с применением Pin API. Pin API настолько архитектурно-независим, насколько это возможно, а архитектурно-зависимые
компоненты используются лишь тогда, когда без этого не обойтись.
Это позволяет писать Pin-инструменты, которые переносимы с одной
архитектуры на другую или требуют лишь минимальных изменений.
Для создания Pin-инструмента пишутся функции двух видов: функции оснащения и функции анализа. Функции оснащения говорят Pin,
какой код оснащения добавить и куда именно; они работают только
тогда, когда Pin впервые встречает еще не оснащенный участок кода.
Чтобы оснастить код, функция оснащения устанавливает обратные
вызовы функций анализа, которые и содержат код оснащения, и вызываются при каждом выполнении оснащенной кодовой последовательности.
Не следует путать функции оснащения Pin с кодом оснащения в смысле SBI. Код оснащения – это новый код, добавленный в оснащенную
1

258

Pin предлагает также режим зондирования, в котором весь код оснащается
сразу, а затем работает естественным образом, не полагаясь на JIT-ком­
пи­лятор. Режим зондирования быстрее, чем режим JIT, но в нем доступно
лишь подмножество API. Поскольку режим зондирования поддерживает
только оснащение на уровне функций (RTN), для чего необходимы символы функций, я в этой главе ограничусь лишь режимом JIT. Если интересно,
можете прочитать о режиме зондирования в документации по Pin.

Глава 9

программу, он соответствует функциям анализа Pin, а не функциям
оснащения, которые вставляют обратные вызовы функций анализа.
Различие между функциями оснащения и анализа станет яснее при
рассмотрении практических примеров ниже.
Благодаря популярности Pin многие другие платформы двоичного анализа основаны на нем. Например, мы снова встретимся с Pin
в главах 10–13, посвященных анализу заражения и символическому
выполнению.
В этой главе мы рассмотрим два примера, реализованных с по­
мощью Pin: профилировщик и автоматический распаковщик. В процессов реализации этих инструментов мы ближе познакомимся
с внут­ренним устройством Pin, в частности поддерживаемыми точками оснащения. Начнем с профилировщика.

9.4

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

9.4.1 Структуры данных профилировщика
и код инициализации
В листинге 9.1 показана первая часть кода профилировщика. В этом
обсуждении мы опускаем стандартные включаемые файлы и функции, не имеющие отношения к функциональности Pin, например
функции печати информации о порядке вызова и результатов. Все это
можно найти в исходном файле profiler.cpp на виртуальной машине.
Листинг 9.1. profiler.cpp
 #include "pin.H"
 KNOB ProfileCalls(KNOB_MODE_WRITEONCE, "pintool", "c", "0",

"Profile function calls");
KNOB ProfileSyscalls(KNOB_MODE_WRITEONCE, "pintool", "s", "0",
"Profile syscalls");
 std::map cflows;

std::map calls;
std::map syscalls;
std::map funcnames;
unsigned
unsigned
unsigned
unsigned

long
long
long
long

insn_count = 0;
cflow_count = 0;
call_count = 0;
syscall_count = 0;

int
Оснащение двоичных файлов

259

main(int argc, char *argv[])
{
 PIN_InitSymbols();
 if(PIN_Init(argc,argv)) {
print_usage();
return 1;
}
IMG_AddInstrumentFunction(parse_funcsyms, NULL);
TRACE_AddInstrumentFunction(instrument_trace, NULL);
INS_AddInstrumentFunction(instrument_insn, NULL);
 if(ProfileSyscalls.Value()) {
PIN_AddSyscallEntryFunction(log_syscall, NULL);
}




PIN_AddFiniFunction(print_results, NULL);



/* Не возвращает управления */
PIN_StartProgram();
return 0;
}

Любой Pin-инструмент должен включать файл pin.H, необходимый
для доступа к Pin API 1. Это единственный заголовочный файл, он
содержит объявления всего API.
Заметим, что Pin наблюдает за выполнением программы, начиная
с первой команды, а следовательно, профилировщик видит не только
код приложения, но и команды, выполняемые динамическим загрузчиком и разделяемыми библиотеками. Об этом нужно помнить при
написании любых Pin-инструментов.

Параметры командной строки и структуры данных
Pin-инструменты могут обрабатывать параметры командной строки,
которые в терминологии Pin называются регуляторами (knob). Pin API
включает специальный класс KNOB, который можно использовать для
создания параметров командной строки. В листинге 9.1 имеются два
булевых параметра (KNOB): ProfileCalls и ProfileSyscalls.
Для обоих задан режим KNOB_MODE_WRITEONCE, поскольку это булевы
флаги, которые устанавливаются только один раз. Чтобы активировать параметр ProfileCalls, нужно передать Pin-инструменту флаг
–c, а чтобы активировать ProfileSyscalls – флаг –s. (Как передаются флаги, мы увидим, когда дойдем до тестов профилировщика.) По
умолчанию оба параметра равны 0, т. е. если флаг не передан, то принимает значение false. Pin позволяет также создавать параметры
командной строки других типов, например string или int. Дополнительные сведения о параметрах можно почерпнуть из онлайновой
документации или из кода примеров.
1

260

Заглавная буква H в имени pin.H – соглашение, показывающее, что это заголовочный файл программы, написанной на C++, а не на C.

Глава 9

В профилировщике используется несколько структур std::map
и счетчиков, в которых хранятся статистические данные о работе программы . Структуры cflows и calls отображают адреса целей управления потоком (простых блоков или функций) на другое отобра­жение,
которое, в свою очередь, хранит адреса команд управления потоком
(переходов, вызовов и т. д.), обращающихся к каждой цели, и подсчитывает, сколько раз каждая из этих команд выполнялась. Отображение syscall просто запоминает, сколько раз выполнялся системный
вызов с данным номером, а funcnames отображает адреса функций на
символические имена, если они известны. В счетчиках (insn_count,
cflow_count, call_count и syscall_count) хранится соответственно общее число выполненных команд, число команд управления потоком,
вызова и системного вызова.

Инициализация Pin
Как и для любой программы на C/C++, выполнение Pin-инструмента
начинается в функции. Первая функция Pin, вызываемая профилировщиком, – PIN_InitSymbols , которая читает таблицы символов приложения. Если вы собираетесь использовать в своем Pin-инструменте
символы, то должны вызвать PIN_InitSymbols раньше любой другой
функции Pin API. Профилировщик использует символы, если они доступны, чтобы показать статистику вызова функций в понятном человеку виде.
Далее профилировщик вызывает функцию PIN_Init , которая
инициализирует Pin и должна вызываться раньше всех остальных
функций Pin, кроме PIN_InitSymbols. Она возвращает true, если во
время инициализации произошла какая-то ошибка, тогда профилировщик печатает краткую справку и завершается. Функция PIN_Init
обрабатывает параметры командной строки самого Pin, а также параметры вашего Pin-инструмента, заданные в виде объектов KNOB.
Обычно Pin-инструменту нет необходимости реализовывать обработку своих параметров командной строки.

Регистрация функций оснащения
После того как движок Pin инициализирован, пора инициализировать Pin-инструмент. Здесь самое важное – зарегистрировать функции оснащения, ответственные за оснащение приложения.
Профилировщик регистрирует три функции оснащения . Первая,
parse_funcsyms, оснащает код на уровне образа, а две другие, instru‑
ment_trace и instrument_insn, – на уровне трассы и команды соответственно. Для их регистрации используются соответственно функции
IMG_AddInstrumentFunction, TRACE_AddInstrumentFunction и INS_Add‑
InstrumentFunction. Заметим, что можно добавить сколько угодно
функций оснащения каждого типа.
Вскоре мы увидим, что эти функции оснащения принимают объекты типа IMG, TRACE и INS соответственно. Кроме того, все они принимают второй параметр типа void*, который позволяет передать зависяОснащение двоичных файлов

261

щую от инструмента структуру данных, задаваемую при регистрации
функций оснащения с по­мощью *_AddInstrumentFunction. Наш профилировщик не пользуется этой возможностью (второй параметр
всегда равен NULL).

Регистрация функции входа в системный вызов
Pin тоже позволяет зарегистрировать функции, которые вызываются
до или после каждого системного вызова. Делается это так же, как
при регистрации функций оснащения. Заметим, что невозможно задать обратные вызовы только для некоторых системных вызовов,
различать системные вызовы вам придется внутри функции обратного вызова.
Профилировщик пользуется функцией PIN_AddSyscallEntryFunc‑
tion, чтобы зарегистрировать функцию log_syscall, которая вызывается при входе в системный вызов . Чтобы зарегистрировать обратный вызов, срабатывающий после возврата из системного вызова,
воспользуйтесь функцией PIN_AddSyscallExitFunction. Профилировщик регистрирует этот обратный вызов, только если ProfileSyscalls.
Value() – значение регулятора ProfileSyscalls – равно true.

Регистрация финишной функции
И последняя регистрируемая профилировщиком функция – это финишная функция, которая вызывается при завершении приложения
или отсоединении Pin от него . Финишные функции получают код
выхода (INT32) и определенный пользователем указатель void*. Для
их регистрации служит функция PIN_AddFiniFunction. Отметим, что
для некоторых программ нельзя гарантировать вызов финишных
функций; это зависит от того, как программа завершается.
Финишная функция, регистрируемая профилировщиком, отвечает
за печать результатов профилирования. Я не буду ее рассматривать,
потому что она не содержит никакого специфичного для Pin кода, но
результат работы print_results мы увидим при тестировании профилировщика.

Запуск приложения
Последний шаг инициализации любого Pin-инструмента – вызов
функции PIN_StartProgram, которая запускает приложение . Пос­
ле этого регистрировать новые обратные вызовы уже нельзя; Pinинструмент снова получает управление только при вызове функции
оснащения или анализа. Функция PIN_StartProgram не возвращает
управление, а это значит, что предложение return 0 в конце main никогда не будет выполнено.

9.4.2 Разбор символов функций
Теперь, когда мы знаем, как инициализируется Pin-инструмент, как
регистрируются функции оснащения и другие обратные вызовы,

262

Глава 9

рассмотрим подробнее сами зарегистрированные функции. Начнем
с функции parse_funcsyms, показанной в листинге 9.2.
Листинг 9.2. profiler.cpp (продолжение)
static void
parse_funcsyms(IMG img, void *v)
{
 if(!IMG_Valid(img)) return;
 for(SEC sec = IMG_SecHead(img); SEC_Valid(sec); sec = SEC_Next(sec)) {
 for(RTN rtn = SEC_RtnHead(sec); RTN_Valid(rtn); rtn = RTN_Next(rtn)) {
 funcnames[RTN_Address(rtn)] = RTN_Name(rtn);

}
}
}

Напомню, что функция оснащения parse_funcsyms вызывается на
уровне образа, об этом говорит тот факт, что она получает объект
IMG в качестве первого аргумента. Функции оснащения образа вызываются в момент загрузки нового образа (исполняемого файла
или разделяемой библиотеки), что позволяет оснастить образ в целом. Среди прочего мы можем в цикле обойти все функции в образе и добавить функции анализа, которые будут вызываться до
или после каждой функции. Заметим, что оснащение функций надежно работает, только если двоичный файл содержит информацию
о символах, а оснащение «после функции» вообще не работает, если
были произведены некоторые оптимизации, например хвостовых
вызовов.
Однако parse_funcsyms не добавляет никакого оснащения. Вместо
этого она пользуется еще одним свойством оснащения образа, которое позволяет инспектировать символические имена функций в образе. Профилировщик сохраняет эти имена, чтобы их можно впоследствии прочитать и показать в распечатке.
Прежде чем использовать аргумент IMG, parse_funcsyms вызывает
IMG_Valid, чтобы проверить корректность образа . Если все в порядке, то parse_funcsyms в цикле перебирает все объекты SEC в образе,
представляющие секции образа . Функция IMG_SecHead возвращает
первую секцию образа, а SEC_Next – следующую секцию; цикл продолжается, пока SEC_Valid не вернет false, сообщая тем самым, что
больше секций не осталось.
Для каждой секции parse_funcsyms в цикле перебирает все функции
(представленные объектами RTN, от слова «routine»)  и отображает
адрес каждой функции (полученный от RTN_Address) из отображения
funcnames на ее символическое имя (полученное от RTN_Name) . Если
имя функции неизвестно (например, когда в двоичном файле нет таб­
лицы символов), то RTN_Name возвращает пустую строку.
По завершении parse_funcsyms объект funcnames содержит отображение всех известных адресов функций на символические имена.
Оснащение двоичных файлов

263

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

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

Реализация оснащения простого блока
Pin API не позволяет непосредственно оснастить простые блоки, т. е.
не существует функции BBL_AddInstrumentFunction. Чтобы это всетаки сделать, нужно добавить функцию оснащения на уровне трассы, а затем в цикле перебрать все простые блоки в трассе и оснастить
каждый из них, как показано в листинге 9.3.
Листинг 9.3. profiler.cpp (продолжение)
static void
instrument_trace(TRACE trace, void *v)
{
 IMG img = IMG_FindByAddress(TRACE_Address(trace));
if(!IMG_Valid(img) || !IMG_IsMainExecutable(img)) return;
 for(BBL bb = TRACE_BblHead(trace); BBL_Valid(bb); bb = BBL_Next(bb)) {
 instrument_bb(bb);

}
}
static void

264

Глава 9

instrument_bb(BBL bb)
{
 BBL_InsertCall(
bb, IPOINT_ANYWHERE, (AFUNPTR)count_bb_insns,
IARG_UINT32, BBL_NumIns(bb),
IARG_END
);
}

Первая функция в этом листинге, instrument_trace, – функция оснащения на уровне трассы, которую профилировщик зарегистрировал ранее. Ее первым аргументом является объект оснащаемой трассы TRACE.
Сначала instrument_trace вызывает IMG_FindByAddress, передавая
ей адрес трассы, чтобы та нашла образ IMG, частью которого является
трасса . Затем она удостоверяется, что образ корректен, и вызывает IMG_IsMainExecutable, чтобы проверить, является ли трасса частью
главного исполняемого файла приложения. Если нет, то instrument_
trace возвращается, не оснащая трассу. Идея в том, что в процессе
профилирования приложения мы обычно хотим учитывать только
код, принадлежащий ему самому, а не разделяемым библиотекам или
динамическому загрузчику.
Если трасса допустима и является частью главного приложения, то
instrument_trace перебирает все простые блоки (объекты BBL) в трассе . Для каждого BBL вызывается функция instrument_bb , которая
и производит оснащение блока.
Чтобы оснастить BBL, instrument_bb вызывает функцию Pin API
BBL_InsertCall , которая принимает три обязательных аргумента:
оснащаемый простой блок (в данном случае bb), точку вставки и указатель на функцию анализа, которую мы хотим добавить.
Точка вставки определяет, в какое место простого блока нужно
вставить обратный вызов. В данном случае мы передали значение IP‑
OINT_ANYWHERE , поскольку нам не важно, в какой точке простого блока будет увеличен счетчик команд. Это позволяет Pin оптимизировать
размещение обратного вызова функции анализа. В табл. 9.2 перечислены все возможные точки вставки – не только для оснащения на уровне
простых блоков, но и на уровне команд и на всех остальных уровнях.
Функция анализа называется count_bb_insns , а ее реализация будет приведена ниже. Pin предоставляет тип AFUNPTR, к которому нужно
приводить указатели на функции, передаваемые функциям Pin API.
Таблица 9.2. Точки вставки Pin
Точка вставки

Обратный вызов анализа
Перед оснащенным объектом
На ветви «проваливания» (команды
перехода или «регулярной» команды)
IPOINT_ANYWHERE
В любом месте оснащенного объекта
IPOINT_TAKEN_BRANCH На ветви перехода

IPOINT_BEFORE
IPOINT_AFTER

Допустимость
Всегда допустимо
Если INS_HasFallthrough
равно true
Только для TRACE и BBL
Если INS_IsBranchOrCall
равно true

Оснащение двоичных файлов

265

После обязательных аргументов BBL_InsertCall можно добавить
факультативные для передачи функции анализа. В данном случае
имеется факультативный аргумент типа IARG_UINT32 , имеющий
значение BBL_NumIns. Таким образом, функция анализа (count_bb_in‑
sns) принимает аргумент типа UINT32, содержащий число команд
в простом блоке; именно на эту величину она увеличивает счетчик
команд. Мы еще встретимся с другими типами аргументов в этом
и следующем примерах. Полный список всех возможных типов аргументов можно найти в документации по Pin. После всех факультативных аргументов мы передаем специальный аргумент IARG_END ,
информирующий Pin, что список аргументов закончен.
В результате выполнения кода в листинге 9.3 Pin оснащает все выполняемые простые блоки в главном приложении обратным вызовом
функции count_bb_insns, которая увеличивает счетчик команд профилировщика на число команд в простом блоке.

9.4.4 Оснащение команд управления потоком
Помимо общего числа команд, выполненных приложением, профилировщик подсчитывает количество передач управления и, факультативно, число вызовов функций. Для вставки обратных вызовов, подсчитывающих передачи управления и вызовы, используется
функция оснащения на уровне команд, показанная в листинге 9.4.
Листинг 9.4. profiler.cpp (продолжение)
static void
instrument_insn(INS ins, void *v)
{
 if(!INS_IsBranchOrCall(ins)) return;
IMG img = IMG_FindByAddress(INS_Address(ins));
if(!IMG_Valid(img) || !IMG_IsMainExecutable(img)) return;
 INS_InsertPredicatedCall(

ins, IPOINT_TAKEN_BRANCH, (AFUNPTR)count_cflow,
IARG_INST_PTR, IARG_BRANCH_TARGET_ADDR,
IARG_END
);
 if(INS_HasFallThrough(ins)) {

INS_InsertPredicatedCall(
ins, IPOINT_AFTER, (AFUNPTR)count_cflow,
IARG_INST_PTR, IARG_FALLTHROUGH_ADDR,
IARG_END
);
}
 if(INS_IsCall(ins)) {

if(ProfileCalls.Value()) {
INS_InsertCall(

266

Глава 9

ins, IPOINT_BEFORE, (AFUNPTR)count_call,
IARG_INST_PTR, IARG_BRANCH_TARGET_ADDR,
IARG_END
);
}
}
}

Функция оснащения instrument_insn получает в качестве первого
аргумента объект типа INS, представляющий оснащаемую команду.
Сначала instrument_insn вызывает INS_IsBranchOrCall, чтобы проверить, является ли ins командой управления потоком . Если нет,
то никакое оснащение не добавляется. Убедившись, что это действительно команда управления потоком, instrument_insn проверяет, является ли она частью главного приложения – так же, как при оснащении простого блока.

Оснащение ветви перехода
Для регистрации передач управления и вызовов instrument_insn
вставляет три разных обратных вызова анализа. Сначала используется функция INS_InsertPredicatedCall , чтобы вставить обратный
вызов на ветви перехода  (см. рис. 9.5). Вставленная функция анализа count_cflow увеличивает счетчик команд управления потоком
(cflow_count) в случае, если производится переход, а также запоминает начальный и конечный адреса передачи управления. Функция
анализа принимает два аргумента: значение счетчика программы
в момент обратного вызова (IARG_INST_PTR)  и конечный адрес перехода (IARG_BRANCH_TARGET_ADDR) .
ВВ1

Ветвь проваливания
(IPOINT_AFTER)
ВВ2

Ветвь перехода
(IPOINT_TAKEN_BRANCH)

ВВ3

Рис. 9.5. Точки вставки на ветви проваливания
и ветви перехода команды перехода

Заметим, что IARG_INST_PTR и IARG_BRANCH_TARGET_ADDR – специальные типы аргументов, для которых тип и значение данных под­
Оснащение двоичных файлов

267

разуме­ваются неявно. С другой стороны, для аргумента IARG_UINT32,
который мы видели в листинге 9.3, необходимо отдельно задавать
тип (IARG_UINT32) и значение (в примере – BBL_NumIns).
В табл. 9.2 ветвь перехода является допустимой точкой оснащения
только для команд перехода и вызова (INS_IsBranchOrCall должна
возвращать true). В данном случае выполнение этого условия гарантирует проверка в начале instrument_insn.
Отметим, что instrument_insn вызывает для вставки функции
анализа INS_InsertPredicatedCall, а не INS_InsertCall. Некоторые
команды x86, например команда условного перемещения (cmov)
и операции над строками с префиксами повторения (rep), имеют
встроенные предикаты, которые вызывают повторение команды при
выполнении определенных условий. Функции анализа, вставленные
с по­мощью INS_InsertPredicatedCall, вызываются, только если это
условие истинно и команда действительно выполняется. Напротив,
функции, вставленные с по­мощью INS_InsertCall, вызываются, даже
если условие повторения не выполняется, что приводит к завышению
счетчика выполненных команд.

Оснащение ветви проваливания
Выше мы видели, как профилировщик оснащает ветвь перехода команд управления потоком. Но профилировщик должен также регист­
рировать команды передачи управления вне зависимости от направления перехода. Иными словами, нужно оснащать не только ветвь
перехода, но и ветвь проваливания в командах, где таковая имеется
(см. рис. 9.5). Заметим, что некоторые команды, например безусловного перехода, не имеют ветви проваливания, поэтому необходимо
явно проверять этот факт с по­мощью функции INS_HasFallthrough,
перед тем как пытаться оснастить ветвь проваливания . Еще отметим, что, согласно определению, принятому в Pin, команды, не управляющие потоком, а просто продолжающие выполнение со следующей
команды, имеют ветвь проваливания.
Если оказывается, что у данной команды есть ветвь проваливания, то instrument_insn вставляет обратный вызов функции анализа
count_cflow на этой ветви, как и для ветви перехода. Разница только
в том, что для этого обратного вызова указывается точка вставки IP‑
OINT_AFTER  и в качестве целевого адреса, подлежащего регистрации, передается адрес проваливания (IARG_FALLTHROUGH_ADDR) .

Оснащения вызовов
Наконец, профилировщик хранит отдельный счетчик и отображение
для отслеживания вызванных функций, чтобы было понятно, какие
функции заслуживают особого внимания в качестве объектов оптимизации. Напомню, что для отслеживания вызванных функций необходимо задать параметр профилировщика –c.
Для оснащения вызовов instrument_insn сначала обращается
к INS_IsCall, чтобы отделить команды вызова от всех прочих . Если

268

Глава 9

текущая команда действительно является командой вызова и Pinинструменту был передан параметр –c, то профилировщик вставляет
обратный вызов функции анализа count_call перед командой вызова
(в точке вставки IPOINT_BEFORE) , передавая начальный (IARG_INST_
PTR) и конечный (IARG_BRANCH_TARGET_ADDR) адреса команды. Заметим,
что в данном случае можно безопасно вызывать INS_InsertCall вмес­
то INS_InsertPredicatedCall, потому что не существует команд вызова со встроенными предикатами.

9.4.5 Подсчет команд, передач управления
и системных вызовов
Итак, мы рассмотрели весь код, отвечающий за инициализацию Pinинструмента и вставку оснащения в форме обратных вызовов функций анализа. А вот что мы еще не рассмотрели, так это сами функции
анализа, которые ведут сбор и хранение статистики во время работы
приложения. В листинге 9.5 показаны все используемые профилировщиком функции анализа.
Листинг 9.5. profiler.cpp (продолжение)
static void
 count_bb_insns(UINT32 n)

{
insn_count += n;
}
static void
 count_cflow(ADDRINT ip, ADDRINT target)

{
cflows[target][ip]++;
cflow_count++;
}
static void
 count_call(ADDRINT ip, ADDRINT target)

{
calls[target][ip]++;
call_count++;
}
static void
 log_syscall(THREADID tid, CONTEXT *ctxt, SYSCALL_STANDARD std, VOID *v)

{
syscalls[PIN_GetSyscallNumber(ctxt, std)]++;
syscall_count++;
}

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

269

тому что функции анализа часто вызываются во время выполнения
приложения и потому оказывают существенное влияние на производительность Pin-инструмента.
Первая функция анализа, count_bb_insns , вызывается при выполнении простого блока и всего лишь увеличивает счетчик insn_count
на число команд в этом блоке. Аналогично count_cflow  увеличивает cflow_count, когда выполняется команда управления потоком.
Дополнительно она запоминает начальный и конечный адреса перехода в отображении cflows и увеличивает счетчик, ассоциированный
с данной конкретной комбинацией начального и конечного адресов.
В Pin для хранения адресов используется целочисленный тип ADDRINT
. Функция анализа count_call , служащая для сбора информации
о вызовах, аналогична count_cflow.
Последняя функция в листинге 9.5, log_syscall , – не обычная
функция анализа, а обратный вызов для событий входа в системные
вызовы. В Pin обработчики системных вызовов принимают четыре
аргумента: THREADID, определяющий поток, выполнивший системный вызов; указатель на контекст CONTEXT*, содержащий такие вещи,
как номер системного вызова, аргументы и возвращенное значение
(только для событий выхода из системного вызова); SYSCALL_STANDARD,
описывающий соглашение о вызове системы, и, наконец, уже знакомый указатель void*, который позволяет передать определенную
пользователем структуру данных.
Напомним, что цель функции log_syscall – запомнить, сколько
раз встречается каждый системный вызов. Для этого она вызывает
PIN_GetSyscallNumber, чтобы получить номер текущего системного
вызова , и регистрирует факт вызова в отображении syscalls.
Итак, мы видели весь сколько-нибудь важный код профилировщика, так давайте его протестируем!

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

Профилирование приложения с момента запуска
В листинге 9.6 показано, как профилировать приложение с момента
запуска.
Листинг 9.6. Профилирование /bin/true с по­мощью Pin-инструмента
профилировщик
 $ cd ~/pin/pin-3.6-97554-g31f0a167d-gcc-linux/
 $ ./pin -t ~/code/chapter9/profiler/obj-intel64/profiler.so -c -s -- /bin/true
 executed 95 instructions

270

Глава 9

 ******* CONTROL TRANSFERS *******

0x00401000 getComment().c_str());
}
 disasm_one(sec, slice_addr, mnemonic, operands);

std::string target = mnemonic; target += " "; target += operands;
printf("(slice for %s @ 0x%jx: %s)\n", regname, slice_addr, target.c_str());
}

Напомним, что срезы вычисляются относительно определенного регистра, заданного параметром reg. Чтобы вычислить срез, нам
нужно ассоциированное с регистром символическое выражение сразу после эмуляции команды по адресу среза. Для получения этого выражения print_slice вызывает функцию api.getSymbolicRegisters,
которая возвращает отображение всех регистров на ассоциированные
с ними символические выражения, а потом получает из этого отображения выражение, ассоциированное с reg . Затем она получает срез
всех символических выражений, которые вносят вклад в выражение
reg, обращаясь к функции api.sliceExpressions , возвращающей
срез в виде объекта std::map, отображающего идентификаторы целочисленных выражений на объекты типа triton::engines::symbolic::
SymbolicExpression*.
Теперь у нас имеется срез символических выражений, но нужен-то
срез ассемблерных команд x86. Именно в этом и заключается смысл
комментариев к символическим выражениям, которые ассоциируют
каждое выражение с мнемоническим именем и операндами команды
(в виде строк), породившей данное выражение. Таким образом, чтобы
Практическое символическое выполнение с помощью Triton

373

напечатать срез, print_slice в цикле обходит срез символических выражений, получает комментарии к ним с по­мощью getComment и выводит комментарии на экран . Для полноты картины print_slice дизассемблирует и печатает еще и команду, в которой вычисляется срез .
Можете запустить программу backward_slice на ВМ, как показано
в листинге 13.5.
Листинг 13.5. Вычисление обратного среза регистра rcx по адресу
0x404b1e
 $ ./backward_slicing /bin/ls empty.map 0x404b00 0x404b1e rcx
 mov rcx, qword ptr [rdi]

not rcx
(slice for rcx @ 0x404b1e: mov r9, rcx)

Здесь я воспользовался программой backward_slicing, чтобы вычислить срез по фрагменту кода /bin/ls в листинге 13.1 . Я указал
пус­той конфигурационный файл символического выполнения (empty.
map), а в качестве адреса точки входа, адреса среза и регистра, для
которого вычисляется срез, задал соответственно 0x404b00, 0x404b1e
и rcx. Как видим, результат получился такой же, как при ручном вычислении среза .
В этом примере допустимо использовать пустой конфигурационный файл, потому что для анализа безразлично, являются ли какие-то
регистры или адреса памяти символическими, и для управления выполнением не требуются какие-то конкретные значения, поскольку
анализируемый фрагмент не содержит ветвлений. Далее мы рассмот­
рим другой пример, где необходим непустой конфигурационный
файл, чтобы можно было исследовать несколько путей в одной и той
же программе.

13.4 Использование Triton для увеличения
покрытия кода
Поскольку в примере с обратным нарезанием от Triton нам нужно
было только умение прослеживать символические выражения для
регистров и адресов памяти, мы не использовали главное преимущество символического выполнения: рассуждение о свойствах программы путем решения задачи удовлетворения ограничений. В этом
примере мы познакомимся с возможностями Triton в этом плане,
рассмотрев классическое применение символического выполнения –
покрытие кода.
В листинге 13.6 приведена первая часть исходного кода инструмента code_coverage. Нельзя не заметить, что значительная часть кода такая же или почти такая же, как в предыдущем примере. Я даже опус­
тил функцию set_triton_arch, потому что она ничем не отличается от
рассмотренной выше.

374

Глава 13

Листинг 13.6: code_coverage.cc
#include "../inc/loader.h"
#include "triton_util.h"
#include "disasm_util.h"
#include
#include
int
main(int argc, char *argv[])
{
Binary bin;
triton::API api;
triton::arch::registers_e ip;
std::map regs;
std::map mem;
std::vector symregs;
std::vector symmem;
if(argc < 5) {
printf("Usage: %s \n", argv[0]);
return 1;
}
std::string fname(argv[1]);
if(load_binary(fname, &bin, Binary::BIN_TYPE_AUTO) < 0) return 1;
if(set_triton_arch(bin, api, ip) < 0) return 1;
api.enableMode(triton::modes::ALIGNED_MEMORY, true);


if(parse_sym_config(argv[2], &regs, &mem, &symregs, &symmem) < 0) return 1;
for(auto &kv: regs) {
triton::arch::Register r = api.getRegister(kv.first);
api.setConcreteRegisterValue(r, kv.second);
}

for(auto regid: symregs) {
triton::arch::Register r = api.getRegister(regid);
api.convertRegisterToSymbolicVariable(r)->setComment(r.getName());
}
for(auto &kv: mem) {
api.setConcreteMemoryValue(kv.first, kv.second);
}
 for(auto memaddr: symmem) {
api.convertMemoryToSymbolicVariable(
triton::arch::MemoryAccess(memaddr, 1))->setComment(std::to_string(memaddr));
}


uint64_t pc
= strtoul(argv[3], NULL, 0);
uint64_t branch_addr = strtoul(argv[4], NULL, 0);
Section *sec = bin.get_text_section();


while(sec->contains(pc)) {
char mnemonic[32], operands[200];
Практическое символическое выполнение с помощью Triton

375

int len = disasm_one(sec, pc, mnemonic, operands);
if(len bytes+(pc-sec->vma), len);
insn.setAddress(pc);
api.processing(insn);
 if(pc == branch_addr) {

find_new_input(api, sec, branch_addr);
break;
}
pc = (uint64_t)api.getConcreteRegisterValue(api.getRegister(ip));
}
unload_binary(&bin);
return 0;
}

Чтобы воспользоваться инструментом code_coverage, мы должны
указать в командной строке подлежащий анализу двоичный файл,
конфигурационный файл символического выполнения, адрес точки
входа для анализа и адрес команды прямого перехода. Предполагается, что конфигурационный файл содержит конкретные входные
данные, заставляющие выбрать одну из двух возможных ветвей (не
важно, какую именно). Затем используется решатель задач удовлетворения ограничений, который вычисляет модель, содержащую новый набор конкретных входных данных, который заставит пойти по
другой ветви. Чтобы решатель дал полезный результат, необходимо
сделать символическими все регистры и адреса памяти, от которых
зависит «перещелкиваемая» ветвь.
Как видно из листинга, code_coverage включает те же служебные
и Triton’овские заголовочные файлы, что и в предыдущем примере.
Более того, функция main почти такая же, как в программе backward_
slicing. Как и раньше, она начинается с загрузки двоичного файла,
конфигурирования архитектуры Triton и включения оптимизации
ALIGNED_MEMORY.

13.4.1 Создание символических переменных
Различие между этим и предыдущим примерами заключается в том,
что код, который разбирает конфигурационный файл, передает два
необязательных параметра (symregs и symmem) функции parse_sym_
config. В них parse_sym_config записывает списки регистров и адресов памяти, которые должны быть сделаны символическими. В конфигурационном файле мы хотим объявить символическими все
регистры и адреса памяти, которые содержат пользовательские входные данные, так чтобы модель, возвращенная решателем, присвоила
им конкретные значения.

376

Глава 13

Присвоив конкретные значения, заданные в конфигурационном
файле, main в цикле обходит список регистров и делает их символическими, вызывая функцию Triton api.convertRegisterToSymbolicVari‑
able . В той строчке кода, где регистр делается символическим, сразу
же задается комментарий для только что созданной символической
переменной, в котором прописывается строковое имя регистра. Получив впоследствии модель от решателя, мы будем знать, как отобразить присваивания символическим переменным модели на реальные
регистры и адрес памяти.
Цикл, в котором делаются символическими адреса памяти, аналогичен. Для каждой включенной в список ячейки памяти строится
объект типа triton::arch::MemoryAccess, в котором задается адрес
и размер (в байтах) этой ячейки. В данном случае я зашил в код размер 1 байт, потому что формат конфигурационного файла позволяет
ссылаться на однобайтовые участки памяти. Чтобы сделать символическим адрес, указанный в объекте MemoryAccess, мы обращаемся
к функции api.convertMemoryToSymbolicVariable . После этого задается комментарий, который отображает новую символическую переменную на адрес памяти в понятном человеку виде.

13.4.2 Нахождение модели для нового пути
Цикл эмуляции  такой же, как в backward_slicing, только на этот раз
эмуляция продолжается, пока pc не станет равен адресу ветви, для которой мы хотим найти новый набор входных данных . Чтобы найти
эти входные данные, code_coverage вызывает функцию find_new_in‑
put, показанную в листинге 13.7.
Листинг 13.7. code_coverage.cc (продолжение)
static void
find_new_input(triton::API &api, Section *sec, uint64_t branch_addr)
{
 triton::ast::AstContext &ast = api.getAstContext();
 triton::ast::AbstractNode *constraint_list = ast.equal(ast.bvtrue(), ast.bvtrue());
printf("evaluating branch 0x%jx:\n", branch_addr);
const std::vector &path_constraints
= api.getPathConstraints();
 for(auto &pc: path_constraints) {
 if(!pc.isMultipleBranches()) continue;
 for(auto &branch_constraint: pc.getBranchConstraints()) {
bool flag
= std::get(branch_constraint);
uint64_t src_addr = std::get(branch_constraint);
uint64_t dst_addr = std::get(branch_constraint);
triton::ast::AbstractNode *constraint = std::get(branch_constraint);


 if(src_addr != branch_addr) {

/* это не наша целевая ветвь, поэтому оставляем существующее ограничение
равным "true"*/
Практическое символическое выполнение с помощью Triton

377

 if(flag) {

constraint_list = ast.land(constraint_list, constraint);
}
 } else {
/* это наша целевая ветвь, вычисляем новый набор входных данных */
printf("
0x%jx -> 0x%jx (%staken)\n",

src_addr, dst_addr, flag ? "" : "not ");
 if(!flag) {

printf("
computing new input for 0x%jx -> 0x%jx\n",

src_addr, dst_addr);
constraint_list = ast.land(constraint_list, constraint);
for(auto &kv: api.getModel(constraint_list)) {
printf("
SymVar %u (%s) = 0x%jx\n",

kv.first,

api.getSymbolicVariableFromId(kv.first)->getComment().c_str(),

(uint64_t)kv.second.getValue());
}
}
}
}
}
}

Чтобы найти, при каких входных данных достигается ранее не исследованное направление ветви, find_new_input передает решателю
список ограничений, которым необходимо удовлетворить для достижения желаемой ветви, а затем запрашивает модель, удовлетворяющую этим ограничениям. Напомним, что в Triton ограничения
представляются в виде абстрактных синтаксических деревьев, поэтому для кодирования ограничений ветвей нужно построить соответствующее AST. Поэтому в самом начале find_new_input вызывает
функцию api.getAstContext для получения ссылки (я назвал ее ast) на
объект типа AstContext – класс построителя формул AST.
Чтобы сохранить список ограничений, которые будут моделировать
путь, ведущий на неисследованное направление ветви, find_new_input
пользуется объектом типа triton::ast::AbstractNode, достижимым
по указателю constraint_list . AbstractNode – это класс Triton для
представления узлов AST. Список constraint_list инициализируется
формулой ast.equal(ast.bvtrue(), ast.bvtrue()), означающей логическую тавтологию true == true, где каждое true – битовый вектор. Это
просто способ инициализировать список ограничений синтаксически корректной формулой, которая не налагает никаких ограничений
и к которой легко дописывать дополнительные ограничения.

Копирование и перещелкивание ограничений ветвей
Затем find_new_input вызывает api.getPathConstraints, чтобы получить список путевых ограничений, который Triton построил в ходе
эмуляции кода . Список представляет собой вектор (std::vector)
объектов triton::engines::symbolic::PathConstraint, где каждый

378

Глава 13

PathConstraint ассоциирован с одной командой перехода. Этот список содержит все ограничения, которым необходимо удовлетворить,
чтобы пройти по только что эмулированному пути. Чтобы превратить
его в список ограничений для нового пути, мы копируем все ограничения, кроме того, что соответствует ветви, которую требуется изменить, а эту ветвь «перещелкиваем» на другое направление.
Чтобы реализовать эту идею, find_new_input обходитсписок путевых ограничений  и копирует или перещелкивает каждое из них.
В каждом объекте PathConstraint Triton хранит одно или несколько ограничений ветвей, по одному для каждого из возможных направлений ветви. В контексте покрытия кода нас интересуют только многопутевые ветвления, например условные переходы, потому
что однопутевые ветвления типа прямых вызовов или безусловных
переходов не имеют дополнительных направлений, которые можно было бы исследовать. Чтобы определить, представляет ли объект
PathConstraint, названный pc, многопутевое ветвление, вызывается
функция pc.isMultipleBranches , возвращающая true, если ветвление многопутевое.
Для объектов PathConstraint, содержащих несколько ограничений ветвей, find_new_input получает все эти ограничения, вызывая
pc.getBranchConstraints, а затем в цикле обходит полученный список ограничений . Каждое ограничение представляет собой кортеж,
содержащий булев флаг, начальный и конечный адреса (оба в виде
triton::uint64) и AST, кодирующее ограничение ветви. Флаг говорит
о том, было ли направление ветви, представленное данным ограничением, выбрано во время эмуляции. Например, рассмотрим следующую условную ветвь:
4055dc: 3c 25
4055de: 0f 8d f4 00 00 00

cmp
jge

al,0x25
4056d8

При эмуляции jge Triton создает объект PathConstraint с двумя
ограничениями ветви. Предположим, что первое ограничение представляет направление перехода jge (т. е. направление, которое выбирается, когда условие выполнено) и что это направление было выбрано
во время эмуляции. Тогда в первом ограничении ветви, хранящемся
в PathConstraint, флаг равен true (поскольку она была выбрана во время эмуляции), начальный и конечный адреса равны соответственно
0x4055de (адрес команды jge) и 0x4056d8 (адрес, на который переходит
jge). AST для этого ограничения ветви кодирует условие al ≥ 0x25. Для
ограничения второй ветви флаг равен false, она представляет направление, по которому эмуляция не пошла. Начальный и конечный адреса равны 0x4055de и 0x4055e4 (адрес, на который jge проваливается),
а AST кодирует условие al < 0x25 (точнее, not(al ≥ 0x25)).
Теперь find_new_input копирует ограничение ветви с флагом true
для всех объектов PathConstraint, кроме того, что ассоциирован
с коман­дой ветвления, которую мы хотим «перещелкнуть», а для нее
Практическое символическое выполнение с помощью Triton

379

копируется ограничение ветви с флагом false, т. е. мы инвертируем
решение, принятое в этой точке. Чтобы понять, какую ветвь «перещелкнуть», find_new_input использует начальный адрес ветви. Для
ограничений, где начальный адрес не равен адресу инвертируемой
ветви , ограничение ветви с флагом true  копируется и добавляется в конец списка constraint_list с по­мощью логического оператора
AND; эта операция реализована функцией ast.land.

Получение модели от решателя задач удовлетворения ограничений
Наконец, find_new_input встречает объект PathConstraint, ассоциированный с ветвью, которую мы хотим «перещелкнуть». Он содержит несколько ограничений ветви, в которых начальный адрес равен адресу
перещелкиваемой ветви . Чтобы наглядно показать все возможные
направления ветвей в распечатке code_coverage, find_new_input печатает каждое условие вместе с начальным адресом, независимо от
флага.
Если флаг равен true, то find_new_input не добавляет ограничение
ветви в список constraint_list, потому что это направление уже было
исследовано. Если же флаг равен false , значит, направление ветви
еще не исследовано, поэтому find_new_input добавляет это ограничение в конец списка и передает список решателю, обращаясь к функции api.getModel.
Функция getModel вызывает решатель Z3 и запрашивает у него модель, удовлетворяющую список ограничений. Если модель найдена,
то getModel возвращает ее в виде объекта std::map, который отображает идентификаторы символических переменных Triton на объекты
triton::engines::solver::SolverModel. Модель представляет новое
множество конкретных входных данных, которые заставляют анализируемую программу выбрать ранее не исследованное направление
ветвления. Если модель не найдена, то возвращенное отображение
пусто.
Каждый объект SolverModel содержит конкретное значение, которое решатель присвоил соответствующей символической переменной. Чтобы предъявить модель пользователю, инструмент code_cover‑
age в цикле обходит отображение и печатает идентификатор каждой
символической переменной и комментарий, содержащий понятное
имя соответствующего регистра или ячейки памяти, а также конкретное значение, присвоенное в модели (его возвращает функция-член
SolverModel::getValue).
Чтобы посмотреть, как выглядит результат инструмента code_cov‑
erage, протестируем его на тестовой программе и найдем входные
данные, покрывающие указанную вами ветвь.

13.4.3 Тестирование инструмента покрытия кода
В листинге 13.8 приведена простая тестовая программа, на которой
мы проверим способность code_coverage генерировать входные данные, приводящие к исследованию нового направления ветвления.

380

Глава 13

Листинг 13.8. branch.c
#include
#include
void
branch(int x, int y)
{
 if(x < 5) {
 if(y == 10) printf("x < 5 && y == 10\n");
else printf("x < 5 && y != 10\n");
} else {
printf("x >= 5\n");
}
}
int
main(int argc, char *argv[])
{
if(argc < 3) {
printf("Usage: %s \n", argv[0]);
return 1;
}
 branch(strtol(argv[1], NULL, 0), strtol(argv[2], NULL, 0));

return 0;
}

Как видим, программа branch содержит функцию branch, принимающую два целых числа x и y. Функция branch содержит внешнее
ветвление if/else по значению x и вложенное ветвление if/else
по значению y . Функция вызывается из main с аргументами x и y,
которые задает пользователь .
Сначала выполним branch с x = 0 и y = 0, так чтобы внешнее ветв­
ление пошло в направлении if, а вложенное – в направлении else.
Затем можно будет воспользоваться code_coverage и найти входные
данные, которые «перещелкивают» вложенное ветвление, так чтобы
выбиралось направление if. Но сначала построим конфигурационный файл символического выполнения, необходимый для запуска
code_coverage.

Построение конфигурационного файла символического
выполнения
Чтобы воспользоваться инструментом code_coverage, нам необходим
конфигурационный файл, а чтобы его составить, нужно знать, какие
регистры и адреса памяти используются в откомпилированной версии branch. В листинге 13.9 показан результат дизассемблирования
функции branch.

Практическое символическое выполнение с помощью Triton

381

Листинг 13.9. Фрагмент результата дизассемблирования
~/code/chapter13/branch
$ objdump -M intel -d ./branch
...
00000000004005b6 :
4005b6: 55
push
4005b7: 48 89 e5
mov
4005ba: 48 83 ec 10
sub
 4005be: 89 7d fc
mov
 4005c1: 89 75 f8
mov
 4005c4: 83 7d fc 04
cmp
 4005c8: 7f 1e
jg
 4005ca: 83 7d f8 0a
cmp
 4005ce: 75 0c
jne
4005d0: bf 04 07 40 00 mov
4005d5: e8 96 fe ff ff call
4005da: eb 16
jmp
4005dc: bf 15 07 40 00 mov
4005e1: e8 8a fe ff ff call
4005e6: eb 0a
jmp
4005e8: bf 26 07 40 00 mov
4005ed: e8 7e fe ff ff call
4005f2: c9
leave
4005f3: c3
ret
...

rbp
rbp,rsp
rsp,0x10
DWORD PTR [rbp-0x4],edi
DWORD PTR [rbp-0x8],esi
DWORD PTR [rbp-0x4],0x4
4005e8
DWORD PTR [rbp-0x8],0xa
4005dc
edi,0x400704
400470
4005f2
edi,0x400715
400470
4005f2
edi,0x400726
400470

В ОС Ubuntu, установленной на ВМ, используется версия двоичного
интерфейса приложения (ABI) System V для x64, которая определяет
соглашение о вызове. В этом соглашении первый и второй аргументы функции передаются в регистрах rdi и rsi соответственно1. В данном случае это означает, что параметр x функции branch находится
в регистре rdi, а параметр y – в регистре rsi. Функция branch сразу
же копирует x в память по адресу rbp–0x4 , а y – в память по адресу
rbp–0x8 . Затем branch сравнивает значение в ячейке, содержащей x,
с константой 4 , после чего следует команда jg по адресу 0x4005c8,
реализующая внешнее ветвление if/else .
По конечному адресу 0x4005e8 команды jg находится ветвь else
(x ≥ 5), а по адресу проваливания 0x4005ca – ветвь if. Внутри ветви
if расположено вложенное ветвление if/else, которое реализовано
командой cmp, сравнивающей значение y с 10 (0xa) , и следующей за
ней командой jne, которая переходит по адресу 0x4005dc, если y ≠ 10 
(вложенная ветвь else), или проваливается по адресу 0x4005d0 в противном случае (вложенная ветвь if).
Зная, какие регистры содержат входные данные x и y, а также адрес
0x4005ce вложенной ветви, которую мы хотим «перещелкнуть», можно создать конфигурационный файл символического выполнения.
1

Точнее, первые шесть аргументов передаются в регистрах rdi, rsi, rdx, rcx,

r8 и r9, а остальные в стеке.

382

Глава 13

В листинге 13.10 показан файл, который мы будем использовать для
тестирования.
Листинг 13.10. branch.map
 %rdi=$

%rdi=0
 %rsi=$

%rsi=0

В конфигурационном файле мы делаем регистр rdi (представляющий x) символическим и присваиваем ему конкретное значение 0 .
То же самое мы делаем для регистра rsi, содержащего y . Поскольку
x и y символические, то, когда мы будем генерировать модель для новых входных данных, решатель выдаст их конкретные значения.

Генерирование новых входных данных
Напомним, что в конфигурационном файле символического выполнения мы присвоили значение 0 обеим переменным x и y, создав
опору, отталкиваясь от которой, code_coverage может сгенерировать
новые входные данные, покрывающие новый путь. Если запустить
программу branch с этими данными, то она напечатает x < 5 && y != 10,
как показано в следующем листинге:
$ ./branch 0 0
x < 5 && y != 10

Теперь воспользуемся code_coverage, чтобы сгенерировать новые
входные данные, которые «перещелкивают» вложенное ветвление,
где проверяется значение y. Если затем подать эти данные на вход
branch, то она напечатает x < 5 && y == 10. См. листинг 13.11.
Листинг 13.11. Нахождение входов, при которых выбирается
альтернативная ветвь по адресу 0x4005ce
 $ ./code_coverage branch branch.map 0x4005b6 0x4005ce

evaluating branch 0x4005ce:
 0x4005ce -> 0x4005dc (taken)
 0x4005ce -> 0x4005d0 (not taken)
 computing new input for 0x4005ce -> 0x4005d0
 SymVar 0 (rdi) = 0x0

SymVar 1 (rsi) = 0xa

Мы вызываем code_coverage, указывая на входе программу branch,
созданный нами конфигурационный файл (branch.map), начальный
адрес 0x4005b6 функции branch (точка входа для анализа) и адрес
0x4005ce вложенного ветвления, которое нужно «перещелкнуть» .
Практическое символическое выполнение с помощью Triton

383

Когда эмуляция дойдет до адреса ветвления, code_coverage вычислит и напечатает все ограничения ветвей, которые Triton включил
в объект PathConstraint, ассоциированный с этим ветвлением. Первое ограничение относится к направлению ветвления с конечным
адресом 0x4005dc (вложенная ветвь else), именно это направление
выбирается во время эмуляции, потому что таковы конкретные входные значения, заданные в конфигурационном файле . Как сообщает code_coverage, направление проваливания с конечным адресом
0x4005d0 (вложенная ветвь if) не было выбрано , поэтому code_cov‑
erage пытается вычислить новые входные значения, так чтобы выполнение прошло по этому пути .
Хотя в общем случае для нахождения новых входных значений
решателю может потребоваться довольно много времени, для таких
простых ограничений, как в этом случае, он должен управиться за несколько секунд. После того как решатель найдет модель, code_cover‑
age выведет ее на экран . Как видим, модель присваивает конкретное значение 0 регистру rdi (x) и значение 0xa регистру rsi (y).
Запустим программу branch с этими новыми входными данными и посмотрим, приведут ли они к «перещелкиванию» вложенного
ветв­ления.
$ ./branch 0 0xa
x < 5 && y == 10

При новых входных данных branch печатает x < 5 && y == 10, а не x < 5
&& y != 10, как в предыдущем прогоне. Входные данные, сгенерированные code_coverage, действительно привели к «перещелкиванию»
вложенного ветвления!

13.5

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

384

Глава 13

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

13.5.1 Уязвимая программа
Сначала рассмотрим программу, которую собираемся эксплуатировать, и присутствующий в ей уязвимый вызов. В листинге 13.12 приведен исходный файл уязвимой программы icall.c. Makefile компилирует программу в двоичный файл icall типа setuid root1, который
содержит косвенный вызов одной из нескольких функций-обработчиков. Так веб-серверы, в частности nginx, используют указатели на
функции для выбора подходящего обработчика полученных данных.
Листинг 13.12. icall.c
#include
#include
#include
#include
#include







void forward (char *hash);
void reverse (char *hash);
void hash (char *src, char *dst);
 static struct {

void (*functions[2])(char *);
char hash[5];
} icall;
int
main(int argc, char *argv[])
{
unsigned i;


icall.functions[0] = forward;
icall.functions[1] = reverse;
if(argc < 3) {
printf("Usage: %s \n", argv[0]);
return 1;
}
1

Двоичный файл типа setuid root выполняется с привилегиями root, даже
если запущен непривилегированным пользователем. Это позволяет обычным пользователям запускать программы, выполняющие привилегированные операции, например создание простых сетевых сокетов или изменение файла /etc/passwd.
Практическое символическое выполнение с помощью Triton

385

if(argc > 3 && !strcmp(crypt(argv[3], "$1$foobar"),
"$1$foobar$Zd2XnPvN/dJVOseI5/5Cy1")) {
/* секретный административный участок */
if(setgid(getegid())) perror("setgid");
if(setuid(geteuid())) perror("setuid");
execl("/bin/sh", "/bin/sh", (char*)NULL);
 } else {
 hash(argv[2], icall.hash);
 i = strtoul(argv[1], NULL, 0);


printf("Calling %p\n", (void*)icall.functions[i]);
 icall.functions[i](icall.hash);

}
return 0;
}
void
forward(char *hash)
{
int i;
printf("forward: ");
for(i = 0; i < 4; i++) {
printf("%02x", hash[i]);
}
printf("\n");
}
void
reverse(char *hash)
{
int i;
printf("reverse: ");
for(i = 3; i >= 0; i--) {
printf("%02x", hash[i]);
}
printf("\n");
}
void
hash(char *src, char *dst)
{
int i, j;
for(i = 0; i < 4; i++) {
dst[i] = 31 + (char)i;
for(j = i; j < strlen(src); j += 4) {
dst[i] ˆ= src[j] + (char)j;
if(i > 1) dst[i] ˆ= dst[i-2];
}
}
dst[4] = '\0';
}

386

Глава 13

В основе программы icall лежит глобальная структура, которую
я также назвал icall . Эта структура содержит массив icall.func‑
tions, в котором есть место для двух указателей на функции, и массив символов icall.hash, в котором хранится 4-байтовый хеш-код
и завершающий символ NULL. Функция main инициализирует первый
элемент icall.functions указателем на функцию forward, а второй
указателем на функцию reverse . Обе функции принимают хеш-код
в виде char* и печатают его байты в прямом или обратном порядке
соответственно.
Программа icall принимает два аргумента в командной строке:
целочисленный индекс и строку. Индекс определяет, какой элемент
icall.functions вызывать, а по строке генерируется хеш-код, как мы
скоро увидим.
Существует еще третий аргумент, о котором в информации о порядке вызова не сообщается. Это пароль для административного
участка программы, который дает доступ к оболочке от имени root.
Для проверки пароля icall хеширует его с по­мощью функции GNU
crypt (объявлена в файле crypt.h), и если хеш-код правилен, то пользователю предоставляется доступ к оболочке . Наша цель – перехватить косвенный вызов и перенаправить его на секретный участок,
не зная пароля.
Если секретный пароль не указан , то icall вызывает функцию
hash, которая вычисляет 4-байтовый хеш-код переданной программе
строки и помещает его в icall.hash . После вычисления хеш-кода
icall разбирает индекс, указанный в командной строке , и вызывает
функцию по указателю в соответствующем элементе массива icall.
functions, передавая ей только что вычисленный хеш-код в качестве
аргумента . Этот косвенный вызов я и намереваюсь использовать
в эксплойте. Для диагностики icall печатает адрес функции, которую
собирается вызвать; при написании эксплойта это нам поможет.
При нормальных обстоятельствах по указателю вызывается функция forward или reverse, которая печатает хеш-код на экране:
 $ ./icall 1 foo
 Calling 0x400974
reverse: 22295079

Здесь я задал в качестве индекса 1, что соответствует вызову функции reverse, и входную строку foo . Мы видим, что косвенно вызывается функция по адресу 0x400974 (начало reverse) , также напечатан
хеш-код строки foo в обратном порядке: 0x22295079 .
Вы, конечно, обратили внимание на уязвимость косвенного вызова: нигде не проверяется, что заданный пользователем индекс не
входит за границы массива icall.functions, поэтому, задав слишком
большой индекс, пользователь может заставить программу icall использовать данные вне массива icall.functions в качестве адреса косвенного вызова! А поле icall.hash как раз находится рядом с icall.
Практическое символическое выполнение с помощью Triton

387

functions в памяти, поэтому, указав индекс 2, пользователь сможет
заставить программу использовать icall.hash в качестве цели кос-

венного вызова, как показано в следующем листинге:
$ ./icall 2 foo
 Calling 0x22295079
 Segmentation fault (core dumped)

Смотрите-ка – вызванный адрес совпадает с хеш-кодом, интерпретированным как адрес в прямом порядке ! По этому адресу нет
кода, так что программа «падает» из-за ошибки сегментации . Однако вспомним, что пользователь контролирует не только индекс, но
и строку, по которой вычисляется хеш-код. Штука в том, чтобы найти строку, для которой хеш-код в точности равен адресу секретного
административного участка, а затем обманом выполнить косвенный
вызов по этому адресу и тем самым передать управление на адми­
нистративный участок и получить доступ к оболочке с правами root,
не зная пароля.
Чтобы вручную сконструировать эксплойт для этой уязвимости,
нам нужно либо прибегнуть к полному перебору, либо дизассемблировать функцию hash и понять, какая входная строка дает нужный
нам хеш-код. Так вот, символическое выполнение как средство генерирования эксплойта хорошо тем, что автоматически решает уравнение с функцией hash, давая нам возможность рассматривать ее как
черный ящик!

13.5.2 Нахождение адреса уязвимой команды вызова
Для автоматического конструирования эксплойта нам нужны две
вещи: адрес уязвимой команды косвенного вызова, которую эксплойт
должен перехватить, и адрес секретного административного участка,
на который нужно перенаправить управление. В листинге 13.13 показан результат дизассемблирования функции main из двоичного файла
icall, который содержит оба адреса.
Листинг 13.13. Фрагмент результата дизассемблирования
~/code/chapter13/icall
0000000000400abe :
400abe: 55
push
400abf: 48 89 e5
mov
400ac2: 48 83 ec 20
sub
400ac6: 89 7d ec
mov
400ac9: 48 89 75 e0
mov
400acd: 48 c7 05 c8 15 20 00 mov
400ad4: 16 09 40 00
400ad8: 48 c7 05 c5 15 20 00 mov
400adf: 74 09 40 00
400ae3: 83 7d ec 02
cmp

388

Глава 13

rbp
rbp,rsp
rsp,0x20
DWORD PTR [rbp-0x14],edi
QWORD PTR [rbp-0x20],rsi
QWORD PTR [rip+0x2015c8],0x400916
QWORD PTR [rip+0x2015c5],0x400974
DWORD PTR [rbp-0x14],0x2








400ae7:
400ae9:
400aed:
400af0:
400af3:
400af8:
400afd:
400b02:
400b07:
400b0c:
400b10:
400b12:
400b16:
400b1a:
400b1d:
400b22:
400b25:
400b2a:
400b2f:
400b32:
400b37:
400b39:
400b3b:
400b40:
400b42:
400b47:
400b49:
400b4b:
400b50:
400b55:
400b5a:
400b5c:
400b61:
400b63:
400b65:
400b6a:
400b6f:
400b74:
400b79:
400b7e:
400b83:
400b88:
400b8a:
400b8e:
400b92:
400b95:
400b9a:
400b9d:
400ba2:
400ba6:
400baa:
400bad:
400bb2:

7f
48
48
48
bf
b8
e8
b8
e9
83
7e
48
48
48
be
48
e8
be
48
e8
85
75
e8
89
e8
85
74
bf
e8
e8
89
e8
85
74
bf
e8
ba
be
bf
b8
e8
eb
48
48
48
be
48
e8
48
48
48
ba
be

23
8b
8b
89
a1
00
5e
01
ea
7d
78
8b
83
8b
bd
89
56
c8
89
69
c0
4f
70
c7
79
c0
0a
e9
7b
16
c7
8f
c0
0a
f0
61
00
f7
f7
00
78
67
8b
83
8b
b0
89
30
8b
83
8b
00
00

45
00
c6
0c
00
fc
00
00
ec

e0

45
c0
00
0c
c7
fc
0c
c7
fc

e0
18

40
00
ff
00
00
03

00
00
ff
00
00

40 00
ff ff
40 00
ff ff

fc ff ff
fc ff ff

0c 40 00
fc ff ff
fc ff ff
fc ff ff

0c
fc
00
0c
0c
00
fc

40
ff
00
40
40
00
ff

45
c0
00
20
c7
fe
45
c0
00
00
00

e0
10

00
ff
00
00
00
00
ff

60 00
ff ff
e0
08
00 00
00 00

jg
mov
mov
mov
mov
mov
call
mov
jmp
cmp
jle
mov
add
mov
mov
mov
call
mov
mov
call
test
jne
call
mov
call
test
je
mov
call
call
mov
call
test
je
mov
call
mov
mov
mov
mov
call
jmp
mov
add
mov
mov
mov
call
mov
add
mov
mov
mov

400b0c
rax,QWORD PTR [rbp-0x20]
rax,QWORD PTR [rax]
rsi,rax
edi,0x400ca1
eax,0x0
400760
eax,0x1
400bf6
DWORD PTR [rbp-0x14],0x3
400b8a
rax,QWORD PTR [rbp-0x20]
rax,0x18
rax,QWORD PTR [rax]
esi,0x400cbd
rdi,rax
400780
esi,0x400cc8
rdi,rax
4007a0
eax,eax
400b8a
4007b0
edi,eax
4007c0
eax,eax
400b55
edi,0x400ce9
4007d0
400770
edi,eax
4007f0
eax,eax
400b6f
edi,0x400cf0
4007d0
edx,0x0
esi,0x400cf7
edi,0x400cf7
eax,0x0
400800
400bf1
rax,QWORD PTR [rbp-0x20]
rax,0x10
rax,QWORD PTR [rax]
esi,0x6020b0
rdi,rax
4009d2
rax,QWORD PTR [rbp-0x20]
rax,0x8
rax,QWORD PTR [rax]
edx,0x0
esi,0x0

Практическое символическое выполнение с помощью Triton

389

400bb7:
400bba:
400bbf:
400bc2:
400bc5:
400bcc:
400bcd:
400bd0:
400bd5:
400bda:
400bdf:
400be2:
400be9:
400bea:
 400bef:
400bf1:
400bf6:
400bf7:
400bf8:
400bff:

48
e8
89
8b
48
00
48
bf
b8
e8
8b
48
00
bf
ff
b8
c9
c3
0f
00

89
21
45
45
8b

c7
fc ff ff
fc
fc
04 c5 a0 20 60

mov
call
mov
mov
mov

rdi,rax
4007e0
DWORD PTR [rbp-0x4],eax
eax,DWORD PTR [rbp-0x4]
rax,QWORD PTR [rax*8+0x6020a0]

89
ff
00
81
45
8b

c6
0c
00
fb
fc
04

mov
mov
mov
call
mov
c5 a0 20 60 mov

rsi,rax
edi,0x400cff
eax,0x0
400760
eax,DWORD PTR [rbp-0x4]
rax,QWORD PTR [rax*8+0x6020a0]

40 00
00 00
ff ff

b0 20 60 00
d0
00 00 00 00

mov
call
mov
leave
ret
1f 84 00 00 00 00 nop

edi,0x6020b0
rax
eax,0x0

DWORD PTR [rax+rax*1+0x0]

Код секретного административного участка начинается по адресу

0x400b3b , именно сюда мы хотим перенаправить управление. То,

что это действительно административный участок, доказывают обращения к функциям setgid  и setuid , с по­мощью которых icall
подготавливает привилегии root перед открытием оболочки, и обращение к execl , которое, собственно, и запускает оболочку. Уязвимая
команда косвенного вызова находится по адресу 0x400bef .
Имея необходимые адреса, давайте создадим инструмент символического выполнения для генерирования эксплойта.

13.5.3 Построение генератора эксплойта
В двух словах: принцип работы инструмента, генерирующего эксплойт, заключается в том, чтобы конколически выполнить программу icall, сделав символическими все аргументы командной строки,
задаваемые пользователем, и завести при этом по одной символической переменной на каждый байт входных данных. Затем инструмент прослеживает символическое состояние на всем пути от начала
программы, через функцию hash, пока выполнение не дойдет до мес­
та косвенного вызова. В этот момент генератор эксплойта вызывает
решатель и спрашивает у него, существует ли способ присвоить конк­
ретные значения символическим переменным, так чтобы конечный
адрес косвенного вызова был равен адресу административного участка программы. Если такая модель существует, то генератор эксплойта
печатает ее на экране, и мы можем воспользоваться этими значениями, сделав их аргументами программы icall.
Заметим, что в отличие от предыдущих примеров в этом используется конколический режим Triton, а не режим символической эму-

390

Глава 13

ляции. Причина в том, что для генерирования эксплойта необходимо
проследить символическое состояние на протяжении всей программы, через несколько функций, что в режиме эмуляции неудобно
и долго. Кроме того, конколическое выполнение позволяет легко экспериментировать с различными длинами входной строки.
В отличие от большинства примеров в книге, этот написан на Python, потому что для использования конколического режима подходит
только Python API. Конколические инструменты Triton – это скрипты
на Python, которые передаются специальному Pin-инструменту, реа­
лизующему движок конколического выполнения. Triton предоставляет скрипт-обертку triton, который берет на себя детали вызова Pin,
так что нам остается только указать, какой инструмент Triton использовать и какую программу анализировать. Скрипт-обертка находится в каталоге ~/triton/pin-2.14-71313-gcc.4.4.7-linux/source/tools/Triton/
build, а как им пользоваться, мы увидим, когда будем тестировать инструмент автоматического генерирования эксплойта.

Подготовка к конколическому выполнению
В листинге 13.14 показана первая часть инструмента генерирования
эксплойта exploit_callsite.py.
Листинг 13.14. exploit_callsite.py
#!/usr/bin/env python2
## -*- coding: utf-8 -* import triton

import pintool
 taintedCallsite = 0x400bef # Найдено на предыдущем проходе DTA

target = 0x400b3b # Куда должен вести косвенный переход
 Triton = pintool.getTritonContext()

def main():
 Triton.setArchitecture(triton.ARCH.X86_64)

Triton.enableMode(triton.MODE.ALIGNED_MEMORY, True)
 pintool.startAnalysisFromSymbol('main')
 pintool.insertCall(symbolize_inputs, pintool.INSERT_POINT.ROUTINE_ENTRY, 'main')
 pintool.insertCall(hook_icall, pintool.INSERT_POINT.BEFORE)
 pintool.runProgram()

if __name__ == '__main__':
main()

Конколические инструменты Triton типа exploit_callsite.py должны
импортировать модули triton и pintool , предоставляющие доступ
к Triton API и к привязкам Triton для взаимодействия с Pin. К сожалеПрактическое символическое выполнение с помощью Triton

391

нию, не существует способа передать конколическим инструментам
аргументы в командной строке, поэтому мне пришлось зашить в код
адрес эксплуатируемой команды косвенного вызова (taintedCall‑
site) и адрес секретного административного участка (target) , куда
требуется перенаправить управление. Имя переменной taintedCall‑
site выбрано, исходя из предположения, что этот адрес был найден
в процессе выполненного ранее анализа заражения. Если не хочется
зашивать аргументы в код, то можно было, например, передать их
в переменных окружения.
Конколические инструменты Triton хранят состояние символического выполнения в глобальном контексте Triton, доступ к которому
дает функция pintool.getTritonContext() . Она возвращает объект
типа TritonContext, который можно использовать для доступа к подмножеству уже знакомых нам функций Triton API. Скрипт exploit_
callsite.py сохраняет ссылку на этот объект в глобальной переменной
Triton.
Основная логика exploit_callsite.py начинается в функции main, которая вызывается в начале скрипта. Как и в предыдущих инструментах
символического выполнения, написанных на C++, она первым делом
конфигурирует архитектуру Triton и включает оптимизацию ALIGNED_
MEMORY . Поскольку этот инструмент все равно заточен под эксплуа­
тируемый двоичный файл icall, я просто зашил в код архитектуру
x86-64 и не стал делать ее конфигурируемой.
Далее exploit_callsite.py пользуется API pintool, чтобы установить
начальную точку для конколического анализа. Он просит Triton запустить символический анализ с функции main уязвимой программы
icall. Это означает, что весь код инициализации icall, предшест­
вующий main, работает с выключенным символическим анализом,
а анализ Triton вступает в игру, когда выполнение достигает main.
Мы предполагаем, что символы доступны, иначе Triton не будет
знать, где начинается функция main. В таком случае нужно будет найти адрес main самостоятельно, дизассемблировав программу, и попросить Triton начать анализ с этого адреса, вызвав функцию pintool.
startAnalysisFromAddress вместо pintool.startAnalysisFromSymbol.
Сконфигурировав начальную точку анализа, exploit_callsite.py ре­
гистрирует два обратных вызова, обращаясь к функции pintool.in‑
sertCall. Эта функция принимает как минимум два аргумента: функцию обратного вызова и точку вставки, за которой могут следовать
дополнительные аргументы, зависящие от типа точки вставки.
Первая функция обратного вызова называется symbolize_inputs,
для нее используется точка вставки INSERT_POINT.ROUTINE_ENTRY ;
это означает, что обратный вызов срабатывает, когда выполнение
достигает точки входа в функцию, имя которой задается в дополнительном аргументе insertCall. В случае symbolize_inputs я указал
main в качестве функции, для которой устанавливается обратный
вызов, потому что цель symbolize_inputs – сделать символическими
все входные данные, передаваемые пользователем программе icall,
а значит, и функции main. Когда имеет место обратный вызов типа

392

Глава 13

ROUTINE_ENTRY, Triton передает идентификатор текущего потока в качестве аргумента функции обратного вызова.
Второй обратный вызов называется hook_icall и устанавливается
в точку вставки INSERT_POINT.BEFORE , т. е. срабатывает перед каждой командой. Задача hook_icall – проверить, дошло ли выполнение
до уязвимого косвенного вызова, и если да, сгенерировать эксплойт
по результатам символического анализа. Когда обратный вызов срабатывает, Triton передает hook_icall аргумент Instruction, содержащий подробные сведения о команде, которую собирается выполнить,
так что hook_icall может проверить, действительно ли это команда
косвенного вызова, которую мы хотим эксплуатировать. В табл. 13.1
перечислены все поддерживаемые Triton точки вставки.
Таблица 13.1. Точки вставки обратных вызовов в конколическом режиме Triton
Точка вставки

Момент обратного вызова

AFTER
BEFORE
BEFORE_SYMPROC
FINI
ROUTINE_ENTRY
ROUTINE_EXIT
IMAGE_LOAD

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

SIGNALS
SYSCALL_ENTRY

Доставлен сигнал
Перед системным вызовом

SYSCALL_EXIT

После системного вызова

Аргументы

Аргументы обратного вызова
Объект Instruction
Объект Instruction
Объект Instruction

Имя функции
Имя функции

ИД потока
ИД потока
Путь к образу, базовый адрес,
размер
ИД потока, ИД сигнала
ИД потока, дескриптор
системного вызова
ИД потока, дескриптор
системного вызова

Наконец, после завершения инициализации exploit_callsite.py вызывает функцию pintool.runProgram, чтобы запустить анализируемую программу . На этом завершается необходимая подготовка
к анализу программы icall, но я еще не обсудил код, отвечающий
за само генерирование эксплойта. Устраним это упущение и рассмот­
рим обработчики обратных вызовов symbolize_inputs и hook_icall,
которые реализуют превращение пользовательских входных данных
в символы и эксплуатацию косвенного вызова соответственно.

Превращение входных данных в символы
В листинге 13.15 показана реализация функции symbolize_inputs, вызываемой, когда выполнение достигает функции main анализируемой
программы. В полном соответствии с табл. 13.1 symbolize_inputs принимает идентификатор потока, потому что это обратный вызов в точке вставки ROUTINE_ENTRY. Нам идентификатор потока не нужен, так
что просто игнорируем его. Как уже было сказано, symbolize_inputs
делает символическими все аргументы командной строки, заданные
пользователем, так чтобы решатель впоследствии смог решить, как
Практическое символическое выполнение с помощью Triton

393

манипулировать этими символическими переменными для конст­
руирования эксплойта.
Листинг 13.15. exploit_callsite.py (продолжение)
def symbolize_inputs(tid):
 rdi = pintool.getCurrentRegisterValue(Triton.registers.rdi) # argc

rsi = pintool.getCurrentRegisterValue(Triton.registers.rsi) # argv
# для каждой строки в argv
 while rdi > 1:
 addr = pintool.getCurrentMemoryValue(

rsi + ((rdi-1)*triton.CPUSIZE.QWORD),
triton.CPUSIZE.QWORD)
# сделать строку текущего аргумента (включая завершающий NULL)
№ символической
c = None
s = ''
 while c != 0:
 c = pintool.getCurrentMemoryValue(addr)
s += chr(c)
 Triton.setConcreteMemoryValue(addr, c)
 Triton.convertMemoryToSymbolicVariable(

triton.MemoryAccess(addr, triton.CPUSIZE.BYTE)
).setComment('argv[%d][%d]' % (rdi-1, len(s)-1))
addr += 1
rdi -= 1
print ‘Symbolized argument %d: %s' % (rdi, s)

Чтобы сделать входные данные символическими, symbolize_inputs
необходим доступ к счетчику аргументов (argc) и вектору аргументов
(argv) анализируемой программы. Поскольку symbolize_inputs вызывается в момент начала работы main, мы можем получить argc и argv
из регистров rdi и rsi, которые содержат первые два аргумента main
в соответствии с System V ABI для x86-64 . Чтобы прочитать текущее значение регистра при конкретном выполнении, мы обращаемся
к функции pintool.getCurrentRegisterValue, передавая ей идентификатор регистра.
Получив argc и argv, symbolize_inputs обходит в цикле все аргументы, уменьшая значение rdi (argc), пока оно не станет равным 0 . Напомним, что в программах на C/C++ argv – это массив указателей на
строки символов. Чтобы получить указатель из argv, symbolize_inputs
читает 8 байт (triton.CPUSIZE.QWORD) элемента argv, индекс которого
находится в rdi, обращаясь к функции pintool.getCurrentMemoryVal‑
ue, которая принимает адрес и размер , и сохраняет прочитанный
указатель в addr.
Затем symbolize_inputs читает все символы из строки, на которую
указывает addr, увеличивая addr, пока не встретится символ NULL .
Для чтения символа снова используется функция getCurrentMemo‑
ryValue , но на этот раз без указания размера, поскольку по умолча-

394

Глава 13

нию она и так читает 1 байт. Прочитав символ, symbolize_inputs делает его конкретным значением байта по этому адресу в глобальном
контексте Triton  и преобразует адрес памяти, содержащий входной
байт, в символическую переменную , задавая для нее комментарий,
который позднее напомнит нам, какому индексу argv она соответствует. Все это должно быть знакомо по рассмотренным ранее примерам на C++.
По завершении symbolize_inputs все аргументы командной строки, заданные пользователем, будут преобразованы в отдельные символические переменные (по одной на каждый входной байт) и установлены в качестве конкретного состояния в глобальном контексте
Triton. Теперь посмотрим, как exploit_callsite.py пользуется решателем
для разрешения этих символических переменных и построения эксплойта для уязвимого вызова.

Нахождение эксплойта
В листинге 13.16 показана функция hook_icall, вызываемая перед
каждой командой.
Листинг 13.16. exploit_callsite.py (продолжение)
def hook_icall(insn):
 if insn.isControlFlow() and insn.getAddress() == taintedCallsite:
 for op in insn.getOperands():
 if op.getType() == triton.OPERAND.REG:

print 'Found tainted indirect call site \'%s\'' % (insn)
 exploit_icall(insn, op)

Для каждой команды hook_icall проверяет, является ли она той
командой косвенного вызова, которую мы хотим эксплуатировать.
Сначала проверяется, что это команда управления потоком  и что
ее адрес совпадает с интересующим нас. Затем в цикле перебираются
операнды команды  в поисках регистрового операнда, содержащего
конечный адрес вызова . Наконец, если все проверки прошли успешно, hook_icall вызывает функцию exploit_icall, чтобы вычислить
сам эксплойт . В листинге 13.17 показана реализация exploit_icall.
Листинг 13.17. exploit_callsite.py (продолжение)
def exploit_icall(insn, op):
 regId = Triton.getSymbolicRegisterId(op)
 regExpr = Triton.unrollAst(Triton.getAstFromId(regId))
 ast = Triton.getAstContext()
 exploitExpr = ast.equal(regExpr, ast.bv(target, triton.CPUSIZE.QWORD_BIT))
 for k, v in Triton.getSymbolicVariables().iteritems():
 if 'argv' in v.getComment():

# Символы аргумента должны иметь графическое начертание
 argExpr = Triton.getAstFromId(k)

Практическое символическое выполнение с помощью Triton

395

 argExpr = ast.land([





ast.bvuge(argExpr, ast.bv(32, triton.CPUSIZE.BYTE_BIT)),
ast.bvule(argExpr, ast.bv(126, triton.CPUSIZE.BYTE_BIT))
])

 exploitExpr = ast.land([exploitExpr, argExpr])

print 'Getting model for %s -> 0x%x' % (insn, target)
 model = Triton.getModel(exploitExpr)

for k, v in model.iteritems():
print '%s (%s)' % (v, Triton.getSymbolicVariableFromId(k).getComment())

Чтобы вычислить эксплойт для уязвимого вызова, exploit_icall
сначала получает идентификатор регистра в регистровом операнде,
содержащем конечный адрес косвенного вызова . Затем вызывается Triton.getAstFromId, чтобы получить AST, содержащее символическое выражение для этого регистра, и Triton.unrollAst, чтобы «развернуть» его в полное AST без ссылочных узлов .
Далее exploit_icall создает объект Triton AstContext, который использует для построения AST-выражения для решателя  – точно так
же, как в примере покрытия кода из раздела 13.4. Основное ограничение, которому нужно удовлетворить, очевидно: требуется найти
такое решение, при котором символическое выражение для целевого
регистра косвенного вызова равно адресу секретного административного участка, хранящемуся в глобальной переменной target .
Заметим, что константа triton.CPUSIZE.QWORD_BIT равна размеру
четверного машинного слова (8 байт) в битах в отличие от константы
triton.CPUSIZE.QWORD, которая представляет тот же размер в байтах.
Это означает, что ast.bv(target, triton.CPUSIZE.QWORD_BIT) строит
битовый вектор шириной 64 бита, содержащий адрес секретного административного участка.
Помимо основного ограничения на целевой регистр, эксплойт должен удовлетворять некоторым ограничениям на форму входных данных. Чтобы наложить эти ограничения, exploit_icall в цикле обходит все символические переменные  и проверяет их комментарии
с целью проверить, представляют ли они заданные пользователем
байты из argv . Если да, то exploit_icall получает AST-выражение
символической переменной  и ограничивает его таким образом,
чтобы байт был ASCII-символом, имеющим графическое начертание
 (≥ 32 и ≤ 126). Затем это ограничение добавляется в общий список
ограничений для эксплойта .
Наконец, exploit_icall вызывает Triton.getModel, чтобы получить
модель эксплойта для только что построенного множества ограничений . Если такая модель существует, то она выводится на экран,
чтобы ей можно было воспользоваться для эксплуатации программы icall. Для каждой переменной модели в распечатке приведен ее
идентификатор Triton ID и комментарий, сообщающий, какому байту
argv эта символическая переменная соответствует. Таким образом,
пользователю нетрудно отобразить модель на конкретные аргументы

396

Глава 13

командной строки. Попробуем сгенерировать эксплойт для программы icall и использовать его для получения оболочки с правами root.

13.5.4 Получение оболочки с правами root
В листинге 13.18 показано, как на практике использовать скрипт exploit_callsite.py, чтобы сгенерировать эксплойт для программы icall.
Листинг 13.18. Попытка найти эксплойт для icall с длиной входных
данных 3
 $ cd ~/triton/pin-2.14-71313-gcc.4.4.7-linux/source/tools/Triton/build
 $ ./triton /code/chapter13/exploit_callsite.py \
/code/chapter13/icall 2 AAA
 Symbolized argument 2: AAA

Symbolized argument 1: 2
 Calling 0x223c625e
 Found tainted indirect call site '0x400bef: call rax'
 Getting model for 0x400bef: call rax -> 0x400b3b

# модель не найдена

Первым делом мы переходим в главный каталог Triton на ВМ, где
находится скрипт-обертка . Напомню, что Triton предоставляет
этот скрипт, который берет на себя настройку Pin для конколических
инструментов. Не вдаваясь в подробности, скажу, что он запускает
анализируемую программу (icall) под управлением Pin, используя
конколическую библиотеку Triton в качестве Pin-инструмента. Биб­
лиотека принимает пользовательский конколический инструмент
(exploit_callsite.py) в качестве аргумента и делает все необходимое для
его запуска.
Для выполнения анализа нам остается только вызвать скриптобертку triton , передав ему скрипт exploit_callsite.py , а также имя
и аргументы анализируемой программы (icall с индексом 2 и входной строкой AAA) . Скрипт triton позаботится о том, чтобы запус­
тить icall с заданными аргументами в контексте Pin под управлением скрипта exploit_callsite.py. Заметим, что входная строка AAA – это не
эксплойт, а просто произвольная строка для управления конколическим выполнением.
Скрипт перехватывает функцию main программы icall и делает
символическими все заданные пользователем байты argv . Дойдя
до места косвенного вызова, icall использует в качестве конечного адреса 0x223c625e , т. е. хеш-код строки AAA. Это бессмысленный
адрес, который вообще-то должен привести к краху, но в данном случае это не имеет значения, потому что вместо выполнения косвенного вызова exploit_callsite.py вычисляет модель эксплойта.
Прямо перед тем как будет выполнен косвенный вызов , exploit_
callsite.py пытается найти модель, которая дала бы такие входные
данные, что их хеш-код равен адресу секретного административного
Практическое символическое выполнение с помощью Triton

397

участка 0x400b3b . Отметим, что этот шаг может занять значительное время, до нескольких минут, в зависимости от имеющегося оборудования. К сожалению, решатель не смог найти модель, поэтому
exploit_callsite.py завершается, так и не сгенерировав эксплойт.
Но это еще не значит, что эксплойта не существует. Напомню, что
мы задали для конколического выполнения icall входную строку
AAA и что exploit_callsite.py создает отдельную символическую переменную для каждого из трех байтов, составляющих эту строку. Таким
образом, решатель пытался найти модель на основе входной строки
длины 3. И неудача означает лишь, что не существует входной строки длины 3, порождающей нужный эксплойт. Так, может быть, стоит
попытать счастья со строками другой длины? Вместо того чтобы подбирать длину входной строки вручную, мы можем автоматизировать
этот процесс, как показано в листинге 13.19.
Листинг 13.19. Попытки найти эксплойт для входных строк разной длины
$ cd ~/triton/pin-2.14-71313-gcc.4.4.7-linux/source/tools/Triton/build
 $ for i in $(seq 1 100); do
 str=`python -c "print 'A'*"${i}`

echo "Trying input len ${i}"
 ./triton ~/code/chapter13/exploit_callsite.py ~/code/chapter13/icall 2 ${str} \

| grep -a SymVar
done
 Trying input len 1
Trying input len 2
Trying input len 3
Trying input len 4
 SymVar_0 = 0x24 (argv[2][0])
SymVar_1 = 0x2A (argv[2][1])
SymVar_2 = 0x58 (argv[2][2])
SymVar_3 = 0x26 (argv[2][3])
ymVar_4 = 0x40 (argv[2][4])
SymVar_5 = 0x20 (argv[1][0])
SymVar_6 = 0x40 (argv[1][1])
Trying input len 5
 SymVar_0 = 0x64 (argv[2][0])
SymVar_1 = 0x2A (argv[2][1])
SymVar_2 = 0x58 (argv[2][2])
SymVar_3 = 0x26 (argv[2][3])
SymVar_4 = 0x3C (argv[2][4])
SymVar_5 = 0x40 (argv[2][5])
SymVar_6 = 0x40 (argv[1][0])
SymVar_7 = 0x40 (argv[1][1])
Trying input len 6
ˆC

Я воспользовался имеющимся в bash предложением for, чтобы
в цикле перебрать все целые числа i от 1 до 100 . На каждой итерации создается строка, содержащая i букв «A» , а затем производится

398

Глава 13

попытка сгенерировать эксплойт со строкой такой длины , как было
показано в листинге 13.18 для длины 31.
Чтобы не загромождать вывод, можно воспользоваться grep и выводить только строки, содержащие слово SymVar. Тогда мы будем видеть лишь строки, напечатанные для успешно созданных моделей,
а неудачные попытки генерирования эксплойта не будут отвлекать
внимание.
Вывод цикла генерирования эксплойта начинается в точке . Для
строк длины от 1 до 3 найти модель не удалось, но для длины 4 
и длины  всё получилось. Потом я прервал выполнение, потому что
незачем проверять другие длины, коль скоро эксплойт уже найден.
Проверим первый эксплойт, показанный в распечатке (для длины 4). Чтобы преобразовать вывод в строку эксплойта, нужно конкатенировать ASCII-символы, которые решатель присвоил символическим переменным, соответствующим байтам от argv[2][0] до argv[2]
[3], потому что именно эти байты служат входными данными для
функции хеширования в icall. Как видно из листинга 13.19, решатель
присвоил эти байтам значения 0x24, 0x2A, 0x58 и 0x26 соответственно.
Байт argv[2][4] должен быть нулем, завершающим входную строку,
но решатель об этом не знает, поэтому выбрал для этой позиции случайное значение 0x40, которое мы можем просто игнорировать.
Значения,присвоенные байтам от argv[2][0] до argv[2][3] в модели, образуют ASCII-строку эксплойта $*X&. Подадим эту строку на вход
icall, как показано в листинге 13.20.
Листинг 13.20. Экслуатация программы icall





$ cd ~/code/chapter13
$ ./icall 2 '$*X&'
Calling 0x400b3b
# whoami
root

Для проверки эксплойта вернитесь в каталог с кодом для этой
главы, где находится icall , и вызовите icall, задав индекс 2, выходящий за границы массива, и только что сгенерированную строку эксплойта . Как видите, хеш-код этой строки в точности равен
0x400b3b, адресу секретного административного участка . Из-за отсутствия проверки индекса на выход за границы массива мы обманом
заставили icall выполнить вызов по этому адресу и открыть для нас
оболочку с правами root . Как видите, команда whoami печатает root,
подтверждая, что мы действительно обладаем правами root. Мы ав1

Заметим, что того же эффекта можно достичь, не перезапуская программу,
если воспользоваться моментальными снимками Triton. Так, скрипт ~/triton/pin-2.14-71313-gcc.4.4.7-linux/source/tools/Triton/src/examples/pin/inject_
model_with_snapshot.py, входящий в комплект поставки Triton, дает пример
взлома пароля.
Практическое символическое выполнение с помощью Triton

Powered by TCPDF (www.tcpdf.org)

399

томатически сгенерировали эксплойт, применив символическое выполнение!

13.6

Резюме
В этой главе мы научились использовать символическое выполнение для создания инструментов, которые автоматически получают
нетривиальную информацию о двоичных программах. Символическое выполнение – один из самых мощных методов анализа двоичных файлов, хотя применять его следует с осторожностью, чтобы не
столкнуться с проблемами масштабируемости. Как показал пример
автоматического генерирования эксплойта, эффективность инструментов символического выполнения можно повысить, сочетая их
с другими методами, например динамическим анализом заражения.
Если вы прочитали книгу до конца, то теперь в вашем арсенале
имеются разнообразные приемы двоичного анализа, которые можно применить для самых разных целей – от хакерства и тестирования
безопасности до обратной разработки, анализа вредоносных программ и отладки. Я надеюсь, что эта книга поможет вам лучше справляться со своими проектами в области двоичного анализа и что она
заложила прочный фундамент, на котором вы можете продолжить
изыскания в этой области и, быть может, даже внести свой вклад, способствуя ее развитию!

Упражнение
1. Генерирование лицензионных ключей
В каталоге кода для этой главы имеется программа license.c, которая принимает серийный номер и проверяет, действителен он
или нет (как проверяются лицензионные ключи в коммерческих
программах). Напишите инструмент символического выполнения на основе Triton, который будет генерировать действительные лицензионные ключи, принимаемые license.c.

ЧАСТЬ IV
ПРИЛОЖЕНИЯ

A

КРАТКИЙ КУРС
АССЕМБЛЕРА X86

П

оскольку язык ассемблера – стандартное представление машинных команд, из которых состоят двоичные файлы, многие
виды двоичного анализа основаны на дизассемблировании.
Поэтому, чтобы извлечь максимум пользы из этой книги, важно понимать основы языка ассемблера x86. Это приложение является введением в основные понятия, необходимые для чтения книги.
Я не ставлю себе целью научить вас писать программы на ассемб­
лере (есть другие книги, специально посвященные этому предмету),
но хочу снабдить достаточной информацией для понимания дизассемблированных программ. Вы узнаете, как устроены ассемблерные
программы и команды x86 и как они ведут себя во время выполнения. Кроме того, вы увидите, как на языке ассемблера представляются
стандартные конструкции языка C/C++. Я буду рассматривать только
команды 64-разрядного процессора x86, употребляемые в режиме
пользователя, и опущу команды с плавающей точкой и дополнительные наборы команд типа SSE или MMX. Для краткости я буду называть
64-разрядный вариант x86 (x86-64 или x64) просто x86, потому что
именно он и изучается в этой книге.

402

Приложение A

A.1

Структура ассемблерной программы
В листинге A.1 показана простая программа на C, а в листинге A.2 –
соответствующая ей ассемблерная программа, порожденная компилятором gcc 5.4.0. (В главе 1 рассказано о том, как компиляторы
преобразуют C-программы в ассемблерный код и в конечном итоге
в двоичные файлы.)
В процессе обработки двоичного файла дизассемблер стремится
точно или максимально близко к оригиналу восстановить ассемблерные команды, сгенерированные компилятором. Пока что давайте
рассмотрим структуру ассемблерной программы, не вдаваясь в детали самих ассемблерных команд.
Листинг A.1. «Hello, world!» на C
#include
int
 main(int argc, char *argv[])

{
printf("Hello, world!\n");

return 0;
}

Листинг A.2. Ассемблерный код, сгенерированный gcc
.file "hello.c"
.intel_syntax noprefix
 .section .rodata
.LC0:
 .string "Hello, world!"
 .text
.globl main
.type main, @function
 main:
push
rbp
mov
rbp, rsp
sub
rsp, 16
mov
DWORD PTR [rbp-4], edi
mov
QWORD PTR [rbp-16], rsi
 mov
edi, OFFSET FLAT:.LC0
 call
puts
mov
eax, 0
leave
ret
.size
main, .-main
.ident   "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.9)"
.section .note.GNU-stack,"",@progbits
Краткий курс ассемблера x86

403

Листинг A.1 содержит функцию main , которая вызывает printf 
для печати строковой константы "Hello, world!" . На верхнем уровне соответствующая ассемблерная программа содержит компоненты
четырех типов: команды, директивы, метки и комментарии.

A.1.1 Ассемблерные команды, директивы, метки
и комментарии
В табл. A.1 приведены примеры компонентов каждого типа. Отметим,
что синтаксис зависит от конкретного ассемблера или дизассемблера.
Для целей этой книги близкое знакомство со всеми синтаксическими
тонкостями ассемблера необязательно, вы должны только научиться читать и анализировать дизассемблированный код, а не писать
программы на ассемблере самостоятельно. Поэтому я ограничусь
синтаксисом, которого придерживается gcc при наличии параметра
–masm=intel.
Таблица A.1. Компоненты ассемблерной программы
Тип
Команда
Директива
Директива
Директива
Метка
Комментарий

Пример

mov eax, 0
.section .text
.string "foobar"
.long 0x12345678
foo: .string "foobar"
# this is a comment

Назначение
Поместить 0 в eax
Поместить следующее далее содержимое в секцию .text
Определить ASCII-строку "foobar"
Определить двойное слово, имеющее значение 0x12345678
Определить строку "foobar" с символическим именем foo
Понятный человеку комментарий

Команды – это операции, выполняемые процессором. Директивы –
это указания ассемблеру породить определенный элемент данных,
поместить команды или данные в определенную секцию и т. д. Наконец, метки – это символические имена, по которым можно ссылаться
на команды или данные, а комментарии – понятные человеку строки,
предназначенные для документирования. После ассемблирования
и компоновки программы в двоичный файл все символически имена
заменяются адресами.
Ассемблерная программа в листинге A.2 инструктирует ассемблер
поместить строку "Hello, world!" в секцию .rodata , предназначенную специально для хранения постоянных данных. Директива .sec‑
tion сообщает ассемблеру, в какую секцию поместить следующее далее
содержимое, а директива .string позволяет определить ASCII-строку.
Существуют также директивы для определения данных других типов,
например .byte (определить байт), .word (двухбайтовое слово), .long
(4-байтовое двойное слово) и .quad (8-байтовое четверное слово).
Функция main помещается в секцию .text , предназначенную
для хранения кода. Директива .text – сокращенная запись .section
.text, а main: – символическая метка функции main.
После метки находятся команды, входящие в состав main. Эти
коман­ды могут ссылаться на ранее объявленные данные по симво-

404

Приложение A

лическим именам, например .LC0  (символическое имя, выбранное
gcc для строки "Hello, world!"). Поскольку программа печатает константную строку (без переменного числа аргументов), gcc заменил
обращение к printf обращением к puts , более простой функции,
печатающей заданную строку на экран.

A.1.2 Разделение данных и кода
В листинге A.2 есть один важный момент: компиляторы обычно помещают код и данные в разные секции. Это удобно во время дизассемблирования и анализа двоичного файла, потому что мы знаем,
какие байты программы следует интерпретировать как код, а какие – как данные. Однако в архитектуре x86 ничто не препятствует
смешению кода и данных в одной секции, и на практике некоторые
компиляторы или программы, написанные вручную на ассемблере,
так и поступают.

A.1.3 Синтаксис AT&T и Intel
Как уже отмечалось, в разных ассемблерах используется различный
синтаксис. Кроме того, существует два формата представления машинных команд x86: синтаксис Intel и синтаксис AT&T.
В синтаксисе AT&T именам регистров всегда предшествует символ %, а именам констант – символ $, тогда как в синтаксисе Intel эти
символы опускаются. В этой книге я пользуюсь более лаконичным
синтаксисом Intel. Но самое важное различие между AT&T и Intel –
порядок операндов команды. В синтаксисе AT&T операнд-источник
предшествует операнду-приемнику, поэтому помещение константы
в регистр edi записывается так:
mov $0x6,%edi

Напротив, в синтаксисе Intel та же команда записывается следующим образом (операнд-приемник – первым):
mov edi,0x6

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

A.2

Структура команды x86
Получив представление о структуре ассемблерных программ, пе­
рейдем­ к формату ассемблерных команд. Заодно мы познакомимся
со структурой машинных команд, представленных ассемблерными.
Краткий курс ассемблера x86

405

A.2.1 Ассемблерное представление команд x86
На уровне ассемблера команды x86 имеют вид mnemonic destination,
source. Здесь mnemonic – понятное человеку мнемоническое обозначение машинной команды, а source и destination – соответственно

ее операнд-источник и операнд-приемник. Например, ассемблерная
команда mov rbx, rax копирует значение из регистра rax в регистр rbx.
Заметим, что не все команды имеют ровно два операнда, у некоторых
вообще нет операндов, как мы скоро увидим.
Как я уже сказал, мнемонические имена – это высокоуровневые
представления машинных команд, понятных процессору. Посмот­
рим, как команды x86 представляются на машинном уровне. Иногда
это полезно знать, например для модификации существующего двоичного файла.

A.2.2 Структура команд x86 на машинном уровне
В архитектуре x86 команды имеют переменную длину; существуют
однобайтовые команды, но есть также команды, занимающие несколько байтов, максимально 15. Кроме того, команда может начинаться с любого адреса в памяти. Это означает, что процессор не требует специального выравнивания кода, хотя компиляторы это часто
делают, чтобы оптимизировать время выборки команд из памяти. На
рис. A.1 показана структура команды x86 на машинном уровне.
Режим адресации
(MOD-REG-R/M)
Префикс

Код операции

0–4 байтов

1–3 байта

Байт МИБ
(масштаб–индекс–база)

0–1 0–1

Смещение

Непосредственный

0/1/2/4 байтов

0/1/2/4 байтов

Операнды

Рис. A.1. Структура команды x86

Команда x86 включает префиксы, код операции и нуль или более
операндов: все части, кроме кода операции, факультативны. Код операции определяет тип команды. Например, код операции 0x90 соответствует команде nop, которая ничего не делает, а коды операций
0x00–0x05 соответствуют различным типам команды add. Префиксы
модифицируют поведение команды, например означают, что ее нужно повторить несколько раз, или указывают, что требуется обратиться
к другому сегменту памяти. Наконец, операнды – это данные, к которым применяется команда.
Режим адресации, который также называют байтом MOD-R/M или
MOD-REGR/M, содержит метаданные о типах операндов команды.
Байты МИБ (масштаб–индекс–база) и смещения служат для кодирования операндов в памяти, а поле «непосредственный» может содержать непосредственный операнд (числовую константу). Что все эти
поля означают, мы скоро увидим.

406

Приложение A

Помимо явных операндов, показанных на рис. A.1, у некоторых
команд­ имеются неявные операнды. Они не указаны в команде явно,
но подразумеваются кодом операции. Например, операндом-приемником команды с кодом операции 0x05 (add) всегда является rax,
а переменным может быть только операнд-источник, который кодируется явно. Другой пример – команда push неявно изменяет регистр
rsp (указатель стека).
В x86 команды могут иметь операнды трех типов: регистровые,
в памяти и непосредственные. Рассмотрим их поочередно.

A.2.3 Регистровые операнды
Регистры – это небольшие области хранения с очень быстрым доступом, расположенные в самом процессоре. Есть специализированные
регистры, например указатель команд, который отслеживает текущий
адрес выполнения, или указатель стека, отслеживающий положение
вершины стека. Есть также регистры общего назначения, в которых
могут храниться переменные исполняемой процессором программы.

Регистры общего назначения
В оригинальной системе команд процессора 8086, положенного в основу x86, регистры были 16-разрядными. В архитектуре x86 они были
расширены до 32 разрядов, а в архитектуре x86-64 – до 64 разрядов.
В целях обратной совместимости регистры в более современных системах команд являются надмножеством прежних регистров.
На языке ассемблера регистр задается своим именем. Например,
команда mov rax,64 помещает значение 64 в регистр rax. На рис. A.2
показано, как на 64-разрядный регистр rax отображаются унаследованные 32- и 16-разрядный регистры. Младшие 32 разряда rax образуют регистр eax, а младшие 16 разрядов последнего образуют оригинальный регистр ax процессора 8086. К младшему байту регистра
ax можно обратиться по имени al, а к старшему байту – по имени ah.
rax
64

32

16

8

ah

0

al
ax

eax

Рис. A.2. Структура регистра rax в архитектуре x86-64

Для других регистров схема именования аналогична. В табл. A.2
приведены имена регистров общего назначения, имеющихся в x8664, а также унаследованных «подрегистров». Регистры r8–r15 были
добавлены в x86-64, у них нет аналогов в более ранних вариантах.
Если задать значение 32-разрядного подрегистра, например eax, то
Краткий курс ассемблера x86

407

остальные разряды в объемлющем регистре (в данном случае rax) автоматически будут обнулены. Если же устанавливаются более узкие
подрегистры, например ax, al или ah, то остальные разряды не изменяются.
Таблица A.2. Регистры общего назначения в архитектуре x86
Описание

64-разрядный

Младшие
32 разряда

Младшие
16 разрядов

Младший
байт

Второй
байт

Аккумулятор
База
Счетчик
Данные
Указатель стека
Указатель базы
Индекс источника
Индекс приемника
Регистры общего
назначения x86-64

rax
rbx
rcx
rdx
rsp
rbp
rsi
rdi
r8–r15

eax
ebx
ecx
edx
esp
ebp
esi
edi
r8d–r15d

ax
bx
cx
dx
sp
bp
si
di
r8w–r15w

al
bl
cl
dl
spl
bpl
sil
dil
r8l–r15l

ah
bh
ch
dh

Не стоит придавать слишком большое значение столбцу «Описание». Эти описания берут начало в системе команд 8086, но в наши
дни большинство регистров взаимозаменяемы. Однако, как мы увидим в разделе A.4.1, указатель стека (rsp) и указатель базы (rbp) считаются специальными регистрами, потому что служат для адресации
стека, хотя в принципе и их можно использовать как регистры общего
назначения.

Другие регистры
Помимо регистров, перечисленных в табл. A.2, процессоры x86 содержат другие, специализированные регистры. Из них наиболее важны
rip (называется eip в 32-разрядном x86 и ip в 8086) и rflags (называется eflags и flags в более ранних архитектурах). Указатель команды
всегда содержит адрес следующей исполняемой команды и автоматически устанавливается процессором, записать в него вручную невозможно. В x86-64 можно прочитать значение указателя команд, но
в 32-разрядном x86 даже это нельзя сделать. Регистр флагов состояния применяется при сравнениях, условных переходах и для получения информации о том, равен ли результат последней команды нулю,
случилось ли при ее выполнении переполнение и т. д.
В архитектуре x86 также имеются сегментные регистры cs, ds, ss,
es, fs и gs, которые можно использовать для разбиения памяти на
сегменты. Сегментация вышла из употребления, и в x86-64 от ее поддержки почти отказались, так что я не стану вдаваться в детали. Интересующиеся могут обратиться к литературе по языку ассемблера x86.
Существуют также управляющие регистры, например cr0–cr10, которые ядро использует для управления поведением процессора, например для переключения между реальным и защищенным режима-

408

Приложение A

ми. Кроме того, отладочные регистры dr0–dr7 служат для аппаратной
поддержки таких средств отладки, как точки останова. В x86 управляющие и отладочные регистры недоступны в пользовательском режиме, доступ к ним имеет только ядро. Поэтому я не стану рассматривать их в этом приложении.
Имеются также различные моделезависимые регистры (model-specific register – MSR) и регистры, используемые в дополнительных наборах команд, например SSE и MMX, которыми оборудованы не все
процессоры x86. Команда cpuid позволяет узнать, какие возможности
поддерживает процессор, а команды rdmsr и wrmsr – читать и записывать моделезависимые регистры. Большинство этих специальных
регистров доступны только ядру, поэтому в этой книге они не встречаются.

A.2.4 Операнды в памяти
Операнды в памяти задают адрес в памяти, по которому CPU должен
выбрать один или несколько байтов. В архитектуре x86 поддерживается не более одного явного операнда в памяти в команде. То есть
невозможно непосредственно переместить байты из одного участка
памяти в другой командой mov. Для этого придется использовать регистр в качестве промежуточного хранилища.
В x86 операнды в памяти имеют вид [база + индекс*масштаб + смещение], где база и индекс – 64-разрядные регистры, масштаб – целое
число, равное 1, 2, 4 или 8, а смещение – 32-разрядная константа или
символ. Все эти компоненты необязательны. Процессор вычисляет
результат этого выражения и получает окончательный адрес в памяти. База, индекс и масштаб кодируются в байте МИБ команды, а смещение – в одноименном поле. Масштаб по умолчанию равен 1, а смещение – нулю.
Этот формат операнда в памяти обладает достаточной гибкостью
для беспрепятственной реализации многих типичных парадигм кодирования. Например, команду вида mov eax, DWORD PTR [rax*4 + arr]
можно использовать для доступа к элементу массива, где arr – смещение, содержащее начальный адрес массива, rax – индекс нужного
элемента и предполагается, что длина каждого элемента равна 4 байтам. Здесь DWORD PTR говорит ассемблеру, что мы хотим выбрать из
памяти 4 байта (двойное слово, или DWORD). Аналогично один из
способов обратиться к полю структуры struct – поместить начальный адрес структуры в регистр базы и прибавить смещение нужного
поля.
В x86-64 разрешено использовать в качестве базы операнда в памяти регистр rip (указатель команды), хотя в таком случае запрещается использовать индексный регистр. Компиляторы нередко
пользуются этой возможностью, в частности для создания позиционно-независимого кода и доступа к данным, так что в двоичных
файлах на платформе x86-64 нередко можно встретить адресацию
относительно rip.
Краткий курс ассемблера x86

409

A.2.5 Непосредственные операнды
Непосредственные операнды – это целые числа, являющиеся частью
самой команды. Например, в команде add rax, 42 значение 42 – непосредственный операнд.
В x86 непосредственные операнды кодируются в прямом формате – младший байт многобайтового целого числа располагается в памяти первым. Иначе говоря, если ассемблерная команда имеет вид
mov ecx, 0x10203040, то в соответствующей машинной команде байты
непосредственного операнда будут следовать в порядке 0x40302010.
Для кодирования целых чисел со знаком в x86 применяется дополнительный код, когда отрицательное значение получается из положительного инвертированием всех битов и прибавлением 1, переполнения при этом игнорируются. Например, для кодирования 4-байтового
целого, равного –1, мы берем целое 0x00000001 (шестнадцатеричное
представление 1), инвертируем все биты, получая 0xfffffffe, и прибавляем к результату 1 – в итоге получается представление в дополнительном коде, 0xffffffff. Когда в процессе дизассемблирования
кода вы видите непосредственное значение или значение в памяти,
начинающееся несколькими байтами 0xff, то, скорее всего, это отрицательное число.
Познакомившись с форматом и общими принципами работы команд x86, рассмотрим семантику некоторых часто употребляемых команд, которые встретятся вам в этой книге и собственных проектах
двоичного анализа.

A.3

Употребительные команды x86
В табл. A.3 описано несколько команд x86. Для получения информации
о командах, не упомянутых в таблице, обратитесь к онлайновому справочному руководству, например http://ref.x86asm.net/, или к официальному руководству Intel по адресу https://software.intel.com/en-us/articles/
intel-sdm/. Большая часть перечисленных команд не нуждается в объяснении, но некоторые заслуживают более подробного обсуждения.

Таблица A.3. Употребительные команды x86
Команда

Описание
Пересылка данных

mov dst, src
dst = src
xchg dst1, dst2 Обменять местами dst1 и dst2
Поместить src в стек и уменьшить rsp
 push src


add
sub
inc
neg
 cmp

410

dst, src
dst, src
dst
dst
src1, src2

Арифметические
dst += src
dst –= src
dst –= 1
dst = –dst
Установить флаги состояния на основе результата вычисления src1 – src2

Приложение A

Таблица А.3 (окончание)
Команда

Описание

Логические/поразрядные
dst &= src
dst |= src
dst ^= src
dst = ~dst
Установить флаги состояния на основе результата вычисления src1 & src2
Безусловные переходы
jmp addr
Переход по адресу
call addr
Поместить адрес возврата в стек, затем вызвать функцию по адресу
ret
Извлечь адрес возврата из стека и вернуть управление по этому адресу
Войти в ядро и выполнить системный вызов
 syscall
Условные переходы (в зависимости от флагов состояния)
jcc addr переходит по адресу, только если выполнено условие cc,
иначе проваливается на следующую команду
jncc addr переходит по адресу, только если условие cc не выполнено
 je addr/jz addr Перейти, если поднят флаг нуля (например, в результате последней команды
cmp операнды оказались равны)
ja addr
Перейти, если в результате последнего сравнения без знака оказалось dst > src
(above)
jb addr
Перейти, если в результате последнего сравнения без знака оказалось dst < src
(below)
jg addr
Перейти, если в результате последнего сравнения со знаком оказалось dst > src
(greater than)
jl addr
Перейти, если в результате последнего сравнения со знаком оказалось dst < src
(less than)
jge addr
Перейти, если в результате последнего сравнения со знаком оказалось dst >= src
jl addr
Перейти, если в результате последнего сравнения со знаком оказалось dst 5, если argc
больше 5, или сообщение argc 5) {
printf("argc > 5\n");
} else {
printf("argc