Как посмотреть дату создания файла в Linux
В системе Linux в свойствах файла файлового менеджера отображается только информация о дате последнего обращения к нему и дате изменения. Но вот дата создания там отсутствует. При этом иногда нужно посмотреть именно ее, например, чтобы узнать, с какого момента ведется запись лога. В этой инструкции расскажем, как посмотреть какие данные хранятся в файловых системах Linux и объясним, как узнать дату создания файла Linux . Будут упомянуты сразу же два удобных способа, каждый из которых со своими особенностями.
Дата создания файла
В стандарте POSIX прописаны только 3 вида временных меток, которые должна хранить файловая система:
- atime – время последнего обращения к файлу.
- mtime – время последнего изменения содержимого.
- ctime – время последней модификации прав доступа или владельца.
Поэтому в старых файловых системах посмотреть информацию о дате создания файла зачастую невозможно. А вот в современных файловых системах (ext4, zfs, XFS и т. д.) она уже сохраняется.
Данные о дате создания записываются в специальном поле:
- Ext4 – crtime
- ZFS – crtime
- XFS – crtime
- Btrfs – otime
- JFS – di_otime
Известно две методики просмотра этой информации: с помощью утилиты stat и debugfs. Однако первый метод подойдет пользователям не каждого дистрибутивов Linux. Второй способ – универсальный, но не такой простой в использовании. Разберемся с каждым из них по отдельности.
Используем Stat
Утилита stat выводит подробные сведения о файле. В том числе выводится дата создания файла Linux. Для ее запуска в терминале достаточно указать путь к файлу. Для примера посмотрим информацию про изображение pic_1.jpeg, хранящееся в каталоге /home/root-user/Pictures:

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

Но есть одна небольшая трудность. На некоторых дистрибутивах Linux при использовании утилиты stat это поле будет пустым.

Основная причина заключается в том, что выводимая информация отображается в в statx(2). Оболочка была добавлена в библиотеку glibc версии 2.28. То есть поддержка этой оболочки появилась в наборе базовых системных утилит GNU coreutils 8.31. Чтобы проверить версию, следует выполнить такую команду:

Следовательно, stat сможет выводить данные о создании файла только при наличии конкретных условий, которые были описаны выше. Например, в дистрибутиве Ubuntu 21.10 все работает без каких-либо проблем, а в Ubuntu 20.04 поле будет пустым.
Используем DebugFS
Утилита DebugFS не имеет ограничений по версии. Она будет работать всегда. Но и процедура использования у нее несколько более запутанная. Связано это с тем, что для просмотра даты создания файла через debugfs, нужно узнать номер его inode и файловую систему. Получить inode выйдет с помощью команды ls с опцией -i, указав путь к файлу:
$ ls -i /home/root-user/scripts/main_script.txt

Для просмотра файловой системы пригодится команда df:

Теперь все нужные данные собраны, и можно переходить к использованию утилиты debugfs. Ей нужно передать опцию -R, указать номер inode, а затем название файловой системы:

После этого в терминале вы сможете найти поле, в котором хранится дата создания. В нашем случае это crtime.

На этом инструкции завершена.
Введение в ELF-файлы в Linux: понимание и анализ
Есть в мире вещи, которые мы принимаем как нечто само собой разумеющееся, хотя они являются истинными шедеврами. Одними из таких вещей являются утилиты Linux, такие, как ls и ps. Хотя они обычно воспринимаются как простые, это оказывается далеко не так, если мы заглянем внутрь. И таким же оказывается ELF, Executable and Linkable Format. Формат файлов, который используется повсеместно, но мало кто его понимает. Это краткое руководство поможет вам достичь понимания.

Прочтя это руководство, вы изучите:
- Зачем нужен формат ELF и для каких типов файлов он используется
- Структуру файла ELF и детали его формата
- Как читать и анализировать бинарное содержимое файла ELF
- Какие инструменты используются для анализа бинарных файлов
Что представляет собой файл ELF?
ELF — это сокращение от Executable and Linkable Format (формат исполняемых и связываемых файлов) и определяет структуру бинарных файлов, библиотек, и файлов ядра (core files). Спецификация формата позволяет операционной системе корректно интерпретировать содержащиеся в файле машинные команды. Файл ELF, как правило, является выходным файлом компилятора или линкера и имеет двоичный формат. С помощью подходящих инструментов он может быть проанализирован и изучен.
Зачем изучать ELF в подробностях?
Перед тем, как погрузиться в технические детали, будет не лишним объяснить, почему понимание формата ELF полезно. Во-первых, это позволяет изучить внутреннюю работу операционной системы. Когда что-то пошло не так, эти знания помогут лучше понять, что именно случилось, и по какой причине. Также возможность изучения ELF-файлов может быть ценна для поиска дыр в безопасности и обнаружения подозрительных файлов. И наконец, для лучшего понимания процесса разработки. Даже если вы программируете на высокоуровневом языке типа Go, вы всё равно будет лучше знать, что происходит за сценой.
Итак, зачем изучать ELF?
- Для общего понимания работы операционной системы
- Для разработки ПО
- Цифровая криминалистика и реагирование на инциденты (DFIR)
- Исследование вредоносных программ (анализ бинарных файлов)
От исходника к процессу
Какую бы операционную систему мы не использовали, необходимо каким-то образом транслировать функции исходного кода на язык CPU — машинный код. Функции могут быть самыми базовыми, например, открыть файл на диске или вывести что-то на экран. Вместо того, чтобы напрямую использовать язык CPU, мы используем язык программирования, имеющий стандартные функции. Компилятор затем транслирует эти функции в объектный код. Этот объектный код затем линкуется в полную программу, путём использования линкера. Результатом является двоичный файл, который может быть выполнен на конкретной платформе и конкретном типе CPU.
Прежде, чем начать
Этот пост содержит множество команд. Лучше запускать их на тестовой машине. Скопируйте существующие двоичные файлы, перед тем, как запускать на них эти команды. Также мы напишем маленькую программу на С, которую вы можете скомпилировать. В конечном итоге, практика — лучший способ чему-либо научиться.
Анатомия ELF-файла
Распространённым заблуждением является то, что файлы ELF предназначены только для бинарных или исполняемых файлов. Мы уже сказали, что они могут быть использованы для частей исполняемых файлов (объектного кода). Другим примером являются файлы библиотек и дампы ядра (core-файлы и a.out файлы). Спецификация ELF также используется в Linux для ядра и модулей ядра.

Структура
В силу расширяемости ELF-файлов, структура может различаться для разных файлов. ELF-файл состоит из:
- заголовка ELF
- данных

