С-шные трюки от мыщъх'а (выпуск 19h)

Автор: (c)Крис Касперски ака мыщъх

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

Трюк 1 - генерация таблицы вызовов на стадии компиляции

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

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

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

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

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

0401034		push	0
0401036		push	offset aHello_1
040103B		push	offset aHello_0
0401040		push	0
0401042		call	off_405030	; вызов MessageBoxA
...
0405030		off_405030 dd 3D81h	; <- ничего не говорящий RVA-адрес

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

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

Трюк 2 - универсальный загрузчик динамических библиотек

Для чужих библиотек мы, действительно, ничего не можем сделать, поэтому дальше будем говорить только о своих собственных. Совсем несложно расположить в 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, прилагаемый к журналу.

Трюк 3 - реальный хардкод физических адресов

Предыдущий вариант можно значительно улучшить, отказавшись от процедуры инициализации фактических адресов функций, складывающей 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-*), но это ерунда. А вот если расположить динамическую библиотеку перед исполняемым файлом (т.е. в более младших адресах), это серьезно озадачит дамперы процессов и все полученные дампы для непосредственного дизассемблирования окажутся непригодными.

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