PDA

Просмотр полной версии : [Статья] Минимальный по размеру EXE в Windows (XP)


Leo_ня
31.01.2010, 10:01
Статья не для новичков, довольно сложная и по-ламерски написанная.

Необходимый инструментарий:
WinHex ([Ссылки могут видеть только зарегистрированные и активированные пользователи]). Скачивайте версию 14. Но в ней не поддерживается кириллица. Если вы хотите писать в WinHex'е русскими буквами, то ищите кряк для 13-й версии.
FASM ([Ссылки могут видеть только зарегистрированные и активированные пользователи]). В отчёте вирустотала не нуждается :agreed:

Пояснения:
BYTE - один байт.
WORD - два байта.
DWORD - четыре байта.
Если перед числом стоит "0x" или после числа "h", это значит, что оно шестнадцатиричное. (пояснения для новичков, кто всё-таки решил прочитать).
Смещение\Offset - количество байт относительно начала файла или начала адресного пространства.

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

В большинстве программ под Windows используется формат PE - Portable Executable. Этот формат удобен тем, что код в нём делится на секции, на которые можно установить разные права (на чтение, запись, исполнение), да и сам код получается читабельней. Не буду описывать другие плюсы этого формата, ибо их не знаю.

Сейчас мы попробуем создать минимальный EXE (Executable File, исполняемый файл) под Windows с форматом PE. Мы не будем использовать интерфейса, в нашей программе будет только одна инструкция - выход из программы.

Что представляет из себя PE-формат:

MZ-заголовок - привет в прошлое, то есть DOS'у. Это формат EXE программы DOS. MZ-заголовок представляет из себя сигнатуру "MZ", разные параметры и код, который выводит сообщение и выходит в DOS. Нужен для оповещения юзера о том, что эта программа под венду, и на DOS'е не пойдёт. Если при запуске EXE загрузчик Windows не встретит вначале "MZ", то он пустит программу в эмулятор DOS. Поэтому, без этого заголовка никуда.
PE-заголовок. Начинается с сигнатуры "PE", несёт в себе всякую полезную (и не очень) информацию о программе загрузчику. Некоторые параметры в нём вообще не нужны, некоторые нужны, но и без них работает. Поэтому мы будем его безжалостно тереть.
Таблица секций, секции. Их наличие определяется PE-заголовком, их может не быть. Так как секции=удобочитаемость\удобоисполняемость=дополнит ельная куча байтов, мы их использовать не будем.


Теперь поподробнее о первых двух.
MZ-заголовок мы удалим полностью, оставив только "MZ" вначале. Простите нас, досовцы, если такие ещё остались.
Т.к. PE-заголовок "плавает" в файле, т.е. может быть записан в разных местах, на него должен быть указатель. Этот указатель находится по смещению 3Ch, состоит из четырёх байт (DWORD, Double Word, двойное слово. Слово = 2 байта). Он также находится в MZ-заголовке. Он указывает не именно на PE-заголовок, а на начало расширенного заголовка, которых существует несколько. Я знаю только PE и NE (New Executable).

В MZ-заголовке остаётся куча свободного пространства, поэтому мы расположим PE-заголовок прямо в нём. В PE-заголовке есть много необязательных параметров (По-моему мнению. Установлено экспериментальным путём :rolleyeyes:), поэтому какой-то необязательный параметр должен придтись на 3Ch, чтобы не конфликтовали разные параметры, читаемые с одного адреса.
До указателя, кстати, 58 свободных байт.

Структура PE-заголовка.
DWORD Signature // 'PE' и два нульбайта. Относительный адрес 0x00 (от начала заголовка)
IMAGE_FILE_HEADER //Смещение: 0x04
IMAGE_OPTIONAL_HEADER32 //Смещение: 0x18. Заканчивается в 0xE0 (224 байта заголовок, нехило)

WORD Machine //Идентификатор процессора. По умолчанию - I386=014Сh (в файле должно быть записано как 4C01, т.е. байты в обратном порядке). Этот параметр обязателен, изменять нельзя.
WORD NumberOfSections //Количество секций. У нас будет равно нулю. Паараметр обязателен. изменять нельзя.
DWORD TimeDateStamp //Время создания программы. Нафиг не нужно :D -
DWORD PointerToSymbolTable //Для отладчика, уберём. -
DWORD NumberOfSymbols //Тоже для отладчика. -
WORD SizeOfOptionalHeader //Размер следующей части PE-заголовка. По идее обязателен, но без него пашет. -
WORD Characteristics //Флаги. Тип файла, его атрибуты. Изменять стандартное значение для PE-EXE мы не будем, оно равно 0102 (0201 в файле). И означает вот что: программа исполняемая, нужна 32-битность.
Итого: 14 свободных байтов.

