Pull to refresh

Создание исполняемого файла ELF вручную

Reading time9 min
Views9.9K
Original author: Robin Hoksbergen

Привет, класс, и добро пожаловать в x86 Masochism 101. Здесь вы узнаете, как использовать опкоды непосредственно для создания исполняемого файла, даже не прикасаясь к компилятору, ассемблеру или компоновщику. Мы будем использовать только редактор, способный изменять двоичные файлы (т.е. шестнадцатеричный редактор), и «chmod», чтобы сделать файл исполняемым.

Если это вас не заводит, то я даже не знаю...

Если серьезно, то это одна из тех вещей, которые я лично считаю очень интересными. Очевидно, вы не собираетесь использовать это для создания серьезных программ с миллионами строк. Тем не менее, вы можете получить огромное удовольствие, узнав, что вы действительно понимаете, как такие вещи действительно работают на низком уровне. Также здорово иметь возможность сказать, что вы написали исполняемый файл, даже не касаясь компилятора или интерпретатора. Помимо этого, существуют приложения для программирования ядра, реверс-инжиниринга и (что неудивительно) создания компиляторов.

Прежде всего, давайте очень быстро посмотрим, как на самом деле работает выполнение файла ELF. Многие детали будут опущены. Что важно, так это получить хорошее представление о том, что делает ваш компьютер, когда вы говорите ему выполнить двоичный файл ELF.

Когда вы говорите компьютеру выполнить двоичный файл ELF, первое, что он будет искать, - это соответствующие заголовки ELF. Эти заголовки содержат всевозможную важную информацию об архитектуре процессора, сегментах и секциях файла и многое другое - мы поговорим об этом позже. Заголовок также содержит информацию, которая помогает компьютеру идентифицировать файл как ELF. Что наиболее важно, заголовок ELF содержит информацию о таблице заголовков программы (program header table) в случае исполняемого файла и виртуальном адресе, на который компьютер передает управление при выполнении.

Таблица заголовков программы, в свою очередь, определяет несколько сегментов. Если вы когда-либо программировали на ассемблере, вы можете думать о некоторых сегментах, таких как «text» и «data», как о сегментах в исполняемом файле. Заголовки программы также определяют, где в фактическом файле находятся данные этих сегментов, и какой адрес виртуальной памяти им назначить.

Если все было сделано правильно, компьютер загружает все сегменты в виртуальную память на основе данных в заголовках программ, затем передает управление на адрес виртуальной памяти, назначенный в заголовке ELF, и начинает выполнение инструкций.

Прежде чем мы начнем практиковаться, убедитесь, что у вас есть настоящий шестнадцатеричный редактор на вашем компьютере, и что вы можете запускать двоичные файлы ELF и вы используете компьютер архитектуры x86. Большинство шестнадцатеричных редакторов должны работать и позволять редактировать и сохранять вашу работу - мне лично нравится Bless. Если вы работаете в Linux, с двоичными файлами ELF все будет в порядке. Некоторые другие Unix-подобные операционные системы тоже могут работать, но разные ОС реализуют вещи немного по-разному, поэтому я не могу быть уверен. Я также широко использую системные вызовы, что еще больше ограничивает совместимость. Если вы используете Windows, вам не повезло. Точно так же, если архитектура вашего процессора отличается от x86 (хотя x86_64 должна работать), поскольку я просто не могу предоставить коды операций для каждой архитектуры.

Создание исполняемого файла ELF состоит из трех этапов. Сначала мы создадим фактическую полезную нагрузку (payload), используя опкоды. Во-вторых, мы создадим заголовки ELF и program header table, чтобы превратить эту полезную нагрузку в рабочую программу. Наконец, мы убедимся, что все смещения и виртуальные адреса верны, и заполним последние пробелы.

Предупреждение: создание исполняемого файла ELF вручную может быть очень неприятным. Я сам предоставил пример двоичного файла, который вы можете использовать для сравнения своей работы, но имейте в виду, что нет компилятора или компоновщика, который бы сказал вам, что вы сделали не так. Если (читайте: когда) вы облажались, ваш компьютер сообщит вам только «Ошибка ввода-вывода» или «Ошибка сегментации», что затрудняет отладку этих программ. И никаких отладочных символов не будет!

Создание полезной нагрузки

Давайте постараемся сделать полезную нагрузку простой, но достаточно сложной, чтобы быть интересной. Наша полезная нагрузка должна вывести "Hello World!" на экран, затем выйти с кодом 93. Это сложнее, чем кажется. Нам понадобится как текстовый сегмент (содержащий исполняемые инструкции), так и сегмент данных (содержащий строку «Hello World!» и некоторые другие второстепенные данные). Давайте посмотрим на ассемблерный код, который нам нужен для этого:

