Статья Создаём РЕ-файл минимального размера

Можно-ли урезать размер исполняемых файлов Win до минимума, чтобы не таскать с собой лишний груз? Как оказалось, даже РЕ64 с MessageBox() на борту можно «побрить налысо» до 276 байт, а РЕ32 итого меньше. В статье рассматривается способ удаления из исполняемых файлов всего, что только возможно, и это будет хорошим опровержением спецификации на РЕ-файл от Microsoft.

1. Проприетарный стаб MS-DOS.​
2. Поля в РЕ и опциональном заголовке.​
3. Выравнивание секций в файле и памяти.​
4. Удаление записей из каталога.​
5. Секции кода и импорта.​
6. Сборка бинарника.​



1. Проприетарный стаб MS-DOS

Файлы EXE с сигнатурой «MZ» (инициалы Mark Zbikowski) использовались ещё во-времена MS-DOS, эпоха которой закончилась ровно 30 лет назад с выходом Win95, где она осталась в качестве аппендикса. В то время Win могла запускать досовские EXE через свою вирт.машину NTVDM, а вот обратная схема уже не работала, т.к. у доса не было загрузчика РЕ. На случай, когда юзер по ошибке стартовал WinEXE из под чистого доса, в структуре РЕ-файла была предусмотрена 40h-байтная заглушка «DOS Stub», которая прерыванием int-21h AH=9 выводила на консоль мессагу типа «Эта программа не для MS-DOS».

Поскольку дос давно уже отправился на свалку истории, этот стаб просто занимает место в бинарнике, так-что его можно смело удалить. Правда в нём есть 2 поля, которые проверяет загрузчик образов Win – это первые 4 байта с сигнатурой «MZ», и последний дворд «e_lfanew» по смещению 0x3C, куда компилятор прописывает относительный RVA указатель на РЕ-заголовок (Relative Virtual Address). Например на скрине ниже этот линк имеет значение 0x80 (выделен красным), и соответственно там начинается РЕ-Header. От сюда следует, что если мы изменим указатель e_lfanew на значение(4) оставив нетронутой сигнатуру MZ, то сможем переместить весь РЕ-заголовок на место бесполезного стаба DOS. Как результат, размер файла уменьшится на 40h=64 байта – это продемонстрированно на рис.ниже:

Stub.webp


2. Поля в РЕ и опциональном заголовке

Изначально загрузчик образов Win был довольно массивным модулем ОС, о чём свидетельствуют аж 25 записей в опциональном заголовке. Но со временем политика стала более лояльней к образам – сейчас 13 из 25 полей атрофировалось, и лоадер просто игнорирует их. При хороших обстоятельствах можно было-бы удалить эти поля, однако этому препятствуют жёстко привязанные адреса критически важных 12-ти оставшихся полей, которые должны оставаться на своём месте, т.к. код загрузчика будет искать их по строго предписанным в спецификации смещениям.

То-есть хотим мы того или нет, из РЕ-заголовка нельзя ничего вырезать, ..тем более, что сразу за опциональным следует и каталог секций «IMAGE_DATA_DIRECTORY», который так-же имеет захардкорденный свой оффсет. В файлах РЕ64, три эти структуры «РЕ+Optional+Dir» занимают 264 байта, а в РЕ32 чуть меньше 248 из-за размера некоторых полей. В представленной ниже структуре, игнорируемые лоадером поля затенены серым, но поскольку они всё равно должны присутствовать, для перестраховки разумно будет записать в них валидные значения:

PE_Hdr.webp

На предыдущем этапе мы переместили весь РЕ-заголовок в заглушку DOS-Stub начиная со-смещения(4), в результате чего важное поле по смещению 0x3C «e_lfanew» выполняет теперь сразу 2 функции: во-первых оно хранит (как и положено) указатель на РЕ-хидер из стаба DOS, а во-вторых по совместительству и значение «SectionAlignment» из самого заголовка РЕ, т.к. 0x3C=60 отнять 4 = смещение(56) в опциональном хидере (см.структуру выше). Другими словами, на «e_lfanew» сверху спроецировалась «SectionAlignment». Ничего особенного.. просто так сложились звёзды.


3. Выравнивание секций в файле и памяти

Выравнивание данных в памяти играет огромную роль в архитектуре процессоров х86. В первую очередь это связано с разрядностью шины памяти DDR, которая равна 64-бита или 8 байт ещё со-времён проциков i286. Если данные не выровнены на 8-байтную границу, контроллёру ОЗУ приходится делать 2 обращения к транспортной шине вместо одного, т.к. адрес попадает на её середину, а не начало/конец. По процессорным меркам это занимает много времени особенно на многоядерных системах, где одну шину DDR делят между собой сразу несколько ядер ЦП. В общем как ни крути, но выравнивание данных в памяти вполне оправдано.