WORD Magic //Тип опционального PE-заголовка. 0x010b для PE32, 0x0107 для PE64. Обязателен.
BYTE MajorLinkerVersion //Верхняя часть версии линкера. (если 1.05, то 1 - верхняя, 5 - нижняя). Не нужен. -
BYTE MinorLinkerVersion //Нижняя часть версии линкера. Не нужен. -
DWORD SizeOfCode //Размер кода. Тоже не нужен, т.к. код у нас будет фиг знает где и фиг знает как. -
DWORD SizeOfInitializedData //Размер инициализированных данных. Такие у нас не имеются. Опустим. -
DWORD SizeOfUninitializedData //Размер неинициализированных данных. Таких тоже нет. -
DWORD AddressOfEntryPoint //Адрес начала исполняемого кода. Так называемая Точка Входа. Обязательна. Значение должно быть равно адресу первой инструкции в файле. Начало кода высчитывается по ImageBase+EntryPoint. Т.е, если EntryPoin=1000h, ImageBase=400000h, то начало кода должно быть в 401000h. По этому адресу чаще всего и располагают секцию кода.
DWORD BaseOfCode //На самом деле не играет никакой роли, уберём. -
DWORD BaseOfData //Тоже. -
DWORD ImageBase //Желаемый адрес загрузки программы в памяти. Желаемый, потому что загрузчик может загрузить в другое место, если ему это будет нужно. Чаще всего равно 400000h. Вроде бы должен быть кратен 100000h. Мне кажется, что если указан неправильный адрес (0xFFFF00000, например) то загрузчик загрузит по другому правильному адресу. Т.е., два байта можно ещё занять под какой-нибудь код.
DWORD SectionAlignment //Выравнивание секций в памяти. Так как секции мы не используем, этот параметр также занулим. Но воспринимать его как свободные байты не будем - об этом позже.
DWORD FileAlignment //Выравнивание секций и заголовков в файле. Экспериментальным путём выяснено, что если секций нет, то должно быть равно четырём.
WORD MajorOperatingSystemVersion //Верхняя часть версии ОС. Не используется. -
WORD MinorOperatingSystemVersion //Нижняя. Тоже. -
WORD MajorImageVersion //Верхняя часть версии образа (образ=MZ-заголовок+PE-заголовок). Не используется. -
WORD MinorImageVersion //Нижняя часть. -
WORD MajorSubsystemVersion //Верхняя часть версии подсистемы. Если я ничего не путаю, то здесь версия Windows NT - 4.0. Т.е. здесь 0x0004 (0400 в файле). Хотя, может и не так, но всё сходится :D
WORD MinorSubsystemVersion //Нижняя часть. Не имеет значения, можно вытянуть из этого 2 байта. -
DWORD Win32VersionValue //Не используется. -
DWORD SizeOfImage //Размер всего образа. Как ни странно, но в нашем случае должен быть просто больше SizeOfHeaders на единицу. SizeOfHeaders+1. Поэтому, этот и следующий параметр можно использовать для кода, если оппкоды будут подходить под эти условия. -
DWORD SizeOfHeaders //Размер заголовков. Опять же, странный параметр. Чтобы наша программа работала, он должен быть больше 2Ch. -
DWORD CheckSum //Чек-сумма заголовков. Не нужна. -
WORD Subsystem //Подсистема. Последний обязательный параметр (жаль, что он идёт не до чек-суммы, иначе бы можно было сделать файл ещё меньше). Мы используем Win32GUI, его значение равно 2. Т.е. 0x0002 (0200).
WORD DllCharacteristics //Этот и последующие параметры необязательны, мы вообще не будем включать их в заголовок. Почему? А потому что при загрузке программы эти параметры будут равны нулю, и лишние нули мы в файл добавлять не будем.
DWORD SizeOfStackReserve
DWORD SizeOfStackCommit
DWORD SizeOfHeapReserve
DWORD SizeOfHeapCommit
DWORD LoaderFlags
DWORD NumberOfRvaAndSizes;
IMAGE_DATA_DIRECTORY
Итого мы здесь насчитали 14+4+8+18=44 почти необязательных и почти свободных байтов.


Теперь поближе к практике. Откроем WinHex и создадим новый файл. Размер не особо важен, но лучше побольше. Я выбрал 1111 :d
Теперь начнём заполнять наши заголовки.

MZ-заголовок:
В первые два байта пишем "MZ". Теперь переходим к указателю на расширенный заголовок (3Ch). Заполняем его FF'ками, чтобы он был заметен. Теперь отложим его.
[Ссылки могут видеть только зарегистрированные и активированные пользователи]******.net/upload/33d5470a.png

