воскресенье, 4 января 2009 г.

DllMain - страшилка на ночь

Это перевод DllMain : a horror story. Автор: Олег Львович. Примечание: в отличие от других постов, этот пост сильно отличается от оригинала. Произведено множество замен от C к Delphi.

В последний раз я говорил о DllMain и всяческих нехороших вещах, которые могут произойти, если вы используете её не по назначению. Я также упомянул, что это не тот случай, когда вы можете сказать "я всегда осторожен и со мной такого никогда не случится" - всё может выйти из под контроля очень быстро.

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

Позвольте мне показать пример, как просто это может быть (поведение может отличаться в разных версиях ОС, я использую Windows XP SP2).

Рассмотрим такой код:

Project1.dpr:
library Project1;

uses
  Windows,
  SysUtils;

var
  ThisModuleFileName: String;

procedure OutputModuleInfo1;
begin
  try
    OutputDebugString('Entering Project1.OutputModuleInfo1');
    OutputDebugString(PChar(ThisModuleFileName));
    OutputDebugString(PChar('0x' + IntToHex(HInstance, 8)));
  except
    OutputDebugString('Ooops. Exception in Project1.OutputModuleInfo1');
  end;
end;

exports
  OutputModuleInfo1;

// Вызывается как часть DllMain с dwReason = DLL_PROCESS_ATTACH
begin
  OutputDebugString('Project1.DllMain');
  ThisModuleFileName := GetModuleName(HInstance);
end.
Project2.dpr:
library Project2;

uses
  Windows,
  SysUtils;

var
  ThisModuleFileName: String;

procedure OutputModuleInfo2;
begin
  try
    OutputDebugString('Entering Project2.OutputModuleInfo2');
    OutputDebugString(PChar(ThisModuleFileName));
    OutputDebugString(PChar('0x' + IntToHex(HInstance, 8)));
  except
    OutputDebugString('Ooops. Exception in Project2.OutputModuleInfo2');
  end;
end;

exports
  OutputModuleInfo2;

// Никогда не делайте это в DllMain!!!
procedure DoBadThings;
var
  Lib: HMODULE;
  Proc: procedure;
begin
  Lib := LoadLibrary('Project1.dll');
  Proc := GetProcAddress(Lib, 'OutputModuleInfo1');
  Proc;
end;

// Вызывается как часть DllMain с dwReason = DLL_PROCESS_ATTACH
begin
  OutputDebugString('Project2.DllMain');
  ThisModuleFileName := GetModuleName(HInstance);
  DoBadThings;
end.
Project3.dpr:
program Project3;

uses
  Windows;

procedure OutputModuleInfo2; external 'Project2.dll';
procedure OutputModuleInfo1; external 'Project1.dll';

begin
  OutputDebugString('Entering Project3.Main');
  OutputModuleInfo2;
  OutputModuleInfo1;
end.
Так что не так с этим кодом? Давайте загибать пальцы. Ну, конечно же, это полное отсутствие контроля ошибок и утечка ресурсов (отсутствие парного вызова к LoadLibrary) - налицо весьма фундаментальные проблемы. Закроем на это глаза. Давайте скажем, что в зависимости от того, как этот код будет скомпилирован, он может:
  • Работать и вывести ожидаемый результат
  • Работать и вывести неверный результат
  • Взорваться с AV

Ага, всё так и есть.

Давайте покопаемся в этом.

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

Как вы можете видеть, у нас есть две DLL и один EXE, который использует эти две DLL. Первая DLL (Project1) - получает своё имя файла в DllMain(DLL_PROCESS_ATTACH) и сохраняет его в глобальной переменной (в оригинале там ещё стояло получение самого описателя модуля DLL, но в Delphi эта работа выполняется автоматически в модуле System; при этом дескриптор записывается в глобальную переменную HInstance - прим. пер.). Экспортируемая функция OutputModuleInfo1 просто выводит эти значения (напомним, что для просмотра вывода OutputDebugString можно использовать отладчик или DebugView).

Вторая DLL (Project2) практически идентична первой, только она ещё динамически подгружает Project1.dll сразу после сбора своей собственной информации. Это немного странно, но в конце концов это же просто примитивный пример.

Главное приложение является обычным GUI приложением, но без окон и цикла выборки сообщений. Оно выходит сразу же после вывода информации из обоих модулей (вызовы OutputModuleInfo1 и OutputModuleInfo2).

Все три проекта собраны статически (без run-time пакетов), скомпилированы в Delphi 7.1.