Однако не знаю, чем руководствовались разрабы формата РЕ, когда для выравнивания секций в файле на диске, они решили установить дефолтный шаг аж в 512 байт, в результате чего в бинарнике всегда болтаются безхозные байты нулей (полностью заполненная секция скорее исключение, чем правило). Аналогичный паддинг данных устанавливается и для образа программы в памяти, который совпадает с размером вирт.страницы 4 КБ. Эти 2 значения загрузчик берёт из полей «Section + FileAlignment» по смещениям 56 и 60 опционального заголовка выше. Обратите внимание, что после того-как мы наложили РЕ-заголовок на заглушку DOS-Stub, шаг выравнивания в памяти стал «SectionAlign=4 байта», и такое-же значение мы присвоим полю «FileAlign=4», чтобы избавиться в бинаре от топкого болота нулей. Этот известный финт позволит на порядок уменьшить размер исполняемого EXE на диске.

FileAlign.webp


4. Удаление записей из каталога секций

Каталог «IMAGE_DATA_DIRECTORY» хранит указатели на секции и их размер. Согласно спеки на РЕ файл, всего поддерживается 15 секций (не считая Code и Data), которые представлены на рис.ниже. Каждая запись состоит из двух двордов =8 байт: первый хранит относительный RVA-адрес секции, а второй её фактический размер (без байтов выравнивания). Таким образом, весь каталог занимает в бинарнике 16*8=128 байт, а кол-во записей в нём хранится в последнем поле опционального заголовка выше «NumberOfRvaAndSize».

Img_Dir.webp

Сразу после данного каталога располагается таблица секций, которая включает в себя массив структур «IMAGE_SECTION_HEADER» – сколько секций в файле, столько и структур. На руку нам играет то, что если смещение начала каталога имеет постоянный адрес (строго после опционального заголовка), то конец этого каталога загрузчик определяет по значению в поле «NumberOfRvaAndSize * 8», где ожидает увидеть таблицу секций.