PE-заголовок:
Отступим место от MZ-заголовка. Начнём писать со смещения 0x70.
Первые четыре байта будут "PE",0,0 (50450000). Теперь ID процессора - 4C01 (перевёрнутое 14C). Потом идёт количество секций - их ноль, переместим мышку на два байта вперёд, т.е. на 0x78.
Как мы помним (вернее, мы не помним, просто пролистаем страницу вверх), далее следуют 14 свободных байт. 0x78+E=0x86. Переходим на 0x86. Следующие два байта - опциональные флаги программы. Они у нас равны 0x0102, запишем в обратном порядке 0201.
FileHeader закончился, теперь OptionalHeader. Начинается он с идентификатора заголовка PE32 - 0x010b. В обратном порядке 0b01. Записываем. И снова 14 нулевых байт. По адресу 0x98 (8A+E. Можно не считать, просто от адреса 0x8A выделять байты мышкой, пока в нижнем правом углу не появится число E (14 в десятичной). Там отображается размер выделенных байтов) будет наша Точка Входа. Пока мы не знаем, где будет наш код, просто введём FF'ки.
Восемь пустых байтов. Тыкаем мышкой в смещение 0xA4, там ImageBase. Его можно не писать (оставить нулями) - но запомните, что его изменять не нужно. Или же введите 0004000 (перевёрнутое 0x0040000). После идут четыре байта SectionAlignment. Они будут нулями. Следующие четыре байта - FileAlignment. Оно равно четырём, т.е. 04000000. Затем 8 нулевых байт. Здесь MajorSubsystemVersion, оно равно четырём (0400). После него 6 пустых байтов. Тут у нас SizeOfImage. Его мы запишем как 2Dh (2D000000), чтобы он был на 1 больше SizeOfHeaders, минимум значения которого 2Ch. Теперь SizeOfHeaders (2C000000). Четыре пустых байта чек-суммы и завершающий байт Subsystem - 2.
Вот что получим:
[Ссылки могут видеть только зарегистрированные и активированные пользователи]******.net/upload/f3e3fcc2.png

Теперь нам, собственно, надо вложить PE в MZ. Логично предположить, что чем ближе к сигнатуре "MZ" мы вставим PE, то тем меньше выйдет размер экзешника. Мы не можем записать PE по адресу 0x02 (следующий байт после "MZ") или 0x03, т.к. тогда на 3Ch (указатель на PE) попадёт FileAlignment, а он должен быть равен 4. Если мы запишем PE по адресу 0x04, то на 3Ch попадёт SectionAlignment, который может быть любого значения.
Выделим PE-заголовок и скопируем его (Ctrl+Shift+C, чтобы скопировать HEX-значения). И запишем его по адресу 0x04 через Ctrl+B.
[Ссылки могут видеть только зарегистрированные и активированные пользователи]******.net/upload/147a2f93.png

Теперь нам надо изменить точку входа и указатель на PE. PE у нас находится по адресу 0x00000004, разворачиваем: 04000000, записываем это в указатель. Чтобы программа правильно работала, из неё нужно выйти. Вместо API ExitProcess можно использовать просто инструкцию RET. Она берёт последний адрес из стека и переходит по нему. При загрузке программы в стеке лежит адрес Kernel32, а там процедура выхода из программы. Опкод RET'а - C3. Чтобы не занимать лишнего места, мы запишем C3 прямо в MZ&PE-заголовки на свободное место. Первые свободные байты сразу после MZ - туда и запишем. Теперь правим EP на 0x00000002 (адрес нашего RET'а). Т.е. записываем 02000000.
Мы получили работоспособную программу. Осталось лишь обрезать лишние байты. Последний обязательный параметр Subsystem состоит из двух байт 0200. Но, т.к. в памяти всё после загруженной программы равно нулю, мы можем не включать последний нульбайт в программу. Обрезаем до 0x60. И мы получим вполне работоспособную программу весом в 97 байт. Это минимум.
[Ссылки могут видеть только зарегистрированные и активированные пользователи]******.net/upload/8098d0a8.png

Но не будем же мы останавливаться на этом, не будем же мы вручную через винхекс писать код в программу?!
Для этого мы напишем на FASM'е этот MZ&PE заголовок. Вернее, я его уже написал.
; PEinMZ. Урезанный exe'шнег. Thanks to Ivan2k2 for the shutd0wn97 :)
; Перед компиляцией необходимо поставить перед вашим кодом метку EntryPoint:
; Полученный PEinMZ.bin переименовать в *.exe.