(text segment)
mov ebx, 1
mov eax, 4
mov ecx, HWADDR
mov edx, HWLEN
int 0x80

mov eax, 1
mov ebx, 0x5D
int 0x80

Приведенный выше код не слишком сложен, даже если вы никогда не программировали на ассемблере. Прерывание 0x80 используется для выполнения системных вызовов, причем значения в регистрах EAX и EBX сообщают ядру, что это за вызов. Вы можете получить более подробную информацию о системных вызовах и их значениях в соответствующем мануале.

Для нашей полезной нагрузки нам нужно преобразовать эти инструкции в шестнадцатеричные коды операций. К счастью, есть хорошие онлайн-мануалы, которые помогают нам в этом. Попробуйте найти такой для семейства x86 и посмотрите, сможете ли вы понять, как перейти от приведенного выше кода к приведенным ниже шестнадцатеричным кодам:

0xBB 0x01 0x00 0x00 0x00
0xB8 0x04 0x00 0x00 0x00
0xB9 0x** 0x** 0x** 0x**
0xBA 0x0D 0x00 0x00 0x00
0xCD 0x80

0xB8 0x01 0x00 0x00 0x00
0xBB 0x5D 0x00 0x00 0x00 
0xCD 0x80

(Здесь звёздочки обозначают виртуальные адреса. Мы их еще не знаем, поэтому пока оставим их пустыми)

Вторая часть полезной нагрузки состоит из сегмента данных, который на самом деле представляет собой просто строку «Hello World!\n». Используйте таблицу преобразования ASCII ('man ascii'), чтобы преобразовать эти значения в шестнадцатеричный формат, и вы увидите, как мы получим следующие данные:

(data segment)
0x48 0x65 0x6C 0x6C 0x6F 0x20 0x57 0x6F 0x72 0x6C 0x64 0x21 0x0A

И вот наша полезная нагрузка готова!

Создание заголовков

Вот где это может очень быстро стать очень сложным. Я объясню некоторые из наиболее важных параметров в процессе построения заголовков, но вы, вероятно, захотите внимательно взглянуть на несколько более подробное руководство, если вы когда-нибудь собираетесь создавать заголовки ELF полностью самостоятельно. Заголовок ELF имеет следующую структуру, размер в байтах в скобках:

e_ident(16), e_type(2), e_machine(2), e_version(4), e_entry(4), e_phoff(4),
e_shoff(4), e_flags(4), e_ehsize(2), e_phentsize(2), e_phnum(2), e_shentsize(2)
e_shnum(2), e_shstrndx(2)

Теперь мы заполним структуру, и я объясню немного больше об этих параметрах, где это необходимо.

e_ident (16) - этот параметр содержит первые 16 байтов информации, которая идентифицирует файл как файл ELF. Первые четыре байта всегда содержат 0x7F, 'E', L ', F'. Байты с пятого по седьмой содержат 0x01 для 32-битных двоичных файлов на машинах с little-endian. Байты с восьмого по пятнадцатый являются заполнителями, поэтому они могут быть 0x00, а шестнадцатый байт содержит длину этого блока, поэтому он должен быть 16 (= 0x10).

e_type (2) - установите в 0x02 0x00. По сути, это говорит компьютеру, что это исполняемый файл ELF.

e_machine (2) - установите значение 0x03 0x00, что сообщает компьютеру, что файл ELF был создан для работы на процессорах типа i386.

e_version (4) - установите 0x01 0x00 0x00 0x00.

e_entry (4) - передать управление на этот виртуальный адрес при исполнении. Мы еще не определили его, поэтому пока это 0x** 0x** 0x** 0x**.

e_phoff (4) - смещение от файла к program header table. Мы помещаем его сразу после заголовка ELF, так что размер заголовка ELF в байтах: 0x34 0x00 0x00 0x00.

e_shoff (4) - смещение от начала файла к таблице заголовков раздела. Нам это не нужно. 0x00 0x00 0x00 0x00.

e_flags (4) - флаги нам тоже не нужны. 0x00 0x00 0x00 0x00 снова.

e_ehsize (2) - размер заголовка ELF, поэтому содержит 0x34 0x00.

e_phentsize (2) - размер заголовка программы. Технически мы этого еще не знаем, но я уже могу сказать вам, что он должен содержать 0x20 0x00. Прокрутите вниз, чтобы проверить, если хотите.

