На Хабре уже не раз публиковались статьи про устройство формата ELF и написание файлов в таком формате вручную, но я не считаю лишним опубликовать и свой вариант решения этой задачи. В результате получился файл, вызывающий /bin/sh и занимающий всего 76 байт (что на 8 байт меньше, чем сумма длин заголовков ELF, необходимых для запуска). Начну с того, откуда взялась задача. На этой неделе проходил AmateursCTF 2023, одной из задач на pwn была «ELFcrafting-v2»: The smallest possible 64 bit ELF is 80 bytes. Can you golf it down to 79 bytes? И приложен файл, крутящийся на удалённом сервере. Открывая файл в Ghidra видим, что от нас требуется: Таким образом единственный способ решить задачу и получить флаг — написать выполняемый ELF-файл (потому что проверяется наличие сигнатуры в начале файла). Конечно, на всякий случай был проверен код ядра Linux на наличие других способов запуститься с такой сигнатурой, но к сожалению, shebang строка должна обязательно начинаться с #!, а остальные форматы файлов подключаются модулями, и в целом тоже не подходят. Вооружившись стандартом (ссылка) попробуем для начала собрать хоть какой-то запускаемый ELF-файл. Для начала видим, что начинаться файл должен с ELF-заголовка, структура которого описывается следующим образом: Здесь используются собственные типы данных для полей, они определяются для 32-битного формата ELF (в 64-битном другие определения) чуть выше в документе: Имя Размер Выравнивание Пояснение Elf32_Addr 4 4 Беззнаковый программный адрес Elf32_Half 2 2 Беззнаковое число среднего размера Elf32_Off 4 4 Беззнаковое файловое смещение Elf32_Sword 4 4 Знаковое большое число Elf32_Word 4 4 Беззнаковое большое число unsigned char 1 1 Беззнаковое маленькое число Сами же поля заголовка имеют следующее значение: e_ident — идентификатор ELF-файла (подробно описан ниже) e_type — тип ELF-файла, для наших целей подойдёт значение 2 «Исполняемый файл» e_machine — поле призвано определить требуемую архитектуру процессора для работы с ELF-файлом, но по всей видимости на деле не очень используется. В документе указан список из некоторых первых типов, но он неполный (например, компилятор GCC на моей системе ставит тип 0x3e «Advanced Micro Devices X86-64»), выберем 3 «Intel 80386» по причине, описанной ниже e_version — если верить документу, должно быть равно 1 e_entry — важное для нас поле, адрес точки входа в программу в памяти (не от начала файла) e_phoff — ещё одно важное поле, смещение от начала файла до заголовков программы (Program Header), которые используются для запуска ELF-файла e_shoff — поле, задающее смещение до заголовков секций (Section Header), которые используются для линковки файла, для наших целей его значение не играет роли и это будет важно в будущем e_flags — поле задающее флаги, специфичные для процессора, как оказалось, также не важны e_ehsize — размер заголовка ELF-файла, несмотря на кажущуюся значимость, ядро Linux видимо игнорирует значение этого поля при запуске файла e_phentsize — размер каждого из заголовков программы, в моём случае это 32 байта, в соответствии с заголовком по спецификации e_phnum — число заголовков программы, 1 e_shentsize — размер каждого из заголовков секции, 0 e_shnum — число заголовков секции, должно иметь значение 0 e_shstrndx — индекс в массиве заголовков секции, соответствующий секции с таблицей строковых имён, также должен иметь значение 0 На данном этапе прочтения не очень понятно, какой должен быть endianness у чисел и документация отвечает на данный вопрос ниже, в разделе про ELF identification. Идентификатор ELF-файла имеет длину 16 байт (как было указано выше) и имеет следующую структуру: Сигнатура: \x7fELF, или 7f 45 4c 46 в шестандцатиричном представлении Класс файла: 1 для ELF32, 2 для ELF64 Кодирование: 1 для little-endian, 2 для big-endian Версия: аналогично полю версии в заголовке, 1 Выравнивание: 9 нулей для заполнения до 16 байт, по стандарту их значение игнорируется Сильно ниже, в разделе Intel identification, указано, что для идентификации необходимо использовать little-endian значения, а в поле e_machine должно быть значение 3. Теперь всё готово к тому, чтобы собрать первый заголовок файла: Здесь пока пропущено поле e_entry, потому что мы пока не знаем, как его заполнить. Поэтому пора приступить к второму заголовку — заголовку программы. Его структура описывается так: Описание полей: p_type — тип сегмента, в нашем случае это будет 1 «Загружаемый» p_offset — смещение от начала файла до содержимого сегмента p_vaddr — виртуальный адрес сегмента p_paddr — физический адрес сегмента для ОС, которые не используют виртуальную адресацию p_filesz — размер сегмента в файле p_memsz — размер сегмента в памяти, не может быть меньше p_filesz, если больше — хвост сегмента заполняется нулями p_flags — флаги сегмента, в нашем случае это будет значение 5, соответствующее установке бит 1 на выполнение и 4 на чтение p_align — требуемое выравнивание сегмента, на практике оказалось, что оно не может быть совсем любым, для системы Linux подошло значение 0x1000, соответствующее выравниванию страниц виртуальной памяти Теперь можно написать и заголовок программы: В этом заголовке уже совсем много значений оставлено на будущее, а значит надо написать код самой программы. Для простоты напишем просто успешный выход: Для сборки файла я использую pwntools, поэтому итоговый код будет выглядеть следующим образом: За кадром остался выбор значений p_vaddr и p_paddr. На моей системе работает 0x1000, но уже когда вся работа была сделана и пришло время отправлять решение на сервер, программа отказалась работать. Оказалось, что, например, в Ubuntu Linux, установлено ограничение vm.mmap_min_addr равное 0x10000. При этом вообще, рекомендуется использовать значение 0x8048100, о чём написано в документе в разделе «Operating System Specific (UNIX System V Release 4)». Также нельзя загрузить программу откуда попало, например нельзя взять байты после заголовка, потому что они не выравнены по 0x1000, который указан в p_align. Поэтому загружаем с начала файла, а потом просим начать выполнение уже со старта. Проверяем, что всё работает:  Конечно, не требуемые 79 байт, но оно хотя бы работает. Но 93 байта тоже неплохо, хоть и для программы, которая ничего не делает. Для того, чтобы уменьшить размер файла, посмотрим на него в шестнадцатиричном коде: Красным отмечен наш ELF-заголовок, зелёным — заголовок программы, остальное — код. Здесь сразу бросается в глаза совпадение последних 8 байт заголовка ELF и первых 8 байт заголовка программы. Причём это совпадение почти никак не влияет на нас, как программистов, потому что там содержатся в основном данные, которые мы всё равно не могли ничем заменить для корректной работы. Теперь подумаем о том, что бы мы хотели вообще сделать. Ясно, что пространства у нас и так немного, поэтому читать файл флага (который расположен в той же директории и имеет имя flag.txt) и выводить на экран не получится. Попробуем тогда классический приём и запустим /bin/sh. Это делается всего одним системным вызовом, но всё ещё надо где-то хранить строку с путём для запуска. Здесь нам поможет 9 байт выравнивания в конце идентификатора! Также нужно передать массивы argv и envp для аргументов и переменных среды, но мы просто проигнорируем это и отправим нулевые указатели. К счастью, это разрешено, и более того, при запуске программы система заботливо заполнила нам все регистры нулями, поэтому нам вообще ничего не нужно делать. Таким образом, с упомянутыми оптимизациями, код теперь выглядит так: Проверяем, что всё ещё работает: На данном этапе уже становится ясно, откуда взялись 76 байт из предисловия, потому что нам осталось лишь убрать куда-нибудь ассемблерный код. Для начала посмотрим, как мы вообще можем уменьшить наш текущий код: Ясно, что последнюю инструкцию никуда не деть, заполнение ebx тоже, хотя это зависит от значения, но уменьшить до 2 байт точно не получится, зато инициализация eax спокойно переписывается в mov al, 11: Получаем всего 9 байт! Вспоминаем из начала рассказа о том, что поля e_shoff, e_flags и e_ehsize не используются, а они как раз занимают 10 байт в заголовке. Остаётся только собрать всё в кучу: Можно заметить, как мы перешли от elf_header, program_header и code сначала к header и code, а теперь уже и к просто header =) Для начала убедимся, что ядро всё ещё может запустить файл: Но есть, конечно, и неприятные моменты. Например, gdb теперь отказывается запускать наш файл, хотя это обходится запуском через gdbserver, но всё равно неприятно. Для получения флага напишем ещё один скрипт, чтобы не утруждать себя вознёй с перенаправлением ввода сначала из файла, а затем из терминала, или чем-то ещё подобным: И, наконец, получим флаг: Конечно, по ходу были упущены многие моменты как решения задания, так и деталей формата ELF, но данный материал стоит воспринимать в первую очередь как how-to для решения аналогичных заданий CTF, то есть write-up, нежели чем полноценный учебник по формату ELF. Спасибо за прочтение! Надеюсь, описанное выше показалось интересным, не стоит бояться разбираться в спецификациях и уж тем более воспринимать работу отдельных частей компьютера или операционной системы как нечто магическое. У всего есть исходники.  Разработчик