Сообщений 34    Оценка 505        Оценить  

Загрузчик PE-файлов

Исследование формата Portable Executable, сопровождающееся написанием PE-загрузчика

Автор: Максим М. Гумеров
MirkWood
Опубликовано: 20.03.2003
Исправлено: 13.03.2005
Версия текста: 1.3
Background
Формат файлов Portable Executable
Прототип функции-загрузчика
Предварительные замечания о выгрузке DLL
Загрузка: шаг 1
Загрузка: шаг 2
Загрузка: шаг 3
Загрузка: шаг 4
Загрузка: шаг 5
Загрузка: шаг 6
Загрузка: шаг 7
Полный текст функции XLoadLibrary (без вложенных функций)
Функциональные ограничения предложенного загрузчика
Код, выгружающий DLL
Литература

Background

Не вдаваясь в подробности, скажу лишь, что исследование было начато ради сокрытия использования программой на Delphi некоей DLL (написанной на VC++). То есть оператор видит один только Exe-файл, запускает его, а тот каким-то образом подключает функции, содержащиеся изначально (при компиляции проекта) в некоторой DLL.

Как сделать так, чтобы никто не заметил рядом с исполнимым файлом эту DLL? Ясно, что распространять ее вместе с файлом нельзя. Создание файла DLL из информации, хранимой в самом приложении (например, в виде ресурса) в %TEMP% или где-нибудь в GetTempXXX было признано решением, недостойным программиста. Оставалось два очевидных варианта: либо создавать виртуальный диск, помещать на него тем или иным образом (например, загружая из ресурса основного приложения) DLL и загружать ее, а по окончании работы – уничтожать диск, либо загружать DLL самостоятельно. Допуская вольность, отнесу любой перехват функций подсистемы ввода-вывода к решениям первого типа, т.к. все эти варианты для успешного решения задачи требуют написания драйвера. Но пример драйвера виртуального диска уже есть в DDK, а мы не ищем легких путей. Кроме того, необходимо загружать драйвер динамически, без перезагрузки системы, что вызвало бы проблемы при работе в Windows 95. Поэтому решено было писать загрузчик DLL.

Существенным ограничением стало желание свести к минимуму использование не документированной Microsoft информации. Для того, чтобы обеспечить приемлемый уровень функциональности загрузчика, достаточно обойтись двумя такими утверждениями – о смысле HMODULE в WinAPI и о необходимости загрузки в память заголовков PE-файла. Далее каждое из этих утверждений рассматривается подробно.

В качестве языка программирования при написании статьи использовался Object Pascal, но многие описания в статье сопровождаются комментариями относительно того, как можно ими воспользоваться при программировании на C/C++. Кроме того, алгоритм-то ведь один!

Формат файлов Portable Executable

К настоящему моменту есть несколько общедоступных источников информации о формате PE, большую часть из которых составляют публикации в журнале MSDN Magazine. Эти публикации подчас не во всем стыкуются с другими источниками; кроме того, даже если собрать воедино все содержащиеся в них данные, все равно остаются отдельные “белые пятна”. Работа, которую Вы читаете, является попыткой, во-первых, рассмотреть формат PE с практической точки зрения (то есть не с обычной позиции сбора информации о PE-файле, а ради создания кода, способного в значительной степени заменить встроенный в систему загрузчик PE-файлов, повысив его гибкость), и, во-вторых, устранить неясности или, как минимум, точно указать их.

Формат PE – это основной формат исполнимых файлов приложений в 32-разрядных системах Microsoft Windows (а теперь и в 64-разрядных). Наиболее часто встречаются три вида файлов в формате PE: исполнимые модули (*.EXE), динамически подключаемые библиотеки (*.DLL), драйверы устройств, работающие в режиме ядра (Kernel mode drivers) и объектные файлы в формате COFF (создаваемые компиляторами от Microsoft). По причинам, изложенным в Background, в этой работе акцент делается на разбор формата DLL; формат COFF в общих чертах неплохо описан в статье Matt Pietrek (кроме того, по нему существует достаточно обширная документация), загрузка драйверов вообще не наше дело – этим занимается ядро, так что и подход нужен особый. Загрузка же EXE-файлов имеет первым этапом (до подготовки начала выполнения) практически те же действия, что и загрузка DLL.

PE-файл состоит из следующих основных частей:

Я не буду подробно описывать форматы заголовков – большая часть из них описана в Windows.pas (кроме того, на C++ все они описаны в WinNT.h, а также некоторые - в статье Matt Pietrek). Остановлюсь лишь на тех полях, которые имеют значение для поставленной задачи – загрузки DLL.

Заголовок MS-DOS служит, насколько я могу понять, единственно для выдачи сообщения в духе “Эта программа не может работать в среде MS-DOS” при попытке ее таки заставить работать в этом режиме. Заголовок представляет собой запись типа TImageDosHeader (или IMAGE_DOS_HEADER - далее я буду в скобках указывать имена, под которыми соответствующие сущности описаны в WinNT.h). Нас в ней будет интересовать единственное поле e_lfanew, которое в Windows.pas вследствие опечатки называется _lfanew, - оно содержит смещение (от начала файла) PE-заголовка.

Заголовок PE состоит из двух частей: обязательной и дополнительной. Формат COFF допускает разные форматы дополнительной части и даже ее отсутствие. Однако в случае EXE и DLL-файлов дополнительная часть заголовка всегда присутствует. К заголовку PE мы еще вернемся после знакомства с секциями PE-файла.

Область файла после блока заголовков делится на секции (как правило, секции соответствуют создаваемым компоновщиками сегментам). Секция характеризуется набором атрибутов, среди которых (названия я пока дам произвольно):

PE-формат подразумевает, что при загрузке в память секции должны размещаться одна за другой в пределах области памяти, выделяемой для загрузки файла. Размер этой области (ImageSize) указан в PE-заголовке.

Действия загрузчика, таким образом, сводятся к 9 шагам:

  1. Выделить в адресном пространстве процесса, к которому подключается DLL, область памяти размером ImageSize.
  2. Для каждой секции DLL: выделить внутри отведенной DLL области памяти по смещению от ее начала, равному RVA, область для загружаемой секции размером VirtualSize, и загрузить в начало выделенной для секции области памяти данные размером RawDataSize, находящиеся в исходном файле по смещению RawDataOffset. При этом RawDataSize может быть меньше VirtualSize – в таком случае не затронутую при загрузке часть отведенной для секции памяти следует инициализировать нулями. Этот эффект может возникать из-за выравнивания начала следующей секции по границе страницы памяти (4Кб) или при загрузке сегмента неинициализированных данных, порождаемого многими компиляторами. В разделе, посвященном этому шагу, есть по этому поводу важное замечание.
  3. Произвести необходимые действия по настройке ссылок внутри DLL (см. далее).
  4. Для каждой загруженной секции установить защиту отведенной ей области памяти в соответствии с флагами Protection этой секции.
  5. Обработать таблицу импорта DLL (см. далее). Это можно сделать после шага 4, т.к. секция с таблицей импорта не защищается от записи.
  6. Вызвать функцию DllMain загруженной DLL (точнее, передать управление на точку входа DLL) для уведомления DLL о загрузке. Если функция возвращает ошибку, следует прекратить загрузку и восстановить исходное состояние (т.е. до выполнения пункта 1). Если же все прошло нормально, зарегистрировать загруженный модуль в системных структурах Windows, чтобы его можно было отыскать функциям такого рода, как GetModuleHandle.
  7. Обработать таблицу экспорта (см. далее), чтобы обращаться к функциям DLL из процесса, в адресное пространство которого мы ее загружаем.

Итак, приступаем к реализации, попутно разбираясь, что к чему.

ПРИМЕЧАНИЕ

Должен сразу попросить прощения за нечеткость синтаксиса: компилятор Delphi допускает неявную разадресацию указателей (т.е. если тип TArr – это массив, а переменная P – указатель на такой массив (т.е. P:^TArr), то “P[i]” эквивалентно “P^[i]”), и я иногда этим пользуюсь, хотя, признаю, это плохой стиль.

Кроме того, далее я, употребляя термин “индекс элемента в массиве”, буду подразумевать, что элементы проиндексированы начиная с 0 (если только явно не указано обратное).

Прототип функции-загрузчика

Как будет выглядеть функция-загрузчик с точки зрения программного интерфейса? Очевидно, она должна каким-то образом узнавать, откуда следует брать информацию, интерпретируемую как содержимое файла DLL. Напрашивается указание в качестве источника объекта класса TStream, но хочется иметь доступ к источнику как к блоку памяти. Поэтому я предпочел класс TCustomMemoryStream: во-первых, его потомок TResourceStream умеет работать с ресурсными данными приложения как с областью памяти (свойство Memory, унаследованное от TCustomMemoryStream), и, во-вторых, в другой потомок, TMemoryStream, всегда можно скопировать информацию из любого TStream. В дальнейшем для простоты изложения будем обозначать источник информации, заменяющий нам файл DLL, словосочетанием исходный файл.

