c-tricks-19h

сишные трюки\\ (19h выпуск)

крис касперски ака мыщъх, a.k.a. souriz, a.k.a. nezumi, no-email

очередная порция трюков от мыщъх'а — загрузка dll с турбо-наддувом. прямого отношения к си не имеет, однако, работает (не без изменений, конечно) как под windows, так и Linux/BSD, ускоряя загрузку динамических библиотек в десятки и даже тысячи раз, а как дополнительный бонус — затрудняет дизассемблирование программы и препятствует снятию дампа, что очень даже хорошо!

Загрузка динамических библиотек занимает значительное время, особенно при большом количестве импортируемых функций. И хотя Microsoft предлагает кучу продвинутых типов импорта (bound import, delay import) положение они не исправляют, а при динамическом импорте, когда определение адресов функций определяется посредством GetProcAddress (один вызов на каждую функцию), производительность вообще падает ниже плинтуса. В Linux/BSD ситуация обстоит не так плачевно, но все равно издержки на загрузку динамических библиотек весьма значительны, потому, оптимизацией приходится заниматься самостоятельно.

Идея состоит в переносе вызовов GetProcAddress из реал-тайма на стадию компиляции программы, при которой время их выполнения уже не так существенно (в самом деле, какая разница сколько собирается программа — 60 или 90 минут, главное, — чтобы она работала как фотонный звездолет).

Последовательность действий при этом такова (разумеется, здесь дается лишь общая схема без углубления в детали):

  1. компилируем DLL как обычно;
  2. пишем вспомогательную утилиту, загружающую DLL вызовом h = LoadLibrary(«dll_name.dll») для определения ее базового адреса, зная который нетрудно вычислить RVA-адреса всех экспортируемых функций: RVA_Fn = (DWORD)GetProcAddress(«Fn») - (DWORD)h; остается только сгенерировать заголовочный .h файл, поместив туда прототипы функций: typedef int (*$Fn)(int); $Fn Fn;вместе c процедурой их инициализации:init_name_dll(HANDLE h) {Fn = ($Fn) ((DWORD)Fn + (DWORD)h );}. Конечно, без хака тут не обошлось и наглое преобразование указателей в DWORD при переносе на другие платформы ни к чему хорошему не приведет, поэтому, в коммерческих продуктах, придется чуть-чуть усовершенствовать наш генератор, поставляя вместо DWORD целочисленный тип с размером, равным размеру указателю на функцию, что делается либо вручную с учетом разрядности конкретной платформы (например, x86-64), либо цепочкой #if/#else в препроцессоре, но это уже детали;
  3. подключаем сгенерированный заголовочный файл к базовой программе, загружаем динамическую библиотеку через h = LoadLibrary(«dll_name.dll») и передаем полученный базовый адрес процедуре инициации init_dll_name(h);
  4. экспортируемые функции вызываем как обычно, например, a=Fn(b); (законченный пример реализации можно найти в файлах trick-01-*, собранных в архив tricks-19h.7z, прилагаемый к журналу);

За счет чего достигается преимущество в скорости? На первый взгляд, процедура инициации должна «съесть» весь выигрыш. Однако, функция GetProcAddressвыполняется _намного_ медленнее, чем сложение двух переменных ((DWORD)Fn + (DWORD)h) в процедуре инициализации загружаемой динамической библиотеки. Тоже самое относится и к статической компоновке, при которой для каждой импортируемой функции осуществляется «полнотекстовой» поиск в таблице экспорта.

Накладных расходов на вызов функции у нас нет и они вызываются так же, как и функции, импортируемые обычным образом (CALL DS:[func_name]), но если с обычным импортом любой дизассемблер справляется на ура, то в нашем случае func_name представляет RVA адрес, совершенно ничего не говорящий ни дизассемблеру, ни хакеру (см. листинг 1) и чтобы определить что именно за функция вызывается, необходимо прогнать программу под отладчиком или снять с нее дамп (а помешать отладчику намного проще, чем дизассемблеру!).

0401034push0

0401036pushoffset aHello_1

040103Bpushoffset aHello_0

0401040push0

0401042calloff_405030; вызов MessageBoxA

0405030off_405030 dd 3D81h; ← ничего не говорящий RVA-адрес

Листинг 1 IDA Pro не смогла распознать «хитрый» импорт API-функции MessageBoxA

