Marylin
21.11.2025, 16:35
Можно-ли урезать размер исполняемых файлов 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 байта – это продемонстрированно на рис.ниже:
https://forum.antichat.xyz/attachments/4949380/img_e3e7e987ce.png
2. Поля в РЕ и опциональном заголовке
Изначально загрузчик образов Win был довольно массивным модулем ОС, о чём свидетельствуют аж 25 записей в опциональном заголовке. Но со временем политика стала более лояльней к образам – сейчас 13 из 25 полей атрофировалось, и лоадер просто игнорирует их. При хороших обстоятельствах можно было-бы удалить эти поля, однако этому препятствуют жёстко привязанные адреса критически важных 12-ти оставшихся полей, которые должны оставаться на своём месте, т.к. код загрузчика будет искать их по строго предписанным в спецификации смещениям.
То-есть хотим мы того или нет, из РЕ-заголовка нельзя ничего вырезать, ..тем более, что сразу за опциональным следует и каталог секций «IMAGE_DATA_DIRECTORY», который так-же имеет захардкорденный свой оффсет. В файлах РЕ64, три эти структуры «РЕ+Optional+Dir» занимают 264 байта, а в РЕ32 чуть меньше 248 из-за размера некоторых полей. В представленной ниже структуре, игнорируемые лоадером поля затенены серым, но поскольку они всё равно должны присутствовать, для перестраховки разумно будет записать в них валидные значения:
https://forum.antichat.xyz/attachments/4949380/img_e1c1aadb1b.png
На предыдущем этапе мы переместили весь РЕ-заголовок в заглушку 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 на диске.
https://forum.antichat.xyz/attachments/4949380/img_f9ac4ec447.png
4. Удаление записей из каталога секций
Каталог «IMAGE_DATA_DIRECTORY» хранит указатели на секции и их размер. Согласно спеки на РЕ файл, всего поддерживается 15 секций (не считая Code и Data), которые представлены на рис.ниже. Каждая запись состоит из двух двордов =8 байт: первый хранит относительный RVA-адрес секции, а второй её фактический размер (без байтов выравнивания). Таким образом, весь каталог занимает в бинарнике 16*8=128 байт, а кол-во записей в нём хранится в последнем поле опционального заголовка выше «NumberOfRvaAndSize».
https://forum.antichat.xyz/attachments/4949380/img_8866586cdb.png
Сразу после данного каталога располагается таблица секций, которая включает в себя массив структур «IMAGE_SECTION_HEADER» – сколько секций в файле, столько и структур. На руку нам играет то, что если смещение начала каталога имеет постоянный адрес (строго после опционального заголовка), то конец этого каталога загрузчик определяет по значению в поле «NumberOfRvaAndSize * 8», где ожидает увидеть таблицу секций.
В нашем подопытном EXE мы будем использовать только секцию-импорта, запись о которой «Import Directory» находится под номером(2) в каталоге выше. Первую запись «Export Dir» мы не можем вырезать, т.к. каждая запись имеет свой индекс (порядковый номер), поэтому придётся тащить её с собой. А вот остальные все до номера(#15) ничего не мешает отправить в топку. При этом важно будет в поле «NumberOfRvaAndSize» прописать значение(2) = Export+Import, вместо дефолтных(16). Теперь загрузчик найдёт и таблицу секций, которая переместится на место 14-ти удалённых из каталога записей. Так мы освободим ещё 14*8=112 байт из исполняемого файла на диске. В общем случае, очерёдность следования служебных блоков в файле выглядит так (в правом столбце размеры в hex и dec):
https://forum.antichat.xyz/attachments/4949380/img_da2e0fa95e.png
5. Секции кода и импорта
ОС Win7 и ниже спокойно запускают РЕ-файлы вообще без секций на борту, а вот загрузчик Win10 требует уже наличия хотя-бы одной (без разницы какой). Да и без импорта сделать что-то существенное врядли получится, и такой РЕ будет предствлять собой «вещь в себе» – даже для динамической загрузки API нужно заранее импортировать как минимум
LoadLibrary() + GetProcAddress()
. В своей демке я планирую вывести окно
MessageBox()
, и соответственно мне нужно загрузить либу user32.dll. Взаимосвязь структур при статическом импорте представлена на схеме ниже:
https://forum.antichat.xyz/attachments/4949380/img_32392b43c4.png
Столь запутанная схема решает вопросы кроссплатформенности РЕ-файла, т.к. мы не можем предсказать, в какой именно ОС будет исполняться наш файл. В каждой из ОС библиотеки подсистемы Win32 загружаются в память по разным базовым адресам, а учитывая наличие механизма рандомизации адреса ASLR на х64, этого не знает даже сама ОС. Поэтому в задачи загрузчика образов входит динамический поиск системных DLL в памяти ОЗУ, далее определение базовых адресов импортируемых нами API, и сохранения этих адресов в таблице «IMPORT_ADDRESS_TABLE», или коротко IAT. Вторая таблица «LOOKUP» хранит указатели на имена функций, и они всегда ходят парой.
Таким образом, из этой цепочки структур мы не можем ничего вырезать, т.к. они связаны между собой крепкими узами. Но учитывая, что первое и последнее поле в дескрипторе импорта хранят в себе указатели на таблицы Lookup и IAT соответственно, то ничего не мешает нам сделать сальто и переместить эти таблицы назад, например, в область расположенного выше опционального заголовка, в котором (как мы уже выяснили) есть неиспользуемые загрузчиком поля – это могут быть члены опц.хидера «TimeStamp» и «LinkerVersion» (см.рис.2). Так мы съэкономим ещё 16-байт в образе исполняемого файла.
6. Сборка бинарника
Чтобы предоставить пруфы вышеизложенному, возьмём компилятор fasm’a для Win, распакуем архив в любую папку, запустим "fasmw.exe", и скопировав исходный код ниже в окно редактора, соберём его по
F9
. Как результат, из выхлопной трубы получим экзешник размером всего 276 байт, который будет исправно отрабатывать на всех системах от Win2k до Win10, показывая такое диалоговое окно
MessageBox()
:
https://forum.antichat.xyz/attachments/4949380/img_8b03435e8d.png
Интересно посмотреть на реакцию РЕ-вьюверов, если вскормить им этот бинарь.
Например вот лог о его загрузке в программу PE-Anatomist, от которой не ускользнёт ни один тушканчик.
https://forum.antichat.xyz/attachments/4949380/img_375070cbe9.png
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 байта – это продемонстрированно на рис.ниже:
https://forum.antichat.xyz/attachments/4949380/img_e3e7e987ce.png
2. Поля в РЕ и опциональном заголовке
Изначально загрузчик образов Win был довольно массивным модулем ОС, о чём свидетельствуют аж 25 записей в опциональном заголовке. Но со временем политика стала более лояльней к образам – сейчас 13 из 25 полей атрофировалось, и лоадер просто игнорирует их. При хороших обстоятельствах можно было-бы удалить эти поля, однако этому препятствуют жёстко привязанные адреса критически важных 12-ти оставшихся полей, которые должны оставаться на своём месте, т.к. код загрузчика будет искать их по строго предписанным в спецификации смещениям.
То-есть хотим мы того или нет, из РЕ-заголовка нельзя ничего вырезать, ..тем более, что сразу за опциональным следует и каталог секций «IMAGE_DATA_DIRECTORY», который так-же имеет захардкорденный свой оффсет. В файлах РЕ64, три эти структуры «РЕ+Optional+Dir» занимают 264 байта, а в РЕ32 чуть меньше 248 из-за размера некоторых полей. В представленной ниже структуре, игнорируемые лоадером поля затенены серым, но поскольку они всё равно должны присутствовать, для перестраховки разумно будет записать в них валидные значения:
https://forum.antichat.xyz/attachments/4949380/img_e1c1aadb1b.png
На предыдущем этапе мы переместили весь РЕ-заголовок в заглушку 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 на диске.
https://forum.antichat.xyz/attachments/4949380/img_f9ac4ec447.png
4. Удаление записей из каталога секций
Каталог «IMAGE_DATA_DIRECTORY» хранит указатели на секции и их размер. Согласно спеки на РЕ файл, всего поддерживается 15 секций (не считая Code и Data), которые представлены на рис.ниже. Каждая запись состоит из двух двордов =8 байт: первый хранит относительный RVA-адрес секции, а второй её фактический размер (без байтов выравнивания). Таким образом, весь каталог занимает в бинарнике 16*8=128 байт, а кол-во записей в нём хранится в последнем поле опционального заголовка выше «NumberOfRvaAndSize».
https://forum.antichat.xyz/attachments/4949380/img_8866586cdb.png
Сразу после данного каталога располагается таблица секций, которая включает в себя массив структур «IMAGE_SECTION_HEADER» – сколько секций в файле, столько и структур. На руку нам играет то, что если смещение начала каталога имеет постоянный адрес (строго после опционального заголовка), то конец этого каталога загрузчик определяет по значению в поле «NumberOfRvaAndSize * 8», где ожидает увидеть таблицу секций.
В нашем подопытном EXE мы будем использовать только секцию-импорта, запись о которой «Import Directory» находится под номером(2) в каталоге выше. Первую запись «Export Dir» мы не можем вырезать, т.к. каждая запись имеет свой индекс (порядковый номер), поэтому придётся тащить её с собой. А вот остальные все до номера(#15) ничего не мешает отправить в топку. При этом важно будет в поле «NumberOfRvaAndSize» прописать значение(2) = Export+Import, вместо дефолтных(16). Теперь загрузчик найдёт и таблицу секций, которая переместится на место 14-ти удалённых из каталога записей. Так мы освободим ещё 14*8=112 байт из исполняемого файла на диске. В общем случае, очерёдность следования служебных блоков в файле выглядит так (в правом столбце размеры в hex и dec):
https://forum.antichat.xyz/attachments/4949380/img_da2e0fa95e.png
5. Секции кода и импорта
ОС Win7 и ниже спокойно запускают РЕ-файлы вообще без секций на борту, а вот загрузчик Win10 требует уже наличия хотя-бы одной (без разницы какой). Да и без импорта сделать что-то существенное врядли получится, и такой РЕ будет предствлять собой «вещь в себе» – даже для динамической загрузки API нужно заранее импортировать как минимум
LoadLibrary() + GetProcAddress()
. В своей демке я планирую вывести окно
MessageBox()
, и соответственно мне нужно загрузить либу user32.dll. Взаимосвязь структур при статическом импорте представлена на схеме ниже:
https://forum.antichat.xyz/attachments/4949380/img_32392b43c4.png
Столь запутанная схема решает вопросы кроссплатформенности РЕ-файла, т.к. мы не можем предсказать, в какой именно ОС будет исполняться наш файл. В каждой из ОС библиотеки подсистемы Win32 загружаются в память по разным базовым адресам, а учитывая наличие механизма рандомизации адреса ASLR на х64, этого не знает даже сама ОС. Поэтому в задачи загрузчика образов входит динамический поиск системных DLL в памяти ОЗУ, далее определение базовых адресов импортируемых нами API, и сохранения этих адресов в таблице «IMPORT_ADDRESS_TABLE», или коротко IAT. Вторая таблица «LOOKUP» хранит указатели на имена функций, и они всегда ходят парой.
Таким образом, из этой цепочки структур мы не можем ничего вырезать, т.к. они связаны между собой крепкими узами. Но учитывая, что первое и последнее поле в дескрипторе импорта хранят в себе указатели на таблицы Lookup и IAT соответственно, то ничего не мешает нам сделать сальто и переместить эти таблицы назад, например, в область расположенного выше опционального заголовка, в котором (как мы уже выяснили) есть неиспользуемые загрузчиком поля – это могут быть члены опц.хидера «TimeStamp» и «LinkerVersion» (см.рис.2). Так мы съэкономим ещё 16-байт в образе исполняемого файла.
6. Сборка бинарника
Чтобы предоставить пруфы вышеизложенному, возьмём компилятор fasm’a для Win, распакуем архив в любую папку, запустим "fasmw.exe", и скопировав исходный код ниже в окно редактора, соберём его по
F9
. Как результат, из выхлопной трубы получим экзешник размером всего 276 байт, который будет исправно отрабатывать на всех системах от Win2k до Win10, показывая такое диалоговое окно
MessageBox()
:
https://forum.antichat.xyz/attachments/4949380/img_8b03435e8d.png
Интересно посмотреть на реакцию РЕ-вьюверов, если вскормить им этот бинарь.
Например вот лог о его загрузке в программу PE-Anatomist, от которой не ускользнёт ни один тушканчик.
https://forum.antichat.xyz/attachments/4949380/img_375070cbe9.png