ПРИМЕЧАНИЕ

Наибольшей универсальности можно добиться, если принимать в качестве параметра адрес области памяти, в которой уже находится содержимое исходного файла. При этом еще и обеспечивается совместимость функции с C. Более красивое и стандартизованное решение, тоже позволяющее обеспечить совместимость – задание входного потока не экземпляром класса, а интерфейсом IStream (см. также в справке по Delphi описание класса-переходника TStreamAdapter). Однако для пользователя нашей функции, пишущего на Delphi, наиболее прост в использовании, на мой взгляд, именно предлагаемый вариант.

Будет привычно, если функция будет, как и стандартная функция LoadLibrary, возвращать дескриптор HMODULE. А главное, это позволит передавать возвращаемое значение в такие функции, как LoadIcon. Естественно, при этом нужно придать возвращаемому значению тот же смысл, что и в WinAPI. В WinAPI во всех случаях (кроме Win16) HINSTANCE и HMODULE модуля равны и совпадают с его базовым адресом.

В связи с тем, что осуществляется явное связывание с DLL, подключение функций через таблицу импорта основного приложения невозможно. Чтобы избежать необходимости импортировать используемые приложением функции DLL по одной последовательными вызовами GetProcAddress, что загромождает код приложения, нагрузка на осуществление импортирования я сделал задачей загрузчика. Для этого вторым параметром в функцию XLoadLibrary (так я назвал функцию-загрузчик) передается своебразный суррогат таблицы импорта: массив записей, каждая из которых содержит название функции и указатель на процедурную переменную (процедурная переменная - аналог указателя на функцию в C), в которую загрузчик помещает адрес, начиная с которого загружена в память соответствующая процедура. Для такого подхода есть и еще одна причина, более значимая – функция GetProcAddress с загружаемой нестандартным образом DLL просто не работает (об этом подробнее в разделе статьи, посвященном экспортированию).

Type TImportItem=record
      Name:string;
      PProcVar: ^pointer //для упрощения пусть будет просто указатель на указатель;
                         // с контролем типов сами разберемся 
end;

function XLoadLibrary(Source: TCustomMemoryStream; Imports: array of TImportItem):HMODULE;

Вызов же XLoadLibrary может теперь выглядеть примерно так:

Var
  //Прототипы функций, объявляем как процедурные переменные
  AttachView:         function (HWND:HWND):THandle; cdecl;
  DetachView:         procedure (View:THandle); cdecl;
  SetDrawWindowRect:  procedure (View:THandle;var lpRect:TRect); cdecl;
  PaintView:          procedure (View:THandle;dc:HDC;x,y:integer); cdecl;
  //В библиотеке они действительно cdecl

  //”Таблица импорта”
  MyImports:array [0..3] of TImportItem =(
   (Name: 'CreateDllObject';   PProcVar:@@AttachView), //@@ - адрес самой процедурной переменной
   (Name: 'DeleteDllObject';   PProcVar:@@DetachView),
   (Name: 'SetDrawWindowRect'; PProcVar:@@SetDrawWindowRect),
   (Name: 'DrawHDCFrom';       PProcVar:@@PaintView)
  );

  L:HMODULE;
  S:TResourceStream;

Begin
  S:=TResourceStream.Create(HInstance,'PEFILE1','PEFILE');
  try
    L:=XLoadLibrary(S,MyImports);
  finally
    S.Free;
  end;
End.

Предварительные замечания о выгрузке DLL

Может показаться, что я пытаюсь бежать впереди собственной телеги. Однако от того, как мы намерены выгружать DLL, зависят детали реализации, сопровождающие загрузку.

Для выгрузки DLL служит процедура XFreeLibrary, принимающая по аналогии с FreeLibrary один параметр – HModule загруженной DLL. Существенным отличием является то, что наш вариант XFreeLibrary (как и XLoadLibrary!) не будет выполнять подсчет ссылок (желающие могут добавить такую возможность).

У нее три основные задачи: (1) если адрес DllMain не равен 0, вызвать DllMain, сообщив DLL о выгрузке; (2) освободить выделенную для хранения образа DLL память и (3) уменьшить счетчики ссылок на модули, которые были загружены из-за того, что содержащиеся в них символы потребовались данной DLL.

Наиболее сложна реализация пункта 3. Можно, конечно, пройтись во второй раз (первый проход осуществляется при загрузке) по таблице импорта, выяснив таким образом, счетчики ссылок каких DLL следует уменьшить. Альтернативой является аккумулирование информации в процессе загрузки.

Кроме того, надо вести список загруженных “вручную” DLL (просто в целях хранения полезной информации).

Для этого введем список LoadedLibs, содержащий указатели на записи типа TLibInfo:

 Type
   TLibInfo=record
     ImageBase:pointer;     //Базовый адрес образа
     DllProc:TDllEntryProc; //Указатель на DllMain (используется при выгрузке DLL)
     LibsUsed:TStringList;  //Список, содержащий имена импользуемых DLL модулей и их значения HModule
   end;

 Var
   LoadedLibs:TList=nil;

Загрузка: шаг 1

Необходимо: по полю e_lfanew DOS-заголовка найти PE-заголовок, а затем воспользоваться данными PE-заголовка для выделения памяти.

ПРЕДУПРЕЖДЕНИЕ

Недокументированная информация:

Для нормального функционирования стандартных API нужно еще записать в начало отведенной для загрузки DLL области памяти (этот адрес начала называется базой образа PE-файла) записать блок заголовков. Это нигде не описано официально (по крайней мере, я таких источников найти не сумел), и непонятно, есть ли гарантия, что расстояние от базы DLL до начала ближайшей к ней секции достаточно велико для размещения заголовка, но иначе не работают, например, функции для работы с ресурсами загружаемой DLL. Поэтому придется писать заголовок на свой страх и риск.

Впрочем, это распространенное допущение. Например, в [8] можно найти такое утверждение: “Все секции (в том числе и "псевдосекция" заголовков) присутствуют и в программном файле, и в памяти, причем располагаются в одном и том же порядке.”. Чтение из заголовка в предположении, что с него начинается образ модуля, используется и в [9].

Поэтому у нас есть выбор: можно обойтись без использования не подтвержденных документацией предположений, лишив при этом загружаемую DLL доступа к содержащимся в ней ресурсам, а можно – положиться на приведенное утверждение. Я решил поверить авторитетам и пойти по второму пути, так как случаи, когда DLL содержит нужные ей для работы ресурсы, нередки.

Коль скоро мы решили пойти на эту жертву, нужно еще загрузить в начало отведенной области памяти блок заголовков (т.е. первые PEHeader.OptionalHeader.SizeOfHeaders байт исходного файла).

Заголовок PE имеет следующий формат:

  _IMAGE_NT_HEADERS = packed record
    Signature: DWORD;
    FileHeader: TImageFileHeader;
    OptionalHeader: TImageOptionalHeader; 
  end;
  TImageNtHeaders = _IMAGE_NT_HEADERS;

Строго говоря, это квазиструктура, т.к. OptionalHeader в принципе может отсутствовать. Его наличие (а в будущем, вероятно, и версия) индицируется полем SizeOfOptionalHeader записи FileHeader. Однако, как я уже упоминал выше, для DLL эта часть заголовка является обязательной. Вот форматы двух частей PE-заголовка.

Обязательная:

Type
  _IMAGE_FILE_HEADER = packed record  //(C++: IMAGE_FILE_HEADER)
    Machine: Word;           //CPU назначения, для 32-bit Intel это 014С (hex)
    NumberOfSections: Word;  //Число PE-секций
    TimeDateStamp: DWORD;    //Дата и время (число секунд с момента 16:00 31.12.1969) создания файла
    PointerToSymbolTable: DWORD;   //Для OBJ-файлов поле указывает на таблицу символов
                                   //Для DLL - на таблицу с отладочной информацией, но таких может
                                   // быть несколько, и лучше руководствоваться элементом 
                                   // IMAGE_DIRECTORY_ENTRY_DEBUG каталога OptionalHeader.DataDirectory
                                   // (см. далее)
    NumberOfSymbols: DWORD;  //Кол-во символов для OBJ; для DLL справедливо предыдущее замечание
    SizeOfOptionalHeader: Word;    //Размер дополнительной части PE-заголовка
    Characteristics: Word;   //Различные информационные флаги; по большому счету, не влияют
                             // на процесс загрузки
  end;
  TImageFileHeader = _IMAGE_FILE_HEADER;

Дополнительная:

