c-tricks-III-q-import
сверхбыстрый импорт API-функций
крис касперски ака мыщъх, no-email
окруженный компьютерами, опутанный проводами, мыщъх сидел в глубине своей хакерской норы и точил зверский план, который должен был обогнать Microsoft и ведь обогнал! да еще как обогнал! скорость импорта возросла на порядок, отлично работая как на древней 9x, так и на новом Windows Server 2003, включая все промежуточные системы, причем без грамма ассемблерного кода! все на 100% Си!
введение
Импорт API-функций «отъедает» существенный процент от общего времени загрузки исполняемых файлов и возникает естественное желание его сократить. Системный загрузчик крайне неэффективен и выполняет множество лишних проходов. Разбирая стандартную таблицу импорта, для каждой импортируемой функции он выполняет _полный_ _поиск_ соответствующего имени/ординала в таблице экспорта, не обращая внимания на то, что экспорт KERNEL32.DLL да и других системных библиотек упорядочен по алфавиту и, если таким же образом упорядочить импорт пользовательских программ, все API-функции можно слинковать за _один_ проход, используя минимум операций сравнения.
В принципе, не заставляет нас пользоваться стандартным загрузчиком. Формат таблиц экспорта хорошо описан и при желании необходимые API-функции можно импортировать и «вручную». В частности, линкер ulink от Юрия Харона именно так и поступает, загружая необходимые ему API-функции по вышеописанному алгоритму (о чем подробно описывают «записки мыщъх'а» выложенные на ftp://nezumi.org.ru), однако, это еще не предел оптимизации и далеко не предел.
коварство и любовь от Microsoft
Рассмотрим устройство стандартной таблицы импорта. На вершине иерархии находится структура Import Directory Table, представляющая собой массив структур IMAGE_IMPORT_DESCRIPTOR, завершаемых нулевым элементом. Каждый IMAGE_IMPORT_DESCRIPTOR содержит ссылки на две подчиненные структуры – lookup-таблицу, содержащую имена и/или ординалы импортируемых функций (Import Name Table), и таблицу импортируемых адресов (Import Address Table), так же известную как Thunk Table. В процессе загрузки файла сюда записываются эффективные адреса импортируемых функций.
Обе таблицы представляют собой массив 32-битных элементов, индексы которых взаимно соответствуют друг другу. То есть, если необходимая нам функция some_func находится в i‑элементе lookup-таблицы, тогда (после загрузки файла в память) i-индекс таблицы импортируемых адресов будет содержать эффективный виртуальный адрес some_func.
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORDCharacteristics; 0 for terminating null import descriptor DWORDOriginalFirstThunk; RVA to original unbound IAT
};
DWORDTimeDateStamp; 0 if not bound, -1 if bound, and real date\time stamp
in IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT (new) O.W. date/time stamp of DLL bound to (old)
DWORDForwarderChain; -1 if no forwarders DWORDName; DWORDFirstThunk; RVA to IAT
} IMAGE_IMPORT_DESCRIPTOR;
Листинг 1 прототипструктуры IMAGE_IMPORT_DESCRIPTOR
До загрузки файла в память таблица импортируемых адресов дублирует lookup-таблицу, что (теоретически) позволяет загрузчику обходится одной лишь таблицей виртуальных адресов, избавляясь от прыжков по памяти, но практически он игнорирует ее.
Создадим простейшую программу test.c и откомпилируем ее компилятором Microsoft Visual C++ с настройками по умолчанию.
#include <stdio.h>
main()
{
printf(«hello, world!\n»);
}
Листинг 2 простейшая экспериментальная программа test.c
Образовавшийся файл test.exe пропустим через утилиту dumpbin, входящую в состав MS VC (dumpbin /IMPORTS test.exe > out), и посмотрим, что хорошего она нам скажет:
Dump of file test.exe
KERNEL32.dll
405000 Import Address Table
4054AC Import Name Table
0 time date stamp
0 Index of first forwarder reference
2DFWriteFile
174GetVersion
7DExitProcess
…
Листинг 3 импорт нашей программы test.exe, выданный утилитой dumpbin
Ага, таблица адресов располагается по адресу 405000h, а lookup-таблица — по 4054ACh. Заглянув туда hiew'ом мы увидим следующее:
.00405000:D8 56 00 00-62 55 00 00-70 55 00 00-7E 55 00 00
.00405010:92 55 00 00-A6 55 00 00-C2 55 00 00-D8 55 00 00
.00405020:F2 55 00 00-0C 56 00 00-22 56 00 00-3A 56 00 00
Листинг 4 содержимое таблицы адресов — RVA адреса имен импортируемых функций
.004054AC:D8 56 00 00-62 55 00 00-70 55 00 00-7E 55 00 00
.004050DC:92 55 00 00-A6 55 00 00-C2 55 00 00-D8 55 00 00
.004050EC:F2 55 00 00-0C 56 00 00-22 56 00 00-3A 56 00 00
Листинг 5 содержимое lookup-таблицы — RVA адреса имен импортируемых функций
Как видно, обе таблицы действительно полностью совпадают и указывают на массив имен/ординалов импортируемых функций:
.004056D8:DF 02 57 72-69 74 65 46 61 70 41 6C-6C 6F 63 00 ▀☻WriteFile
Листинг 6 содержимое таблицы имен — имена импортируемых функций
А теперь пропустим через dumpbin «Блокнот» из стандартной поставки NT (dumpbin /IMPORTS notepad.exe > out) и увидим в чем разница.
KERNEL32.dll
1001080 Import Address Table
1006784 Import Name Table
FFFFFFFF time date stamp
FFFFFFFF Index of first forwarder reference
77E99F421EF LocalUnlock
77E8B7F41AE GlobalUnlock
77E8CCA31A7 GlobalLock
Листинг 7 импорт «Блокнота» от Microsoft'а
Таблица адресов еще _до_ загрузки файла в память _уже_ содержит готовые эффективные виртуальные адреса! Если не верите — смотрите hiew'ом:
.010012D4:22 6A AF 76-47 26 AF 76-9E DB AE 76-5F FC AF 76
.010012E4:32 6A AF 76-E2 16 AE 76-71 6F AF 76-C2 AC AF 76
.010012F4:9C 1D AF 76-00 00 00 00-00 00 00 00-00 00 00 00
Листинг 8 содержимое таблицы адресов — эффективные виртуальные адреса импортируемых функций!
Благодаря этой хитрости, системному загрузчику уже не нужно тратить время на импорт функций. Он просто смотрит на поле временной отметки (TimeDateStamp) импортируемой DLL и если оно совпадет с DLL, установленной на компьютере, реальный импорт _не_ производится. В противном случае, конечно, приходится напрягаться и тратить такты процессора на загрузку, но Microsoft обновляет свои прикладные приложения синхронно с обновлением системных библиотек, поэтому ее программы получают огромное преимущество над конкурентами. Какое коварство!!!
Такая техника импорта функций называется биндингом (binding) и при желании может быть реализована с помощью утилиты editbin, позаимствованной все из того же компилятора (editbin /BINDtest.exe). Посмотрим, что она сделала с нашим тестовым файлом? А сделала она с ним вот что:
Dump of file test.exe
KERNEL32.dll
405000 Import Address Table
4054AC Import Name Table
44B17B02 time date stamp Mon Jul 10 01:54:10 2006
13 Index of first forwarder reference
7944639C2DF WriteFile
79450D1D174 GetVersion
794569BE7D ExitProcess
Листинг 9 импорт нашей тестовая утилита после биндинга – RVA адреса имен API-функций сменились эффективные виртуальные адресами самих API-функций
Ура! Теперь и наша программа будет загружаться не хуже, чем у Microsoft!!! А вот и ни хрена подобного! Это на _вашей_ системе она будет загружаться «не хуже», а вот у большинства остальных пользователей временная отметка DLL наверняка не совпадет с вашей, и вся оптимизация пойдет насмарку, тем более, что Microsoft имеет тенденцию обновлять DLL не только с каждой версией операционной системы, но даже с установкой очередного ServicePack'а! Кажется, что ситуация ласты, но это не так…
как утереть нос Microsoft
Самое простое решение, которое только приходит на ум — это тащить за собой editbin (благо лицензия этого вроде бы не запрещает) и делать биндинг непосредственно при установке программы. Не желающие связываться с Microsoft могут реализовать утилиту для биндинга самостоятельно или воспользоваться линкером ulink от Юрия Харона, который это тоже умеет и уж точно не имеет проблем с лицензированием.
Но, прежде чем открывать пиво и праздновать победу, задумаемся: что произойдет если пользователь обновит систему после установки нашей программы? Правильно! Биндинг тут же перестанет работать, скорость загрузки упадет в разы, а это нехорошо. Можно, конечно, порекомендовать пользователю переустанавливать нашу программу после всякого обновления системы, но это не гуманно и вообще жестоко. Гораздо проще поступить так.
Пусть при каждом запуске наша программа проверяет TimeDateStamp всех импортируемых DLL и если он изменился, запускает editbin (или другую утилиту) для ре-биндинга. Поскольку, править активный процесс нельзя, его необходимо завершить, породив перед этим дочерний субпроцесс или запустив bat-файл, который бы ре-биндил нашу программу и тут же перезапускал ее вновь, чтобы эти махинации протекали прозрачно для пользователя и не высаживали его на измену.
экстремальная оптимизация
Дизассемблировав notepad.exe или наш оптимизированный test.exe, мы увидим, что все API-функции вызываются косвенным образом, что совсем не способствует производительности.
.text:0040115F 68 FF 00 00 00push0FFh; uExitCode
.text:00401164 FF 15 08 50 40 00callds:[ExitProcess]
Листинг 10 косвенный вызов API-функций, сгенерированный компилятором
Прямой call addr намного быстрее, чем call [addr] (особенно в циклах), так почему бы не извернуться и не «вживить» в программу эффективные адреса API-функций, определяемые на стадии установки через GetProcAddress (естественно, не забывая о контроле отметки времени). Ни одна из известных мыщъх'у утилит этого делать не умеет, поэтому приходится шевелить хвостом и кодить на Си самостоятельно.
Разбирая таблицу импорта откомпилированной программы, находим все перекрестные ссылки на API-функции и если там будет FFh 15h XXh XXh XXh XXh (косвенный call) записываем поверх него EB YYh YYh YYh YYh 90h (непосредственный CALL + NOP; зачем нам нужен NOP? а затем, что непосредственный вызов на байт короче), где YYh YYh YYh YYh – относительный адрес API-функции, отсчитываемый от конца инструкции CALL) После этого выбрасываем таблицу импорта на хрен, оставляя лишь KERNEL32.DLL с единственной импортируемой функцией (неважно какой). Дело в том, что системный загрузчик Windows 2000 содержал ошибку и отказывался загружать программы, не импортирующие ни одной функции из KERNEL32.DLL, а, значит, не проецирующих ее на свое адресное пространство. Поскольку, сам загрузчик нуждался в KERNEL32.DLL, но забывал проверить: а была ли она вообще спроецирована или нет, приложения без таблицы импорта падали с исключением.
В конечном счете, мы: а) сократим размер файла за счет отказа от таблицы импорта; б) ускорим загрузку файла; в) слегка оптимизируем вызов API-функций (впрочем, поскольку выполнение подавляющего большинства API-функций занимает существенное время, разница между прямым и косвенным вызовом будет не столь уж и заметной, однако, существуют API-функции содержащие всего несколько строк, например, GetLastError).
заключение
Это только кажется, что Windows истоптана вдоль и поперек! На самом деле, потенциал оптимизации еще не исчерпан и творчески мыслящий программист всегда найдет неординарное решение, обгоняющее по скорости саму Microsoft!