Создаем EXE

Самоизоляция это отличное время приступить к тому, что требует много времени и сил. Поэтому я решил заняться тем, чем всегда хотел — написать свой компилятор.

Сейчас он способен собрать Hello World, но в этой статье я хочу рассказать не про парсинг и внутреннее устройство компилятора, а про такую важную часть как побайтовая сборка exe файла.

Начало


Хотите спойлер? Наша программа будет занимать 2048 байт.

Обычно работа с exe файлами заключается в изучении или модификации их структуры. Сами же исполняемые файлы при этом формируют компиляторы, и этот процесс кажется немного магическим для разработчиков.

Но сейчас мы с вами попробуем это исправить!

Для сборки нашей программы нам потребуется любой HEX редактор (лично я использовал HxD).

Для старта возьмем псевдокод:

Исходный код
func MessageBoxA(u32 handle, PChar text, PChar caption, u32 type) i32 ['user32.dll']
func ExitProcess(u32 code) ['kernel32.dll']

func main()
{
	MessageBoxA(0, 'Hello World!', 'MyApp', 64)
	ExitProcess(0)
}


Первые две строки указывают на функции импортируемые из библиотек WinAPI. Функция MessageBoxA выводит диалоговое окно с нашим текстом, а ExitProcess сообщает системе о завершении программы.
Рассматривать отдельно функцию main нет смысла, так как в ней используются функции, описанные выше.

DOS Header


Для начала нам нужно сформировать корректный DOS Header, это заголовок для DOS программ и влиять на запуск exe под Windows не должен.

Более-менее важные поля я отметил, остальные заполнены нулями.

Стуктура IMAGE_DOS_HEADER
Struct IMAGE_DOS_HEADER
{
     u16 e_magic	// 0x5A4D	"MZ"
     u16 e_cblp		// 0x0080	128
     u16 e_cp		// 0x0001	1
     u16 e_crlc
     u16 e_cparhdr	// 0x0004	4
     u16 e_minalloc	// 0x0010	16
     u16 e_maxalloc	// 0xFFFF	65535
     u16 e_ss
     u16 e_sp		// 0x0140	320
     u16 e_csum		
     u16 e_ip
     u16 e_cs
     u16 e_lfarlc	// 0x0040	64
     u16 e_ovno
     u16[4] e_res
     u16 e_oemid
     u16 e_oeminfo
     u16[10] e_res2
     u32 e_lfanew	// 0x0080	128
}


Самое главное, что этот заголовок содержит поле e_magic означающее, что это исполняемый файл, и e_lfanew — указывающее на смещение PE-заголовка от начала файла (в нашем файле это смещение равно 0x80 = 128 байт).

Отлично, теперь, когда нам известна структура заголовка DOS Header запишем ее в наш файл.

(1) RAW DOS Header (Offset 0x00000000)
4D 5A 80 00 01 00 00 00  04 00 10 00 FF FF 00 00
40 01 00 00 00 00 00 00  40 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00  00 00 00 00 80 00 00 00



Уточнение

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

Поэтому для удобства в первой скобке каждого блока указан порядок добавления в файл, а в последней смещение в файле (Offset) по которому должен располагаться данный блок.

Например, первый блок мы вставляем по смещению 0x00000000, и он займет 64 байта (0x40 в 16-ричной системе), следующий блок мы будем вставлять уже по этому смещению 0x00000040 и т.д.
Готово, первые 64 байта записали. Теперь нужно добавить еще 64, это так называемый DOS Stub (Заглушка). Во время запуска из-под DOS, она должна уведомить пользователя что программа не предназначена для работы в этом режиме.

Но в целом, это маленькая программа под DOS которая выводит строку и выходит из программы.
Запишем наш Stub в файл и рассмотрим его детальнее.

(2) RAW DOS Stub (Offset 0x00000040)
0E 1F BA 0E 00 B4 09 CD  21 B8 01 4C CD 21 54 68
69 73 20 70 72 6F 67 72  61 6D 20 63 61 6E 6E 6F
74 20 62 65 20 72 75 6E  20 69 6E 20 44 4F 53 20
6D 6F 64 65 2E 0D 0A 24  00 00 00 00 00 00 00 00



А теперь этот же код, но уже в дизассемблированном виде

Asm DOS Stub
0000	push cs			; Запоминаем Code Segment(CS) (где мы находимся в памяти)
0001	pop ds			; Указываем что Data Segment(DS) = CS
0002	mov dx, 0x0E	; Указываем адрес начала строки DS+DX, которая будет выводиться до символа $(Конец строки) 
0005	mov ah, 0x09	; Номер инструкции (Вывод строки)
0007	int 0x21		; Вызов системного прерывания 0x21
0009	mov ax, 0x4C01	; Номер инструкции 0x4C (Выход из программы) 
						; Код выхода из программы 0x01 (Неудача)
000c	int 0x21		; Вызов системного прерывания 0x21
000e	"This program cannot be run in DOS mode.\x0D\x0A$" ; Выводимая строка


Это работает так: сначала заглушка выводит строку о том, что программа не может быть запущена, а затем выходит из программы с кодом 1. Что отличается от нормального завершения (Код 0).

Код заглушки может немного отличатся (от компилятора к компилятору) я сравнивал gcc и delphi, но общий смысл одинаковый.