заголовок ELF
Как видно на скриншоте, заголовок ELF начинается с «магического числа». Это «магическое число» даёт информацию о файле. Первые 4 байта определяют, что это ELF-файл (45=E,4c=L,46=F, перед ними стоит значение 7f).
Заголовок ELF является обязательным. Он нужен для того, чтобы данные корректно интерпретировались при линковке и исполнении. Для лучшего понимания внутренней работы ELF-файла, полезно знать, для чего используется эта информация.
Класс
После объявления типа ELF, следует поле класса. Это значение означает архитектуру, для которой предназначен файл. Оно может равняться 01 (32-битная архитектура) или 02 (64-битная). Здесь мы видим 02, что переводится командой readelf как файл ELF64, то есть, другими словами, этот файл использует 64-битную архитектуру. Это неудивительно, в моей машине установлен современный процессор.
Данные
Далее идёт поле «данные», имеющее два варианта: 01 — LSB (Least Significant Bit), также известное как little-endian, либо 02 — MSB (Most Significant Bit, big-endian). Эти значения помогают интерпретировать остальные объекты в файле. Это важно, так как разные типы процессоров по разному обрабатывают структуры данных. В нашем случае используется LSB, так как процессор имеет архитектуру AMD64.
Эффект LSB становится видимым при использовании утилиты hexdump на бинарном файле. Давайте посмотрим заголовок ELF для /bin/ps.
$ hexdump -n 16 /bin/ps 0000000 457f 464c 0102 0001 0000 0000 0000 0000 0000010
Мы видим, что пары значений другие, из-за интерпретации порядка данных.
Версия
Затем следует ещё одно магической значение «01», представляющее собой номер версии. В настоящее время имеется только версия 01, поэтому это число не означает ничего интересного.
OS/ABI
Каждая операционная система имеет свой способ вызова функций, они имеют много общего, но, вдобавок, каждая система, имеет небольшие различия. Порядок вызова функции определяется «двоичным интерфейсом приложения» Application Binary Interface (ABI). Поля OS/ABI описывают, какой ABI используется, и его версию. В нашем случае, значение равно 00, это означает, что специфические расширения не используются. В выходных данных это показано как System V.
Версия ABI
При необходимости, может быть указана версия ABI.
Машина
Также в заголовке указывается ожидаемый тип машины (AMD64).
Тип
Поле типа указывает, для чего предназначен файл. Вот несколько часто встречающихся типов файлов.
CORE (значение 4)
DYN (Shared object file), библиотека (значение 3)
EXEC (Executable file), исполняемый файл (значение 2)
REL (Relocatable file), файл до линковки (значение 1)
Смотрим полный заголовок
Хотя некоторые поля могут быть просмотрены через readelf, их на самом деле больше. Например, можно узнать, для какого процессора предназначен файл. Используем hexdump, чтобы увидеть полный заголовок ELF и все значения.
7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF. | 02 00 3e 00 01 00 00 00 a8 2b 40 00 00 00 00 00 |..>. +@. | 40 00 00 00 00 00 00 00 30 65 01 00 00 00 00 00 |@. 0e. | 00 00 00 00 40 00 38 00 09 00 40 00 1c 00 1b 00 |. @.8. @. |
(вывод hexdump -C -n 64 /bin/ps)
Выделенное поле определяет тип машины. Значение 3e — это десятичное 62, что соответствует AMD64. Чтобы получить представление обо всех типах файлов, посмотрите этот заголовочный файл.
Хотя вы можете делать всё это в шестнадцатиричном дампе, имеет смысл использовать инструмент, который сделает работу за вас. Утилита dumpelf может быть полезна. Она показывает форматированный вывод, соответствующий заголовку ELF. Хорошо будет изучить, какие поля используются, и каковы их типичные значения.
Теперь, кгда мы объяснили значения этих полей, время посмотреть на то, какая реальная магия за ними стоит, и перейти к следующим заголовкам!
Данные файла
Помимо заголовка, файлы ELF состоят из трёх частей.
- Программные заголовки или сегменты
- Заголовки секций или секции
- Данные
Заголовки программы
Файл ELF состоит из нуля или более сегментов, и описывает, как создать процесс, образ памяти для исполнения в рантайме. Когда ядро видит эти сегменты, оно размещает их в виртуальном адресном пространстве, используя системный вызов mmap(2). Другими словами, конвертирует заранее подготовленные инструкции в образ в памяти. Если ELF-файл является обычным бинарником, он требует эти программные заголовки, иначе он просто не будет работать. Эти заголовки используются, вместе с соответствующими структурами данных, для формирования процесса. Для разделяемых библиотек (shared libraries) процесс похож.