Достаточно просто? Let's roll!

1. Работает! Оно работает! Давайте просто скомпилируем всё это и запустим - обратите внимание на порядок, в котором обе DLL перечисленны в Project3. Потом окажется, что это немаловажно - мы увидим почему это так чуть позже. Когда мы запускаем приложение, оно выводит:
Project1.DllMain 
Project2.DllMain 
Entering Project1.OutputModuleInfo1 
E:\Programming\Delphi7\Projects\Project1.dll 
0x00330000 
Entering Project3.Main  
Entering Project2.OutputModuleInfo2 
E:\Programming\Delphi7\Projects\Project2.dll 
0x004A0000 
Entering Project1.OutputModuleInfo1 
E:\Programming\Delphi7\Projects\Project1.dll 
0x00330000
Как вы видите, всё, похоже, работает нормально. Можно заметить интересный момент - как вы можете видеть из вывода - DllMain для Project1.dll вызывается раньше DllMain из Project2.dll (хотя в Project3 у нас обратный порядок). Почему? Ну, с технической точки зрения, у нас нет никаких гарантий относительно порядка вызова - всё в руках загрузчика. Как я уже говорил, загрузчик смотрит на статические зависимости и строит список DllMain, которые он будет вызывать. Но что произойдёт, если этот порядок не важен? С точки зрения загрузчика, Project3.exe зависит и от Project2.dll, и от Project1.dll. И нет никаких причин предпочесть одну DLL перед другой (вспомним, что факт использования Project1 из Project2 является только нашим маленьким грязным секретом).

Ну, на самом деле, получается так, что загрузчик использует тот же порядок, в котором перечисленны DLL в секции импорта EXE. Если вы просмотрите эту секцию, то увидите, что по каким-то причинам линкёр Delphi разместил эти DLL именно в таком порядке. Все низкоуровневые детали вы можете прочитать в статье Matt Pietrek, но для наших целей достаточно сказать, что каждый PE-файл (EXE или DLL) знает, на какие модули он ссылается (т.е. какие функции и откуда он импортирует). И этот список модулей вместе с функциями находится в его PE-заголовке. При составлении заголовка линкёр от Microsoft похоже использует тот же порядок, в котором ему перечисляются библиотеки на входе. Какой логикой руководствуется линкёр Delphi - неясно.

Что произойдёт, если мы это поменяем? Давайте посмотрим.

2. Эээ? Не понял. Итак, давайте скомпилируем тот же код, но поменяем местами импорт из библиотек в Project3 (достаточно просто поменять местами строки со словом "external"). Теперь запустим:
Project2.DllMain 
Entering Project1.OutputModuleInfo1 

0x00000000
Project1.DllMain 
Entering Project3.Main 
Entering Project1.OutputModuleInfo1 
E:\Programming\Delphi7\Projects\Project1.dll 
0x004A0000 
Entering Project2.OutputModuleInfo2 
E:\Programming\Delphi7\Projects\Project2.dll 
0x00330000
Любопытно... Как вы можете видеть, в этот раз DllMain из Project2 вызывается первой. Она загружает Project1.dll и вызывает его OutputModuleInfo1... до вызова его DllMain! Не удивительно, что OutputModuleInfo1 печатает одну пустоту. Заметим, что повторный вызов OutputModuleInfo1 прошёл нормально, потому что к этому времени DllMain от Project1 уже отработала.

Чёрт побери, почему загрузчик ОС так себя ведёт? Он что не понял, что мы загрузили Project1.dll? Мы же явно вызвали LoadLibrary, что должно приводить к загрузке её с диска, укладывании её в память, разрешение внешних ссылок (импорта) и т.д. Почему же не была вызвана DllMain? Если вы немного поэкспериментируете, то вы обнаружите, что в большинстве случаев DllMain для динамически загружаемых библиотек будет вызываться, даже если библиотека загружается "запрещённой" LoadLibrary. Единственный случай, когда это не так - когда загрузчик ОС уже "знает" загружаемую DLL, но её DllMain он ещё не вызывал - в точности то, что произошло у нас.

Project3.exe статически зависит от Project1.dll, поэтому эта библиотека уже включена в план загрузчика. Похоже, что загрузчик весьма неохотно меняет уже установленный план вызова, который он построил по статическим зависимостям. Если загружается новый модуль, то загрузчик вызывает его DllMain, но если загружаемый модуль "старый" (т.е. уже известен и сидит в плане вызовов), то загрузчик пропускает его, т.к. его DllMain всё равно будет вызвана позже.