Type
  _IMAGE_OPTIONAL_HEADER = packed record  //(C++: IMAGE_OPTIONAL_HEADER)
    Magic: Word;	//Сигнатура:
			//  010B для 32-битного PE,
			//  020B для 64-битного,
			//  0107 для ROM (???)
    MajorLinkerVersion: Byte;
    MinorLinkerVersion: Byte;  //Понятно без слов
    SizeOfCode: DWORD;     //Суммарный виртуальный размер всех секций, содержащих код
    SizeOfInitializedData: DWORD;   //То же для инициализированных данных
    SizeOfUninitializedData: DWORD; // и для неинициализированных
    AddressOfEntryPoint: DWORD; //Виртуальный адрес точки входа в PE-файл (для DLL это адрес
          // DllMain, - процедуры, обрабатывающей сообщения о загрузке/выгрузке данной DLL в
          // какой-либо процесс). 0, если точки входа нет.
    BaseOfCode: DWORD;   //  Виртуальный адрес секции с кодом. Что содержит это поле, если секций 
                         //несколько, мне точно не известно – но это и не представляет интереса
    BaseOfData: DWORD;   //То же для данных
    ImageBase: DWORD;    //Предпочтительный базовый адрес загрузки. Внутри DLL все абсолютные (т.е.
           // не в виде смещения от ссылающейся инструкции, а в виде адреса) ссылки на содержащиеся
           // в ней объекты формируются в предположении, что DLL загружается в память именно с
           // этого базового адреса. Если это не так, ссылки нужно корректировать при помощи
           // информации из секции перемещений (Relocation), см. раздел о коррекции ссылок
    SectionAlignment: DWORD;  //Все виртуальные адреса секций кратны этому числу
    FileAlignment: DWORD;     //Для любой секции данные, помещаемые в нее, находятся в исходном
                              // файле по смещению, кратному этому числу
    MajorOperatingSystemVersion: Word;
    MinorOperatingSystemVersion: Word; //Тоже, вроде бы, понятно
    MajorImageVersion: Word;
    MinorImageVersion: Word;
    MajorSubsystemVersion: Word;
    MinorSubsystemVersion: Word;
    Win32VersionValue: DWORD;   //Зарезервировано. Есть мысль, что сюда положат версию
                                // Win32-эмулятора для Win64-систем
    SizeOfImage: DWORD;  //Размер области памяти, необходимый для размещения образа PE-файла
                         //Равен виртуальному адресу, начиная с которого могла бы располагаться
                         // секция, идущая в памяти сразу за последней существующей секцией (т.е.
                         // вирт. адрес конца последней секции, дополненной до границы секции с
                         // учетом SectionAlignment)
    SizeOfHeaders: DWORD;   //Размер области заголовков. Областью заголовков считается все
                            // пространство исходного файла до списка секций
    CheckSum: DWORD;     //Возможно, род цифровой подписи или вид CRC, но обычно это поле равно 0
    Subsystem: Word;     //Для исполнимых файлов – требуемая для работы подсистема
    DllCharacteristics: Word;  //Свойства DLL. Значения 1,2,4 и 8 зарезервированы;
                               // 2000hex означает WDM-драйвер
    SizeOfStackReserve: DWORD;    // *
    SizeOfStackCommit: DWORD;     // * Эти 4 полей управляют действиями,
    SizeOfHeapReserve: DWORD;     // * выполняемыми при загрузке EXE-файла 
    SizeOfHeapCommit: DWORD;      // *
    LoaderFlags: DWORD;           //Рудимент, более не используется
    NumberOfRvaAndSizes: DWORD;       // Количество элементов в каталоге DataDirectory
    //Далее следует массив из NumberOfRvaAndSizes элементов, ссылающиеся на важные структуры данных,
    // такие как: секция импорта, секция экспорта, секция ресурсов и т.п.
    //Лучше для доступа к этим структурам применять именно DataDirectory, а не искать нужную
    // секцию путем перебора всех секций, т.к. в одной секции могут (теоретически) находиться
    // сразу несколько управляющих структур (напр., и таблицы импорта, и экспорта)
    DataDirectory: packed array[0..IMAGE_NUMBEROF_DIRECTORY_ENTRIES-1] of TImageDataDirectory;
  end;
  TImageOptionalHeader = _IMAGE_OPTIONAL_HEADER;

  //Формат элементов DataDirectory
  _IMAGE_DATA_DIRECTORY = record  //(C++: IMAGE_DATA_DIRECTORY)
    VirtualAddress: DWORD;	//Виртуальный адрес начала структуры
    Size: DWORD;  //Размер структуры (фактический, т.е. без выравнивания до начала следующей секции)
  end;
  TImageDataDirectory = _IMAGE_DATA_DIRECTORY;

ПРИМЕЧАНИЕ

Отмечу, что AddressOfEntryPoint совсем не обязан указывать на точку внутри какой-то из секций. Контрпримером является вирус Win.CIH [8].

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

function XLoadLibrary(Source: TCustomMemoryStream; Imports: array of TImportItem):HMODULE;
var ImageBase:pointer;	     // Базовый адрес (база) DLL
    PEHeader:PImageNtHeaders; //(C++: IMAGE_NT_HEADERS* )
    Src:pointer;
begin
  Src:=Source.Memory;

  PEHeader:=pointer(int64(cardinal(Src))+PImageDosHeader(Src)._lfanew);
    //int64 нужен, чтобы избавиться от предупреждения компилятора
  ImageBase:=VirtualAlloc(nil,PEHeader.OptionalHeader.SizeOfImage,MEM_RESERVE,PAGE_NOACCESS{READWRITE});
    //Попытались выделить(точнее, зарезервировать) память
  If ImageBase=nil then raise EXLL_CannotAllocateMemory.Create('');
    //Если не вышло, выбросили исключение

  //Скопируем заголовки:
  SectionBase:=VirtualAlloc(ImageBase,PEHeader.OptionalHeader.SizeOfHeaders,MEM_COMMIT,
        PAGE_READWRITE); //Выделяем часть зарезервированной памяти
  move(Src^,SectionBase^,PEHeader.OptionalHeader.SizeOfHeaders);
  VirtualProtect(SectionBase,PEHeader.OptionalHeader.SizeOfHeaders,PAGE_READONLY,Temp_Cardinal);
  //На всякий случай сделаем заголовок Read-Only (документация ничего не говорит о режиме доступа)

Тип Cardinal – это Delphi-эквивалент unsigned long, или DWORD.

Загрузка: шаг 2

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

Список секций PE-файла располагается сразу после области заголовков и представляет собой массив из PEHeader.FileHeader.NumberOfSections элементов, каждый из которых имеет следующий формат:

  TISHMisc = packed record
    case Integer of  //(C++: Это просто объединение (union))
      0: (PhysicalAddress: DWORD); //Для DLL и EXE используется вторая интерпретация:
      1: (VirtualSize: DWORD);     // - размер секции в памяти
  end;

  _IMAGE_SECTION_HEADER = packed record  //(C++: IMAGE_SECTION_HEADER)
    Name: packed array[0..IMAGE_SIZEOF_SHORT_NAME-1] of Byte;
        //Для EXE и DLL - имя в виде IMAGE_SIZEOF_SHORT_NAME=8 символов (не 0-терминированное!)
    Misc: TISHMisc; //(C++: В Delphi объединения могут находиться только в конце структуры)
    VirtualAddress: DWORD;  //Виртуальный адрес начала секции
    SizeOfRawData: DWORD;   //Количество инициализирующих данных (помещаемых в
                            //  начало секции из исходного файла при ее создании); округлено
                            //  вверх до FileAlignment
    PointerToRawData: DWORD;    //Смещение инициализирующих данных от начала исходного файла
    PointerToRelocations: DWORD;
      //  В случае OBJ это поле содержит смещение (от начала файла) собственной таблицы Relocations
      //данной секции; в EXE и DLL равно 0
    PointerToLinenumbers: DWORD;   //Указатель на таблицу номеров строк исходного текста PE-файла
                                   // (отладочная информация). В ряде случаев номера строк размещаются
                                   // не там, а среди остальной отладочной информации
    NumberOfRelocations: Word;	//Количество элементов в таблице, адресуемой PointerToRelocations
    NumberOfLinenumbers: Word;  //То же для PointerToLineNumbers
    Characteristics: DWORD;
         //  А вот это поле характеристик уже очень даже значимое для загрузки. Некоторые важные флаги 
         //приведены далее.
  end;
  TImageSectionHeader = _IMAGE_SECTION_HEADER;

СОВЕТ

Необходимо отметить следующий факт: RawDataSize может быть как меньше, так и больше VirtualSize (разумеется, возможно и равенство). Меньше бывает только в случае, когда секция содержит неинициализированные данные (т.е. такие, для которых в секции отводится место, но значения не загружаются из исходного файла, а устанавливаются равными 0).

Больше же может быть из-за того, что RawDataSize округляется вверх до ближайшего числа, кратного FileAlignment. Например, если размер секции 4090, а FileAlignment=512, и в секции нет неинициализированных данных, то VirtualSize=4090, тогда как RawDataSize=4096.