Программный заголовок в бинарном ELF-файле
Мы видим в этом примере 9 программных заголовков. Сначала трудно понять, что они означают. Давайте погрузимся в подробности.
GNU_EH_FRAME
Это сортированная очередь, используемая компилятором GCC. В ней хранятся обработчики исключений. Если что-то пошло не так, они используются для того, чтобы корректно обработать ситуацию.
GNU_STACK
Этот заголовок используется для сохранения информации о стеке. Интересная особенность состоит в том, что стек не должен быть исполняемым, так как это может повлечь за собой уязвимости безопасности.
Если сегмент GNU_STACK отсутствует, используется исполняемый стек. Утилиты scanelf и execstack показывают детали устройства стека.
# scanelf -e /bin/ps TYPE STK/REL/PTL FILE ET_EXEC RW- R-- RW- /bin/ps # execstack -q /bin/ps - /bin/ps
Команды для просмотра программного заголовка:
- dumpelf (pax-utils)
- elfls -S /bin/ps
- eu-readelf –program-headers /bin/ps
Секции ELF
Заголовки секции
Заголовки секции определяют все секции файла. Как уже было сказано, эта информация используется для линковки и релокации.
Секции появляются в ELF-файле после того, как компилятор GNU C преобразует код С в ассемблер, и ассемблер GNU создаёт объекты.
Как показано на рисунке вверху, сегмент может иметь 0 или более секций. Для исполняемых файлов существует четыре главных секций: .text, .data, .rodata, и .bss. Каждая из этих секций загружается с различными правами доступа, которые можно посмотреть с помощью readelf -S.
.text
Содержит исполняемый код. Он будет упакован в сегмент с правами на чтение и на исполнение. Он загружается один раз, и его содержание не изменяется. Это можно увидеть с помощью утилиты objdump.
12 .text 0000a3e9 0000000000402120 0000000000402120 00002120 2**4 CONTENTS, ALLOC, LOAD, READONLY, CODE
.data
Инициализированные данные, с правами на чтение и запись.
.rodata
Инициализированные данные, с правами только на чтение. (=A).
.bss
Неинициализированные данные, с правами на чтение/запись. (=WA)
[24] .data PROGBITS 00000000006172e0 000172e0 0000000000000100 0000000000000000 WA 0 0 8 [25] .bss NOBITS 00000000006173e0 000173e0 0000000000021110 0000000000000000 WA 0 0 32
Команды для просмотра секций и заголовков.
- dumpelf
- elfls -p /bin/ps
- eu-readelf –section-headers /bin/ps
- readelf -S /bin/ps
- objdump -h /bin/ps
Группы секций
Некоторые секции могут быть сгруппированы, как если бы они формировали единое целое. Новые линкеры поддерживают такую функциональность. Но пока такое встречается не часто.
# readelf -g /bin/ps There are no section groups in this file.
Хотя это может показаться не слишком интересным, большие преимущества даёт знание инструментов анализа ELF-файлов. По этой причине, обзор этих инструментов и их назначения приведён в конце статьи.
Статические и динамические бинарные файлы
Когда мы имеем дело с бинарными файлами ELF, полезно будет знать, как линкуются эти два типа файлов. Они могут быть статическими и динамическими, и это относится к библиотекам, которые они используют. Если бинарник «динамический», это означает, что он использует внешние библиотеки, содержащие какие-либо общие функции, типа открытия файла или создания сетевого сокета. Статические бинарники, напротив, включают в себя все необходимые библиотеки.
Если вы хотите проверить, является ли файл статическим или динамическим, используйте команду file. Она покажет что-то вроде этого:
$ file /bin/ps /bin/ps: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.24, BuildID[sha1]=2053194ca4ee8754c695f5a7a7cff2fb8fdd297e, stripped
Чтобы определить, какие внешние библиотеки использованы, просто используйте ldd на том же бинарнике:
$ ldd /bin/ps linux-vdso.so.1 => (0x00007ffe5ef0d000) libprocps.so.3 => /lib/x86_64-linux-gnu/libprocps.so.3 (0x00007f8959711000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f895934c000) /lib64/ld-linux-x86-64.so.2 (0x00007f8959935000)
Совет: Чтобы посмотреть дальнейшие зависимости, лучше использовать утилиту lddtree.
Инструменты анализа двоичных файлов
Если вы хотите анализировать ELF-файлы, определённо будет полезно сначала посмотреть на существующие инструменты. Существуют тулкиты для обратной разработки бинарников и исполняемого кода. Если вы новичок в анализе ELF-файлов, начните со статического анализа. Статический анализ подразумевает, что мы исследуем файлы без их запуска. Когда вы начнёте лучше понимать их работу, переходите к динамическому анализу. Запускайте примеры и смотрите на их реальное поведение.
Популярные инструменты
Radare2
Тулкит Radare2 создан Серджи Альваресом (Sergi Alvarez). Число 2 подразумевает, что код был полностью переписан по сравнению с первой версией. Сейчас он используется многими исследователями, для изучения работы кода.
Программные пакеты
Большинство Linux-систем имеют установленный пакет binutils. Другие пакеты могут помочь вам увидеть больше информации. Правильный тулкит упростит вашу работу, особенно если вы занимаетесь анализом ELF-файлов. Я собрал здесь список пакетов и утилит для анализа ELF-файлов.
elfutils
/usr/bin/eu-addr2line
/usr/bin/eu-ar – альтернатива ar, для создания и обработки архивных файлов
/usr/bin/eu-elfcmp
/usr/bin/eu-elflint – проверка на соответствие спецификациям gABI и psABI
/usr/bin/eu-findtextrel – поиск релокаций текста
/usr/bin/eu-ld – комбинирует объектный и архивные файлы
/usr/bin/eu-make-debug-archive
/usr/bin/eu-nm – показывает символы объектного и исполняемого файлов
/usr/bin/eu-objdump – показывает информацию из объектного файла
/usr/bin/eu-ranlib – создаёт индекс архивных файлов
/usr/bin/eu-readelf – показывает ELF-файл в читаемой форме
/usr/bin/eu-size – показывает размер каждой секции (text, data, bss, etc)
/usr/bin/eu-stack – показывает стек текущего процесса или дампа ядра
/usr/bin/eu-strings – показывает текстовые строки (как утилита strings)
/usr/bin/eu-strip – удаляет таблицу символов из файла ELF
/usr/bin/eu-unstrip – добавляет символы и отладочную информацию в бинарник
Примечание: пакет elfutils будет хорошим началом, он содержит большинство утилит для анализа
elfkickers
/usr/bin/ebfc – компилятор языка Brainfuck
/usr/bin/elfls – показывает программные заголовки и заголовки секций с флагами
/usr/bin/elftoc – преобразует бинарник в программу на С
/usr/bin/infect – утилита, инжектирующая дроппер, создаёт файл setuid в /tmp
/usr/bin/objres – создаёт объект из обычных или бинарных данных
/usr/bin/rebind – изменяет связывание и видимость символов в ELF-файлах
/usr/bin/sstrip – удаляет ненужные компоненты из ELF-файла
Примечание: автор пакета ELFKickers сфокусирован на манипулировании ELF-файлами, что позволяет вам получить больше информации при работе с «неправильными» ELF-бинарниками
pax-utils
/usr/bin/dumpelf – дамп внутренней структуры ELF
/usr/bin/lddtree – как ldd, с установкой уровня показываемых зависимостей
/usr/bin/pspax – выводит ELF/PaX информацию о запущенных процессах
/usr/bin/scanelf – широкий диапазон информации, включая подробности PaX
/usr/bin/scanmacho – показывает подробности бинарников Mach-O (Mac OS X)
/usr/bin/symtree – показывает символы в виде дерева
Примечание: некоторые утилиты в этом пакете могут рекурсивно сканировать директории, и идеальны для анализа всего содержимого директории. Фокус сделан на инструментах для исследования подробностей PaX. Помимо поддержки ELF, можно извлекать информацию из Mach-O-бинарников.
scanelf -a /bin/ps TYPE PAX PERM ENDIAN STK/REL/PTL TEXTREL RPATH BIND FILE ET_EXEC PeMRxS 0755 LE RW- R-- RW- - - LAZY /bin/ps
prelink
/usr/bin/execstack – можно посмотреть или изменить информацию о том, является ли стек исполняемым
/usr/bin/prelink – релоцирует вызовы в ELF файлах, для ускорения процесса
Часто задаваемые вопросы
Что такое ABI?
ABI — это Бинарный Интерфейс Приложения (Application Binary Interface) и определяет, низкоуровневый интерфейс между операционной системой и исполняемым кодом.
Что такое ELF?
ELF — это Исполняемый и Связываемый Формат (Executable and Linkable Format). Это спецификация формата, определяющая, как инструкции записаны в исполняемом коде.
Как я могу увидеть тип файла?
Используйте команду file для первой стадии анализа. Эта команда способна показать подробности, извлечённые из «магических» чисел и заголовков.
Заключение
Файлы ELF предназначены для исполнения и линковки. В зависимости от назначения, они содержат необходимые сегменты и секции. Ядро ОС просматривает сегменты и отображает их в память (используя mmap). Секции просматриваются линкером, который создаёт исполняемый файл или разделяемый объект.
Файлы ELF очень гибкие и поддерживаются различные типы CPU, машинные архитектуры, и операционные системы. Также он расширяемый, каждый файл сконструирован по-разному, в зависимости от требуемых частей. Путём использования правильных инструментов, вы сможете разобраться с назначением файла, и изучать содержимое бинарных файлов. Можно просмотреть функции и строки, содержащиеся в файле. Хорошее начало для тех, кто исследует вредоносные программы, или понять, почему процесс ведёт себя (или не ведёт) определённым образом.
Ресурсы для дальнейшего изучения
Если вы хотите больше знать про ELF и обратную разработку, вы можете посмотреть работу, которую мы выполняем в Linux Security Expert. Как часть учебной программы, мы имеем модуль обратной разработки с практическими лабораторными работами.
Для тех из вас, кто любит читать, хороший и глубокий документ: ELF Format и документ за авторством Брайана Рейтера (Brian Raiter), также известного как ELFkickers. Для тех, кто любит разбираться в исходниках, посмотрите на документированный заголовок ELF от Apple.
Совет:
если вы хотите стать лучше в анализе файлов, начните использовать популярные инструменты анализа, которые доступны в настоящее время.
- Программирование
- Анализ и проектирование систем
- Системное программирование
Строение ELF-файлов⚓︎
ELF — сокращение от «Executable and Lincable Format» — формат исполняемых и связываемых файлов. ELF определяет их структуру. Данная спецификация позволяет UNIX-подобным(/образным) системам правильно интерпретировать содержащиеся в файле машинные команды. Используется во многих операционных системах: GNU/Linux, FreeBSD, Solaris, etc.
Понимание строения ELF файла может редко пригодиться, но, тем не менее, оно будет полезно для понимания процесса разработки программного обеспечения, поиска дыр в безопасности и обнаружения подозрительных программ или файлов.
Начальное строение⚓︎