А еще забавно, что строка заглушки заканчивается как \x0D\x0D\x0A$. Скорее всего причина такого поведения в том, что c++ по умолчанию открывает файл в текстовом режиме. В результате символ \x0A заменяется на последовательность \x0D\x0A. В результате получаем 3 байта: 2 байта возврата каретки Carriage Return (0x0D) что бессмысленно, и 1 на перевод строки Line Feed (0x0A). В бинарном режиме записи (std::ios::binary) такой подмены не происходит.

Для проверки корректности записи значений я буду использовать Far с плагином ImpEx:



NT Header


Спустя 128 (0x80) байт мы добрались до NT заголовка (IMAGE_NT_HEADERS64), который содержит в себе и PE заголовок (IMAGE_OPTIONAL_HEADER64). Несмотря на название IMAGE_OPTIONAL_HEADER64 является обязательным, но различным для архитектур x64 и x86.

Структура IMAGE_NT_HEADERS64
Struct IMAGE_NT_HEADERS64
{
	u32 Signature	// 0x4550 "PE"
	
	Struct IMAGE_FILE_HEADER 
	{
		u16 Machine	// 0x8664 архитектура x86-64
		u16 NumberOfSections	// 0x03 Количество секций в файле 
		u32 TimeDateStamp		// Дата создания файла
		u32 PointerToSymbolTable
		u32 NumberOfSymbols
		u16 SizeOfOptionalHeader // Размер IMAGE_OPTIONAL_HEADER64 (Ниже)
		u16 Characteristics	// 0x2F 
	}
	
	Struct IMAGE_OPTIONAL_HEADER64
	{
		u16 Magic	// 0x020B Указывает что наш заголовок для PE64
		u8 MajorLinkerVersion
		u8 MinorLinkerVersion
		u32 SizeOfCode
		u32 SizeOfInitializedData
		u32 SizeOfUninitializedData	
		u32 AddressOfEntryPoint	// 0x1000 
		u32 BaseOfCode	// 0x1000 
		u64 ImageBase	// 0x400000 
		u32 SectionAlignment	// 0x1000 (4096 байт)
		u32 FileAlignment	// 0x200
		u16 MajorOperatingSystemVersion	// 0x05	Windows XP
		u16 MinorOperatingSystemVersion	// 0x02	Windows XP
		u16 MajorImageVersion
		u16 MinorImageVersion
		u16 MajorSubsystemVersion	// 0x05	Windows XP
		u16 MinorSubsystemVersion	// 0x02	Windows XP
		u32 Win32VersionValue
		u32 SizeOfImage	// 0x4000
		u32 SizeOfHeaders // 0x200 (512 байт)
		u32 CheckSum
		u16 Subsystem	// 0x02 (GUI) или 0x03 (Console)
		u16 DllCharacteristics
		u64 SizeOfStackReserve	// 0x100000
		u64 SizeOfStackCommit	// 0x1000
		u64 SizeOfHeapReserve	// 0x100000
		u64 SizeOfHeapCommit	// 0x1000
		u32 LoaderFlags
		u32 NumberOfRvaAndSizes // 0x16 
		
		Struct IMAGE_DATA_DIRECTORY [16] 
		{
			u32 VirtualAddress
			u32 Size
		}
	}
}


Разберемся что хранится в этой структуре:

Описание IMAGE_NT_HEADERS64
Signature — Указывает на начало структуры PE заголовка

Далее идет заголовок IMAGE_FILE_HEADER общий для архитектур x86 и x64.

Machine — Указывает для какой архитектуры предназначен код в нашем случае для x64
NumberOfSections — Количество секции в файле (О секциях чуть ниже)
TimeDateStamp — Дата создания файла
SizeOfOptionalHeader — Указывает размер следующего заголовка IMAGE_OPTIONAL_HEADER64, ведь он может быть заголовком IMAGE_OPTIONAL_HEADER32.

Characteristics — Здесь мы указываем некоторые атрибуты нашего приложения, например, что оно является исполняемым (EXECUTABLE_IMAGE) и может работать более чем с 2 Гб RAM (LARGE_ADDRESS_AWARE), а также что некоторая информация была удалена (на самом деле даже не была добавлена) в файл (RELOCS_STRIPPED | LINE_NUMS_STRIPPED | LOCAL_SYMS_STRIPPED).