Поэтому при чтении инициализирующих секцию данных необходимо читать из исходного файла min(VirtualSize, RawDataSize) байт данных.

Приведу обещанные описания наиболее важных флагов из поля Characteristics.

ПРИМЕЧАНИЕ

Флаг IMAGE_SCN_MEM_SHARED не будет работать в полной мере для секций, имеющих режим доступа Copy-on-Write (а такой режим автоматически устанавливается для секций, у которых Characteristics содержит одновременно и IMAGE_SCN_MEM_EXECUTE, и IMAGE_SCN_MEM_WRITE). Пока из секции выполняется лишь чтение, разделение доступа работает; при попытке же какого-либо процесса произвести запись в эту секцию страница памяти, содержащая подлежащие изменению данные, просто копируется (и изменяется лишь после этого), в результате чего остальные процессы теряют из вида эти изменения, продолжая работать с оригинальными данными. Не вполне понятно, действует ли это правило во время загрузки DLL (стандартным загрузчиком). Если да (автор [4] считает, что это так), то при наличии в доступной для записи и выполнения секции ссылок(адресов), нуждающихся в настройке (см. соответсвующий шаг загрузки), содержимое секции оказывается десинхронизированным между загруженными экземплярами модуля уже сразу после загрузки модуля.

Как при загрузке в память учесть флаги IMAGE_SCN_MEM_NOT_PAGED, IMAGE_SCN_MEM_SHARED и IMAGE_SCN_NO_DEFER_SPEC_EXC, а также IMAGE_SCN_MEM_DISCARDABLE (в установленном и в сброшенном состоянии) мне неизвестно. Буду благодарен за любую информацию на этот счет.

Относительно IMAGE_SCN_MEM_SHARED: как бы то ни было, будем пока (в рамках изначально стоящей перед нами задачи) считать, что никто больше в системе не сможет загрузить другой экземпляр DLL (просто потому, что такой DLL нет ни на одном логическом диске). Соответственно, этот флаг будем игнорировать.

Перейдем к коду:

function XLoadLibrary(Source: TCustomMemoryStream; Imports: array of TImportItem):HMODULE;
Var  //Следует присоединить к уже имеющему списку локальных переменных функции XLoadLibrary
...
  PSections:^TSections;
  S:integer;
  SectionBase:pointer;
  VirtualSectionSize, RawSectionSize: Cardinal; 
begin
... //То, что мы уже добавили
  PSections:=pointer(pchar(@(PEHeader.OptionalHeader))+PEHeader.FileHeader.SizeOfOptionalHeader);
    //Находим список секций
  for S:=0 to PEHeader.FileHeader.NumberOfSections-1 do //для каждой из них
  begin
    VirtualSectionSize:=PSections[S].Misc.VirtualSize;
    RawSectionSize:=PSections[S].SizeOfRawData;

    if RawSectionSize>VirtualSectionSize
       then RawSectionSize:=VirtualSectionSize; //Берем меньшее из двух значений
 
    SectionBase:=VirtualAlloc(PSections[S].VirtualAddress+pchar(ImageBase),VirtualSectionSize,MEM_COMMIT,
        PAGE_READWRITE);
      //Подтверждаем выделение части зарезервированной для образа DLL памяти
    fillchar(SectionBase^,VirtualSectionSize,0);
      //Сначала занулим всю секцию – так проще всего, хотя можно и обнулять только “хвост”
    move( (pchar(src)+PSections[S].PointerToRawData)^,SectionBase^,
       RawSectionSize);
     //Копируем инициализирующие данные
  end;

Здесь для наглядности кода использован небольшой трюк: список секций рассматривается как массив с очень большим числом элементов. Вот как у меня определен тип TSections:

Type TSections=array[0..100000] of TImageSectionHeader;

Загрузка: шаг 3

Необходимо: произвести необходимые действия по настройке ссылок внутри DLL.

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

Если, например, некоторая инструкция в сегменте кода ссылалась на некие данные D в сегменте данных, то нужно было учесть, насколько далеко загрузчик разнес в памяти эти сегменты (а это расстояние могло быть почти произвольным), и исправить инструкцию так, чтобы она действительно обращалась к D. Как правило, исправление инструкций сводилось к тому, что первоначально код инструкции включал неверный адрес данных D, а в результате исправления часть инструкции, содержащая адрес, заменялась на фактический адрес этих данных.

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

Принято, что начальное значение таких ссылок генерируется компилятором исходя из предположения о том, что образ загружен в память по рекомендуемому базовому адресу (PEHeaders.OptionalHeader.ImageBase). Тогда все, что нам нужно сделать при загрузке – вычислить, на какую величину фактический базовый адрес отличается от рекомендуемого, и величину сдвига прибавить ко всем нуждающимся в правке ссылкам. Можно, конечно, и вычитать вместо сложения – зависит от того, какой адрес из какого мы вычитали для вычисления сдвига. Для определенности пусть величина сдвига находится так:

ImageBaseDelta:=cardinal(ImageBase)-PEHeader.OptionalHeader.ImageBase;

В таком случае ImageBaseDelta надо прибавить к значениям всех нуждающихся в корреции ссылок (т.е. абсолютных адресов).

Информация о подлежащих коррекции перемещаемых ссылках в DLL- и EXE-файлах сосредоточена в специальной структуре, на которую ссылается элемент с индексом IMAGE_DIRECTORY_ENTRY_BASERELOC каталога PEHeaders.OptionalHeader.DataDirectory.

ПРИМЕЧАНИЕ

Как верно заметил Matt Pietrek, теоретически загружаемый модуль может положиться на то, что его загрузят по рекомендуемому адресу (стандартный загрузчик Windows постарается это сделать; мы же такими тонкостями заниматься не будем – иначе за деревьями не будет видно леса). В этом случае информацию о перемещаемых ссылках вообще можно не включать в исходный файл. Конечно, при любой невозможности загрузки по рекомендуемому адресу такой модуль вообще не удастся загрузить (вернее, он не будет работать); тем не менее, обязательно найдется человек, который захочет сэкономить пару килобайт на надежности, и такую возможность следует учитывать (т.е. нужно проверять наличие информации о перемещаемых ссылках – в случае отсутствия оной упомянутая запись каталога PEHeaders.OptionalHeader.DataDirectory будет содержать 0 в поле виртуального адреса).

Структура состоит из блоков (заполняющих ее целиком), каждый из которых начинается с двух двойных слов:

  TImageBaseRelocation=packed record
    VirtualAddress:cardinal;
    SizeOfBlock:cardinal;
  end;

Остаток блока заполнен элементами типа WORD, задающими тип поправки и смещение ссылки, в ней нуждающейся, относительно адреса VirtualAddress. Старшие четыре бита указывают тип поправки (на 32-битных процессорах Intel могут встречаться либо поправки IMAGE_REL_BASED_ABSOLUTE, которые следует игнорировать, либо IMAGE_REL_BASED_HIGHLOW, обработкой которых мы и будем заниматься). Далее в коде я для упрощения воспользовался тем, что в WinNT.h IMAGE_REL_BASED_ABSOLUTE определено равным 0, что позволяет избежать битовых сдвигов при сравнении типа поправки с IMAGE_REL_BASED_ABSOLUTE. Младшие 12 бит содержат смещение ссылки.

SizeOfBlock – это общий размер блока, включая эти два двойных слова; таким образом, количество поправок, перечисленных в блоке, равно (SizeOfBlock-8)/2.

Правка ссылки типа IMAGE_REL_BASED_HIGHLOW заключается в том, что двойное слово по виртуальному адресу, равному VirtualAddress+Offset (где Offset – смещение ссылки) увеличивается на величину ImageBaseDelta.

Код (приведенная процедура вложена в XLoadLibrary):

    // Type PImageBaseRelocation=^TImageBaseRelocation; //(C++: PIMAGE_BASE_RELOCATION)
    procedure ProcessRelocs(PRelocs:PImageBaseRelocation);
    Var
        PReloc:PImageBaseRelocation;
        RelocsSize:cardinal;
        P:PWord;
        ModCount:cardinal;
        i:cardinal;
    begin
      PReloc:=PRelocs;
      RelocsSize:=PEHeader.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].Size;

      While cardinal(PReloc)-cardinal(PRelocs)<RelocsSize do
        //Так как вся таблица перемещений заполнена целиком, то признак конца таблицы – выход за
        // пределы отведенной для нее области памяти
      begin
        ModCount:=(PReloc.SizeOfBlock-Sizeof(PReloc^)) div 2;
        P:=pointer(cardinal(PReloc)+sizeof(PReloc^));
        for i:=0 to ModCount-1 do
          begin
            if P^ and $f000<>0 //Если тип ссылки - не IMAGE_REL_BASED_ABSOLUTE(=0)
            then
              Inc(pdword(cardinal(ImageBase)+PReloc.VirtualAddress+(P^ and $0fff))^,
                ImageBaseDelta);  //Исправляем ссылку
            Inc(P);
          end;
        PReloc:=pointer(P);
      end;
    end;