Для начала создадим директорию, в которой будут расположены тестовые программы, на которых будем «упражняться»:
mkdir ~/LinuxPrograms cd ~/LinuxPrograms
Типы⚓︎
Есть несколько типов ELF файлов (см. таблицы в конце статьи): * Перемещаемый файл — хранит инструкции (и данные), которые могут быть связаны с другими объектными файлами. Результатом может быть объектный или исполняемый файл. Так же к этому типу относятся объектные файлы статических библиотек. * Разделяемый объектный файл — также как и первый тип, содержит инструкции и данные, может быть связан с другими перемещаемыми и разделяемыми объектными файлами, в результате чего будет создан новый объектный файл, либо же при запуске программы ОС может динамически связывать его с исполняемым файлом программы , в результате чего будет создан исполняемый образ программы (в посл. случае речь идёт о разделяемых библиотеках). * Исполняемый файл — содержит полное описание, позволяющее ОС создать образ процесса. В т.ч.: инструкции, данные, описания необходимых разделяемых объектных файлов и др.
Для того, чтобы вывод всех команд, приведённых ниже, был краток, прост и понятен, напишите какую-нибудь простейшую программу, в которой нет ничего лишнего, что затраднит чтение:
vim simple.c int main() return(0); >
И скомпилируйте её:
gcc -o simple simple.c
Убедитесь в том, что это ELF файл:
file simple
Структура у каждого файла может различаться. Грубо говоря, ELF файл состоит из: * Заголовка * Данных
Подробнее: * Таблица заголовков программы: 0 или более сегментов памяти (только в исполняемом файле). Сообщает, как исполняемый файл должен быть помещён в виртуальную память процесса. Это необходимо для образа процесса, исполняемых файлов и общих объектов. Для перемещаемых объектных файлов это не требуется. * Таблица заголовков разделов: 0 или более разделов. Сообщает, как и куда нужно загрузить раздел. Каждая запись раздела в таблице содержит название и размер раздела. Таблица заголовков раздела должна использоваться для файлов, используемых при редактировании ссылок. * Данные: тпблицы заголовка программы или раздела * Заголовок ELF (54/64 байта для 32/64 бит): определяет использование 32/64 бит (смотреть struct Elf32_Ehdr / struct Elf64_Ehdr в /usr/include/elf.h ) * Заголовок программы: как создать образ процесса. Используются во время выполнения. Сообщают ядру или компоновщику время выполнения ld.so , что загружать в память и как найти информацию о динамической компоновке. * Заголовок разделов: используются во время компоновки или компиляции. Сообщают редактору ссылок ld , как разрашать символы и как группировать похожие потоки байтов из разных двоичных объектов ELF.
- Разделы — самые мелкие неделимые единицы в ELF файле, которые могут быть обработаны. Разделы содержат основную часть информации об объектных файлах для представления связывания. Эти данные включают инструкции, таблицу символов и информацию о перемещении. (просмотр ссылок)
- Сегменты — наименьшие отдельные единицы, которые могут быть отображены в памяти с помощью exec или компоновщика. (исполняемые)
Разделы и сегменты не имеют определённого порядка в ELF. Только заголовок имеет фиксированную позицию.
При помощи утилиты readelf можно просмотреть основную информацию о файле.
Эта утилита входит в состав пакета binutils , поэтому ничего доустанавливать не надо.
Основные возможности readelf :
-
Просмотр заголовка файла:
readelf -h simple
readelf -S -W simple
readelf -s -W simple
Минимизация файла ELF – попробуем в 2021?