SizeOfCode — Размер исполняемого кода в байтах (секция .text)
SizeOfInitializedData — Размер инициализированных данных (секция .rodata)
SizeOfUninitializedData — Размер не инициализированных данных (секция .bss)
BaseOfCode — указывает на начало секции кода блок
SectionAlignment — Размер по которому нужно выровнять секции в памяти
FileAlignment — Размер по которому нужно выровнять секции внутри файла
SizeOfImage — Размер всех секций программы
SizeOfHeaders — Размер всех заголовков вместе (IMAGE_DOS_HEADER, DOS Stub, IMAGE_NT_HEADERS64, IMAGE_SECTION_HEADER[IMAGE_FILE_HEADER.NumberOfSections]) выровненный по FileAlignment
Subsystem — Указывает тип нашей программы GUI или Console
MajorOperatingSystemVersion, MinorOperatingSystemVersion, MajorSubsystemVersion, MinorSubsystemVersion — Говорят о том на какой системе можно запускать данный exe, и что он может поддерживать. В нашем случае мы берем значение 5.2 от Windows XP (x64).
SizeOfStackReserve — Указывает сколько приложению нужно зарезервировать памяти под стек. Этот параметр по умолчанию составляет 1 Мб, максимально можно указать 1Гб. Вроде как умные программы на Rust умеют считать необходимый размер стека, в отличии от программ на C++ где этот размер нужно править вручную.
SizeOfStackCommit — Размер по умолчанию составляет 4 Кб. Как должен работать данный параметр пока не разобрался.
SizeOfHeapReserve — Указывает сколько резервировать памяти под кучу. Равен 1 Мб по умолчанию.
SizeOfHeapCommit — Размер по умолчанию равен 4 Кб. Подозреваю что работает аналогично SizeOfStackCommit, то есть пока неизвестно как.

IMAGE_DATA_DIRECTORY — массив записей о каталогах. В теории его можно уменьшить, сэкономив пару байт, но вроде как все описывают все 16 полей даже если они не нужны. А теперь чуть подробнее.

У каждого каталога есть свой номер, который описывает, где хранится его содержимое. Пример:
Export(0) — Содержит ссылку на сегмент который хранит экспортируемые функции. Для нас это было бы актуально если бы мы создавали DLL. Как это примерно должно работать можно посмотреть на примере следующего каталога.

Import(1) — Этот каталог указывает на сегмент с импортируемыми функциями из других DLL. В нашем случае значения VirtualAddress = 0x3000 и Size = 0xB8. Это единственный каталог, который мы опишем.

Resource(2) — Каталог с ресурсами программы (Изображения, Текст, Файлы и т.д.)
Значения других каталогов можно посмотреть в документации.

Теперь, когда мы посмотрели из чего состоит NT-заголовок, запишем и его в файл по аналогии с остальными по адресу 0x80.

(3) RAW NT-Header (Offset 0x00000080)
50 45 00 00 64 86 03 00  F4 70 E8 5E 00 00 00 00
00 00 00 00 F0 00 2F 00  0B 02 00 00 3D 00 00 00
13 00 00 00 00 00 00 00  00 10 00 00 00 10 00 00
00 00 40 00 00 00 00 00  00 10 00 00 00 02 00 00
05 00 02 00 00 00 00 00  05 00 02 00 00 00 00 00
00 40 00 00 00 02 00 00  00 00 00 00 02 00 00 00
00 00 10 00 00 00 00 00  00 10 00 00 00 00 00 00
00 00 10 00 00 00 00 00  00 10 00 00 00 00 00 00
00 00 00 00 10 00 00 00  00 00 00 00 00 00 00 00
00 30 00 00 B8 00 00 00  00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00


В результате получаем вот такой вид IMAGE_FILE_HEADER, IMAGE_OPTIONAL_HEADER64 и IMAGE_DATA_DIRECTORY заголовков:







Далее описываем все секции нашего приложения согласно структуре IMAGE_SECTION_HEADER

Структура IMAGE_SECTION_HEADER
Struct IMAGE_SECTION_HEADER
{
	i8[8] Name
	u32 VirtualSize
	u32 VirtualAddress
	u32 SizeOfRawData
	u32 PointerToRawData
	u32 PointerToRelocations
	u32 PointerToLinenumbers
	u16 NumberOfRelocations
	u16 NumberOfLinenumbers
	u32 Characteristics
}


Описание IMAGE_SECTION_HEADER
Name — имя секции из 8 байт, может быть любым
VirtualSize — сколько байт копировать из файла в память
VirtualAddress — адрес секции в памяти выровненный по SectionAlignment
SizeOfRawData — размер сырых данных выровненных по FileAlignment
PointerToRawData — адрес секции в файле выровненный по FileAlignment
Characteristics — Указывает какие данные хранит секция (Код, инициализированные или нет данные, для чтения, для записи, для исполнения и др.)

В нашем случае у нaс будет 3 секции.

Почему Virtual Address (VA) начинается с 1000, а не с нуля я не знаю, но так делают все компиляторы, которые я рассматривал. В результате 1000 + 3 секции * 1000 (SectionAlignment) = 4000 что мы и записали в SizeOfImage. Это полный размер нашей программы в виртуальной памяти. Вероятно, используется для выделения места под программу в памяти.

 Name	| RAW Addr	| RAW Size	| VA	| VA Size | Attr
--------+---------------+---------------+-------+---------+--------
.text	| 200		| 200		| 1000	| 3D	  |   CER
.rdata	| 400		| 200		| 2000	| 13	  | I   R
.idata	| 600		| 200		| 3000	| B8	  | I   R

Расшифровка атрибутов:

I — Initialized data, инициализированные данные
U — Uninitialized data, не инициализированные данные
C — Code, содержит исполняемый код
E — Execute, позволяет исполнять код
R — Read, позволяет читать данные из секции
W — Write, позволяет записывать данные в секцию