Загрузка: шаг 4

Необходимо: для каждой загруженной секции установить защиту отведенной ей области памяти в соответствии с указанными в описании секции флагами защиты (введенная мною в начале статьи абстрактная характеристика секции “Protection” реально соответствует полю TImageSectionHeader.Characteristics).

При использовании аппарата VirtualAlloc установка флагов защиты осуществляется либо при выделении памяти, либо позже. Первый вариант в общем случае недопутим, т.к. содержимое секции, возможно, придется корректировать для настройки ссылок. Второй способ заключается в вызове функции VirtualProtect для выделенной области памяти. Один из аргументов как раз и характеризует допустимые типы доступа. Однако нам потребуется вспомогательная функция для преобразования поля TImageSectionHeader.Characteristics в принимаемые VirtualProtect флаги доступа:

function GetSectionProtection(SC:cardinal):cardinal;
//SC – значение ImageSectionHeader.Characteristics,
//на выходе – значение флагов доступа для VirtualProtect
begin
  result:=0;
  if (SC and IMAGE_SCN_MEM_NOT_CACHED)<>0 then
    result:=result or PAGE_NOCACHE;
  //Далее E означает Execute(выполнение), R – Read (чтение) и W – Write (запись)
  if (SC and IMAGE_SCN_MEM_EXECUTE)<>0    //E ?
    then if (SC and IMAGE_SCN_MEM_READ)<>0  //ER ?
           then if (SC and IMAGE_SCN_MEM_WRITE)<>0 //ERW ?
                  then result:=result or PAGE_EXECUTE_READWRITE
                  else result:=result or PAGE_EXECUTE_READ
           else if (SC and IMAGE_SCN_MEM_WRITE)<>0 //EW?
                  then result:=result or PAGE_EXECUTE_WRITECOPY
                  else result:=result or PAGE_EXECUTE
    else if (SC and IMAGE_SCN_MEM_READ)<>0 // R?
           then if (SC and IMAGE_SCN_MEM_WRITE)<>0 //RW?
                  then result:=result or PAGE_READWRITE
                  else result:=result or PAGE_READONLY
           else if (SC and IMAGE_SCN_MEM_WRITE)<>0 //W?
                  then result:=result or PAGE_WRITECOPY
                  else result:=result or PAGE_NOACCESS;
end;

Как уже упоминалось при рассмотрении структуры TImageSectionHeader, описывающей секцию, мне неизвестно, как установить для загруженной секции некоторые из возможных атрибутов, указываемых в Protect. Эти атрибуты просто игнорируются.

Собственно же установка флагов защиты выполняется следующей конструкцией, которую следует добавить в XLoadLibrary после всего, что мы уже успели туда поместить:

  for S:=0 to PEHeader.FileHeader.NumberOfSections-1 do
    VirtualProtect(PSections[S].VirtualAddress+pchar(ImageBase), PSections[S].Misc.VirtualSize,
      GetSectionProtection(PSections[S].Characteristics),Temp_Cardinal);

Здесь Temp_Cardinal – временная переменная типа Cardinal, в которую VirtualProtect записывает предыдущие значения флагов доступа.

Загрузка: шаг 5

Это, пожалуй, самый сложный этап – обработка таблицы импорта загружаемой DLL.

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

PE-файл может экспортировать (делать видимыми и доступными для других программ) какие-то свои элементы (символы). Это могут быть переменные, функции или что-либо другое, статически (т.е. еще до начала выполнения PE-Файла, сразу после его загрузки) размещающееся в образе файла. Экспортирование могут осуществлять как DLL-файлы, так и EXE. Тем не менее, как правило, экспортирование производят DLL, а экспортируемые символы – это какие-либо функции.

Другие модули (EXE или DLL) могут обращаться к этим символам двумя способами. Первый – использовать функцию GetProcAddress, передав в качестве аргументов дескриптор образа экспортирующего символ PE-файла и название символа (или его порядковый номер – но об этом позже). Такой подход называется явным связыванием. Второй (соответственно, неявное связывание) предполагает создание и размещение в файле специальной таблицы импорта, которая содержит информацию, эквивалентную передаваемой при вызове GetProcAddress. Конечно же, в обоих случаях необходимо сначала загрузить используемый PE-файл в свое адресное пространство (правда, во втором случае эти заботы берет на себя код, автоматически генерируемый компилятором).

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

Строго говоря, есть еще один метод – так называемая отложенная загрузка (delayload). Он заключается в том, что подключение осуществляет не системный загрузчик, но и не код, который пишет программист. Вместо этого в файле заводится дополнительная таблица импорта, а ее обработка и перезапись осуществляется генерируемой компилятором оберткой над API. Таким образом, фактически он сводится к явному связыванию.

Таблица импорта фактически представляет собой массив структур, на который указывает элемент IMAGE_DIRECTORY_ENTRY_IMPORT каталога PEHeaders.OptionalHeader.DataDirectory. Каждый из элементов массива описывает импорт из одной DLL.

ПРЕДУПРЕЖДЕНИЕ

Импорт символов из одной и той же DLL могут описывать и несколько разных элементов массива. Например, одна из таблиц описывает символы A и B некоторой DLL “MyDll.DLL”, а другая – символ C этой же DLL.

Такой феномен характерен для TLINK, и далеко не все осознают возможность этого – хотя здесь нет никакого противоречия документации.

Каждый элемент имеет следующий формат:

  TImageImportDescriptor=packed record  //(C++: IMAGE_IMPORT_DESCRIPTOR)
    OriginalFirstThunk:DWORD; //Ранее это поле называлось Characteristics; в целях сохранения 
                              //совместимости кода это название поддерживается и сейчас, но не
                              //отражает содержание поля. Далее мы на нем остановимся подробнее
    TimeDateStamp:DWORD; //0, если импортирование осуществляется без привязки (binding - см. далее)
                         //При импортировании с привязкой содержит отметку времени файла, из которого
                         // импортируем, но:
                         //Если -1, то здесь использовался новый стиль привязки
    ForwarderChain:DWORD; // См. описание испорта с привязкой (далее)
    Name:DWORD; //Виртуальный адрес ASCIIZ-строки с именем файла, из которого импортируем
    FirstThunk:DWORD; //Виртуальный адрес подтаблицы импортируемых символов
  end;
  PImageImportDescriptor=^TImageImportDescriptor;

Массив завершается специальным элементом, у которого все поля равны 0 (в частности, я применяю для проверки сравнение Name c 0).

Внешний символ может быть экспортирован с указанием имени или без него; в любом случае, для него указывается Ordinal - порядковый номер экспорта (натуральное число, меньшее FFFF (hex)). Соответственно, импортировать символ можно по его порядковому номеру или (если при экспортировании указано имя) по имени.

Каждый из элементов подтаблицы, на которую указывает FirstThunk или OriginalFirstThunk (см. далее замечания о разногласиях компоновщиков), имеет длину 4 байта и может содержать

Вернемся к нашей задаче. Связывания, которые загружаемая DLL осуществляет явно, - вообще не наша забота, поскольку вся работа по загрузке/выгрузке используемых модулей и поиску в них используемых символов выполняется самой DLL и GetProcAddress. Загрузчику же остается обработать таблицу импорта, как это описано выше. При этом следует учесть, что таблица импорта теоретически вполне может вообще отсутствовать в исходном файле.

При обработке загрузчиком таблицы импорта большое значение имеет определение того, какой именно из четырех видов импортирования (по номеру, имени, через forwarder или “привязанный”) имеет место. Как я уже объяснил, о варианте со строкой переназначения мне сказать нечего. Фактический адрес символа можно встретить лишь в таком PE-файле, который импортирует содержащий этот символ PE-файл с привязкой. Если импортирование осуществляется без привязки, последний случай исключается, и выбирать следует между первыми двумя.

Если же данная подтаблица содержит, помимо прочего, импортирование с привязкой, ситуация несколько осложняется. Дело в том, что компоновщики Microsoft и Borland в очередной раз разошлись во мнениях. Точнее сказать, Microsoft забыла своевременно поделиться информацией об изменении назначения первого поля TImageImportDescriptor, и в результате во многих (если не всех существующих на данный момент) скомпонованных TLINK PE-файлах это поле содержит 0, а на подтаблицу ссылается поле FirstThunk.

Компоновщики же Microsoft начиная с определенной их версии (самое позднее, с 1994 года) помещают в это поле (которое теперь называется OriginalFirstThunk) виртуальной адрес еще одной подтаблицы. Если не используется привязка, то до загрузки модуля эта подтаблица является копией основной (так я буду называть ту, на которую указывает FirstThunk), а после загрузки (в ходе которой основная подтаблица, FirstThunk, перезаписывается) она не изменяется, т.е. содержит оригинальную таблицу импорта. Если же привязка используется, то при осуществлении привязки (см. далее) эта дополнительная таблица также не изменяется. Таким образом, при загрузке DLL загрузчик всегда может по подтаблице OriginalFirstThunk установить, как же выглядела оригинальная таблица импорта.