Экспериментальный проект по максимальному уменьшению ELF-файла с программой Hello, World! под целевую систему Linux x64 с помощью NASM. В предыдущем опыте начала 2000-х удалось добиться размера рабочего файла в 45 байтов, но с тех пор ядро сильно изменилось. Каков же будет минимальный жизнеспособный ELF в 2021?
Небольшое дополнение: пока я работаю над полноценным обновлением, хочу отметить, что несколько участников на reddit и hackernews указали способы для уменьшения общего размера программы до 112 байт с сохранением вывода всей строки «Hello, world!». Вот несколько приёмов, о которых при написании статьи я не знал:
- Более короткий способ инициализации регистров.
- В начале выполнения Linux инициализирует состоянии регистров как 0.
- (Тот же пост, что и выше) Поле заголовка программы size in file может быть больше, чем сам файл при условии, что будет вписываться в объём памяти.
- Можно переписать количество заголовков разделов, если размер заголовка раздела установлен на 0 .
Мотив проекта
Много лет назад я наткнулся на известную статью, которая сильно повлияла на дальнейшее развитие моей карьеры. В то время я в качестве стажёра работал над системой сборки для откровенно большой базы Java-кода. Именно поэтому меня особо привлекла статья, в которой автор стремился сделать противоположное «энтерпрайзной Java»: удалить все, кроме наиболее важных компонентов, необходимых для определения валидной программы Linux. (А потом удалить ещё!).
Если вкратце, то та статья описывает создание двоичного файла Linux размером 45 байт (!). И даже при том, что итоговый бинарник явно нельзя назвать «валидным» файлом ELF, в Linux он всё же запускался. По крайней мере в то время. Не знаю, к сожалению или к лучшему, но позже эта ОС стала более строгой в отношении загрузки ELF (Точную дату публикации оригинальной статьи отследить мне не удалось, но в начале 2000-х она уже точно была, а миграция многих систем на 64-битные CPU снизила актуальность 32-битных ELF-ов).
Мои цели
Как и автор той самой вдохновившей меня статьи, я намерен создать наименьший ELF-файл, способный выполняться в современной среде Linux (на момент написания ядро 5.14). При этом я буду, как и в оригинальной публикации, также использовать ассемблер NASM, поскольку его легко установить, мне нравится его синтаксис, и он остаётся одним из лучших ассемблеров для x86 из доступных.
Тем не менее некоторые из моих целей отличаются от задач оригинальной статьи:
- Я буду создавать файл под x64_64 (64-битная архитектура AMD/Intel) Linux, так как будет преувеличением заявить, что 32-битный формат Intel (использованный в оригинале) актуален в настоящее время.
- В качестве дополнительной задачи я решил создать программу Hello, world! (выводящую в stdout текст Hello, world! , сопровождаемый переносом строки), вместо менее впечатляющего варианта из оригинала, где программа завершалась с кодом 42 . Как станет ясно позже, в итоге это потребовало совсем немного байтов.
- Моя программа должна успешно завершаться с кодом 0 .
Справка по формату ELF64
Файлы ELF используются в Linux (да и во многих других ОС) повсеместно и служат в качестве простых исполняемых программ, статических библиотек, создаваемых компиляторами, динамических библиотек и другого. Двоичный файл ELF, который и станет героем этой статьи, содержит следующие компоненты:
- Заголовок ELF верхнего уровня.
- Таблицу заголовков программы, где указано, какие части файла и куда нужно загружать в память. Иногда она также называется таблица заголовков сегментов.
- Таблица заголовков разделов, где указываются разделы файла ELF. Для простой загрузки файла она не является обязательной, но информация о разделах важна для другой функциональности ELF, например компоновки.
- Фактический исполняемый байткод и любые нужные ему данные.
В качестве дополнительного пояснения приведу схематическое изображение структуры формата ELF.
Полномасштабная версия схемы тут
Начало: минимальный, но валидный ELF «Hello world»
Даже те, кто в них не заглядывал, наверняка поняли, что типичные создаваемые gcc бинарники полны элементов, вовсе не обязательных для простого Hello world . Для тех же, кто хочет в этом убедиться, в оригинальной статье рассматривается несколько итераций версии на Си, которая не требует обновления для актуального использования даже в современных средах.
Так что вместо того, чтобы воссоздавать полный аналог предыдущей статьи, я перейду сразу к коду ассемблера и определю весь валидный ELF-файл.
Вот что у меня получилось:
; Использование ассемблера: ; nasm -f bin -o hello_world hello_world.asm [bits 64] ; Виртуальный адрес, в который наш ELF должен отображаться в памяти. ; Его выбор произволен, но он не должен быть 0 и лучше, если он ; будет выровнен по страницам. file_load_va: equ 4096 * 40 ; Заголовок ELF. ; Сигнатура. db 0x7f, 'E', 'L', 'F' ; "Class" = 2, 64-bit db 2 ; Порядок следования байтов = 1, от младшего к старшему. db 1 ; Версия ELF = 1 db 1 ; OS ABI, не используется, должен быть 0. db 0 ; Байт расширенного ABI + 7 байт заполнения. Оставляем 0, они игнорируются. dq 0 ; Типа файла ELF. 2 = исполняемый dw 2 ; Целевая архитектура. 0x3e = x86_64 dw 0x3e ; Дополнительная информация о версии ELF. Оставляем как 1. dd 1 ; Адрес точки входа. dq entry_point + file_load_va ; Смещение заголовка программы. Мы поместим его сразу после заголовка ELF. dq program_headers_start ; Смещение заголовка раздела. Поместим его после заголовков программы. dq section_headers_start ; Дополнительные флаги. Насколько знаю, не используются. dd 0 ; Размер этого заголовка, 64 байта. dw 64 ; Размер записи заголовка программы. dw 0x38 ; Количество записей заголовков программы. dw 1 ; Размер записи заголовка раздела. dw 0x40 ; Количество записей заголовков разделов. dw 3 ; Индекс раздела, содержащего таблицу строк с именами разделов. dw 2 program_headers_start: ; Первое поле: тип заголовка программы. 1 = загружаемый сегмент. dd 1 ; Флаги заголовка программы. 5 = без возможности записи. (биты 0, 1 и 2 = исполняемый, с ;возможностью записи и чтения соответственно). dd 5 ; Смещение загружаемого сегмента в файле. Оно будет содержать весь файл, значит ;устанавливаем 0. dq 0 ; Виртуальный адрес для размещения сегмента. dq file_load_va ; "Физический адрес". Не думайте, что он используется, установите то же значение, что и для ;виртуального. dq file_load_va ; Размер сегмента в файле. Заканчивается в таблице строк. dq string_table ; Размер сегмента в памяти. dq string_table ; Выравнивание сегмента. dq 0x200000 ; У нас будет только два раздела: .text и .shstrtab. Хотя заголовок первого должен быть NULL. section_headers_start: ; Заголовок раздела в индексе 0 является нулевым заголовком раздела, заполненным нулём. times 0x40 db 0 ; Смещение имени ".text" в таблице строк. dd text_section_name - string_table ; Его тип – загружаемый раздел "bits" dd 1 ; Флаги для раздела. Биты 0, 1 и 2 означают "с возможностью записи", "размещённый" и ; "исполняемый" соответственно. dq 6 ; "Виртуальный адрес" раздела. dq file_load_va ; Смещение в файле. dq 0 ; Размер раздела. dq file_end ; Связанный индекс раздела. Оставляем 0. dd 0 ; "info" раздела. Оставляем 0 (может с ним нужно что-то ;сделать?) dd 0 ; Выравнивание. Неважно. dq 16 ; Размер записи раздела. 0. dq 0 ; Далее, раздел таблицы строк. dd string_table_name - string_table ; Раздел таблицы строк. dd 3 ; Не нужно загружать. dq 0 ; Этот раздел содержит только таблицу строк, но не весь файл dq file_load_va + string_table dq string_table dq string_table_end - string_table dd 0 dd 0 dq 1 dq 0 ; Мы прошли все заголовки программы и разделов. Далее идёт фактический код. entry_point: ; Номер системного вызова 1: write. mov rax, 1 ; Номер файлового дескриптора 1. mov rdi, 1 ; Буфер. mov rsi, file_load_va + message ; Длина буфера. mov rdx, message_length syscall ; Номер системного вызова 60: exit. mov rax, 60 ; Код выхода. mov rdi, 0 syscall message: db `Hello, world!\n`, 0 message_length: equ $ - message
Здесь доступна версия без комментариев.
Если кому нужно, то вот краткая справка по синтаксису nasm :
- Строки, начинающиеся с ; являются комментариями.
- db , dw , dd и dq – это псевдоинструкции, выводящие инициализированные байты, 2-байтовые слова, 4-байтовые двойные слова и 8-байтовые счетверённые слова соответственно.
- Метки размещаются в начале строки и заканчиваются на : . NASM позволяет использовать их в простых арифметических выражениях вместо чисел.
- Директива equ используется для связывания метки не с расположением в файле, а с произвольным числом.
- Символ $ можно использовать в выражениях для представления в байткоде текущего смещения в байтах. (Здесь есть и другие нюансы, но при создании плоского двоичного файла они обычно не имеют значения).
- Весь код написан с помощью синтаксиса ассемблера в формате Intel x86.
При условии сохранения файла как hello_world.asm можете скомпилировать и выполнить его так:
nasm -f bin -o hello_world hello_world.asm chmod +x hello_world ./hello_world
Очевидно, что вам нужно будет использовать 64-битный Linux с nasm , установленным и доступным в PATH . nasm совсем невелик, и я рекомендую использовать его всем, кто пишет много кода на ассемблере x86.
Что включено в файл?
По сути, это минимальный «рабочий» файл ELF, какой я смог получить. Он содержит список разделов, включая .text для исполняемого кода и .shstrtab (Section Header String Table), где содержатся имена всех разделов (включая его собственное). Весь файл после сборки занял 383 байта, что уже весьма немного, хотя и далеко от возможного.
Так как эта начальная версия делалась сообразно формату ELF, просмотр её содержимого при помощи стандартных инструментов Linux работает исправно. По мере удаления из неё содержимого мы будем постепенно эту возможность утрачивать. Например, сейчас readelf -SW показывает, что наши разделы .text и .shstrtab определены верно:
В смещении 0x78 находятся три заголовка разделов: Section Headers: [Nr] Name Type Address Off Size ES Flg Lk Inf Al [ 0] NULL 0000000000000000 000000 000000 00 0 0 0 [ 1] .text PROGBITS 0000000000028138 000138 000027 00 AX 0 0 16 [ 2] .shstrtab STRTAB 000000000002816e 00016e 000011 00 0 0 1
Аналогичным образом objdump -M intel -d без проблем дизассемблирует код в разделе .text :
Дизассемблированный .text: 0000000000028138 : 28138: b8 01 00 00 00 mov eax,0x1 2813d: bf 01 00 00 00 mov edi,0x1 28142: 48 be 5f 81 02 00 00 movabs rsi,0x2815f 28149: 00 00 00 2814c: ba 0f 00 00 00 mov edx,0xf 28151: 0f 05 syscall 28153: b8 3c 00 00 00 mov eax,0x3c 28158: bf 00 00 00 00 mov edi,0x0 2815d: 0f 05
Прежде чем продолжать, можно взглянуть на фактические байты в нашем ELF и подписать их роль в нем:

Даже навскидку связанная с разделами информация занимает слишком много места. Как я уже говорил, всё это не нужно, если нас интересует лишь загрузка ELF в память и его запуск. Но можно ли взять и просто эту информацию удалить?
Удаление информации разделов
Для этого устанавливаем количество заголовков разделов на 0 и удаляем эти разделы. Ниже показано, как выглядит код после. Его можно собрать и выполнить так же, как и предыдущую версию. Кроме того, я дополнительно убрал все комментарии, кроме аннотаций изменённых строк:
[bits 64] file_load_va: equ 4096 * 40 db 0x7f, 'E', 'L', 'F' db 2 db 1 db 1 db 0 dq 0 dw 2 dw 0x3e dd 1 dq entry_point + file_load_va dq program_headers_start ; Смещение заголовка раздела. Разделов у нас нет, значит пусть он пока будет 0. dq 0 dd 0 dw 64 dw 0x38 dw 1 ; Размер записи заголовка раздела. dw 0x40 ; Количество записей заголовков разделов. Сейчас 0, так как у нас их нет. dw 0 ; Раздел, содержащий имена разделов. Больше не используется, значит устанавливаем как 0. dw 0 program_headers_start: dd 1 dd 5 dq 0 dq file_load_va dq file_load_va ; Мы изменим наш единственный заголовок программы, включив в него весь файл. dq file_end dq file_end dq 0x200000 entry_point: mov rax, 1 mov rdi, 1 mov rsi, file_load_va + message mov rdx, message_length syscall mov rax, 60 mov rdi, 0 syscall code_end: message: db `Hello, world!\n` message_length: equ $ - message file_end:
Удаление информации разделов сокращает размер файла до 173 байтов, экономя более 200 байт в сравнении с первым вариантом, который уже был мал. Очевидно, что так наш файл теряет кое-какие полезные метаданные, в результате чего некоторые утилиты, та же objdump , не смогут найти нужный код. Но при этом работоспособность не нарушена полностью. К примеру, readelf –SW по-прежнему работает и корректно указывает, что ELF не содержит разделов.
Если заглянуть в байткод ELF, то здесь ничего особо интересного. Все, как и раньше, только нет информации разделов:

Итак, что дальше? Другую информацию просто так удалить нельзя: нам нужен код, строка Hello, world! , заголовок ELF и заголовок программы. Однако есть одна деталь, которую можно уменьшить, ничего не сломав: сам код.
Уменьшение размера кода
Байткод х86 определенно не является самой большой частью файла, но он всё равно недостаточно оптимизирован по размеру, занимая 39 из 173 байтов. В данный момент весь код программы состоит из всего восьми инструкций. Неплохо! Но можно заменить эти инструкции более короткими альтернативами. Поскольку изначально их всего восемь, то я просто пройдусь по ним поочерёдно:
- mov rax, 1 : для начала устанавливаем регистр rax на хранение номера системного вызова write : 1. (Если не знакомы с номерами системных вызовов в Linux, то рекомендую заглянуть сюда. С их помощью мы просим Linux выполнить нужную работу от лица программы). Если вы изучили дизассемблированный код выше, то могли заметить, что наш ассемблер автоматически конвертировал эту инструкцию в mov eax, 1 , так как установка eax (младшие 32 бита rax ) автоматически очищает старшие 32 бита rax . И всё же mov eax, 1 занимает целых 5 байтов. Можно это улучшить:
- xor eax, eax : обнулить все биты в регистре eax . Это также обнулит все старшие биты rax , всего заняв два байта.
- inc eax : инкрементировать eax на 1, что тоже займёт два байта.
- mov rdi, 1 : далее мы устанавливаем первый аргумент системного вызова в регистре rdi , чего требует интерфейс системных вызовов Linux x86_64. Файловым дескриптором для stdout является 1, значит устанавливаем rdi на 1 . Опять же, ассемблер автоматически заменил эту инструкцию на её 5-байтовый эквивалент mov edi, eax . Однако мы уже установили rax на 1 , поэтому можно вместо этого использовать mov edi, eax , чтобы скопировать содержимое eax в edi (и очистить старшие биты rdi ). Эта новая инструкция занимает два байта, то есть мы экономим ещё 3.
- mov rsi, file_load_va + message : очередной аргумент системного вызова, виртуальный адрес выводимой строки, отправляется в регистр rsi . Мы вычисляем его здесь на основе произвольного виртуального адреса файла, который выбираем сами, и смещения строки в файле. В итоге эта инструкция занимает 10 байтов: два байта для кода операции и 8 полных байтов для адреса. Можно заменить её на mov esi, file_load_va + message , сэкономив 5 байтов: переход на esi требует на один байт меньше для опкода и использует четырехбайтовый непосредственный операнд. (Не сработает этот приём, только если file_load_va не вместится в 32 бита).
- mov rdx, message_length : последним аргументом для системного вызова будет длина выводимой строки. Здесь, как и в предыдущих случаях, произошла автоматическая замена на эквивалент mov edx, message_length . Тем не менее эта инструкция занимает аж 5 байтов, что вполне можно оптимизировать так:
- xor edx, edx : установить edx (и, как следствие, rdx ) на 0 , что займет два байта.
- mov dl, message_length : установить младшие 8 бит rdx на длину сообщения. Это также займёт два байта и будет работать при условии, что длина строки менее 256.
- syscall : эта инструкция будет вызывать системный вызов write для вывода строки и займёт два байта. Здесь, насколько я понимаю, улучшить уже ничего нельзя.
- mov rax, 60 : теперь, когда мы активировали системный вызов write , нужно активировать системный вызов exit . Его номер 60. Эта инструкция занимает 5 байтов, и в этом случае можно получить куда меньший размер, если предположить, что write выполнится успешно: возвращаемое системным вызовом значение записывается в регистр rax и в случае успеха должно быть 0 . Предполагая, что так и есть, нам не потребуется обнулять биты rax , и мы сможем просто установить его младший байт на 60, используя инструкцию mov al, 60 . Всё это займёт всего два байта, экономя нам ещё 3.
- mov rdi, 0 : единственным аргументом для системного вызова exit будет код выхода. В случае успеха он должен быть 0 , значит устанавливаем rdi на 0 . И здесь NASM снова сформирует 5-байтовую инструкцию, которую можно заменить на xor edi, edi . Таким образом, мы добьёмся того же результата, уложившись в два байта.
- syscall : теперь осуществляем системный вызов exit , чтобы завершить программу. В данном случае более краткой альтернативы я не знаю.
[bits 64] file_load_va: equ 4096 * 40 db 0x7f, 'E', 'L', 'F' db 2 db 1 db 1 db 0 dq 0 dw 2 dw 0x3e dd 1 dq entry_point + file_load_va dq program_headers_start dq 0 dd 0 dw 64 dw 0x38 dw 1 dw 0x40 dw 0 dw 0 program_headers_start: dd 1 dd 5 dq 0 dq file_load_va dq file_load_va dq file_end dq file_end dq 0x200000 entry_point: ; Устанавливаем eax (и, как следствие, rax) на 1. (Номер системного вызова write). xor eax, eax inc eax ; Устанавливаем edi (и, как следствие, rdi) на 1. (Файловый дескриптор для stdout). mov edi, eax ; Устанавливаем esi (и, как следствие, rsi) на виртуальный адрес строки. mov esi, file_load_va + message ; Устанавливаем edx (и, как следствие, rdx) на длину строки. xor edx, edx mov dl, message_length ; Осуществляем системный вызов write. syscall ; Предполагая успешность write, rax уже равен 0, значит устанавливаем номер следующего ;системного вызова на 60 для совершения exit. mov al, 60 ; Устанавливаем статус exit на 0. xor edi, edi ; Выходим из программы. syscall code_end: message: db `Hello, world!\n` message_length: equ $ - message file_end:Его можно собрать и выполнить аналогично предыдущим примерам, и укорачивание исполняемого байткода никак не повлияет на формат ELF. Внесение этих изменений сократило размер файла до 157 байт – то есть минус ещё 16. При этом он всё ещё остаётся вполне дееспособным бинарником Linux. Мы перешли от восьми инструкций, занимавших 39 байтов, к десяти, которые занимают 23. Шестнадцатеричный дамп байтов с прошлого раза изменился не сильно:

Код заметно сократился, и размер в 157 байт можно назвать очень мелким для исполняемого файла. Кроме того, несмотря на недостаток большого количества метаданных, ничто в программе не «сломано» полностью – в ней есть полные, пока ещё заполненные, заголовок ELF и заголовок программы, а также небольшой блок кода для выполнения. Другими словами, у Linux нет серьёзных оснований для отказа в его выполнении. Но это скоро изменится.
Перемещение кода
Оказывается, что сокращение кода имеет и ещё одно преимущество: в нём используются более короткие инструкции, которые можно разбить на сжатые детализированные части и объединить с помощью инструкций jmp . Но в чем здесь польза?
Если вы следовали ходу моей мысли и прочли раздел «Мотивация» в начале статьи, то наверняка поймеёте, что я имеют в виду: несколько полей в заголовке ELF и заголовке программы загрузчиком ELF в Linux не проверяются, и мы можем переписать их собственным кодом. Это позволит полностью удалить байты, занимаемые кодом, переназначив имеющиеся байты заголовков для двух задач.
Но какие байты заголовков можно переписать? Это легко проверить: заменить их в ассемблере на произвольное содержимое и посмотреть, заработает ли программа. Так я и поступил, а результаты записал в таблицу:

В этой таблице каждый байт в заголовке ELF и заголовке программы показаны в отдельных строках. Поля, которые можно переписать мусором, выделены зелёным, а те, которые проверяются или являются необходимыми – красным. Мы воспользуемся этой информацией, чтобы упаковать код (и даже строку Hello, world!\n ) в неиспользуемые байты (по факту, непроверяемые байты). Обновлённый код получится таким:
[bits 64] file_load_va: equ 4096 * 40 db 0x7f, 'E', 'L', 'F' db 2 db 1 db 1 db 0 ; Мы перепишем поле EABI + 7 байт заполнения нашими 8 байтами ;кода. (Я наперёд проверил, чтобы эти инструкции занимали ровно 8 байтов). entry_point: xor eax, eax inc eax mov edi, eax ; Переходим к следующему месту, которое можно заместить нашим кодом, так как следующее ;поле заместить не можем (тип ELF). jmp code_chunk_2 dw 2 dw 0x3e dd 1 dq entry_point + file_load_va dq program_headers_start ; Далее мы перепишем 8-байтовое поле смещения заголовка раздела, а также 4-байтовое поле ;“flags”, идущее за ним. code_chunk_2: mov esi, file_load_va + message xor edx, edx mov dl, message_length ; Перескакиваем к заключительной части кода и добавляем один байт нулей, заполняя 12-й ;байт двух перезаписываемых полей. jmp code_chunk_3 db 0 dw 64 dw 0x38 dw 1 dw 0x40 dw 0 dw 0 program_headers_start: dd 1 dd 5 dq 0 dq file_load_va ; Мы перепишем 8-байтовое поле "physical address"в заголовке программы нашими 8 байтами ;кода. Эти четыре инструкции займут ровно 8 байтов. code_chunk_3: syscall mov al, 60 xor edi, edi syscall dq file_end dq file_end ; Мы можем переписать заключительное 8-байтовое поле "alignment" в заголовке программы и ;сделаем это первыми 8 байтами строки "Hello, world!". message: db `Hello, world!\n` message_length: equ $ - message file_end:И снова его можно собрать и выполнить подобно всем предыдущим примерам. Теперь он занимает всего 126 байтов: 31 байт мы сократили за счёт удаления всех байтов кода и 8 байт за счёт строки Hello, world! . К сожалению, мы не можем упаковать всю строку в какой-либо из доступных промежутков, так как ей требуется 14 байтов, а в заголовках нет последовательности из 14 допускающих замещение байтов. В таком виде заголовок ELF и заголовок программы занимают 120 байтов, и строка «выпирает» из-за последнего ещё 6 байтами.
Вот теперь несколько стандартных инструментов, о которых я говорил ранее, уже не одобряют тот факт, что мы заместили так много полей в заголовках. Например, readelf – WlS начала жаловаться, что смещение заголовка раздела не равно нулю. Утилита objdump при попытке её применения к файлу просто выдаёт ошибку File truncated . И хоть конкретики в этой ошибке нет, я полагаю, что она также вызвана смещением заголовка раздела, так как другие поля, которые мы заместили, представляли просто заполнение, неиспользуемый физический адрес и выравнивание сегмента в памяти. Несмотря на то, что с виду к утрате функционала objdump может привести замещение выравнивания, я убедился, что это не так: установка выравнивания сегмента обратно на 1 (его значение до его замещения строкой) не восстановило работоспособность objdump .
В завершение hex-дамп текущей версии стал весьма интересен и отлично демонстрирует то, как мы исковеркали файл:

Но на этом ещё не все!
Финальная версия
Мы можем сделать ещё кое-что: аналогично тому, как мы наложили код, можно также наложить сами заголовки.
В конце заголовка ELF указано количество заголовков программы, сопровождаемое размером и количеством заголовков разделов, после чего идёт индекс раздела, содержащий таблицу имён разделов. Нам однозначно нужно оставить количество заголовков программы как 1 , а количество заголовков разделов как 0 , но оказывается, что размер заголовка раздела и индекс таблицы имён разделов можно переписать при условии, что мы не будем определять какие-либо разделы. По отдельности каждое из этих полей занимает всего два байта.
А что произойдёт, если начать первый заголовок программы сразу после количества этих заголовков? Как оказалось, это отлично сработает: несмотря на то, что заголовок программы начинается с четырехбайтового поля type , которое не должно быть нулевым, у этого поля установлен только нижний байт – остальные представлены нулями. Так что, если мы начнём наши шесть байт заголовка программы до завершения заголовка ELF, то поле type перехлёстывается с допускающим наложение section header size ; полем заголовка ELF, а также количеством заголовков разделов. Однако байты, перекрывающие количество заголовков разделов, представлены нулями: что нам и нужно.
Далее поле flags заголовка программы (которое тоже не может быть равно нулю) перекрывает поле таблицы строк имён разделов, которое мы, как и говорилось, не используем ввиду отсутствия самих разделов.
В итоге код ассемблера изменился не сильно, но уже представляет финальную версию:
[bits 64] file_load_va: equ 4096 * 40 db 0x7f, 'E', 'L', 'F' db 2 db 1 db 1 db 0 entry_point: xor eax, eax inc eax mov edi, eax jmp code_chunk_2 dw 2 dw 0x3e dd 1 dq entry_point + file_load_va dq program_headers_start code_chunk_2: mov esi, file_load_va + message xor edx, edx mov dl, message_length jmp code_chunk_3 db 0 dw 64 dw 0x38 dw 1 ; Мы просто удалили три двухбайтовых поля, которые раньше здесь были. Единственное, которое ;имело значение – количество заголовков разделов – по-прежнему будет нулевым, так как два ;старших байта этого поля в начале заголовка программы нулевые. program_headers_start: ; Следующие два поля также служат в качестве последних шести байт заголовка ELF. dd 1 dd 5 dq 0 dq file_load_va code_chunk_3: syscall mov al, 60 xor edi, edi syscall dq file_end dq file_end message: db `Hello, world!\n` message_length: equ $ - message file_end:Как и прежде, этот код можно собрать и выполнить аналогично самому первому примеру. После реализации шестибайтового нахлеста между заголовками он сократился до 120 байтов. В обычных условиях этот размер был бы равен суммарному размеру заголовка ELF и одного заголовка программы — наименьшему, какой можно ожидать от исполняемого файла ELF без кода. Для полноты можно также заглянуть в итоговый байткод:
Можно ли пойти ещё дальше?
120 байт – это серьёзный результат. Такой файл может уместиться в одно текстовое сообщение. Он занимает меньше 1/34 от 4Кб-страницы, что намного меньше, чем можно было желать. Если бы мы отклонились от изначальной цели написать полноценную программу Hello, world! , то могли бы срезать ещё шесть байт, получив итоговый размер 114. Можете попробовать сами, изменив код так, чтобы строка Hello, world!\n занимала ровно 8 байт. Например, если заменить соответствующую строку на message: db ‘Hi. \n’ , то в итоге получится рабочий 114-байтовый файл.
Попытки ещё больше укоротить эту строку приведут к чрезмерному сокращению заголовка программы, создав исполняемый файл, который Linux откажется запускать. И здесь открывается интересный нюанс. Это значит, что если мы решим использовать программу return 42 из оригинальной статьи, а не нашу версию с Hello, world! , то меньше 114 байт уже не опустимся – ограничивающим фактором становится наша невозможность дальнейшего наложения поверх заголовка ELF и одного необходимого заголовка программы.
А можно вообще больше не накладывать ничего поверх заголовков? Всё же автор оригинальной статьи смог уменьшить свой ELF вплоть до 45 байтов. К сожалению, это более не является возможным: для этого нужно, чтобы Linux автоматически заполняла незавершённые остатки заголовка ELF и заголовка программы нулями, чего эта система больше не делает. Однако в оригинальной 45-байтовой версии заголовок программы идёт сразу после сигнатуры 0x7f, E, L, F в начале файла. Неужели для нас действительно невозможно найти лучший способ перекрыть заголовок ELF и заголовок программы?
К сожалению, мне кажется, что здесь мы добиваемся наилучшего из возможных на сегодня результатов. Вернитесь ещё раз к скриншоту таблицы, где показано, какие байты можно заместить в заголовке ELF и заголовке программы. Увеличенный размер 64-битного заголовка программы существенно ограничивает возможности наложения, и после тщательной проверки байт за байтом я могу уверенно сказать, что лучшего не добиться, по крайней мере в современных сборках Linux x86-64. Для полноты я приведу шаги, которым следовал, чтобы прийти к такому заключению:
- program header offset в заголовке ELF и поле size in file в заголовке программы должны оба вписываться в один байт (в противном случае получится файл размером больше 255 байтов). При этом они не могут быть идентичны, так что эти 8-байтовые поля вообще не могут накладываться.
- Размер заголовков и тот факт, что заголовок ELF должен идти в начале файла, вместе с предыдущим доводом означают, что поле size in file в заголовке программы должно идти полностью после поля program header offset в заголовке ELF.
- Если попробовать обойти предыдущий пункт, разместив поле size in file сразу после поля program header offset , то поля type и flags заголовка программы (которые не могут быть нулевыми) окажутся в поле program header offset , которое, как говорилось, переписывать нельзя.
- Если и предыдущий пункт попробовать обойти, поместив поля flags и type сразу после поля program header offset , тогда поля ELF header size и program header entry size в заголовке ELF перекроются полем заголовка программы offset in file , который должен быть не более одного байта, не может совпадать с размерами заголовка ELF или заголовка программы и, следовательно, не может быть переписан. К сожалению, эти поля размеров проверяются ядром Linux (чего во времена написания оригинальной статьи не происходило).
- Если мы переместим поля flags и type заголовка программы так, что они будут идти сразу после двух вышеупомянутых полей размера, тогда они перекроют поле number of program headers заголовка ELF, которое должно быть 1 . Поле заголовка программы также равно 1 , и это, вроде бы, должно радовать. Но нет, не сработает…поскольку в результате этого наложения поле flags ляжет поверх количества заголовков разделов. Количество заголовков разделов должно быть равно нулю, а флаги нулевыми быть не могут, потому что биты readable+executable должны быть установлены.
- Наконец, если мы переместим поле заголовка программы за размеры заголовка ELF и заголовка программы, то оно перекроет поле section header size в заголовке ELF. Такой вариант сработает, причины чего описывались выше, и именно на этом заканчивается наша финальная версия.
Заключение
Несмотря на то, что это далеко не так впечатляет, как 45-байтовый исполняемый файл в уже далёком прошлом, многие из крайних приёмов оптимизации до сих пор остаются возможными в современных 64-битных системах Linux. 120 байт (или даже минимум в 114 байтов) – это невероятно крохотная программа по меркам времени, когда раздутое ПО очень уж часто принимается как должное.
Конечно, многое из проделанного нами ушло далеко за устранение «раздутости», но посыл при этом сохраняется: «Наверняка можно устранить куда больше программных излишеств, чем кажется на первый взгляд. А вот вопрос «Стоит ли это приложенных усилий?» уже заслуживает отдельного обсуждения».

- ruvds_перевод
- elf. Linux
- ассемблер (x86)