e_phnum (2) - количество заголовков программы, что напрямую соответствует количеству сегментов в файле. Нам нужен текст и сегмент данных, поэтому это должно быть 0x02 0x00.

e_shentsize (2), e_shnum (2), e_shstrndx (2) - все это на самом деле не актуально, если мы не реализуем заголовки секций (а мы не реализуем), поэтому вы можете просто установить это значение 0x00 0x00 0x00 0x00 0x00 0x00.

И это заголовок ELF! Это первое, что находится в файле, и если вы все сделали правильно, окончательный заголовок в шестнадцатеричном формате должен выглядеть так:

0x7F 0x45 0x4C 0x46 0x01 0x01 0x01 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x10 0x02 0x00 0x03 0x00 0x01 0x00 0x00 0x00
0x** 0x** 0x** 0x** 0x34 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x34 0x00 0x20 0x00 0x02 0x00 0x00 0x00
0x00 0x00 0x00 0x00

Однако мы еще не закончили с заголовками. Теперь нам нужно также создать program header table. Он имеет следующие записи:

p_type(4), p_offset(4), p_vaddr(4), p_paddr(4), p_filesz(4), p_memsz(4),
p_flags(4), p_align(4)

Опять же, я заполню структуру (на этот раз дважды: один для сегмента текста, второй для сегмента данных) и объясню ряд вещей по пути:

p_type (4) - сообщает программе тип сегмента. И текст, и данные используют здесь PT_LOAD (= 0x01 0x00 0x00 0x00).

p_offset (4) - смещение от начала файла. Эти значения зависят от размера заголовков и сегментов, поскольку мы не хотим, чтобы они перекрывались. Пока пусть будет 0x** 0x** 0x** 0x**.

p_vaddr (4) - какой виртуальный адрес назначить сегменту. Пусть будет 0x** 0x** 0x** 0x** 0x**, мы поговорим об этом позже.

p_paddr (4) - физическая адресация не имеет значения, поэтому вы можете указать здесь 0x00 0x00 0x00 0x00.

p_filesz (4) - количество байтов в образе файла сегмента, должно быть больше или равно размеру полезной нагрузки в сегменте. Опять же, установите значение 0x** 0x** 0x** 0x**. Мы изменим это позже.

p_memsz (4) - количество байтов в памяти образа сегмента. Обратите внимание, что это не обязательно равно p_filesz, но может быть и так. Пока оставьте его на 0x** 0x** 0x** 0x**, но помните, что позже мы можем установить его на то же значение, которое мы присваиваем p_filesz.

p_flags (4) - эти флаги могут быть непростыми, если вы не привыкли с ними работать. Что вам нужно запомнить, так это то, что флаг READ - 0x04, флаг WRITE - 0x02, а флаг EXEC - 0x01. Для текстового сегмента мы хотим READ + EXEC, поэтому 0x05 0x00 0x00 0x00, а для сегмента данных мы предпочитаем READ + WRITE + EXEC, поэтому 0x07 0x00 0x00 0x00.

p_align (4) - указывает на выравнивание страниц памяти. Размер страницы обычно составляет 4 КиБ, поэтому значение должно быть 0x1000. Помните, что x86 является little-endian, поэтому окончательное значение равно 0x00 0x10 0x00 0x00.

Уф. Мы, безусловно, уже многое сделали. Мы еще не заполнили многие поля в заголовках программ, и нам также не хватает нескольких байтов в заголовке ELF, но мы приближаемся. Если все пойдет по плану, таблица заголовков вашей программы (которую, кстати, можно вставить непосредственно за заголовком ELF - помните наше смещение в этом заголовке?) Должна выглядеть примерно так:

0x01 0x00 0x00 0x00 0x** 0x** 0x** 0x** 0x** 0x** 0x** 0x**
0x00 0x00 0x00 0x00 0x** 0x** 0x** 0x** 0x** 0x** 0x** 0x**
0x05 0x00 0x00 0x00 0x00 0x10 0x00 0x00 
0x01 0x00 0x00 0x00 0x** 0x** 0x** 0x** 0x** 0x** 0x** 0x**
0x00 0x00 0x00 0x00 0x** 0x** 0x** 0x** 0x** 0x** 0x** 0x**
0x07 0x00 0x00 0x00 0x00 0x10 0x00 0x00

Заполнение пробелов

Хотя мы уже выполнили большую часть тяжелой работы, нам все еще нужно сделать несколько сложных вещей. У нас есть заголовок ELF и таблица программ, которые мы можем разместить в начале нашего файла, и у нас есть полезная нагрузка для нашей реальной программы, но нам все еще нужно поместить что-то в таблицу, которая сообщает компьютеру, где это найти. И нам нужно разместить нашу полезную нагрузку в файле, чтобы ее можно было найти.

