ELF (Executable and Linkable Format) — это стандартный формат исполняемых файлов, объектных модулей, разделяемых библиотек и дампов ядра в Linux. Если вы запускаете программу командой ./program, подключаете библиотеку через ld.so или собираете модуль ядра — почти наверняка работаете с ELF. Понимание внутреннего устройства этого формата помогает при отладке, анализе бинарников, поиске проблем с зависимостями и разборе ошибок вида «cannot open shared object file».
Главный принцип, который стоит усвоить сразу: ELF-файл описывает одни и те же данные с двух точек зрения. С точки зрения компоновщика (linker) файл состоит из секций — логических блоков кода, данных и служебной информации. С точки зрения загрузчика операционной системы файл состоит из сегментов — непрерывных областей памяти, которые нужно отобразить в адресное пространство процесса. Один и тот же файл содержит обе структуры, и они решают разные задачи.
- Зачем разбираться во внутренней структуре ELF
- Общая схема ELF-файла
- ELF-заголовок: что говорит ядро при запуске
- Секции: взгляд компоновщика
- Символы и их таблицы
- Сегменты и программные заголовки: взгляд загрузчика
- Динамическая компоновка: как программа находит свои библиотеки
- Типы ELF-файлов и чем они различаются
- Практический разбор: что можно узнать о бинарнике за пять минут
- Типичные заблуждения и ошибки
- Сценарии: что делать в конкретной ситуации
- Что стоит запомнить
Зачем разбираться во внутренней структуре ELF
Повседневная разработка редко требует знания байтового layout’а бинарника. Но есть ситуации, когда понимание формата экономит часы:
- программа не запускается из-за отсутствующей библиотеки или неверной версии glibc — нужно понять, что именно она требует;
- бинарник подозрительно большой, и хочется узнать, что в него попало при сборке;
- вы анализируете вредоносный или неизвестный файл и хотите понять, что он делает до запуска;
- вы пишете загрузчик, отладчик, инструмент анализа или модуль для CI, который проверяет артефакты сборки;
- нужно понять, почему сегмент памяти получил конкретные права доступа (read/write/execute) и как это связано с безопасностью.
Во всех этих случаях достаточно уметь читать структуру файла стандартными утилитами: readelf, objdump, nm, file. Все они входят в binutils и доступны практически в любом дистрибутиве.
Общая схема ELF-файла
Любой ELF-файл начинается с заголовка, за которым следуют данные, организованные в таблицы и области содержимого. Упрощённо структура выглядит так:
- ELF-заголовок — первые байты файла. Содержит магическую сигнатуру, класс архитектуры, тип файла, целевую платформу, точку входа и смещения таблиц.
- Таблица программных заголовков (Program Header Table) — описание сегментов для загрузчика. Обязательна для исполняемых файлов и библиотек.
- Таблица секционных заголовков (Section Header Table) — описание секций для компоновщика и инструментов анализа. Для работы программы необязательна.
- Сами данные — машинный код, константы, инициализированные и неинициализированные данные, строки, таблицы символов, информация о релокациях.
Проверить, что перед вами ELF-файл, можно одной командой:
file /bin/ls
Вывод покажет класс файла (32 или 64 бита), архитектуру, тип (исполняемый, разделяемая библиотека, relocatable) и признак того, является ли файл динамически связанным. Первые четыре байта любого ELF-файла всегда равны 0x7F 0x45 0x4C 0x46 — это «магическое число», по которому ядро распознаёт формат.
ELF-заголовок: что говорит ядро при запуске
Заголовок занимает 64 байта для 64-битных систем и 52 байта для 32-битных. Ключевые поля:
- e_ident — магия, класс (ELFCLASS32/ELFCLASS64), порядок байтов (little-endian на x86), версия, целевая ОС/ABI;
- e_type — тип файла: ET_EXEC (статически размещаемый исполняемый файл), ET_DYN (позиционно-независимый исполняемый файл или библиотека), ET_REL (объектный модуль до компоновки), ET_CORE (дамп памяти);
- e_machine — архитектура: x86-64, ARM, RISC-V и другие;
- e_entry — виртуальный адрес точки входа, куда передаётся управление после загрузки;
- e_phoff и e_shoff — смещения таблиц программных и секционных заголовков;
- e_phnum и e_shnum — количество записей в каждой таблице.
Посмотреть разобранный заголовок можно так:
readelf -h /bin/ls
Важный нюанс: большинство современных исполняемых файлов Linux имеют тип ET_DYN, а не ET_EXEC. Это следствие позиционно-независимого кода (PIE): такой бинарник может быть загружен по любому базовому адресу, что усложняет эксплуатацию некоторых классов уязвимостей. Тип ET_EXEC означает, что адреса в файле фиксированы и файл должен быть отображён строго по ним.
Секции: взгляд компоновщика
Секция — это именованный блок однородного содержимого. Компоновщик при сборке объединяет одноимённые секции из всех объектных файлов и раскладывает их по итоговому бинарнику. Инструменты вроде objdump используют секции, чтобы найти код, данные или отладочную информацию.
| Секция | Содержимое | Кому нужна |
|---|---|---|
| .text | Исполняемый машинный код | Процессору, загрузчику |
| .data | Инициализированные глобальные переменные | Программе при выполнении |
| .bss | Неинициализированные данные (занимают место только в памяти) | Программе при выполнении |
| .rodata | Константы, строковые литералы | Программе, только чтение |
| .symtab / .dynsym | Таблицы символов: полная и динамическая | Отладка, динамический компоновщик |
| .strtab / .dynstr | Строки имён символов | Интерпретация таблиц символов |
| .rela.dyn / .rela.plt | Релокации для динамической компоновки | Динамическому компоновщику |
| .plt / .got | Механизм вызова внешних функций и доступа к внешним данным | Динамической компоновке |
| .init / .fini | Код инициализации и завершения | Запуску и корректному завершению |
| .debug_* | Отладочная информация DWARF | Отладчику (обычно удаляется в релизе) |
| .comment | Служебные строки, например версия компилятора | Анализу, ничего не влияет |
Полный список секций выводит команда readelf -S файл. Обратите внимание: секционная таблица — это метаданные для инструментов, а не для ядра. Исполняемый файл без секционной таблицы (например, после обфускации или агрессивной оптимизации размера) продолжит запускаться, но readelf и отладчики потеряют значительную часть информации о нём.
Символы и их таблицы
Символ — это имя, привязанное к адресу: функция, глобальная переменная, метка. Таблица .symtab содержит все символы, включая локальные; .dynsym — только те, что нужны во время выполнения для динамической компоновки. Посмотреть символы можно командами nm или readelf -s.
Буква рядом с символом в выводе nm указывает его тип: T — код в .text, D — инициализированные данные, B — .bss, U — неопределённый символ, который программа ожидает найти в библиотеке. Именно список U-символов объясняет, какие функции бинарник берёт извне.
Полоса strip удаляет .symtab и отладочные секции, уменьшая размер файла. Динамические символы (.dynsym) при этом сохраняются — без них программа просто не сможет подключить свои библиотеки.
Сегменты и программные заголовки: взгляд загрузчика
Когда вы запускаете программу, ядро читает не секции, а программные заголовки. Каждый заголовок типа PT_LOAD описывает область файла, которую нужно отобразить в память, вместе с виртуальным адресом, размером и правами доступа. Типичный исполняемый файл содержит несколько PT_LOAD-сегментов:
- код и константы — права чтение + исполнение;
- данные — чтение + запись;
- служебные области для динамического компоновщика.
Такое разделение не случайно: если бы одна область памяти была одновременно записываемой и исполняемой, злоумышленнику было бы проще внедрить и выполнить код. Современные системы дополнительно применяют W^X-политику (write XOR execute) и рандомизацию адресов (ASLR).
Кроме PT_LOAD, встречаются сегменты других типов:
- PT_INTERP — путь к динамическому компоновщику, обычно /lib64/ld-linux-x86-64.so.2;
- PT_DYNAMIC — указатель на секцию .dynamic со списком зависимостей и релокаций;
- PT_NOTE — вспомогательные заметки, например сведения об ABI;
- PT_GNU_STACK — флаг исполняемости стека; его отсутствие или установка NX-атрибута делает стек неисполняемым;
- PT_GNU_RELRO — области, которые делаются доступными только для чтения после релокаций (full RELRO защищает GOT от перезаписи).
Команда readelf -l файл показывает программные заголовки и, что удобно, сопоставление секций с сегментами — сразу видно, какой блок файла попадёт в какую область памяти.
Динамическая компоновка: как программа находит свои библиотеки
Большинство программ в Linux не содержат весь нужный код внутри себя. Функции printf, malloc, работа с сетью — всё это живёт в разделяемых библиотеках, прежде всего libc. Механизм подключения устроен так:
- Ядро загружает ELF-файл, видит сегмент PT_INTERP и запускает указанный там динамический компоновщик (ld.so).
- Компоновщик читает секцию .dynamic, где перечислены зависимости (записи DT_NEEDED) — например, libc.so.6.
- Он ищет каждую библиотеку по путям из LD_LIBRARY_PATH, кеша ldconfig и конфигурационных каталогов, затем отображает её в память.
- Выполняются релокации: адреса внешних функций и переменных подставляются в GOT и PLT.
- Управление передаётся на точку входа программы.
PLT (Procedure Linkage Table) и GOT (Global Offset Table) — это пара таблиц, благодаря которым вызов внешней функции идёт через косвенный адрес. При ленивой привязке адрес подставляется при первом реальном вызове функции, что ускоряет старт программы. Полная привязка (LD_BIND_NOW=1 или full RELRO) разрешает все адреса сразу при загрузке.
Практические команды для диагностики зависимостей:
- ldd ./program — показать все библиотеки и пути, по которым они найдены; «not found» здесь прямо указывает причину ошибки запуска;
- readelf -d ./program | grep NEEDED — список прямых зависимостей из самого файла, без попыток их разрешения;
- ldconfig -p | grep имя — проверить, знает ли системный кеш нужную библиотеку;
- LD_DEBUG=libs ./program — подробный трассировочный вывод поиска библиотек, полезен при сложных случаях.
Частая ловушка: ldd показывает и транзитивные зависимости (библиотеки библиотек), тогда как DT_NEEDED — только прямые. Если программа падает с сообщением о версии glibc (GLIBC_x.y not found), значит собрана она была на системе с более новой libc, чем та, где запускается. Решается это сборкой на более старой системе, использованием контейнера или статической линковкой.
Типы ELF-файлов и чем они различаются
| Тип (e_type) | Что это | Особенности структуры |
|---|---|---|
| ET_REL | Объектный файл после компиляции (.o) | Есть секции и релокации, нет программных заголовков — файл ещё нельзя выполнить |
| ET_DYN | Библиотека (.so) или PIE-исполняемый файл | Программные заголовки есть, базовый адрес выбирается при загрузке |
| ET_EXEC | Исполняемый файл с фиксированными адресами | Загружается строго по адресам из файла |
| ET_CORE | Дамп памяти упавшего процесса (core dump) | Содержит снимки памяти и регистров, используется отладчиком |
Один и тот же формат обслуживает все эти случаи, различаясь набором таблиц. Объектный файл описывает «что с чем склеить», исполняемый — «что куда отобразить», дамп — «каким было состояние процесса».
Практический разбор: что можно узнать о бинарнике за пять минут
Предположим, вам достался неизвестный исполняемый файл. Последовательность проверки выглядит разумно так:
- file binary — убедиться, что это ELF, определить архитектуру и статичность. Бинарник под чужой архитектурой на этой машине не запустится без эмуляции.
- readelf -h binary — тип, точка входа, целевая платформа.
- readelf -d binary — зависимости и флаги динамической компоновки.
- readelf -lW binary — сегменты, наличие PT_GNU_STACK (неисполняемый стек) и RELRO.
- nm -D binary или readelf —dyn-syms binary — какие функции экспортируются и импортируются.
- strings binary | head — беглый взгляд на текстовые строки: пути, сообщения, версии.
- objdump -d binary — дизассемблирование, если нужно понять логику кода.
Условный пример интерпретации: если readelf показывает единственную зависимость libc.so.6, отсутствие PT_INTERP и большой размер файла — перед вами, скорее всего, статически слинкованная программа. Она запустится почти в любом окружении той же архитектуры, но не будет получать исправления libc без пересборки.
Типичные заблуждения и ошибки
- «Секции нужны для запуска». Нет: ядро и ld.so работают с программными заголовками. Секции нужны компоновщику и инструментам анализа. Отсюда же следует, что отсутствие секционной таблицы не мешает работе программы, но сильно мешает её исследовать.
- «ldd безопасно запускать на любом файле». ldd фактически запускает динамический компоновщик над файлом, поэтому на недоверенном бинарнике лучше использовать readelf -d — он лишь читает данные, ничего не выполняя.
- «Strip ломает программу». Корректно выполненный strip удаляет только отладочные и полные таблицы символов; динамические символы остаются, и программа продолжает работать. Ломает программу скорее неудачное ручное редактирование секций.
- «Размер .bss увеличивает файл». Неинициализированные данные занимают место в памяти, но не в файле: загрузчику достаточно знать размер области и обнулить её.
- «Ошибка «No such file or directory» при запуске всегда про сам файл». Часто это сообщение возникает, когда отсутствует интерпретатор из PT_INTERP — например, при запуске 64-битного бинарника в минимальном окружении без ld-linux. Проверяется через readelf -l.
Сценарии: что делать в конкретной ситуации
- Программа не стартует, жалуется на библиотеку. Выполните readelf -d, найдите записи NEEDED, затем ldd, чтобы увидеть, какая именно не находится. Дальше — установить пакет с библиотекой, добавить путь в LD_LIBRARY_PATH или настроить ldconfig.
- Нужен переносимый бинарник. Варианты: статическая линковка (крупнее, но автономнее), сборка в контейнере со старой базовой системой, либо поставка всех .so вместе с приложением и запуск через собственный путь поиска библиотек.
- Бинарник слишком большой. Проверьте наличие отладочных секций (readelf -S, секции .debug_*), соберите с оптимизацией размера и выполните strip. Заодно посмотрите, не втянулись ли лишние статические библиотеки.
- Нужно понять, чем занимается неизвестный файл, не запуская его. Комбинация file, readelf, nm -D и strings даёт представление о происхождении, зависимостях и функциональности без исполнения кода.
- Отладка краха по core dump. Убедитесь, что файл имеет тип ET_CORE, и откройте его в gdb вместе с соответствующим исполняемым файлом: дамп хранит память и состояние регистров, но не сам код.
Что стоит запомнить
ELF — двойственная структура: секции для компоновщика и инструментов, сегменты для загрузчика. Заголовок определяет тип файла и точку входа, программные заголовки управляют отображением в память и правами доступа, а секция .dynamic вместе с PLT/GOT обеспечивает работу с разделяемыми библиотеками. Практически вся диагностика выполняется тремя-четырьмя командами: file, readelf, nm и ldd.
Если вы только начинаете работать с форматом, самый короткий путь к пониманию — разобрать собственную программу: скомпилируйте небольшой файл на C, пройдите по нему readelf -h, -S, -l и -d, сравните объектный файл .o с готовым исполняемым и проследите, как меняется структура после компоновки. На этом примере видны все ключевые элементы формата сразу.