; Здесь идут всякие константы.
Machine=0x014C ; Идентификатор процессора. У нас I386, т.е. 0x014C
NumberOfSections=0 ; Количество секций.
ImageBase=0x00400000 ; Желаемый адрес в памяти для загрузки программы.
FileAlignment=4 ; Должно быть равно 4. Вроде бы О_о
MajorSubsystemVersion=4 ; Опять же, должно быть равно 4.
SizeOfImage=0x2D ; Должен быть больше SizeOfHeaders как минимум на 1. Если будете использовать для кода - учитывайте.
SizeOfHeaders=0x2C ; Должен быть больше 0x2B. Опять же, учитывайте при помещении сюда кода.
Subsystem=2 ; Идентификатор подсистемы. Много описывать, поэтому просто гляньте сюда: [Ссылки могут видеть только зарегистрированные и активированные пользователи](VS.85).aspx
; А здесь константы кончаются.

use32 ; Мы какбэ 32-битную венду юзаем, нэ?
_MZ:
db 'MZ' ; Пишем сигнатуру MZ
_2bytes:
db 2 dup (0) ; Два свободных байта
_PE_FileHeader:
db 'PE',0,0 ; Сигнатура PE
dw Machine
dw NumberOfSections
_14bytes:
db 14 dup (0) ; 14 свободных байтов
_end14bytes:
db 0x02 ; IMAGE_FILE_EXECUTABLE_IMAGE ; The file is executable (there are no unresolved external references).
db 0x01 ; IMAGE_FILE_32BIT_MACHINE ; Computer supports 32-bit words.
_PE_OptionalHeader:
dw 0x010b ; IMAGE_NT_OPTIONAL_HDR32_MAGIC ; The file is an executable image.

__14bytes:
db 14 dup (0) ; 14 свободных байтов.
__end14bytes:
dd EntryPoint ; Точка входа.
_8bytes:
db 8 dup (0) ; 8 свободных байтов.
_end8bytes:
dd ImageBase
dd _PE_FileHeader
dd FileAlignment
__8bytes:
db 8 dup (0) ; 8 свободных байтов.
__end8bytes:
dw MajorSubsystemVersion
_6bytes:
db 6 dup (0) ; 6 свободных байтов.
_end6bytes:
dd SizeOfImage ; SizeOfImage и SizeOfHeaders можно использовать для кода,
dd SizeOfHeaders; если они будут удовлетворять условиям: SizeOfHeaders>0x2B, SizeOfImage>SizeOfHeaders.
_4bytes:
db 4 dup (0) ; 4 свободных байта.
_end4bytes:
db Subsystem ; Последний нульбайт в файл не включаем, поэтому юзаем db.
; Если будете ставить EntryPoint после Subsystem,
; то db замените на dw.

; PEinMZ. Урезанный exe'шнег. Thanks to Ivan2k2 for the shutd0wn97 :)
; Перед компиляцией необходимо поставить перед вашим кодом метку EntryPoint:
; Полученный PEinMZ.bin переименовать в *.exe.

use32

db 'MZ'

db 2 dup (0)

db 'PE',0,0
dw 0x014C
dw 0

db 14 dup (0)

db 0x02
db 0x01
dw 0x010b

db 14 dup (0)

dd EntryPoint

db 8 dup (0)

dd 0x0040000
dd 4
dd 4

db 8 dup (0)

dw 4

db 6 dup (0)

dd 0x2d
dd 0x2c

db 4 dup (0)

db 2
;dw 2
;EntryPoint:

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

В будущем может быть прикручу как-нибудь секции.

В аттаче те же исходники. :down:

forgiven
21.04.2010, 21:22
благодарю, статья занятная, но больше баги загрузчика отражает - он не должен по-хорошему эту вакханалию пропустить.

ни разу не слышал, чтобы кто-то проверял поле MajorSubsystemVersion.
посему его тоже можно обнулить.

а вот по части ret'a забавно - если третий байт будет равен нулю, то эффект от запуска будет тот же - код там управления не получит, проверил, ставя jmp (ЕВ offset)

в общем, не считаю сие корректно запускаемой программой


так всё-таки, как считаешь, можно ли при NumberOfSections=0 передать управление произвольному коду?
я так знаю, что это возможно, но вот пока не могу понять как именно заставить загрузчик проглотить подобное

Leo_ня
22.04.2010, 11:12
можно ли при NumberOfSections=0 передать управление произвольному коду?
Хм. Я ставил jmp $, запускал. Программа не завершалась, жрала проц, значит передать управление можно. Или мб я тебя не понял.

Есть программа - shutd0wn97. Выключает компьютер. Тоже в 97 байт. Так что, вполне рабочая. Но это всего лишь фан. Конечно, работать не на каждой даже WinXP будет.

А без MajorSubsystemVersion у меня не пашет.