Мы будем игнорировать информацию о привязке (она служит лишь для незначительного, порядка 10-20% ([3]) ускорения загрузки модуля). Тем не менее, загружать использующие привязку модули все же хочется, поэтому имеет смысл поступить так: если данная подтаблица не содержит “привязанных” адресов (TimeDateStamp=0), используем для загрузки основную подтаблицу (на которую указывает FirstThunk). Если же содержит, то будем пользоваться OriginalFirstThunk, считая, что это поле не содержит 0. См. по этому поводу предупреждение после объяснения термина “привязка”.

Импорт с привязкой (Bound import) – это вид импорта, при котором предполагается, что для определенной версии модуля, содержащего импортируемый символ, адрес символа уже известен к моменту компиляции. Это относится, главным образом, к системным библиотекам, которые загружаются в память всегда с одного и того же адреса для данной версии библиотеки. В таком случае в таблицу импорта модуля, импортирующего такой символ, можно заранее, еще до загрузки модуля, записать предполагаемый адрес символа. При этом для обеспечения контроля версии модуля, к которому привязан данный, при осуществлении привязки создается дополнительная таблица информации о привязанных модулях, на которую ссылается поле ForwarderChain. Излагать здесь формат элементов этой таблицы я не буду, т.к. в любом случае работа с ней не является чем-то обязательным. В поле же TimeDateStamp помещается отметка времени модуля, содержащего символ (это значение поля TimeDateStamp из PE-заголовка этого модуля).

Существует так называемый “новый стиль” привязки; в этом случае TimeDateStamp= -1, а таблица информации о привязках доступна через элемент IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT каталога каталога PEHeaders.OptionalHeader.DataDirectory.

При настройке таблицы импорта файла, импортирующего символы из некоторого модуля M с привязкой, загрузчик первым делом сравнивает TimeDateStamp для каждого элемента таблицы импорта с временной отметкой (PEHeader.FileHeader.TimeDateStamp) модуля M. Если они равны, то соответствующая этому элементу подтаблица вообще не нуждается в настройке, т.к. содержит верные адреса. В противном же случае адреса считаются устаревшими, и загрузчик перезаписывает основную подтаблицу (на которую указывает FirstThunk данного элемента), исходя из данных оригинальной подтаблицы (на которую ссылается OriginalFirstThunk).

ПРЕДУПРЕЖДЕНИЕ

По причинам, понятным из предыдущего абзаца, файл после привязки обязан содержать оригинальную версию таблиц импорта. Поэтому BIND отказывается производить привязку файлов, скомпонованных TLINK (или любых файлов с OriginalFirstThunk=0).

Итак, единственная пока еще не решенная нами задача – как отличить импортирование по имени от импортирования по номеру. MSDN рекомендует совершенно чудовищный алгоритм проверки: если младшее слово данного 4-байтового элемента подтаблицы меньше, чем максимальный экспортный номер в модуле, из которого происходит импортирование (причем этот номер еще установить надо, например, при помощи средств DbgHelp), то мы столкнулись с импортированием по номеру.

Мало того, что это сложно, так ведь еще и ненадежно! Теоретически, можно в пику MSDN написать DLL, которая будет экспортировать некую функцию под номером FFF0 (причем с указанием имени). С другой стороны, можно в основной программе добиться такого расположения таблицы импорта, что строка с именем этой импортируемой функции будет иметь виртуальный адрес, к примеру, 7FFF. Действуя по предложенному алгоритму, загрузчик посчитает это импортированием по номеру, причем совершенно не тому номеру, который имеет нужная нам функция.

В действительности, если заглянуть в дампы пары библиотек и в WinNT.h, легко сложить два и два и понять, что, по крайней мере в последнее время (точнее, к сожалению, сказать не могу – у меня нет старых версий Platform SDK), при импортировании по номеру старший, 31-й бит двойного слова в подтаблице, установлен, а при импортировании по имени – сброшен! Именно так и написано в [7]. Правда, там довольно неожиданно встречается еще и утверждение о том, что порядковый номер задается нижними 31 битами (а не 16!). Надо отметить, что TLINK ограничивает допустимые порядковые номера символов значением FFFF.

Кроме настройки таблицы импорта нужно, очевидно, еще и загрузить используемые модули. Так как все DLL, используемые данной (загружаемой при помощи XLoadLibrary), тоже загружаются в адресное пространство процесса (как и данная), то их подключение к загружаемой DLL можно выполнить по следующей схеме: сначала загружаем нужную библиотеку, как если бы ее использовал сам процесс (при помощи LoadLibrary), а затем настраиваем обращения из загружаемой DLL к этой библиотеке.

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

Все необходимые структуры данных уже описаны, поэтому перейду непосредственно к коду (как и в предыдущем случае, приведенная функция вложена в XLoadLibrary).

    const IMAGE_ORDINAL_FLAG32=$80000000; //Это, конечно, не вложено 
          IMAGE_ORDINAL_MASK32=$0000FFFF; //   в XLoadLibrary :)

    // Type PImageImportDescriptor=^TImageImportDescriptor; //(C++: PIMAGE_IMPORT_DESCRIPTOR)
    procedure ProcessImports(PImports:PImageImportDescriptor);
    Var	
        PImport:PImageImportDescriptor;
        PRVA_Import:LPDWORD;
        PImportedName:pchar;
        LibHandle:HModule;
        ProcAddress:pointer;
        PLibName:pchar;
        i:integer;

        function IsImportByOrdinal(ImportDescriptor:DWORD;
                   HLib:THandle):boolean;
        begin
          //Желающие могут заменить этот код на рекомендуемые MSDN действия
          // по распознаванию типа импорта
          result:=(ImportDescriptor and IMAGE_ORDINAL_FLAG32)<>0;
        end;

    begin
      PImport:=PImports;
      while PImport.Name<>0 do
        begin
          PLibName:=pchar(
               cardinal(PImport.Name)+cardinal(ImageBase)
             );
          if not PNewLibInfo^.LibsUsed.Find(PLibName,i)
            then //Модуль с таким именем впервые встретился в списке зависимостей
              begin
                LibHandle:=LoadLibrary(PLibName); //Загружаем его и запоминаем его и его HModule
                PNewLibInfo^.LibsUsed.AddObject(PLibName,TObject(LibHandle));
              end
            else //Нет, уже встречался; берем прежний HModule
              LibHandle:=cardinal(PNewLibInfo^.LibsUsed.Objects[i]);

          if PImport.TimeDateStamp=0 //Привязка есть?
            then PRVA_Import:=LPDWORD(pImport.FirstThunk+cardinal(ImageBase))
            else PRVA_Import:=LPDWORD(pImport.OriginalFirstThunk+cardinal(ImageBase));

          while PRVA_Import^<>0 do
          begin
            if IsImportByOrdinal(PRVA_Import^,LibHandle)
              then //Это импортирование по номеру
                  ProcAddress:=GetProcAddress(LibHandle,pchar(PRVA_Import^ and $ffff));
                     //Номер – в младшем слове
              else //Нет, по имени
                begin
                  PImportedName:=pchar(PRVA_Import^+cardinal(ImageBase)+IMPORTED_NAME_OFFSET);
                  ProcAddress:=GetProcAddress(LibHandle,PImportedName);
                end;
            PPointer(PRVA_Import)^:=ProcAddress;
            Inc(PRVA_Import);
          end;
          Inc(PImport);
        end;
    end;

Загрузка: шаг 6

Нам осталось вызвать функцию DllMain загружаемой DLL, сообщив DLL о том, что она загружена.

На этом этапе DllMain может отрапортовать об ошибке инициализации, и если это случится, мы должны будем выполнить отменить загрузку (фактически, т.к. все остальные шаги уже выполнены, то нужно произвести полноценную выгрузку DLL, за исключением одного: не следует вызывать DllMain для уведомления о выгрузке).

DllMain – это условное название для функции, начало которой находится в точке входа в DLL (PEHeader.OptionalHeader.AddressOfEntryPoint). DllMain может отсутствовать, в этом случае EntryPoint=0.

Итак, вот какой код нужно добавить к XLoadLibrary:

  If @PNewLibInfo^.DllProc<>nil then //Если DllMain присутствует
   If not PNewLibInfo^.DllProc(cardinal(ImageBase),DLL_PROCESS_ATTACH,nil)
    Then  //Если инициализация сорвалась
      begin
        PNewLibInfo^.DllProc:=nil; //Имитируем отсутствие DllMain, чтобы XFreeLibrary ее не вызвала
        XFreeLibrary(Result); //Выгружаем загруженную DLL
        raise EXLL_LibraryRefuses.Create(''); //Рапортуем исключение
      end;