.text (.code) — хранит в себе исполняемый код (саму программу), атрибуты CE
.rdata (.rodata) — хранит в себе данные только для чтения, например константы, строки и т.п., атрибуты IR
.data — хранит данные которые можно читать и записывать, такие как статические или глобальные переменные. Атрибуты IRW
.bss — хранит не инициализированные данные, такие как статические или глобальные переменные. Кроме того, данная секция обычно имеет нулевой RAW размер и ненулевой VA Size, благодаря чему не занимает места в файле. Атрибуты URW
.idata — секция содержащая в себе импортируемые из других библиотек функции. Атрибуты IR

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

Теперь, когда нам известно какие секции будет содержать наша программа запишем их в наш файл. Тут смещение оканчивается на 8 и запись будет начинаться с середины файла.

(4) RAW Sections (Offset 0x00000188)
                         2E 74 65 78 74 00 00 00
3D 00 00 00 00 10 00 00  00 02 00 00 00 02 00 00
00 00 00 00 00 00 00 00  00 00 00 00 20 00 00 60
2E 72 64 61 74 61 00 00  13 00 00 00 00 20 00 00
00 02 00 00 00 04 00 00  00 00 00 00 00 00 00 00
00 00 00 00 40 00 00 40  2E 69 64 61 74 61 00 00
B8 00 00 00 00 30 00 00  00 02 00 00 00 06 00 00
00 00 00 00 00 00 00 00  00 00 00 00 40 00 00 40



Следующий адрес для записи будет 00000200 что соответствует полю SizeOfHeaders PE-Заголовка. Если бы мы добавили еще одну секцию, а это плюс 40 байт, то наши заголовки не уложились бы в 512 (0x200) байт и пришлось бы использовать уже 512+40 = 552 байта выровненные по FileAlignment, то есть 1024 (0x400) байта. А все что останется от 0x228 (552) до адреса 0x400 нужно чем-то заполнить, лучше конечно нулями.

Взглянем как выглядит блок секций в Far:



Далее мы запишем в наш файл сами секции, но тут есть один нюанс.

Как вы могли заметить на примере SizeOfHeaders, мы не можем просто записать заголовок и перейти к записи следующего раздела. Так как что бы записать заголовок мы должны знать сколько займут все заголовки вместе. В результате нам нужно либо посчитать заранее сколько понадобиться места, либо записать пустые (нулевые) значения, а после записи всех заголовков вернуться и записать уже их реальный размер.

Поэтому программы компилируются в несколько проходов. Например секция .rdata идет после секции .text, при этом мы не можем узнать виртуальный адрес переменной в .rdata, ведь если секция .text разрастется больше чем на 0x1000 (SectionAlignment) байт, она займет адреса 0x2000 диапазона. И соответственно секция .rdata будет находиться уже не в адресе 0x2000, а в адресе 0x3000. И нам будет необходимо вернуться и пересчитать адреса всех переменных в секции .text которая идет перед .rdata.

Но в данном случае я уже все рассчитал, поэтому будем сразу записывать блоки кода.

Секция .text


Asm segment .text
0000	push rbp
0001	mov rbp, rsp
0004	sub rsp, 0x20
0008	mov rcx, 0x0
000F	mov rdx, 0x402000
0016	mov r8, 0x40200D
001D	mov r9, 0x40
0024	call QWORD PTR [rip + 0x203E]
002A	mov rcx, 0x0
0031	call QWORD PTR [rip + 0x2061]
0037	add rsp, 0x20
003B	pop rbp
003C	ret


Конкретно для этой программы первые 3 строки, ровно, как и 3 последние не обязательны.
Последние 3 даже не будут исполнены, так как выход из программы произойдет еще на второй функции call.

Но скажем так, если бы это была не функция main, а подфункция следовало бы сделать именно так.

А вот первые 3 в данном случае хоть и не обязательны, но желательны. Например, если бы мы использовали не MessageBoxA, а printf то без этих строк получили бы ошибку.

Согласно соглашению о вызовах для 64-разрядных систем MSDN, первые 4 параметра передаются в регистрах RCX, RDX, R8, R9. Если они туда помещаются и не являются, например числом с плавающей точкой. А остальные передаются через стек.

По идее если мы передаем 2 аргумента функции, то должны передать их через регистры и зарезервировать под них два места в стеке, что бы при необходимости функция могла скинуть регистры в стек. Так же мы не должны рассчитывать, что нам вернут эти регистры в исходном состоянии.

Так вот проблема функции printf заключается в том, что, если мы передаем ей всего 1 аргумент, она все равно перезапишет все 4 места в стеке, хотя вроде бы должна перезаписать только одно, по количеству аргументов.

Поэтому если не хотите, чтобы программа себя странно вела, всегда резервируйте как минимум 8 байт * 4 аргумента = 32(0x20) байт, если передаете функции хотя бы 1 аргумент.

Рассмотрим блок кода с вызовами функций

MessageBoxA(0, 'Hello World!', 'MyApp', 64)
ExitProcess(0)

Сначала мы передаем наши аргументы:

rcx = 0
rdx = абсолютный адрес строки в памяти ImageBase + Sections[".rdata"].VirtualAddress + Смещение строки от начала секции, строка читается до нулевого байта
r8 = аналогично предыдущему
r9 = 64(0x40) MB_ICONINFORMATION, значок информации

