Как устроены файлы ELF в Linux: структура, назначение и практический разбор

ELF (Executable and Linkable Format) — это стандартный формат исполняемых файлов, объектных модулей, разделяемых библиотек и дампов ядра в Linux. Если вы запускаете программу командой ./program, подключаете библиотеку через ld.so или собираете модуль ядра — почти наверняка работаете с ELF. Понимание внутреннего устройства этого формата помогает при отладке, анализе бинарников, поиске проблем с зависимостями и разборе ошибок вида «cannot open shared object file».

Главный принцип, который стоит усвоить сразу: ELF-файл описывает одни и те же данные с двух точек зрения. С точки зрения компоновщика (linker) файл состоит из секций — логических блоков кода, данных и служебной информации. С точки зрения загрузчика операционной системы файл состоит из сегментов — непрерывных областей памяти, которые нужно отобразить в адресное пространство процесса. Один и тот же файл содержит обе структуры, и они решают разные задачи.

Зачем разбираться во внутренней структуре ELF

Повседневная разработка редко требует знания байтового layout’а бинарника. Но есть ситуации, когда понимание формата экономит часы:

  • программа не запускается из-за отсутствующей библиотеки или неверной версии glibc — нужно понять, что именно она требует;
  • бинарник подозрительно большой, и хочется узнать, что в него попало при сборке;
  • вы анализируете вредоносный или неизвестный файл и хотите понять, что он делает до запуска;
  • вы пишете загрузчик, отладчик, инструмент анализа или модуль для CI, который проверяет артефакты сборки;
  • нужно понять, почему сегмент памяти получил конкретные права доступа (read/write/execute) и как это связано с безопасностью.

Во всех этих случаях достаточно уметь читать структуру файла стандартными утилитами: readelf, objdump, nm, file. Все они входят в binutils и доступны практически в любом дистрибутиве.

Общая схема ELF-файла

Любой ELF-файл начинается с заголовка, за которым следуют данные, организованные в таблицы и области содержимого. Упрощённо структура выглядит так:

  1. ELF-заголовок — первые байты файла. Содержит магическую сигнатуру, класс архитектуры, тип файла, целевую платформу, точку входа и смещения таблиц.
  2. Таблица программных заголовков (Program Header Table) — описание сегментов для загрузчика. Обязательна для исполняемых файлов и библиотек.
  3. Таблица секционных заголовков (Section Header Table) — описание секций для компоновщика и инструментов анализа. Для работы программы необязательна.
  4. Сами данные — машинный код, константы, инициализированные и неинициализированные данные, строки, таблицы символов, информация о релокациях.

Проверить, что перед вами 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. Механизм подключения устроен так:

  1. Ядро загружает ELF-файл, видит сегмент PT_INTERP и запускает указанный там динамический компоновщик (ld.so).
  2. Компоновщик читает секцию .dynamic, где перечислены зависимости (записи DT_NEEDED) — например, libc.so.6.
  3. Он ищет каждую библиотеку по путям из LD_LIBRARY_PATH, кеша ldconfig и конфигурационных каталогов, затем отображает её в память.
  4. Выполняются релокации: адреса внешних функций и переменных подставляются в GOT и PLT.
  5. Управление передаётся на точку входа программы.

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) Содержит снимки памяти и регистров, используется отладчиком

Один и тот же формат обслуживает все эти случаи, различаясь набором таблиц. Объектный файл описывает «что с чем склеить», исполняемый — «что куда отобразить», дамп — «каким было состояние процесса».

Практический разбор: что можно узнать о бинарнике за пять минут

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

  1. file binary — убедиться, что это ELF, определить архитектуру и статичность. Бинарник под чужой архитектурой на этой машине не запустится без эмуляции.
  2. readelf -h binary — тип, точка входа, целевая платформа.
  3. readelf -d binary — зависимости и флаги динамической компоновки.
  4. readelf -lW binary — сегменты, наличие PT_GNU_STACK (неисполняемый стек) и RELRO.
  5. nm -D binary или readelf —dyn-syms binary — какие функции экспортируются и импортируются.
  6. strings binary | head — беглый взгляд на текстовые строки: пути, сообщения, версии.
  7. 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 с готовым исполняемым и проследите, как меняется структура после компоновки. На этом примере видны все ключевые элементы формата сразу.

PEFile.ru