Далее, если все прошло благополучно, мы должны были бы зарегистрировать модуль в системных структурах Windows. В принципе, чтение [1] дает представление о шагах, которые для этого нужны. Однако шаги эти опираются на работу с данными недокументированной структуры, а жертвовать надежностью загрузчика я не хочу.

Загрузка: шаг 7

А это – самый важный этап, то, ради чего мы и загружаем DLL.

В принципе, просмотр таблицы экспорта загружаемой DLL с целью предоставления использующей эту DLL программе адресов необходимых ей символов не входит в круг задач загрузчика, т.к. обычно он выполняется стандартной процедурой GetProcAddress. К сожалению, наш загрузчик не на 100% вопроизводит функциональность системного загрузчика, и GetProcAddress не находит символы в загруженной нами DLL (в отличие от функций для работы с ресурсами, такими, как LoadBitmap, - те работают вполне замечательно). Поэтому возникает необходимость в написании своего кода для поиска символов в таблице экспорта загружаемой DLL. Кроме того, массированный импорт функций в момент загрузки DLL, на мой взгляд, выглядит красивее, нежели серия вызовов GetProcAddress (к тому же - уже после загрузки). Да и возможностей ошибиться у программиста в этом случае меньше.

Итак, рассмотрим более подробно механизм экспортирования.

Как я уже описал в разделе, относящемся к таблице импорта, чтобы другие модули могли находить и корректно обращаться к символам (функциям, переменным и т.п.) данного модуля, модуль должен их экспортировать. Экспортирование осуществляется путем помещения информации о символах в специальную таблицу экспорта.

Каждому из экспортируемых символов присваивается определенный порядковый номер (ordinal), и любой модуль может импортировать символ, зная его номер. Однако, этот способ импорта чреват ошибками и неудобен в отладке. Хорошим стилем является экспортирование символа с присвоением ему определенного имени, позволяющее импортировать его не по его номеру, а по этому имени. Тем не менее, экспортирование можно производить и без указания имени.

Таблица экспорта находится по адресу, указанному в элементе IMAGE_DIRECTORY_ENTRY_IMPORT каталога PEHeaders.OptionalHeader.DataDirectory, и начинается с такой структуры:

  _IMAGE_EXPORT_DIRECTORY = packed record  //(C++: IMAGE_EXPORT_DIRECTORY)
      Characteristics: DWord;  //Не используется
      TimeDateStamp: DWord;    //Отметка времени создания таблицы; не всегда содержит верное значение! 
      MajorVersion: Word;      //Не используется
      MinorVersion: Word;      //Не используется
      Name: DWord;             //Виртуальный адрес ASCIIZ-строки с именем данной DLL
      Base: DWord;             //Начальный ordinal (обычно – минимальный из порядковых номеров всех 
                               // экспортируемых символов)
      NumberOfFunctions: DWord;  //Общее число экспортируемых символов
      NumberOfNames: DWord;      //Количество символов, экспортированных по именам
      AddressOfFunctions: ^PDWORD;  //Виртуальный адрес массива адресов функций (см. далее)
      AddressOfNames: ^PDWORD;        //То же для массива виртуальных адресов имен функций
      AddressOfNameOrdinals: ^PWord;  //То же для массива, задающего соответствие порядковых номеров
                                      // именам
  end;
  TImageExportDirectory = _IMAGE_EXPORT_DIRECTORY;

Жизненно важной частью структуры является массив AddressOfFunctions, который содержит виртуальные адреса экспортируемых символов в порядке возрастания их порядковых номеров. Если быть точным, то фактически индекс элемента, соответствующего некоторому символу, равен (номер символа) + Base. Последнее, кстати, означает, что, так как не обязательно (хотя и желательно) присваивать экспортируемым символам последовательные номера, в этом массиве могут быть неиспользуемые элементы – такие элементы содержат 0 вместо виртуального адреса.

Массивы же AddressOfNames и AddressOfNameOrdinals служат для эспортирования по имени. Первый для каждого экспортируемого по имени символа содержит двойное слово – виртуальный адрес ASCIIZ-строки имени. Второй массив содержит (для того же символа - под тем же индексом) слово, равное индексу адреса этого символа в массиве AddressOfFunctions.

ПРЕДУПРЕЖДЕНИЕ

Еще раз: несмотря на свое название, поле AddressOfNameOrdinals указывает не на массив порядковых номеров, а на массив индексов в массиве, адресуемом полем AddressOfFunctions! Индекс меньше порядкового номера на величину Base. То есть: чтобы получить Ordinal для экспорта, на имя которого ссылается AddressOfNames[i], надо к AddressOfNameOrdinals[i] прибавить Base.

Существует и еще один вид экспортирования: переназначение экспорта (export forwarding). Этим термином обозначается ситуация, когда некий модуль A желает предоставить его пользователям символ S, притом что S содержится не в A, а в другом модуле B. Такое желание может быть продиктовано соображениями совместимости: к примеру (пусть S-это функция), ранние версии модуля A реализовывали S самостоятельно, а в более поздних S вынесена в B.

Переназначение экспорта осуществляется через массив AddressOfFunctions. Для символов, реализация которых возлагается на другие DLL, соответствующий элемент массива содержит виртуальный адрес не символа, а ASCIIZ-строки переназначения экспорта, описывающей, откуда следует взять символ. Такие строки всегда лежат в пределах таблицы экспорта, т.е. отстоят от ее начала не далее, чем на размер таблицы (значение поля Size элемента IMAGE_DIRECTORY_ENTRY_IMPORT каталога PEHeaders.OptionalHeader.DataDirectory); в то же время, никакой из экспортируемых символов не может располагаться в пределах таблицы экспорта. Поэтому для распознавания факта переназначения экспорта данного символа достаточно проверить, указывает ли виртуальный адрес символа внутрь таблицы экспорта.

Строка переназначения экпорта имеет формат вида “NTDLL.RtlDeleteCriticalSection”, т.е. начинается с названия модуля, к которому передаются обращения к символу (расширение имени модуля не указывается, оно обязательно должно быть .DLL), за которым после символа точки следует имя символа, под которым этот модуль его экспортирует. Вместо имени можно указать номер в виде “#n”, например: “NTDLL.#491” (номер указывается в десятичной системе счисления).

Как и многие другие управляющие структуры, таблица экспорта в PE-файле вполне может отсутствовать – например, это так в подавляющем большинстве EXE-файлов.

С учетом всего этого и без поддержки экспортирования по номеру (желающие могут легко ее добавить, пересмотрев при этом формат записей во входном для XLoadLibrary массиве Imports) получается примерно такой код:

    // Type PImageExportDirectory=^TImageExportDirectory; //(C++: PIMAGE_EXPORT_DIRECTORY)
    procedure ProcessExports(PExports:PImageExportDirectory; BlockSize:cardinal);
        //BlockSize=размер таблицы экспорта (из PEHeader.OptionalHeader.DataDirectory)
        //Считается, что PExports указывает именно на таблицу экспорта (а не на какую-либо ее копию,
        // которую, возможно, Вы захотите сделать), т.к. от этого указателя идет отсчет адресов
        // для определения факта переназначения экпорта
    var i:byte;
        ImportedFn:cardinal;
        PFnName:pchar;
        FnIndex:DWORD;

        function IsForwarderString(pstr:pchar):boolean;
        begin
          result:=pstr>PExports;
          if result then result:=cardinal(pstr-PExports)<BlockSize
                               //^^^^^^^^ избавляемся от предупреждений компилятора
        end;

        function GetForwardedSymbol(ForwarderString:pchar):pointer;
        var s,DllName:string;
            i:integer;
            LibHandle:HModule;
        begin
          s:=ForwarderString;
          while ForwarderString^<>'.' do Inc(ForwarderString);
          Inc(ForwarderString); //Устанавливаем указатель за символ точки

          DllName:=copy(s,1,pos('.',s)-1); //Имя получается без расширения

          if not PNewLibInfo^.LibsUsed.Find(DllName,i) //(Ищется с расширением по умолчанию – DLL)
            then //Эта DLL еще не подгружена
              begin
                LibHandle:=LoadLibrary(pchar(DllName)); //Загружаем
                PNewLibInfo^.LibsUsed.AddObject(DllName,TObject(LibHandle));
              end
            else //Иначе пользуемся загруженным экземпляром
              LibHandle:=cardinal(PNewLibInfo^.LibsUsed.Objects[i]);

          if ForwarderString^='#' 
            then //если указан номер символа в DLL с HModule=LibHandle, а не имя символа
              ForwarderString:=pointer(strtoint((ForwarderString+1)));
                //То преобразуем указатель в формат, понятный GetProcAddress

          result:=GetProcAddress(LibHandle,ForwarderString);
            //И ищем реализацию символа
        end;

    begin
      for i:=0 to PExports.NumberOfNames-1 do //Проход по всем символам, экспортированным по имени
      begin
        PFnName:=pchar(
          PRVAs(cardinal(PExports.AddressOfNames)+cardinal(ImageBase))^[i]
          +cardinal(ImageBase)
        ); //Получаем указатель на имя символа
        for ImportedFn:=low(Imports) to high(Imports) do //Для всех импортируемых из DLL символов
          if Imports[ImportedFn].Name=PFnName then
             //(C++: Имеется в виду сравнение string и pchar, при котором вместо pchar
             // рассматривается строка, на которую он указывает)
          begin
            FnIndex {Индекс (не сам номер)!} :=
               PWordArr(cardinal(PExports.AddressOfNameOrdinals)
                        +cardinal(ImageBase))^[i];
            Imports[ImportedFn].PProcVar^:=pointer(
              PRVAs(cardinal(PExports.AddressOfFunctions)
                    +cardinal(ImageBase))^[FnIndex]
              +cardinal(ImageBase)
            ); //Получили указатель на сам символ, пишем его в переданный нам массив
            If IsForwarderString(Imports[ImportedFn].PProcVar^)
              //Ан нет, это был указатель на строку переназначения. Срочно исправляемся
              then
                Imports[ImportedFn].PProcVar^:=GetForwardedSymbol(Imports[ImportedFn].PProcVar^);
          end;
      end;
    end;