В нашем подопытном EXE мы будем использовать только секцию-импорта, запись о которой «Import Directory» находится под номером(2) в каталоге выше. Первую запись «Export Dir» мы не можем вырезать, т.к. каждая запись имеет свой индекс (порядковый номер), поэтому придётся тащить её с собой. А вот остальные все до номера(#15) ничего не мешает отправить в топку. При этом важно будет в поле «NumberOfRvaAndSize» прописать значение(2) = Export+Import, вместо дефолтных(16). Теперь загрузчик найдёт и таблицу секций, которая переместится на место 14-ти удалённых из каталога записей. Так мы освободим ещё 14*8=112 байт из исполняемого файла на диске. В общем случае, очерёдность следования служебных блоков в файле выглядит так (в правом столбце размеры в hex и dec):

PE_Struct.webp

5. Секции кода и импорта

ОС Win7 и ниже спокойно запускают РЕ-файлы вообще без секций на борту, а вот загрузчик Win10 требует уже наличия хотя-бы одной (без разницы какой). Да и без импорта сделать что-то существенное врядли получится, и такой РЕ будет предствлять собой «вещь в себе» – даже для динамической загрузки API нужно заранее импортировать как минимум LoadLibrary() + GetProcAddress(). В своей демке я планирую вывести окно MessageBox(), и соответственно мне нужно загрузить либу user32.dll. Взаимосвязь структур при статическом импорте представлена на схеме ниже:

Imp.webp

Столь запутанная схема решает вопросы кроссплатформенности РЕ-файла, т.к. мы не можем предсказать, в какой именно ОС будет исполняться наш файл. В каждой из ОС библиотеки подсистемы Win32 загружаются в память по разным базовым адресам, а учитывая наличие механизма рандомизации адреса ASLR на х64, этого не знает даже сама ОС. Поэтому в задачи загрузчика образов входит динамический поиск системных DLL в памяти ОЗУ, далее определение базовых адресов импортируемых нами API, и сохранения этих адресов в таблице «IMPORT_ADDRESS_TABLE», или коротко IAT. Вторая таблица «LOOKUP» хранит указатели на имена функций, и они всегда ходят парой.

Таким образом, из этой цепочки структур мы не можем ничего вырезать, т.к. они связаны между собой крепкими узами. Но учитывая, что первое и последнее поле в дескрипторе импорта хранят в себе указатели на таблицы Lookup и IAT соответственно, то ничего не мешает нам сделать сальто и переместить эти таблицы назад, например, в область расположенного выше опционального заголовка, в котором (как мы уже выяснили) есть неиспользуемые загрузчиком поля – это могут быть члены опц.хидера «TimeStamp» и «LinkerVersion» (см.рис.2). Так мы съэкономим ещё 16-байт в образе исполняемого файла.


6. Сборка бинарника

Чтобы предоставить пруфы вышеизложенному, возьмём для Win, распакуем архив в любую папку, запустим "fasmw.exe", и скопировав исходный код ниже в окно редактора, соберём его по F9. Как результат, из выхлопной трубы получим экзешник размером всего 276 байт, который будет исправно отрабатывать на всех системах от Win2k до Win10, показывая такое диалоговое окно MessageBox():

C-подобный:
format binary as 'exe'

IMAGE_DOS_SIGNATURE             equ 5A4Dh
IMAGE_NT_SIGNATURE              equ 00004550h
PROCESSOR_AMD_X8664             equ 8664h
IMAGE_SCN_CNT_CODE              equ 00000020h
IMAGE_SCN_MEM_READ              equ 40000000h
IMAGE_SCN_MEM_WRITE             equ 80000000h
IMAGE_SCN_CNT_INITIALIZED_DATA  equ 00000040h
IMAGE_SUBSYSTEM_WINDOWS_GUI     equ 2
IMAGE_NT_OPTIONAL_HDR64_MAGIC   equ 20Bh
IMAGE_FILE_RELOCS_STRIPPED      equ 1
IMAGE_FILE_EXECUTABLE_IMAGE     equ 2
IMAGE_DLL_TERMINAL_SERVER_AWARE equ 8000h

include 'win64a.inc'
org 0
use64
IMAGE_BASE   =   400000h

Signature:   dw  IMAGE_DOS_SIGNATURE,0  ;// 'MZ'
ntHeader     dd  IMAGE_NT_SIGNATURE     ;// 'PE'

;// PE_Header--------------------------
.Machine                  dw PROCESSOR_AMD_X8664
.Count_of_section         dw 1
.TimeStump                dd 0
.Symbol_table_offset      dd 0
.Symbol_table_count       dd 0
.Size_of_optional_header  dw section_table - optional_header
.Characteristics          dw 0x20 or\
                             IMAGE_FILE_RELOCS_STRIPPED or\
                             IMAGE_FILE_EXECUTABLE_IMAGE
;//-------------------------------------
optional_header:
.Magic_optional_header          dw IMAGE_NT_OPTIONAL_HDR64_MAGIC
.Linker_ver_major_and_minor     dw 1
.Size_of_code                   dd 0
.Size_of_init_data              dd 0
.Size_of_uninit_data            dd 0
.entry_point                    dd EntryPoint
.base_of_code                   dd ntHeader
.image_base                     dq IMAGE_BASE
.section_alignment              dd 4
.file_alignment                 dd 4
.OS_version_major_minor         dw 5,2
.image_version_major_minor      dd 0
.subsystem_version_major_minor  dw 5,2
.Win32_version                  dd 0
.size_of_image                  dd end_import
.size_of_header                 dd section_table
.checksum                       dd 0
.subsystem                      dw IMAGE_SUBSYSTEM_WINDOWS_GUI
.DLL_flag                       dw IMAGE_DLL_TERMINAL_SERVER_AWARE
.Stack_allocation               dq 0x100000
.Stack_commit                   dq 0x1000
.Heap_allocation                dq 0x100000
.Heap_commit                    dq 0x1000
.loader_flag                    dd 0
.number_of_dirs                 dd (section_table-export_RVA_size)/8

;//-------------------------------------
export_RVA_size                 dq 0
.import_RVA                     dd import_
.import_size                    dd end_import-import_

;//-------------------------------------
section_table                   dq '.text'
.virtual_size                   dd end_import-EntryPoint
.virtual_address                dd EntryPoint
.Physical_size                  dd end_import-EntryPoint
.Physical_offset                dd EntryPoint
.Reloc_and_Linenumbers          dq 0
.Reloc_and_Linenumbers_count    dd 0
.Attributes                     dd IMAGE_SCN_MEM_WRITE or\
                                   IMAGE_SCN_CNT_CODE  or\
                                   IMAGE_SCN_MEM_READ  or\
                                   IMAGE_SCN_CNT_INITIALIZED_DATA
;//-------- Секция кода ----------------
EntryPoint:
enter  20h,0              ;// выделить фрейм в стеке
   xor    ecx, ecx        ;// аргумент(1): rcx = hWnd = NULL
   mov    edx,section_table+IMAGE_BASE  ;// аргумент(2): edx = text
   mov    r8,rdx          ;// аргумент(3): r8  = caption
   mov    r9, rcx         ;// аргумент(4): r9d = uType = MB_OK = 0
   call  [MessageBox]
   leave
ret

;//-------------------------------------
Import_Table:
user32_table:
MessageBox    dq _MessageBox
import_:      dd 0,0,0,user32_dll,user32_table,0
user32_dll    db "user32",0,0,0,0
_MessageBox   db 0,0,"MessageBoxA"
end_import:
EndOfImage:

Mbox.webp

Интересно посмотреть на реакцию РЕ-вьюверов, если вскормить им этот бинарь.
Например вот лог о его загрузке в программу , от которой не ускользнёт ни один тушканчик.

PeA.webp
 
Мы в соцсетях:

Взломай свой первый сервер и прокачай скилл — Начни игру на HackerLab