Трюки от КрысаКрис Касперски Хакер, номер #114, стр. 100 Очередная порция трюков – загрузка dll с турбо-наддувом – прямого отношения к С не имеет. Однако работает (не без изменений, конечно) как под windows, так и Linux/BSD, ускоряя загрузку динамических библиотек в десятки и даже тысячи раз. дополнительный бонус — затрудняет дизассемблирование программы и препятствует снятию дампа! Генерация таблицы вызовов на стадии компиляцииЗагрузка динамических библиотек занимает значительное время, особенно при большом количестве импортируемых функций. И хотя Microsoft предлагает кучу продвинутых типов импорта (bound import, delay import), положение они не исправляют, а при динамическом импорте, когда определение адресов функций определяется посредством GetProcAddress (один вызов на каждую функцию), производительность вообще падает ниже плинтуса. В Linux/BSD ситуация обстоит не столь плачевно, но все равно издержки на загрузку динамических библиотек весьма значительны. Поэтому оптимизацией приходится заниматься самостоятельно. Идея состоит в переносе вызовов GetProcAddress из реал-тайма на стадию компиляции программы, при которой время их выполнения уже не так существенно (в самом деле, какая разница сколько собирается программа – 60 или 90 минут, главное, чтобы она работала, как фотонный звездолет). Последовательность действий при этом такова (разумеется, приводится лишь общая схема без углубления в детали):
За счет чего достигается преимущество в скорости? На первый взгляд, процедура инициации должна «съесть» весь выигрыш. Но функция GetProcAddress выполняется намного медленнее, чем сложение двух переменных ((DWORD)Fn + (DWORD)h) в процедуре инициализации загружаемой динамической библиотеки. То же самое относится и к статической компоновке, при которой для каждой импортируемой функции осуществляется «полнотекстовой» поиск в таблице экспорта. Накладных расходов на вызов функции у нас нет, и они вызываются так же, как и функции, импортируемые обычным образом (CALL DS:[func_name]). Если с обычным импортом любой дизассемблер справляется на ура, то в нашем случае func_name представляет RVA адрес, совершенно ничего не говорящий ни дизассемблеру, ни хакеру. Чтобы определить, что именно за функция вызывается, необходимо прогнать программу под отладчиком или снять с нее дамп (а помешать отладчику намного проще, чем дизассемблеру!). IDA Pro не смогла распознать «хитрый» импорт API-функции MessageBoxA 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-адрес Единственный недостаток предложенного метода в том, что при изменении динамической библиотеки целевое приложение придется перекомпилировать заново, что не есть гуд. А что мы, собственно, можем сделать? Универсальный загрузчик динамических библиотекДля чужих библиотек мы, действительно, ничего не можем сделать (поэтому дальше будем говорить только о своих собственных). Совсем несложно расположить в DLL специальный массив, хранящий указатели на все «внешние» функции в строго обозначенном порядке. А затем экспортировать его, попутно сократив размер таблицы экспорта, поскольку указатель на массив окажется единственным экспортируемым элементом. При изменении версии DLL адреса функций могут меняться, как и адрес массива указателей на них. Впрочем, нашу программу это не развалит, так как адрес массива прописан в таблице экспорта, а указатели на функции – в нем самом: «Рукотворная» таблица экспорта. Лучше, чем у Microsoft? 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; } Постой, но ведь… при этом мы фактически создадим свой собственный вариант таблицы экспорта. Чем он будет лучше уже существующего в реализации от 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-*)… но это ерунда. А вот если расположить динамическую библиотеку перед исполняемым файлом (в младших адресах) – это серьезно озадачит дамперы процессов, и все полученные дампы для непосредственного дизассемблирования окажутся непригодными. Кстати, оптимизация на этом не заканчивается, а только начинается… |