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), однако, это еще не предел оптимизации и далеко не предел.

Рассмотрим устройство стандартной таблицы импорта. На вершине иерархии находится структура 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'а! Кажется, что ситуация ласты, но это не так…

Самое простое решение, которое только приходит на ум — это тащить за собой 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!