Почему? Думаю, потому что это отлично работает в большинстве сценариев. Загрузчик всё же пытается как-то скомпенсировать наше плохое поведение. Когда мы пытаемся загрузить что-то, о чём он уже в курсе, загрузчик просто придерживается уже установленного плана - я подозреваю, что в противном случае могут происходит всяческие плохие вещи. Напомню, что мы не в том положении, чтобы жаловаться - мы вообще не должны были вызывать LoadLibrary из DllMain. И последнее: это только мои наблюдения - я не пытаюсь дать точный рецепт, как можно оставить загрузчик ОС в дураках. Я просто говорю, что это всегда можно сделать.

Вот так... В этой конкретной ситуации у вас "просто" распечаталось неверное значение, но вы можете себе представить к каким последствиям это может привести в реальных программах - к AV, например. Кстати, говоря...

3. Чего??? О_о Как это произошло?... Давайте соберём проекты ещё раз, но на этот раз используя новые Delphi (выше 7-ки, лично я использовал D2007). Это ведь не должно влиять, верно ведь?
Теперь запустим:
Project2.DllMain 
Entering Project1.OutputModuleInfo1
... а потом... ого...

Project ...Project3.exe raised too many consecutive exceptions: 'access violation at XXX: read of address YYY


Это если вы запускали из под отладчика. На свободе программа просто виснет.

Но почему? Если вы немного покопаетесь, то увидите, что вылет происходит внутри GetMem, который в свою очередь вызывается из строки "OutputDebugString(PChar('0x' + IntToHex(HInstance, 8)))". Это немного странно. Особенно с учётом того, что на D7 всё работало.

Если вы копнёте ещё глубже, то увидите, что в проблемной строке на самом деле происходит работа со строками String. Для результата IntToHex, а также для склеивания результата IntToHex с константой необходимы строки String. Использование строк означает выделение памяти. Выделение памяти - это GetMem. Во всех прочих случаях в OutputDebugString передавались константы или уже готовые ссылки, только в этой строке идёт формирование строки с выделением/освобождением памяти.

Если вы всмомните, что в D7 используется очень примитивный менеджер памяти и посмотрите на initialization модуля System, то увидите, что он может работать безо всякой предварительной инициализации. Он очень простой, поэтому обходится без неё.

В новых Delphi используется навороченный FastMM с кучей вкусностей, платой за которые является вызов InitializeMemoryManager в модуле System. Вылет с AV означает, что менеджер памяти не был инициализирован. Почему? В нашем случае DllMain библиотеки ещё не была вызвана, соответственно - никакой инициализации.

Упс (следовательно, это означает, что практически любой вызов RTL приведёт к AV - ведь сделать что-либо полезное без выделения памяти...)

Упражнение: объяснить, почему отладчик не сообщает о AV как таковом, а только о вложенных исключениях (и откуда они взялись: вложенные исключения?). А также, почему программа виснет в свободном прогоне.

Мораль

Окей, получилось гораздо больше, чем я рассчитывал сказать... но здесь есть мораль: будьте осторожны. Загрузчик ОС вовсе не тупой и прощает вам столько, сколько может, но иногда его доброта вам не поможет - просто потому что он не может читать ваши мысли.

Окей, думаю, что я официально закончил с этой темой - теперь чувствую себя намного лучше :)

Читать далее: пост Реймонда Чена о DllMain.

1 комментарий:

  1. Кстати, это таки тупизна Delphi RTL :-)
    Кто им мешал с самого начала подставить Heap Manager заглушку, например на LocalAlloc & friends, а уже потом её менять на FastMM4 ?
    Ведь самые базовые уровни инициализации почти наверняка и разворачиваться будут в обратном порядке.

    ОтветитьУдалить

Можно использовать некоторые HTML-теги, например:

<b>Жирный</b>
<i>Курсив</i>
<a href="http://www.example.com/">Ссылка</a>

Вам необязательно регистрироваться для комментирования - для этого просто выберите из списка "Анонимный" (для анонимного комментария) или "Имя/URL" (для указания вашего имени и ссылки на сайт). Все прочие варианты потребуют от вас входа в вашу учётку.

Пожалуйста, по возможности используйте "Имя/URL" вместо "Анонимный". URL можно просто не указывать.

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

Примечание. Отправлять комментарии могут только участники этого блога.