Воробьёвы

(-:

Dll в машинных кодах

В статье «Приложение Windows голыми руками» было показано, как с помощью debug «вручную» собрать простейшее Win32 exe-приложение с MessageBox’ом. На этот раз предлагается аналогичным образом создать простейшую dll; это тематическое продолжение прошлой статьи и в то же время необходимый фундамент для статей будущих - поскольку я собираюсь рассказывать в них о создании в машинных кодах компонентов COM, а для них обойтись без dll ну никак нельзя.

Я полагаю, что читатель внимательнейшим образом изучил прошлый материал и умеет теперь с лёту создавать PE-заголовки, таблицы импорта и секции кода и данных J. Предполагается также, что благодаря творчеству Свина и последовавшему за этим повальному поветрию увлечения мануалами от Интела никому не составит особого труда разобраться в hex’ах или хотя бы даже и в бинарных кодах. Поэтому все внимание сосредоточим на более полезных вещах - на том, что же отличает dll от обычных exe-файлов. А отличий главных два: появляются экспортируемые функции и, следовательно, таблица экспорта, а также, как ни прискорбно, придется нам разбираться с настройками (relocations), поскольку загрузка нашей dll по нашему любимому базовому адресу 10000000h вовсе не гарантируется L.

На этот раз для разнообразия сделаем в нашем PE-файле 4 секции - для кода, данных, импорта с экспортом и настроек. Проявим также, в отличие от прошлого раза, почтение и дадим им имена: .code, .data, .rdata и .reloc соответственно. Они расположатся в памяти по смещениям 1000h, 2000h, 3000h, 4000h, а в файле 200h, 400h, 600h и 800h соответственно. Само содержание будет тем же, т.е. экспортируем лишь одну функцию, вся работа которой будет заключаться в отображении MessageBox’а. В данных всего две строки; создаем файл data.txt:

n data.bin
r cx
200
f 0 l 200 0
e 0 "Dll"
e 10 "Экспортированная функция"
m 0 l 200 100
w
q

Надеюсь, понятно, что в файле записаны команды для debug. На этот раз мы решили несколько автоматизировать процесс J.

Итак, строка с заголовком располагается у нас в начале секции данных по смещению 2000h, а «любимый» адрес в памяти будет соответственно 10002000h. Для строки с сообщением цифры будут 2010h и 10002010h соответственно.

А теперь - код! Придется снова импортировать MessageBoxA из User32.dll; на этот раз IAT расположится в собственной секции - .rdata - как обычно, в самом начале, т.е. по смещению 3000h; а «любимый» адрес будет 10003000h. Других импортов нет. Заполняем файл code.txt:

n code.bin
r cx
200
f 0 l 200 0
a 0
; параметры MessageBox’a
db 6a 0
db 68  0 20 0 10
db 68 10 20 0 10
db 6a 0
; вызов MessageBox
db ff 15 0 30 0 10
; возврат
db c3

m 0 l 200 100
w
q

Пустую строку нельзя убирать! А то debug будет ругаться, а вы получите фуфло вместо классного бинарного блока J.

Однако, как мы - не прошло и пяти минут, а уже полфайла составили! На носу новый материал, однако. Посмотрим на код и выделим «топкие» места:

1000: 6a 00 68 00 | 20 00 10 68 | 10 20 00 10 | 6a 00 ff 15
1010: 00 30 00 10 | c3

Я выделил жирным «любимые» адреса, попавшие в состав инструкций. Подлая система может закинуть нашу dll куда-нибудь совсем в другое место - вот тогда-то наши адреса и накроются, а код начнет запихивать в стек всякий хлам и вдобавок отправит все это вместо нашей импортированной функции по невесть какому адресу. Именно эти три 32-разрядных значения и должны быть настроены; а для этого их надо указать в таблице настроек.

Каждая настройка представлена всего лишь двумя байтами - 16-разрядным значением, причем 4 старших бита обозначают тип настройки. Для Win32 это практически всегда значение 3, означающее, что надо «поправить» 32-разрядный адрес по указанному смещению. А смещение указывают оставшиеся 12 битов. Но, как вы понимаете, этого хватает лишь на смещения в пределах одной страницы - 4 Кб. Так оно и есть - настройки группируются в блоки; для каждой настраиваемой страницы имеется свой блок, а в начале блока первые 4 байта содержат смещение данной страницы относительно базового адреса загрузки, а следующие 4 байта - размер блока (вместе с первыми 8 байтами). Остальное содержимое блока - набор настроек для данной страницы (см. рис.)

При загрузке dll система вычисляет так называемую дельту - разницу между базовым адресом загрузки, указанным в PE-заголовке, и адресом, по которому фактически загружена dll. Естественно, если dll загружена по своей «любимой» базе, дельта равна 0 и никаких настроек не требуется. Если же это не так, дельта добавляется к каждому 32-разрядному значению, для которого имеется настройка. Всего-то и делов.

В нашем случае должно быть 3 настройки; их смещения относительно начала страницы - 3, 8 и 10h. С учетом типа (3) получаем числа 3003h, 3008h и 3010h. Блок должен быть выровнен по 32-разрядной границе, поэтому в конец добавим наполнитель из «пустой» настройки (для нее есть даже свой тип - естественно, 0). В итоге получаем: RVA страницы - это смещение настраиваемой, т.е. кодовой страницы - 1000h; размер блока - 8 байт + 3 настройки по 2 байта + 1 «пустая» настройка (2 байта) - всего 10h.

Секция настроек готова! Набираем файл reloc.txt:

n reloc.bin
r cx
200
f 0 l 200 0
a 0
; RVA страницы
dw 1000 0 
; размер блока
dw 10 0 
; настройки
dw 3003
dw 3008
dw 3010

m 0 l 200 100
w
q

Остается экспорт - поскольку статью читают специалисты по импорту, о нем больше ни слова. Главная таблица, объединяющая все остальные - таблица экспорта - имеет следующий вид:

DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name;
DWORD Base;
DWORD NumberOfFunctions;
DWORD NumberOfNames;
DWORD AddressOfFunctions; // RVA from base of image
DWORD AddressOfNames; // RVA from base of image
DWORD AddressOfNameOrdinals; // RVA from base of image

Взаимоотношения различных таблиц показаны на следующем рисунке:

Как видно по рисунку, данные для экспорта представляют собой преимущественно различного рода смещения и индексы. При экспорте по ординалам используются всего две таблицы: основная таблица экспорта и таблица экспортируемых адресов. Последняя представляет собой просто массив 32-разрядных смещений реализаций соответствующих функций относительно базового адреса загрузки. Т.е. у каждой экспортированной функции имеется свой индекс в этом массиве; добавив к этому индексу т.н. базу, получаем ординал этой функции.

Если функции экспортируются по именам, добавляются еще три таблицы: ординалов, указателей имен и самих имен. С таблицей имен все понятно: она содержит экспортируемые имена. Причем их может быть меньше, чем экспортируемых адресов. «Стыковку» же имен с адресами соответствующих функций осуществляют таблицы ординалов и указателей имен; фактически, это два тесно сопряженных друг с другом массива. Сопряжение осуществляется за счет того, что на одном и том же месте (с одинаковым индексом) в обоих массивах находятся данные для одной функции: в первом массиве - индекс для таблицы экспортируемых адресов, во втором - адрес строки с именем соответствующей функции. Причем функции в этих двух массивах расположены так, что их имена упорядочены по алфавиту (в порядке возрастания их индексов в этих массивах). Заметьте: упорядоченными должны быть не сами имена (их можно даже разбросать по всей секции); упорядоченными должны быть указатели на них в другой таблице. Следует также помнить, что индексы в таблице ординалов 16-разрядные, а указатели имен - 32-разрядные.

Пора взяться за последнюю секцию - ‘.rdata’. Сначала прикинем «макет» (см. рис.)

Теперь файл rdata.txt:

n rdata.bin
r cx
200
f 0 l 200 0
a 0
; Импорт
; IAT
dw 3020 0 0 0
; Таблица поиска
dw 3020 0 0 0
; Имя импортируемого модуля
db "User32.dll" 0

a 20
; Импортируемая функция с hint’ом
db 0 0 "MessageBoxA" 0

a 30
; Таблица импорта:
; смещение таблицы поиска
dw 3008 0
; 2 пустых поля
dw 0 0 0 0
; смещение имени dll
dw 3010 0
; смещение IAT
dw 3000 0

a 60
; Экспорт
; Таблица экспорта:
; 3 пустых поля
dw 0 0 0 0 0 0
; смещение имени dll
dw 3094 0
; база ординалов
dw 1 0
; число адресов
dw 1 0
; число имен
dw 1 0
; смещение адреса 
dw 3088 0
; смещение указателя имени
dw 3090 0
; смещение ординала
dw 308C 0
; (Таблица) адресов
dw 1000 0
; (Таблица) ординалов
dw 0 0
; (Таблица) указателей имен
dw 30a0 0
; Имя dll
db "Dll.dll" 0

a a0
; Экспортируемая функция
db "Function1" 0

m 0 l 200 100
w
q

Помните, пустые строки нельзя трогать! Теперь надо лишь слегка подправить PE-заголовок - файл header.txt:

n Header.bin
r cx
200
f 0 l 200 0
e 0 'MZ'
e 3C 40
e 40 'PE'
e 44 4C 01
a 46
; Число секций 
db 04 

a 54
; Размер дополнительного заголовка
db e0 00
; Тип файла: установить флаг dll и флаг, разрешающий
; загружать образ по базовому адресу, отличному от
; указанного в PE-заголовке
db 0E 21
; "Магическое" значение
db 0B 01

a 74
; Базовый адрес загрузки
db 00 00 00 10
; Выравнивание секций
db 00 10 00 00 
; Выравнивание в файле
db 00 02 00 00
; Старшая версия Windows
db 04

a 88
; Старшая версия подсистемы
db 04

a 90
; Размер загруженного файла в памяти
db 00 50 00 00
; Размер всех заголовков в файле
db 00 02

a 9C
; Подсистема
db 02 00 

a A0
; Зарезервированный размер стека
db 00 00 10 00
; Выделенный размер стека
db 00 10 00 00
; Зарезервированный размер кучи
db 00 00 10 00
; Выделенный размер кучи
db 00 10 00 00

a B4
; Число элементов каталога смещений
db 10 00 00 00
;
; Каталога смещений:
; смещение таблицы экспорта
dw 3060 0
; размер данных экспорта
dw 4a 0
; смещение таблицы импорта
dw 3030 0
; размер таблицы импорта
dw 28 0
; пропускаем 24 байта (3 элемента)
dw 0 0 0 0 0 0 0 0 0 0 0 0
; смещение таблицы настроек
dw 4000 0
; размер таблицы настроек
dw 10

a 138
; Начало таблицы секций
;
; имя первой секции
db '.code' 0 0 0
; размер секции в памяти
dw 200 0
; смещение секции относительно адреса загрузки
dw 1000 0
; размер данных секции в файле
dw 200 0
; смещение начала данных секции в файле
dw 200 0
; Пропускаем 12 байтов
dw 0 0 0 0 0 0
; атрибуты первой секции
db 20 00 00 60
; вторая секция
db '.data' 0 0 0
dw 200 0
dw 2000 0
dw 200 0
dw 400 0
dw 0 0 0 0 0 0
db 40 0 0 c0
; третья секция
db '.rdata' 0 0
dw 200 0
dw 3000 0
dw 200 0
dw 600 0
dw 0 0 0 0 0 0
db 40 0 0 40
; четвертая секция
db '.reloc' 0 0
dw 200 0
dw 4000 0
dw 200 0
dw 800 0
dw 0 0 0 0 0 0
db 40 0 0 42

m 0 l 200 100
w
q

Всё! Собираем все вместе в файле make.bat:

@echo off
debug < header.txt > report.lst
debug < code.txt >> report.lst
debug < data.txt >> report.lst
debug < rdata.txt >> report.lst
debug < reloc.txt >> report.lst
copy /b header.bin+code.bin+data.bin+rdata.bin+reloc.bin dll.dll

Запускаем этот файл и - ура! - получаем нашу dll. Всенепременно надо заглянуть в заботливо созданный для вас файл отчета - report.lst, чтобы придирчиво поискать там ошибки, о которых сообщает debug. Ведь вы, конечно, прекрасно знаете: метод cut&paste не спасает от самых дебильных ошибок!

Да, это, конечно, хорошо; но ведь понадобится еще и тестовое exe-приложение, чтобы проверить работу нашей dll? Я нисколько не сомневаюсь, что ваш уровень теперь позволит с легкостью самостоятельно создать в debug эту тестовую программку J.

Ладно, ладно... Вижу вытянувшиеся физиономии некоторых. Вы славно потрудились сегодня (даже если просто прочли это до конца), и в качестве бонуса я решил предоставить «ленивое» тестовое приложение на MASM’е 32-ом. Вот оно:

.386
.model flat,stdcall
option casemap:none

include masm32includewindows.inc
include masm32includekernel32.inc
include masm32includeuser32.inc
includelib masm32libkernel32.lib
includelib masm32libuser32.lib

.data

hM1	dword 0
hM2	dword 0
app	db "Test dll",0
dll	db "dll.dll",0
dll2	db "dll2.dll",0
fname	db "Function1",0
err1	db "LoadLibrary (dll1) failed",0
err1_1	db "LoadLibrary (dll2) failed",0
err2	db "GetProcAddress (first) failed",0
err2_1	db "GetProcAddress (second) failed",0

.code
start:
invoke LoadLibrary,offset dll
.if eax==0
	invoke MessageBox,0,offset err1,offset app,MB_ICONERROR
	ret
.endif
mov hM1,eax
invoke LoadLibrary,offset dll2
.if eax==0
	invoke MessageBox,0,offset err1_1,offset app,MB_ICONERROR
	ret
.endif
mov hM2,eax
invoke GetProcAddress,hM1,offset fname
.if eax==0
	invoke MessageBox,0,offset err2,offset app,MB_ICONERROR
	ret
.endif
call eax
invoke GetProcAddress,hM2,offset fname
.if eax==0
	invoke MessageBox,0,offset err2_1,offset app,MB_ICONERROR
	ret
.endif
call eax
invoke FreeLibrary,hM1
invoke FreeLibrary,hM2
ret
end start

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

До новых встреч!

  [C] Roustem