Полный текст функции XLoadLibrary (без вложенных функций)

function XLoadLibrary(Source: TCustomMemoryStream; Imports: array of TImportItem):HMODULE;
var ImageBase:pointer;
    ImageBaseDelta:integer;
    PEHeader:PImageNtHeaders;
    PSections:^TSections;
    S:integer;
    Src:pointer;
    SectionBase:pointer;
    VirtualSectionSize, RawSectionSize: Cardinal;
    Temp_Cardinal:cardinal;
    PNewLibInfo:^TLibInfo;
//Здесь находится текст вложенных функций
begin
  Src:=Source.Memory;

  PEHeader:=pointer(int64(cardinal(Src))+PImageDosHeader(Src)._lfanew);

  ImageBase:=VirtualAlloc(nil,PEHeader.OptionalHeader.SizeOfImage,MEM_RESERVE,PAGE_NOACCESS);

  If ImageBase=nil then raise EXLL_CannotAllocateMemory.Create('');

  ImageBaseDelta:=cardinal(ImageBase)-PEHeader.OptionalHeader.ImageBase;

  SectionBase:=VirtualAlloc(ImageBase,PEHeader.OptionalHeader.SizeOfHeaders,MEM_COMMIT,
        PAGE_READWRITE);
  move(Src^,SectionBase^,PEHeader.OptionalHeader.SizeOfHeaders);
  VirtualProtect(SectionBase,PEHeader.OptionalHeader.SizeOfHeaders,PAGE_READONLY,Temp_Cardinal);

  PSections:=pointer(pchar(@(PEHeader.OptionalHeader))+PEHeader.FileHeader.SizeOfOptionalHeader);
  for S:=0 to PEHeader.FileHeader.NumberOfSections-1 do
  begin
    VirtualSectionSize:=PSections[S].Misc.VirtualSize;
    RawSectionSize:=PSections[S].SizeOfRawData;
    if VirtualSectionSize<RawSectionSize then
      begin
        VirtualSectionSize:=VirtualSectionSize xor RawSectionSize;
        RawSectionSize:=VirtualSectionSize xor RawSectionSize;
        VirtualSectionSize:=VirtualSectionSize xor RawSectionSize;
      end;
    SectionBase:=VirtualAlloc(PSections[S].VirtualAddress+pchar(ImageBase),VirtualSectionSize,MEM_COMMIT,
        PAGE_READWRITE);
    fillchar(SectionBase^,VirtualSectionSize,0);
    move( (pchar(src)+PSections[S].PointerToRawData)^,SectionBase^,
       RawSectionSize);
  end;
  
  Result:=cardinal(ImageBase);
  If LoadedLibs=nil then LoadedLibs:=TList.Create;
  new(PNewLibInfo);
  PNewLibInfo^.DllProc:=TDllEntryProc(PEHeader.OptionalHeader.AddressOfEntryPoint+cardinal(ImageBase));
  PNewLibInfo^.ImageBase:=pointer(result);
  PNewLibInfo^.LibsUsed:=TStringList.Create;
  PNewLibInfo^.LibsUsed.Duplicates:=dupIgnore;
  //^^^ Не удивляйтесь странному стилю: в Delphi with почему-но не очень дружит с указателями
  //В данном конкретном случае проект просто неверно компилировался; пришлось обойтись без with
  LoadedLibs.Add(PNewLibInfo);

  if PEHeader.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress<>0 then
    ProcessRelocs(pointer(PEHeader.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].
      VirtualAddress
      +cardinal(ImageBase)));

  if PEHeader.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress<>0 then
    ProcessImports(pointer(PEHeader.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].
      VirtualAddress
      +cardinal(ImageBase)));

  for S:=0 to PEHeader.FileHeader.NumberOfSections-1 do
    VirtualProtect(PSections[S].VirtualAddress+pchar(ImageBase),PSections[S].Misc.VirtualSize,
      GetSectionProtection(PSections[S].Characteristics),Temp_Cardinal);

  If @PNewLibInfo^.DllProc<>nil then
   If not PNewLibInfo^.DllProc(cardinal(ImageBase),DLL_PROCESS_ATTACH,nil)
    then
      begin
        PNewLibInfo^.DllProc:=nil;
        XFreeLibrary(Result);
        raise EXLL_LibraryRefuses.Create('');
      end;

  if PEHeader.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress<>0 then
    ProcessExports(pointer(PEHeader.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].
      VirtualAddress
      +cardinal(ImageBase)),
      PEHeader.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].Size
    );
end;

Функциональные ограничения предложенного загрузчика

Построенные нами функции XLoadLibrary и XFreeLibrary обладают некоторыми недостатками по сравнению со стандартным загрузчиком. Большей частью они уже были упомянуты, остается перечислить их здесь в одном списке:

Код, выгружающий DLL

С учетом приведенных в начале статьи предварительных замечаний код функции XFreeLibrary оказывается достаточно тривиален:

procedure XFreeLibrary(L:HMODULE);
var i,j:integer;
begin
  for i:=0 to LoadedLibs.Count-1 do
    if TLibInfo(LoadedLibs[i]^).ImageBase=pointer(L) then
      begin
        with TLibInfo(LoadedLibs[i]^) do
        begin
          if @DllProc<>nil then DllProc(L,DLL_PROCESS_DETACH,nil);
          for j:=0 to LibsUsed.Count-1
            do FreeLibrary(cardinal(LibsUsed.Objects[j]));
          LibsUsed.Free;
        end;
        Dispose(LoadedLibs[i]);
        LoadedLibs.Delete(i);
        break;
      end;
  VirtualFree(pointer(L),0,MEM_RELEASE);
end;

Литература

  1. Russell Osterlund: What Goes On Inside Windows 2000: Solving the Mysteries of the Loader. MSDN Magazine, March 2002. Содержит описание функций системного загрузчика. На мой взгляд, автор черезчур полагается на работу с недокументированными структурами данных, которая может создать проблемы несовместимости с последующими версиями Windows.
  2. Matt Pietrek: An In-Depth Look into the Win32 Portable Executable File Format. MSDN Magazine, April-March 2002. Это – исправленная и существенно дополненная версия статьи [5].
  3. Matt Pietrek: Optimizing DLL Load Time Performance. MSDN Magazine, May 2000. Содержит анализ разновидности импорта – импорта с привязкой.
  4. B. Luevelsmeyer: The PE file format, 1999. Весьма полное описание формата, содержащее, однако, ряд спорных моментов.
  5. Matt Pietrek: Peering Inside the PE: A Tour of the Win32 Portable Executable File Format. MSDN Magazine, March 1994.
  6. Randy Kath: Исследование переносимого формата исполнимых файлов "сверху вниз" (в переводе). Материал взят из Internet: http://education.kulichki.net/comp/hack/27.htm.
  7. Microsoft Corporation: Microsoft Portable Executable and Common Object File Format Specification, Revision 6.0 - February 1999. Материал взят из Internet: http://ganz2001.chat.ru/pecoff.rar.
  8. К.Е. Климентьев: Микстура против ЧИХ-а. Статья посвящена анализу работы печально известного вируса Win.CIH. Материал взят из Internet: http://www.uinc.ru/articles/35/index.shtml.
  9. Jeffrey Richter: Programming Applications for Microsoft Windows. Эта книга, рассчитанная на подготовленного читателя, подробно рассматривает решение ряда встающих перед программистами специальных задач, которые в большей части имеющейся литературы освещаются лишь поверхностно (потоки, работы, процессы, DLL и др.).

Любой из материалов, опубликованных на этом сервере, не может быть воспроизведен в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских прав.
    Сообщений 34    Оценка 505        Оценить