Во-первых, мы хотим вычислить размер наших заголовков и полезной нагрузки, прежде чем мы сможем определить какие-либо смещения. Просто сложите вместе размеры всех полей в заголовках и получите минимальное смещение для любого из сегментов. В заголовке ELF 116 байт + 2 заголовка программы, и 116 = 0x74, поэтому минимальное смещение равно 0x74. Чтобы сделать это безопасно, давайте установим начальное смещение на 0x80. Заполните от 0x74 до 0x7F 0x00, затем поместите текстовый сегмент в 0x80 в файл.

Размер самого текстового сегмента составляет 34 = 0x22 байта, что означает, что минимальное смещение для сегмента данных составляет 0x80 + 0x22 = 0xA2. Поместим сегмент данных в 0xA4 и заполним 0xA2 и 0xA3 значениями 0x00.

Если вы делали все вышеперечисленное в своем шестнадцатеричном редакторе, теперь у вас будет двоичный файл, содержащий ELF, и заголовки программ от 0x00 до 0x73, от 0x74 до 0x7F будут заполнены нулями, текстовый сегмент размещен от 0x80 до 0xA1, 0xA2 и 0xA3 снова являются нулями, и сегмент данных идет от 0xA4 до 0xB0. Если вы следуете этим инструкциям, и не получаете правильного результата, сейчас самое время посмотреть, что пошло не так.

Предполагая, что теперь все находится в нужном месте в файле, пора изменить некоторые из наших предыдущих звездочек на фактические значения. Я просто сначала дам вам значения для каждого параметра, а затем объясню, почему мы используем именно эти значения.

e_entry (4) - 0x80 0x80 0x04 0x08; Мы выберем 0x8048080 в качестве точки входа в виртуальной памяти. Существуют некоторые правила относительно того, что вы можете, а что не можете выбрать в качестве точки входа, но самое важное, что нужно помнить, - это то, что начальный адрес виртуальной памяти по модулю размера страницы должен быть равен смещению в файле по модулю размера страницы. Вы можете проверить это в справочнике по ELF и некоторым другим хорошие книгам для получения дополнительной информации, но если это кажется слишком сложным, просто забудьте об этом и используйте эти значения.

p_offset (4) - 0x80 0x00 0x00 0x00 для текста, 0xA4 0x00 0x00 0x00 для данных. Это из-за очевидной причины, по которой эти сегменты находятся в файле.

p_vaddr (4) - 0x80 0x80 0x04 0x08 для текста, 0xA4 0x80 0x04 0x08 для данных. Мы хотим, чтобы сегмент текста был точкой входа для программы, и мы помещаем сегмент данных в память таким образом, чтобы он прямо соответствовал физическим смещениям.

p_filesz (4) - 0x24 0x00 0x00 0x00 для текста, 0x20 0x00 0x00 0x00 для данных. Это просто байтовые размеры различных сегментов файла и памяти. В этом случае p_memsz = p_filesz, поэтому используйте те же значения там.

Окончательный результат

Если вы выполнили все до буквы, вот что вы получите, если выгрузите все в шестнадцатеричном формате:

7F 45 4C 46 01 01 01 00 00 00 00 00 00 00 00 10 02 00 03 00
01 00 00 00 80 80 04 08 34 00 00 00 00 00 00 00 00 00 00 00
34 00 20 00 02 00 00 00 00 00 00 00 01 00 00 00 80 00 00 00
80 80 04 08 00 00 00 00 24 00 00 00 24 00 00 00 05 00 00 00
00 10 00 00 01 00 00 00 A4 00 00 00 A4 80 04 08 00 00 00 00
20 00 00 00 20 00 00 00 07 00 00 00 00 10 00 00 00 00 00 00
00 00 00 00 00 00 00 00 BB 01 00 00 00 B8 04 00 00 00 B9 A4
80 04 08 BA 0D 00 00 00 CD 80 B8 01 00 00 00 BB 2A 00 00 00
CD 80 00 00 48 65 6C 6C 6F 20 57 6F 72 6C 64 21 0A

Вот и все. Запустите chmod +x для этого двоичного файла, а затем выполните его. Hello World в 178 байтах. Надеюсь, вам понравилось это писать. :-) Если вы считаете этот HOWTO полезным или интересным, дайте мне знать! Я всегда это ценю. Также всегда приветствуются советы, комментарии и / или конструктивная критика.

Tags:
Hubs:
Total votes 26: ↑25 and ↓1+24
Comments6

Articles