А далее идет вызов функции MessageBoxA, с которым не все так просто. Дело в том, что компиляторы стараются использовать как можно более короткие команды. Чем меньше размер команды, тем больше таких команд влезет в кэш процессора, соответственно, будет меньше промахов кэша, подзагрузок и выше скорость работы программы. Для более подробной информации по командам и внутренней работе процессора можно обратиться к документации Intel 64 and IA-32 Architectures Software Developer’s Manuals.

Мы могли бы вызвать функцию по полному адресу, но это заняло бы как минимум (1 опкод + 8 адрес = 9 байт), а с относительным адресом команда call занимает всего 6 байт.

Давайте взглянем на эту магию поближе: rip + 0x203E, это ни что иное, как вызов функции по адресу, указанному нашим смещением.

Я подсмотрел немного вперед и узнал адреса нужных нам смещений. Для MessageBoxA это 0x3068, а для ExitProcess это 0x3098.

Пора превратить магию в науку. Каждый раз, когда опкод попадает в процессор, он высчитывает его длину и прибавляет к текущему адресу инструкции (RIP). Поэтому, когда мы используем RIP внутри инструкции, этот адрес указывает на конец текущей инструкции / начало следующей.
Для первого call смещение будет указывать на конец команды call это 002A не забываем что в памяти этот адрес будет по смещению Sections[".text"].VirtualAddress, т.е. 0x1000. Следовательно, RIP для нашего call будет равен 102A. Нужный нам адрес для MessageBoxA находится по адресу 0x3068. Считаем 0x3068 — 0x102A = 0x203E. Для второго адреса все аналогично 0x1000 + 0x0037 = 0x1037, 0x3098 — 0x1037 = 0x2061.

Именно эти смещения мы и видели в командах ассемблера.

0024	call QWORD PTR [rip + 0x203E]
002A	mov rcx, 0x0
0031	call QWORD PTR [rip + 0x2061]
0037	add rsp, 0x20

Запишем в наш файл секцию .text, дополнив нулями до адреса 0x400:

(5) RAW .text section (Offset 0x00000200-0x00000400)
55 48 89 E5 48 83 EC 20  48 C7 C1 00 00 00 00 48
C7 C2 00 20 40 00 49 C7  C0 0D 20 40 00 49 C7 C1
40 00 00 00 FF 15 3E 20  00 00 48 C7 C1 00 00 00
00 FF 15 61 20 00 00 48  83 C4 20 5D C3 00 00 00
........
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00

Хочется отметить что всего лишь 4 строки реального кода содержат весь наш код на ассемблере. А все остальное нули что бы набрать FileAlignment. Последней строкой заполненной нулями будет 0x000003F0, после идет 0x00000400, но это будет уже следующий блок. Итого в файле уже 1024 байта, наша программа весит уже целый Килобайт! Осталось совсем немного и ее можно будет запустить.


Секция .rdata


Это, пожалуй, самая простая секция. Мы просто положим сюда две строки добив нулями до 512 байт.

.rdata
0400	"Hello World!\0"
040D	"MyApp\0"


(6) RAW .rdata section (Offset 0x00000400-0x00000600)
48 65 6C 6C 6F 20 57 6F  72 6C 64 21 00 4D 79 41
70 70 00 00 00 00 00 00  00 00 00 00 00 00 00 00
........
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00


Секция .idata


Ну вот осталась последняя секция, которая описывает импортируемые функции из библиотек.

Первое что нас ждет новая структура IMAGE_IMPORT_DESCRIPTOR

Структура IMAGE_IMPORT_DESCRIPTOR
Struct IMAGE_IMPORT_DESCRIPTOR
{
	u32 OriginalFirstThunk (INT)
	u32 TimeDateStamp
	u32 ForwarderChain
	u32 Name
	u32 FirstThunk (IAT)
}


Описание IMAGE_IMPORT_DESCRIPTOR
OriginalFirstThunk — Адрес указывает на список имен импортируемых функций, он же Import Name Table (INT)
Name — Адрес, указывающий на название библиотеки
FirstThunk — Адрес указывает на список адресов импортируемых функций, он же Import Address Table (IAT)

Для начала нам нужно добавить 2 импортируемых библиотеки. Напомним:

func MessageBoxA(u32 handle, PChar text, PChar caption, u32 type) i32 ['user32.dll']
func ExitProcess(u32 code) ['kernel32.dll']

(7) RAW IMAGE_IMPORT_DESCRIPTOR (Offset 0x00000600)
58 30 00 00 00 00 00 00  00 00 00 00 3C 30 00 00
68 30 00 00 88 30 00 00  00 00 00 00 00 00 00 00
48 30 00 00 98 30 00 00  00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00  00 00 00 00


У нас используется 2 библиотеки, а что бы сказать что мы закончили их перечислять. Последняя структура заполняется нулями.

 INT	| Time	 | Forward  | Name   | IAT
--------+--------+----------+--------+--------
0x3058	| 0x0    | 0x0      | 0x303C | 0x3068
0x3088	| 0x0    | 0x0      | 0x3048 | 0x3098
0x0000	| 0x0    | 0x0      | 0x0000 | 0x0000

Теперь добавим имена самих библиотек:

Имена библиотек
063С	"user32.dll\0"
0648	"kernel32.dll\0"


(8) RAW имена библиотек (Offset 0x0000063С)
                                     75 73 65 72