Единственный недостаток предложенного метода в том, что при изменении динамической библиотеки, целевое приложение придется перекомпилировать заново, что не есть гуд и нужно что-то делать. А что мы, собственно, можем сделать?!

Для чужих библиотек мы, действительно, ничего не можем сделать, поэтому дальше будем говорить только о своих собственных. Совсем несложно расположить в DLL специальный массив, хранящий указатели на все «внешние» функции в строго обозначенном порядке, а затем экспортировать его, попутно сократив при этом размер таблицы экспорта, поскольку, указатель на массив окажется _единственным_ экспортируемым элементом.

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

int done;

__declspec(dllexport) DWORD f_table[2];

BOOL WINAPI DllMain(HINSTANCE hs, DWORD reason,LPVOID lpvRes)

{

if (done) return 1; done = 1;

f_table[0] = (DWORD)foo - (DWORD) hs;

f_table[1] = (DWORD)bar - (DWORD) hs;

return 1;

}

Листинг 2 «рукотворная» таблица экспорта. намного лучше, чем у Microsoft

Постойте, но ведь… при этом мы фактически создадим свой собственный вариант таблицы экспорта. Чем он будет лучше уже существующего в реализации от Microsoft?! А тем, что в _нашем_ массиве поиск экспортируемых функций _не_ осуществляется. Вместо этого выполняется обращение по предопределенным индексам. Оверхид на вызов функций ничуть не увеличивается, защищенность программы так же остается на высоте (дизассемблер показывает ничего незначащие RVA-адреса), а единственным побочным эффектом становится невозможность удаления из массива уже существующих индексов (иначе нарушится их последовательность!). Добавлять новые функции (к концу массива) — можно, а вот удалять старые — нет. То есть, функции из DLL удалять, конечно, можно, но вот указатели из массива все-таки придется оставить, прописав там 0 (типа нет такой функции) или воткнув указатель на функцию-пустышку, ничего не делающую, а только возвращающую код ошибки.

Готовый пример содержится в файлах trick-03-*, собранных в архив tricks-19h.7z, прилагаемый к журналу.

Предыдущий вариант можно значительно улучшить, отказавшись от процедуры инициализации фактических адресов функций, складывающей RVA-адрес _каждой_ функции с базовым адресом загрузки динамической библиотеки: foo = ($foo) ((DWORD)foo +  (DWORD)h); И ведь все это происходит в ран-тайме! Естественно, чем больше мы импортируем функций, тем дольше длиться загрузка.

К счастью, задел для оптимизации есть и какой задел!!! Во-первых, сначала динамическая библиотека инициализирует массив функций, вычитая (в ран-тайме) базовый адрес загрузки модуля из адреса _каждой_ функции, чтобы получить RVA-адрес, который затем приходится преобразовывать в фактический адрес функции складывая (опять-таки в ран-тайме) RVA с базовым адресом загрузки модуля. Зачем нам делать двойную работу?! А затем, что базовый адрес, прописанный в заголовке DLL, является не более чем рекомендацией и системный загрузчик может расположить библиотеку где-нибудь в другом месте, особенно, если выясниться, что диапазон адресов, на которых она претендует, уже кем-то занят.

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

Главное, выбрать адреса загрузки так, что бы не зацепить библиотеки NTDLL.DLL и KERNEL32.DLL, поскольку они проецируются на адресное пространство процесса еще до его создания и потому становятся неперемещаемыми. Во всех системах вплоть до Вислы, эта парочка прижата к верхней границе пользовательского адресного пространства (2 Гбайта по умолчанию), так что волноваться не приходится. Но вот Висла с ее рандомизацией адресного пространства выбирает случайные адреса загрузки для всех системных библиотек, включая NTDLL.DLL/KEREL32.DLL. Как быть?! Поковырявшись в ядре, мыщъх выяснил, что они ни при каких обстоятельствах не могут опускаться ниже отметки в 32 Мбайта, так что оперативный простор для загрузки своих DLL у нас есть, а остальные — пускай подвинутся.

Скорость загрузки при этом возрастает во много раз, программный код существенно упрощается (см. файлы trick-03-*), но это ерунда. А вот если расположить динамическую библиотеку _перед_ исполняемым файлом (т.е. в более младших адресах) это серьезно озадачит дамперы процессов и все полученные дампы для непосредственного дизассемблирования окажутся _непригодными_

Но оптимизация на этом не заканчивается, а только начинается…