Содержание статьи
- Что нам понадобится
- Подопытные экземпляры
- Делаем исполняемые файлы
- Делаем перемещаемый файл и разделяемую библиотеку
- Заглядываем внутрь ELF-файла
- Заголовок ELF-файла
- Секционное представление ELF-файлов
- Заголовки секций
- Назначение и содержимое основных секций
- Сегментное представление ELF-файлов
- Заключение
Если в мире Windows исполняемые файлы представлены в формате Portable Executable (PE), то в Linux эта роль отведена файлам в формате Executable and Linkable Format (ELF). Сегодня мы заглянем внутрь таких файлов, немного поисследуем их структуру и узнаем, как они устроены.
Сразу отмечу, что в отличие от Windows в Linux от расширения файла не зависит практически ничего (за совсем небольшим исключением). Тип и формат файла определяется его внутренним содержимым и наличием тех или иных атрибутов, поэтому файлы в формате Executable and Linkable Format могут иметь любое расширение.
Что нам понадобится
Вообще, можно позавидовать людям, обитающим в мире Linux, — как правило, в системе «из коробки» идет большое число утилит и программ, которые в Windows необходимо где‑то искать и устанавливать дополнительно, да еще и не всегда бесплатно. В нашем случае для анализа ELF-файлов в Linux присутствует вполне состоятельный арсенал встроенных средств и утилит:
- readelf — с помощью этой утилиты можно практически полностью просматривать все потаенные места ELF-файлов в удобочитаемом виде;
- hexdump — простой просмотрщик файлов в шестнадцатеричном представлении (конечно, до hiew из мира Windows ему далеко, но, во‑первых, он присутствует в системе по умолчанию, а во‑вторых, делает это совершенно бесплатно);
- strings — с помощью этой известной утилиты можно увидеть имена всех импортируемых (или экспортируемых) функций, а также библиотек, из которых эти функции импортированы, названия секций и еще много чего интересного;
- ldd — позволяет выводить имена разделяемых библиотек, из которых импортируются те или иные функции, используемые исследуемой программой;
-
nm — может показывать таблицу имен из состава отладочной информации, которая добавляется в ELF-файлы при их компиляции (эта отладочная информация с помощью команды
stripможет быть удалена из файла, и в этом случае утилита nm ничем не поможет); - objdump — способна вывести информацию и содержимое всех элементов исследуемого файла, в том числе и в дизассемблированном виде.
Часть перечисленного (кроме hexdump и ldd) входит в состав пакета GNU Binutils. Если этого пакета в твоей системе нет, его легко установить. К примеру, в Ubuntu это выглядит следующим образом:
sudo apt install binutils
В принципе, имея все перечисленное, можно уже приступать к анализу и исследованию ELF-файлов без привлечения дополнительных средств. Для большего удобства и наглядности можно добавить к нашему инструментарию известный в кругах реверс‑инженеров дизассемблер IDA в версии Freeware (этой версии для наших целей будет более чем достаточно, хотя никто не запрещает воспользоваться версиями Home или Pro, если есть возможность за них заплатить).
Анализ заголовка ELF-файла в IDA Freeware
Также неплохо было бы использовать вместо hexdump что‑то поудобнее, например 010 Editor или wxHex Editor. Первый hex-редактор — достойная альтернатива Hiew для Linux (в том числе и благодаря возможности использовать в нем большое количество шаблонов для различных типов файлов, среди них и шаблон для парсинга ELF-файлов). Однако он небесплатный (стоимость лицензии начинается с 49,95 доллара, при этом есть 30-дневный триальный период).
Анализ заголовка ELF-файла в 010 Editor
Говоря о дополнительных инструментах, которые облегчают анализ ELF-файлов, нельзя не упомянуть Python-пакет lief. Используя этот пакет, можно писать Python-скрипты для анализа и модификации не только ELF-файлов, но и файлов PE и MachO. Скачать и установить этот пакет получится традиционным для Python-пакетов способом:
Подопытные экземпляры
В Linux (да и во многих других современных UNIX-подобных операционных системах) формат ELF используется в нескольких типах файлов.
-
Исполняемый файл — содержит все необходимое для создания системой образа процесса и запуска этого процесса. В общем случае это инструкции и данные. Также в файле может присутствовать описание необходимых разделяемых объектных файлов, а также символьная и отладочная информация. Исполняемый файл может быть позиционно зависимым (в этом случае он грузится всегда по одному и тому же адресу, для 32-разрядных программ обычно это
0x8048000, для 64-разрядных —0x400000) и позиционно независимым исполняемым файлом (PIE — Position Independent Execution или PIC — Position Independent Code). В этом случае адрес загрузки файла может меняться при каждой загрузке. При построении позиционно независимого исполняемого файла используются такие же принципы, как и при построении разделяемых объектных файлов. -
Перемещаемый файл — содержит инструкции и данные, при этом они могут быть статически связаны с другими объектными файлами, в результате чего получается разделяемый объектный или исполняемый файл. К этому типу относятся объектные файлы статических библиотек (как правило, для статических библиотек имя начинается с lib и применяется расширение
*.a), однако, как мы уже говорили, расширение в Linux практически ничего не определяет. В случае статических библиотек это просто дань традиции, а работоспособность библиотеки будет обеспечена с любым именем и любым расширением. -
Разделяемый объектный файл — содержит инструкции и данные, может быть связан с другими перемещаемыми файлами или разделяемыми объектными файлами, в результате чего будет создан новый объектный файл. Такие файлы могут выполнять функции разделяемых библиотек (по аналогии с DLL-библиотеками Windows). При этом в момент запуска программы на выполнение операционная система динамически связывает эту разделяемую библиотеку с исполняемым файлом программы, и создается исполняемый образ приложения. Опять же традиционно разделяемые библиотеки имеют расширение
*.so(от английского Shared Object). - Файл дампа памяти — файл, который содержит образ памяти того или иного процесса на момент его завершения. В определенных ситуациях ядро может создавать файл с образом памяти аварийно завершившегося процесса. Этот файл также создается в формате ELF, однако мы о такого рода файлах говорить не будем, поскольку задача исследования дампов и содержимого памяти достаточно объемна и требует отдельной статьи.
Для наших изысканий нам желательно иметь все возможные варианты исполняемых файлов из перечисленных выше, чем мы сейчас и займемся.
Делаем исполняемые файлы
Не будем выдумывать что‑то сверхоригинальное, а остановимся на классическом хелловорлде на С:
#include <stdio.h>int main(int argc, char* argv[]) { printf("Hello world"); return 0;}
Компилировать это дело мы будем с помощью GCC. Современные версии Linux, как правило, 64-разрядные, и входящие в их состав по умолчанию средства разработки (в том числе и компилятор GCC) генерируют 64-разрядные приложения. Мы в своих исследованиях не будем отдельно вникать в 32-разрядные ELF-файлы (по большому счету отличий от 64-разрядных ELF-файлов в них не очень много) и основные усилия сосредоточим именно на 64-разрядных версиях программ. Если у тебя возникнет желание поэкспериментировать с 32-разрядными файлами, то при компиляции в GCC нужно добавить опцию -m32, при этом, возможно, потребуется установить библиотеку gcc-multilib. Сделать это можно примерно вот так:
sudo apt-get install gcc-multilib
Итак, назовем наш хелловорлд example.c (кстати, здесь как раз один из немногих случаев, когда в Linux расширение имеет значение) и начнем с исполняемого позиционно зависимого кода:
gcc -no-pie example.c -o example_no_pie
Как ты уже догадался, опция -no-pie как раз и говорит компилятору собрать не позиционно независимый код.
В целом можно выделить четыре этапа работы GCC:
- препроцессирование;
- трансляция в ассемблерный код;
- преобразование ассемблерного кода в объектный;
- компоновка объектного кода.
Чтобы посмотреть на промежуточный результат, к примеру в виде ассемблерного кода, используй в GCC опцию -S:
gcc -S -masm=intel example.c
Обрати внимание на два момента. Первый — мы в данном случае не задаем имя выходного файла с помощью опции -o (GCC сам определит его из исходного, добавив расширение *.s, что и означает присутствие в файле ассемблерного кода). Второй момент — опция -masm=intel, которая говорит о том, что ассемблерный код в выходном файле необходимо генерировать с использованием синтаксиса Intel (по умолчанию будет синтаксис AT&T, мне же, как и, наверное, большинству, синтаксис Intel ближе). Также в этом случае опция -no-pie не имеет смысла, поскольку ассемблерный код в любом случае будет одинаковый, а переносимость обеспечивается на этапе получения объектного файла и сборки программы.
На выходе получим файл example.s с таким вот содержимым (полностью весь файл показывать не будем, чтобы не занимать много места):