Воробьёвы

(-:

Приложение Windows «голыми руками»

Как известно, для того, чтобы небрежно называть себя программистом в компании друзей-чайников, необходимо (и достаточно J) написать программу, выдающую тем или иным способом на экран надпись «Hello, World!». Теперь Windows позволяет сделать это очень просто. Наберите в старом добром Блокноте: MsgBox “Hello, World!” - и сохраните файл с расширением .vbs (например, ‘Hello.vbs’), затем запустите его двойным щелчком. Те, у кого установлен Word из MS Office XP, могут использовать более изощренный вариант:

Set w = CreateObject("Word.Application")
w.Visible = True
Set rng = w.Documents.Add.Range(0,0)
With rng
	.InsertBefore "Hello, World!"
	.ParagraphFormat.Alignment = 1
	With .Font
		.Name = "Arial"
		.Size = 48
		.Italic = True
		.Color = 200
	End With
End With

Но все это к самой операционной системе имеет лишь отдаленное отношение. Можно ли создать «настоящее» приложение Windows, не используя довольно громоздких сред разработки, на самом обычном компьютере? Оказывается, можно.

Впервые запустив вновь установленный Windows XP Pro и набрав в командной строке debug, я был весьма удивлен, увидев дефис в консольном окне - знакомое приглашение старого отладчика DOS. Эта любопытная вещица является именно тем инструментом, который нам нужен. Кроме него, нам потребуется немного знаний о формате исполняемых файлов PE и процессе их загрузки в память.

Исполняемые файлы Win32 EXE используют формат файла PE. Как и старый формат EXE для DOS, PE-файл состоит из заголовка и собственно образа исполняемой программы. Образ программы составлен из одного или нескольких объектов или секций, которые иногда называют по старинке сегментами. Однако они не имеют ничего общего со старой сегментной моделью, также, впрочем, как и с объектами в том значении, как они используются в языках программирования. Поэтому для обозначения разделов образа программы PE-файла лучше использовать термин «секции».

Разделение на секции нацелено главным образом на оптимизацию управления памятью Windows. По этой причине размеры загруженных в оперативную память секций должны быть кратны размеру страницы памяти (обычно 4 Кб) и выровнены по ее границе. Записанные в файл секции должны быть выровнены по границе «файловых страниц», размер которых кратен размеру сектора (512 байт) - это также сделано для оптимизации загрузки.

Образ программы загружается в память, начиная с некоторого указанного в заголовке файла базового адреса загрузки, который должен быть выровнен по 64 Кб границе. Для EXE-файлов он обычно равен 400000h.

Чтобы наши выкладки не выглядели чересчур абстрактными, приступим сразу к конструированию нашего PE-файла. Примем минимально возможные размеры выравнивания секций - 4000 (1000h) байт - и файла - 512 (200h) байт. Образ нашей программы будет состоять всего из одной секции, в которой будут размещены и данные, и код, и вспомогательные таблицы для импорта. Таким образом, размер файла уместится в 1 Кб, а размер образа в памяти - 8 Кб (2 страницы). Первую страницу по адресу загрузки 400000h займет заголовок PE-файла, а со смещения 1000h будет располагаться первая (и единственная) секция нашего файла.

Итак, создадим «болванку». В командной строке набираем «debug». Очистим 1000 (400h - в отладчике debug используется шестнадцатеричная запись чисел) первых байт памяти, заполнив их нулями:

f 0 400 0

В этой области мы будем «собирать» наше приложение. Но для удобства набора используем еще одну область по смещению 1000h, чтобы не запутаться с адресами при наборе:

f 1000 1200 0

Как и для сценария VBS, работа нашего приложения будет заключаться в отображении окна сообщения «Hello, World!», для чего необходимо использовать функцию Win32 API MessageBoxA (из системного модуля USER32.dll). После этого приложение завершает работу (вызвав еще одну функцию API ExitProcess из модуля KERNEL32.dll). Таким образом, необходимо сначала импортировать указанные две функции в наше приложение.

Процесс импорта заключается, во-первых, в отображении (посредством страничной переадресации) нужных нам DLL в адресное пространство нашего процесса, и, во-вторых, в сохранении адресов нужных нам функций из этих DLL в специально отведенных для этого местах - таблицах импортируемых адресов (Import Address Table - IAT). Все это автоматически проделывается системой при загрузке файла на исполнение, но для этого необходимо предусмотреть в образе программы ряд вспомогательных таблиц для импорта.

Таблицы импортируемых адресов (IAT) должны располагаться в самом начале секции. Они представляют собой последовательности 4-байтовых (DWORD) полей, в которые загрузчик Windows заносит при связывании с DLL адреса соответствующих импортируемых функций. Порядок расположения функций должен соответствовать порядку функций в таблице поиска (об этом далее). Каждая таблица содержит данные о функциях из одного модуля (DLL); признаком конца таблицы является поле, заполненное нулями. При необходимости несколько таблиц следуют одна за другой. До связывания с DLL таблица импортируемых адресов должна быть полностью идентична соответствующей таблице поиска, в противном случае загрузчик сообщит об ошибке.

В нашем случае необходимы две IAT: одна для USER32.dll, другая - для KERNEL32.dll. Из обоих модулей импортируется по одной функции, поэтому размер обоих таблиц будет по 8 байт (4 на адрес, 4 - на завершающие 0). Первая IAT будет располагаться по смещению 1000h относительно базового адреса загрузки, вторая - 1008h. Эти значения мы введем позже.

А пока займемся данными. Функция MessageBoxA принимает в числе прочих параметров адреса двух строк (одна - собственно выводимое сообщение, вторая - заголовок). Выровняем адреса по границе параграфа (это не обязательно и сделано лишь для удобства, чтобы не запутаться с адресами). По смещению 1010h поместим ASCII-строку ‘VBScript’ (чтобы заголовок сообщения был аналогичен заголовку сообщения сценария VBS):

a 1010
db "WBScript"
>Enter&rt;

По смещению 1020h поместим строку ‘Hello, World!’ и оставим побольше места (на случай, если потом захотим изменить сообщение):

a 1020
db "Hello, World!"
>Enter&rt;

По смещениям 1040 и 1050 поместим имена импортируемых модулей USER32.dll и KERNEL32.dll соответственно, на которые будет ссылаться таблица импорта:

a 1040
db "USER32.dll"
>Enter&rt;
a 1050
db "KERNEL32.dll"
>Enter&rt;

Необходимо предусмотреть также имена импортируемых функций, но для них используются строки особого формата: первые два байта строки являются «подсказкой» загрузчику, и лишь после них идет собственно имя. Подсказка представляет собой индекс массива указателей экспортируемых имен DLL, по которому предположительно можно обнаружить искомое имя функции. Если в указанном месте нужное имя отсутствует (например, вследствие изменения версии DLL), загрузчик выполняет поиск данного имени по всему списку экспортируемых имен, на что, естественно, требуется больше времени. В нашем случае придется обойтись без подсказок: на их месте оставим нули. По смещению 1060h поместим имя функции ‘MessageBoxA’, по смещению 1070h - ‘ExitProcess’ (необходимо помнить, что, в отличие от имен DLL, в именах функций учитывается регистр символов):

a 1060
db 0,0,"MessageBoxA"
>Enter&rt;
a 1070
db 0,0,"ExitProcess"
>Enter&rt;

Поскольку текстовые данные закончились, самое время проверить правильность введенных данных. Для дампа памяти используется команда отладчика d:

d 1000

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

Далее (по смещению 1080h) разместим таблицы поиска. Аналогично IAT, таблица поиска состоит из последовательного ряда 32-разрядных (DWORD) значений, завершающихся нулевым полем, для импортируемых из одного модуля функций. Поля таблицы указывают на способ поиска функций в списках экспорта DLL: по порядковым номерам или по именам. В последнем случае поле содержит смещение на строку «подсказка-имя» для искомой функции. В нашем случае соответствующие смещения равны 1060h для ‘MessageBoxA’ и 1070h для ‘ExitProcess’ (к сожалению, debug не признает 32-разрядных чисел, поэтому придется вводить их как пары 16-разрядных; при этом надо учесть, что в PC для чисел применяется обратный порядок следования байтов):

a 1080
dw 1060,0,0,0,1070,0,0,0
>Enter&rt;

Поскольку IAT при загрузке должна быть идентична таблице поиска, теперь мы можем вернуться и заполнить оставленные ранее пустыми поля:

a 1000
dw 1060,0,0,0,1070,0,0,0
>Enter&rt;

Мы добрались до самой главной таблицы - таблицы импорта (не путать с таблицей импортируемых адресов). Таблица импорта связывает воедино все подготовленные ранее данные. Каждая строка таблицы импорта состоит из пяти 4-байтовых (DWORD) полей и относится к одному импортируемому модулю (DLL). Первое поле содержит смещение (относительно базового адреса загрузки) таблицы поиска для данной DLL; второе и третье не используются и содержат нули; четвертое - смещение на строку с именем DLL; пятое - смещение на соответствующую IAT. Число входов в таблицу импорта равно числу импортируемых модулей плюс одна строка, в которой все поля заполнены нулями для обозначения конца таблицы. В нашем случае таблица импорта будет состоять из 3 входов (для USER32.dll, KERNEL32.dll и один пустой). Таблица располагается по смещению 1090h и имеет размер 3х5х4=60 (3Ch) байт:

a 1090
dw 1080,0
(смещение первой таблицы поиска)
dw 0,0,0,0
(два пустых поля)
dw 1040,0
(смещение на строку с именем USER32.dll)
dw 1000,0
(смещение первой IAT)

Аналогично заполняем вторую строку:

dw 1088,0,0,0,0,0,1050,0,1008,0
>Enter&rt;

Последующие 20 байтов оставляем пустыми.

Осталось ввести только сам код. Функция MessageBoxA принимает 4 DWORD-параметра: дескриптор окна приложения (в нашем случае окно отсутствует, т.е. 0), указатель на строку сообщения, указатель на строку заголовка, тип окна сообщения (числовая константа; здесь - 0). Параметры функциям Win32 API передаются через стек в обратной последовательности, т.е. первым помещается в стек последний параметр. Поэтому на ассемблере код мог бы выглядеть примерно так:

push	0
push offset title		; здесь - 401010h
push offset message	; здесь - 401020h
push 0
call	IAT[1]			; адрес функции MessageBoxA

Нужно учесть, что в стек помещаются не смещения, а линейные адреса, поэтому к смещениям строк 1020h и 1010h необходимо добавить базовый адрес загрузки (400000h), получив соответственно 401020h и 401010h; а для импортируемого адреса MessageBoxA - 401000h. Поскольку debug не работает с 32-разрядными смещениями, придется кодировать все самостоятельно (не забыв про обратный порядок байтов в числах):

a 10d0
db 6a,0
db 68,10,10,40,0
db 68,20,10,40,0
db 6a,0
db ff,15,0,10,40,0
ExitProcess (адрес которого хранится во второй IAT по линейному адресу 401008h) 
            принимает лишь один аргумент - код завершения (в нашем случае - 0):
db 6a,0
db ff,15,8,10,40,0
>Enter&rt;

Вот и вся программа. У нас получился как бы «образ в памяти» (по смещению 1000h), теперь надо перенести его на свое место в файле (по смещению 200h):

m 1000 1200 200

Осталась самая малость - заполнить заголовок. Заголовок PE-файла можно разделить на «старый» и «новый». «Старый» заголовок, в свою очередь, состоит из несколько модернизированного заголовка EXE-DOS и необязательной программы-заглушки DOS, которая обычно выводит текст «This program cannot be run in DOS mode», когда PE-файл по ошибке пытаются запустить в DOS. Но вместо нее в принципе может быть любая другая DOS-программа. Модернизация DOS-заголовка заключается, во-первых, в резервировании по смещению 20h с начала файла места для идентификатора и имени производителя программы, которое практически всегда остается пустым. Во-вторых, и это уже существенно, по смещению 3Сh находится 32-разрядный указатель на PE-заголовок.

Единственное, что нам нужно оставить в заголовке DOS - это сигнатура EXE-файла (ASCII-символы ‘MZ’):

a 0
db 4d,5a
>Enter&rt;

Программу-заглушку мы вообще опустим; таким образом, PE-заголовок будет следовать непосредственно за 4-байтным указателем по смещению 3Ch, т.е. по смещению 40h. Это значение и запишем в качестве указателя:

a 3c
db 40
>Enter&rt;

«Новый» заголовок представлен собственно PE-заголовком и так называемой таблицей объектов. PE-заголовок, в свою очередь, делится на стандартный и NT-заголовок. В конце последнего дополнительно выделяют каталог смещений/размеров. Каждый вход каталога представлен парой DWORD-значений, первое из которых содержит смещение соответствующей служебной таблицы относительно базового адреса загрузки, второе - размер таблицы. Если какая-либо из таблиц не используется, соответствующие поля содержат нули. В следующей таблице приведены только те поля PE-заголовка, без которых запуск программы невозможен:

Смещение Размер (байт) Описание
Стандартный
00h 4 Сигнатура: ASCII-символы ‘PE’ и два нулевых байта
04h 2 Тип процессора (обычно 14Сh для i386)
06h 2 Число секций в образе программы
14h 2 Размер NT-заголовка вместе с каталогом смещений; обычно E0h
16h 2 Флаги программы; для Win32-приложений обычно 10Fh
NT-заголовок
18h 2 «Магическое» значение 10Bh
28h 4 Точка входа в программу (смещение)
34h 4 Базовый адрес загрузки (для EXE обычно 400000h)
38h 4 Выравнивание секций в ОЗУ (размер системной страницы памяти, 4Кб=1000h)
3Ch 4 Файловая страница: выравнивание секций в файле (кратно 512 (200h) байт)
40h 2 Старший номер версии Windows; обычно 4
48h 2 Старший номер версии подсистемы Windows; обычно 4
50h 4 Размер загруженного образа вместе со всеми заголовками; кратен размеру
выравнивания секций
54h 4 Общий размер всех заголовков (старых и новых)
5Ch 2 Тип приложения (2 - графическое, 3 - консольное)
74h 4 Число входов в каталоге смещений/размеров (обычно - 10h)
Каталог смещений/размеров
80h 4 Смещение таблицы импорта
84h 4 Размер таблицы импорта

Примечание. Для запуска нашего приложения достаточно заполнить указанные в таблице поля (проверено для трех версий Windows: 98 SE, 2000 Server и XP Pro). Однако для более сложных программ может потребоваться также заполнить 4-байтные (DWORD) поля по смещениям 60h (резервируемый размер стека), 64h (выделенный размер стека), 68h (резервируемый размер кучи), 6Ch (выделенный размер кучи). Таблица объектов следует непосредственно за PE-заголовком и описывает секции (объекты) образа программы. Она фактически является картой отображения записанных на диске секций в память. Число входов в таблицу объектов равно числу секций в образе программы и указывается в PE-заголовке в поле со смещением 6. Каждый вход таблицы имеет следующий формат:

Смещение Размер, байт Описание
0 8 Произвольное имя секции (используется при компоновке). Заполняется нулями до 8 байт.
8 4 Количество памяти, отводимое для загрузки секции
0Ch 4 Размещение секции в памяти, смещение относительно базового адреса загрузки
10h 4 Размер секции в файле, выровненный по границе файловой страницы
14h 4 Смещение секции в файле, выровненное по границе файловой страницы
18h 0Ch Зарезервировано; используется в объектных файлах
24h 4 Флаги секции. Наиболее употребляемые: 20h - секция кода; 40h - инициализированные
данные; 80h - неинициализированные данные; 20000000h - разрешено исполнение;
40000000h - разрешено чтение; 80000000h - разрешена запись

Завершим создание заголовка. По смещению 40h находится сигнатура PE:

a 40
db 50,45,0,0

Процессор i386, число секций - 1:

dw 14c,1
>Enter&rt;

Размер NT-заголовка E0h, флаги программы, «магическое» значение:

a 54
dw e0,10f,10b
>Enter&rt;

Точка входа в программу:

a 68
dw 10d0
>Enter&rt;

Базовый адрес загрузки 40 0000h (в обратном порядке), выравнивание в памяти - 1000h, в файле - 200h:

a 74
dw 0,40,1000,0,200,0

Версия ОС - 4.0, версия подсистемы - 4.0; промежутки заполняются нулями:

dw 4,0,0,0,4 >Enter&rt;

Размер образа с заголовками в памяти - 2000h, размер заголовка в файле - 200h, подсистема 2:

a 90
dw 2000,0,200,0,0,0,2
>Enter&rt;

10h входов в каталоге смещений:

a b4
dw 10
>Enter&rt;

В каталоге смещений используем лишь один вход: смещение таблицы импорта - 1090h, размер - 3Ch. Остальные входы оставляем пустыми:

a c0
dw 1090,0,3c
>Enter&rt;

По смещению 140h начинается таблица объектов; в нашем случае она имеет лишь один вход. Никакого имени секции давать не будем. Секция занимает в памяти 1000h байт, начиная со смещения 1000h; в файле занимает 200h байт и находится по смещению 200h:

a 140
dw 1000,0,1000,0,200,0,200,0
>Enter&rt;

И, наконец, флаги: секция является кодовой, имеет разрешения на исполнение, чтение и запись. Сумма всех флагов (в данном случае это эквивалентно побитовому OR) составит E0000020h (порядок слов обратный):

a 15с
dw 20,e000

Отладчик debug позволяет сохранять файлы лишь в com-формате, при этом первые 100h байт на диск не записываются. Поэтому необходимо сначала «сдвинуть» весь (400h) образ в памяти на 100h байт:

m 0 400 100

Озаглавим наш файл; отладчик debug не позволяет записывать файлы в формате exe, поэтому сохраним сначала файл с расширением .bin, потом его переименуем в exe.

n hello.bin

Количество записываемых байтов заносится в регистр СХ, после чего осуществляется собственно запись командой w:

r cx
400
w

Для выхода из отладчика используется команда q. Но перед этим нужно еще раз тщательно проверить правильность всех введенных данных. При ошибочном расположении чисел в таблицах попытка запуска может вызвать сообщение: «>Имя программы&rt; не является приложением Win32», а то и вообще вызвать сбой системы.

Ну что же, примите мои поздравления: вы создали полноценное приложение Win32 даже не на ассемблере, а в машинных кодах!

  [C] Roustem