33 32 2E 64 6C 6C 00 00  6B 65 72 6E 65 6C 33 32
2E 64 6C 6C 00 00 00 00


Далее опишем библиотеку user32:

(9) RAW user32.dll (Offset 0x00000658)
                         78 30 00 00 00 00 00 00 
00 00 00 00 00 00 00 00  78 30 00 00 00 00 00 00
00 00 00 00 00 00 00 00  00 00 4D 65 73 73 61 67 
65 42 6F 78 41 00 00 00


Поле Name первой библиотеки указывает на 0x303C если мы посмотрим чуть выше, то увидим что по адресу 0x063C находится библиотека «user32.dll\0».

Подсказка, вспомните что секция .idata соответствует смещению в файле 0x0600, а в памяти 0x3000. Для первой библиотеки INT равен 3058, значит в файле это будет смещение 0x0658. По этому адресу видим запись 0x3078 и вторую нулевую. Означающую конец списка. 3078 ссылается на 0x0678 это RAW-строка

«00 00 4D 65 73 73 61 67 65 42 6F 78 41 00 00 00»

Первые 2 байта нас не интересуют и равны нулю. А вот дальше идет строка с названием функции, заканчивающаяся нулем. То есть мы можем представить её как "\0\0MessageBoxA\0".

При этом IAT ссылается на аналогичную таблице IAT структуру, но только в нее при запуске программы будут загружены адреса функций. Например, для первой записи 0x3068 в памяти будет значение отличное от значения 0x0668 в файле. Там будет адрес функции MessageBoxA загруженный системой к которому мы и будем обращаться через вызов call в коде программы.

И последний кусочек пазла, библиотека kernel32. И не забываем добить нулями до SectionAlignment.

(10) RAW kernel32.dll (Offset 0x00000688-0x00000800)
                         A8 30 00 00 00 00 00 00 
00 00 00 00 00 00 00 00  A8 30 00 00 00 00 00 00 
00 00 00 00 00 00 00 00  00 00 45 78 69 74 50 72 
6F 63 65 73 73 00 00 00  00 00 00 00 00 00 00 00 
........
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00



Проверяем что Far смог корректно определить какие функции мы импортировали:



Отлично! Все нормально определилось, значит теперь наш файл готов к запуску.
Барабанная дробь…

Финал




Поздравляю, мы справились!

Файл занимает 2 Кб = Заголовки 512 байт + 3 секции по 512 байт.

Число 512(0x200) ни что иное, как FileAlignment, который мы указали в заголовке нашей программы.

Дополнительно:
Если хочется вникнуть чуть глубже, можно заменить надпись «Hello World!» на что-нибудь другое, только не забудьте изменить адрес строки в коде программы (секция .text). Адрес в памяти 0x00402000, но в файле будет обратный порядок байт 00 20 40 00.

Или квест чуть сложнее. Добавить в код вызов ещё одного MessageBox. Для этого придется скопировать предыдущий вызов, и пересчитать в нем относительный адрес (0x3068 — RIP).

Заключение


Статья получилась достаточно скомканной, ей бы, конечно, состоять из 3 отдельных частей: Заголовки, Программа, Таблица импорта.

Если кто-то собрал свой exe значит мой труд был не напрасен.

Думаю в скором времени создать ELF файл похожим образом, интересна ли будет такая статья?)

Ссылки:

Комментарии 48

    +2
    Почему Virtual Address (VA) начинается с 1000, а не с нуля я не знаю, но так делают все компиляторы, которые я рассматривал.

    Видимо потому, что в ImageBase (в памяти) должен располагаться заголовок. В экзотических случаях заголовок может занимать две страницы (больше 4кб).

      0
      по адресу 0 ставят страницу с защитой от записи. чтобы ловить переход на null указатели.
        +2

        В данном контексте (IMAGE_SECTION_HEADER.VirtualAddress) это RVA, то есть адрес относительно начала модуля в памяти

        +3

        Когда-то (во времена MS DOS), насколько помню, от нуля до 0x1000 располагались векторы прерываний. А код — с адреса 0x1000. В .exe файлах, а были еще .com, у последних такого требования не было, и размер их был хоть единицы байт, но не более 64 КБ.

          +1
          До 0x1000 лежала не только IVT, там ещё жила облать BDA (Bios Data Area), в которой были всякие настройки, конфигурация железа, буфер клавиатуры и прочее. А с COM файлами было всё просто — они не могли быть сегментированными, поэтому должны были влезать в один сегмент, размер которого и был 64кб.
            0
            Ну, был вариант линковки COM в два сегмента: 64к для кода и 64к для данных.
              0
              Это неправда. Ограничение в 64 КБ для COM подтверждается Чэнем: devblogs.microsoft.com/oldnewthing/20060130-00/?p=32483
                0
                Чень, конечно, авторитет, но я помню, как в годах 1993-4 создавал com-файлы из двух сегментов с помощью Zortech C++ 3.1. Ну, может чуть позже, давно было, не помню. Это было расширением компилятора, но DOS такому не препятствовал, а c0.obj все-равно, чем DS инициализировать и что в этот сегмент грузить. Ограничение на размер выполнимого файла вроде не было, это просто по умолчанию утилита debug СS и DS инициализировала одинаковыми значениями. Так что если нет дальних вызовов и обращений к памяти — все будет работать: код в CS загрузит системный загрузчик, а данные в DS перенесет код инициализации.
                  0
                  Ну это уже из разряда Unreal Mode на x86 — не описан, но работает.
                    0
                    И описан, и работает. Дело обычное — на ассемблере такие com-файлы делать неудобно, бо надо самому инициализировать .data. Поэтому и не задумывались. А когда стали писать резиденты на C, и не только резиденты… Сначала буфера ввода-вывода, а затем и сегмент данных переехали в другой сегмент. Надобилось нечасто, но это была общеупотребительная практика. Время оставило только «официальную» документацию…
                    А что такое Unreal Mode на x86?
                      +1
                      У него куча всяких других названий (Big Real, Flat Real). Это хитрый режим работы процессоров начиная с 80386, в котором процессор находится в 16 битном режиме, но ему доступны все 4 Гб памяти. Вход в него осуществляется переходом в защищённый режим с установкой сегмента размером в 4 Гб, на который ссылаются нужные сегментные регистры, после чего производится выход из защищённого режима. В результате получается казалось бы странная картина — нам в реальном режиме доступна вся память, надо только префикс 066h перед командами поставить (т.е. использовать всякие mov/push/pop и т.д. от защищённого режима). Работает за счёт того, что процессор для адресации использует теневые копии сегментных регистров и в них сохранены значения от защищённого режима. Но как только мы сделаем что-то типа pop ds, то по этому сегменту мы вылетим из нереального режима обратно в реальный. К примеру можно легко и непринуждённо прогрузить в память 20-30 мегабайт графики и потом спокойно использовать всё это в реальном режиме.
                    0
                    Вот скриншот из эмулятора:

                      0
                      Интересно, а под 6.22/7.0/7.1 досом оно вылетает также?
                        0
                        Сейчас перепроверил под PC-DOS 7.0 — да, так же.
                        –1
                        Вы путаете красное с мягким. И .text, и .data + .bss + буфера ввода-вывода не могут каждый превышать 64к. Но код инициализации C может установить DS и загрузить туда .data и все остальное. После этого доступ к данным будет осуществляться по DS и все будет работать. А в лоб, конечно, не получиться.
                        Просто я так делал и это работало. Картинок, жаль не осталось, но тогда и социальных сетей не было…
                          0
                          Если у вас в COM ещё и секции были, то это точно был не DOS.
                            –1
                            Вы еще скажите, что DOS-экстендеры 286 и 386 — не DOS. А теория Маркса правильна, потому что она верна, да.
                              +1
                              Вы могли забыть, что у COM даже заголовка нет, он грузится в память просто сплошным куском.
                                0
                                Опс, покопался в старом, насчет .data и .bss я нафантазировал: в отдельный сегмент переносились буфера stdin/stdout и куча.
                    0
                    Подтверждаю. В своём первом компиляторе Паскаля я генерировал файл COM с тремя сегментами: код, данные, стек.
                      0
                      Покажите COM размером больше 64 КБ, который бы запускался под DOS.
                        +1

                        Мне кажется, что речь идёт о файле, вмещающемся в один сегмент, но после запуска выделяющем себе ещё два сегмента под промежуточные результаты.

                          0
                          Вы про размер файла или про объём используемой памяти после загрузки? Файлов больше 64 кб быть не может. А примеров программ, занимавших больше 64 кб памяти, у меня в избытке. Посмотрите скриншоты с графиками и фракталами в моём посте по ссылке — это программы в формате COM, запущенные в DOS Box. Раньше они запускались и в штатном NTVDM в Windows.
                          0
                          У настоящего COM-а нет сегментов вообще. Чисто в теории можно создать COM, который при открытии сам сделает себе сегменты и загрузит туда то, что надо, обойдя при этом загрузчик из состава XX-DOS (который поидее должен ругаться и посылать всё >64Kb COM). Но это будет такой-же .com файл, что и rar архив, переименованный в .jpg
                            0
                            Примерно это я и делал. Только обходить загрузчик для этого вовсе необязательно. После того, как он отработал, я волен распоряжаться сегментными регистрами по своему усмотрению. Ни NTVDM, ни DOS Box не жаловался. И вообще, странно предполагать, будто кто-то будет следить за моими манипуляциями с сегментными регистрами после завершения работы загрузчика.
                              +1
                              Это да, но надо, чтобы изначальный размер был менее 64Кб, иначе DOS пошлёт, как было показано на скриншотах в комментариях. И можно дальше пойти по принципу «proga.com + proga.ovl», где .com будет простым мелким загрузчиком, который раскидает .ovl по памяти. Но возникает вопрос: если у нас нет чего-то типа CP/M86 — зачем заниматься извращениями с .com, если можно сделать .exe?
                                0
                                Это уже другой вопрос. Сначала — просто не хотелось заморачиваться с заполнением заголовков. Потом аппетиты выросли, но файл так и остался COM — однако уже многосегментный, да ещё и с 32-битным кодом (в смысле регистров, а не адресов). Затем родилась самокомпилируемая версия под Windows. А теперь — вообще новый проект с компиляцией в байт-код.
                  +5
                  Хорошая статья, мне бы эта информация очень пригодилась когда я разбирался с этой темой для своего компилятора, разработка которого кстати ведется полностью на видео (на англ.).

                  Поэтому программы компилируются в несколько проходов. Например секция .rdata идет после секции .text, при этом мы не можем узнать виртуальный адрес переменной в .rdata, ведь если секция .text разрастется больше чем на 0x1000 (SectionAlignment) байт, она займет адреса 0x2000 диапазона. И соответственно секция .rdata будет находиться уже не в адресе 0x2000, а в адресе 0x3000. И нам будет необходимо вернуться и пересчитать адреса всех переменных в секции .text которая идет перед .rdata.

                  Писать секции в файл не обязательно в этом порядке. Я в итоге сделал так что .data пишется первой и тогда всё расчитывается в один проход, просто RIP-относительный оффсет получится отрицательным.
                    +1
                    Спасибо за интересную подсказку с одним проходом.

                    Порядок секций в файле может быть любым, но насколько я помню он должен совпадать с порядком в IMAGE_SECTION_HEADER (хотя вот это может быть и не критично), а вот что точно должно соблюдаться это порядок VA.

                    Если секция с VA = 0x2000 шла перед 0x1000 то exe переставал запускаться.
                    0
                    Спасибо за статью. Для полноты еще нужен свой лоадер ;)
                      0
                      Помнится, получалось и в 1кб ужаться, из функционала - только код возврата)
                      image

                      На просторах интернета находил и совсем фантастические варианты (97 байт?!), но такое запускается при ну очень специфических условиях
                      • НЛО прилетело и опубликовало эту надпись здесь
                          +2
                          Только что проверил на W10.
                          Файлы размером 1 Кб и 973 байта спокойно запускаются.
                            0
                            Минимальное значение выравнивания не мешает открывать файлы меньше этого значения :-)
                            • НЛО прилетело и опубликовало эту надпись здесь
                            +2
                            +2
                            батюшки думал pe header в прошлом для хабира!
                            • НЛО прилетело и опубликовало эту надпись здесь
                                +2
                                На самом деле ещё меньше, 268 байт для современных Windows. Это будет файл, в котором и код, и данные, и заголовки PE-файла находятся вместе, в «нулевой» секции (там где обычно только заголовки).
                                +3
                                Всё-таки создание EXE это задача линкера, а не компилятора.
                                  0
                                  Согласен, задача компилятора преобразовать исходный код в машинный, а задача линкера собрать из кусочков машинного кода исполняемый файл.

                                  В моем компиляторе всего один файл который занимается и компиляцией и линковкой.
                                  Поэтому везде и написано компилятор, хотя внутри у него есть отдельный модуль с названием Linker.
                                    0
                                    Наверное лучше всё таки разделить компиляцию и компоновку, это позволит проще (в плане масштабируемости и совместимости в будущем) решать проблемы размещения данных в секциях и определения смещения к ним в коде. Например, генерить relocation'ы смещений в секциях, которые компоновщик исправит потом на актуальные, как это сделано в объектниках COFF.
                                    image
                                    Конечно придётся ещё и таблицу символов эмитить, но кмк это в целом будет иметь бОльшую практическую пользу, чем генератор PE/ELF из хелоувордов. Реализация формата не сложна как со стороны компилятора, так и со стороны компоновщика, можно даже в сорсы llvm не лазить.
                                    С этим уже можно подойти и к совместимости с другими инструментами. Сделать сначала свой компилятор, результаты его деятельности скармливать компоновщику из VS или LLVM. Потом и компоновщик свой, который сможет переварить OBJ от стороннего компилятора.
                                    Кстати, сорсы NT'шного загрузчика вполне доступны для изучения — это как минимум избавит от вопросов о правилах размещения секций PE в АП.
                                  +2
                                  Вот еще хорошая статья на тему.
                                  • НЛО прилетело и опубликовало эту надпись здесь
                                      0
                                      Спасибо, взял на заметку.
                                      Действительно очень хорошие представления, что бы рассматривать их вместе с документацией.
                                      0
                                      Остаётся добиться того, чтобы полученным файлом был доволен не только загрузчик Windows, но и все антивирусы. Я в своём старом проекте так и не смог надёжно победить Windows Defender, и каждую неделю отправлял очередную жалобу в Microsoft. Кажется, с этим столкнулись практически все разработчики любительских компиляторов под Windows.
                                        0

                                        Ну кстати пока встроенный в windows 10 антивирус ничего не сказал.


                                        Но помню что это было реальной проблемой когда рисовал через GDI, что на паскале, что на C++. Интересно, что достаточно было поменять 2 строки кода местами как антивирус переставал ругаться.

                                          0
                                          Думаю, из пары десятков программ он обязательно на что-то ругнётся. Я сначала искал проблему в заголовках, потом — в «любительских» конструкциях в самом машинном коде. Однако если Microsoft заявляет, что применяет машинное обучение для отлова подозрительных файлов, то задача разработчика компилятора серьёзно усложняется: может быть, теперь даже сами авторы антивируса не смогли бы сказать, на что именно антивирус ругается.
                                          Иногда проблема касается и профессионалов: разработчики Go тоже жаловались.

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

                                      Самое читаемое