Dynamic API Resolve в malware: от PEB до получения адреса Windows API
Введение
При первичном анализе вредоносного программного обеспечения одним из первых артефактов, на который обращает внимание исследователь, является таблица импортов (Import Address Table, IAT).
Наличие в таблице импортов таких функций, как:
CreateRemoteThread;VirtualAlloc;WriteProcessMemory;InternetOpenA;WinHttpOpenRequest;
Может быстро подсказать направление дальнейшего анализа и позволить сделать предварительные выводы о функционале исследуемого образца.
Однако на практике нередко встречаются сэмплы, у которых таблица импортов содержит лишь несколько функций из Kernel32.dll и Ntdll.dll, несмотря на то что сам образец:
взаимодействует с C2-инфраструктурой;
создает процессы;
выполняет внедрение в другие процессы;
работает с реестром Windows;
использует криптографические функции.
Одной из причин подобного поведения является использование техники Dynamic API Resolve.
Суть данной техники заключается в том, что адреса Windows API не импортируются через стандартный механизм загрузчика Windows. Вместо этого вредоносная программа самостоятельно получает адреса необходимых DLL и выполняет поиск экспортируемых функций непосредственно в памяти процесса.
Подобный подход позволяет:
скрывать подозрительные импорты;
обходить сигнатурные механизмы обнаружения;
усложнять статический анализ.
В рамках статьи рассмотрим:
получение адресов DLL через PEB;
структуру
PEB_LDR_DATA;механизм обхода списка загруженных модулей;
получение адреса структуры
IMAGE_EXPORT_DIRECTORY;поиск адресов экспортируемых функций;
использование API Hashing;
признаки Dynamic API Resolve в декомпилированном коде.
Общая схема Dynamic API Resolve
Упрощенно алгоритм работы выглядит следующим образом:

Далее разберем каждый этап подробнее.
Получение адресов DLL через PEB
PEB (Process Environment Block) — структура данных Windows, создающаяся для каждого процесса при его запуске.
Внутри нее содержится большое количество служебной информации:
ImageBaseAddress;
параметры запуска процесса;
информация об отладчике;
сведения о кучах процесса;
список загруженных DLL;
данные загрузчика Windows.
Часть структуры PEB представлена ниже:
typedef struct PEB {
UCHAR InheritedAddressSpace;
UCHAR ReadImageFileExecOptions;
UCHAR BeingDebugged;
UCHAR BitField;
ULONG ImageUsesLargePages: 1;
ULONG IsProtectedProcess: 1;
ULONG IsLegacyProcess: 1;
ULONG IsImageDynamicallyRelocated: 1;
ULONG SpareBits: 4;
PVOID Mutant;
PVOID ImageBaseAddress;
PPEBLDR_DATA Ldr;
PRTL_USER_PROCESS_PARAMETERS ProcessParameters;
...
ULONG NtGlobalFlag;
ULONG OSMajorVersion;
ULONG OSMinorVersion;
...
} PEB, *PPEB;В контексте исследования образцов ВПО в этой структуре присутсвует множество интересных полей, к которым часто обращаются сэмплы, например, поля BeingDebugged, NtGlobalFlags могут использоваться для определения работы под отладчиком, OSMajorVersion, OSMinorVersion - для определения версии Windows, в которой функционирует сэмпл. Для Dynamic API Resolve особый интерес представляет структура PEB_LDR_DATA, содержащая информацию о загруженных в память процесса модулей, которыми могут являться библиотеки и другие исполняемые файлы.
Структура PPEB_LDR_DATA выглядит следующим образом:
typedef struct PEBLDR_DATA
{
ULONG Length;
UCHAR Initialized;
PVOID SsHandle;
LIST_ENTRY InLoadOrderModuleList;
LIST_ENTRY InMemoryOrderModuleList;
LIST_ENTRY InInitializationOrderModuleList;
PVOID EntryInProgress;
} PEB_LDR_DATA, *PPEB_LDR_DATA;В данной структуре стоит остановиться на трех списках:
InLoadOrderModuleList— список загруженных модулей расположеных в порядке их загрузки.InMemoryOrderModuleList— список загруженных модулей расположеных в порядке их размещения в оперативной памяти (по базовым адресам).InInitializationOrderModuleList— — список загруженных модулей расположеных в порядке их инициализации (вызова точек входа).
Все три списка содержат одинаковые элементы, но в разной последовательности, поэтому, при исследовании сэмпла ВПО для получения списка загруженных в адресное пространство процесса модулей можно увидеть обращения к любому из них.
Все списки относятся к структуре данных типа LIST_ENTRY, формат которой представлен ниже.
typedef struct LIST_ENTRY {
PLIST_ENTRY Flink;
PLIST_ENTRY Blink;
} LIST_ENTRY, *PLIST_ENTRY;Структура LIST_ENTRY представляет собой двусвязный список, в котором Flink - указатель на последующий элемент, а Blink - на предыдущий. Элементы списка указывают на структуры типа LDR_DATA_TABLE_ENTRY, содержащие информацию о конкретной DLL.
Структура LDR_DATA_TABLE_ENTRY содержит в себе метаданные о загруженном в процесс модуле. Формат структуры представлен ниже.
typedef struct LDR_DATA_TABLE_ENTRY
{
LIST_ENTRY InLoadOrderLinks;
LIST_ENTRY InMemoryOrderLinks;
LIST_ENTRY InInitializationOrderLinks;
PVOID DllBase;
PVOID EntryPoint;
ULONG SizeOfImage;
UNICODE_STRING FullDllName;
UNICODE_STRING BaseDllName;
ULONG Flags; WORD LoadCount;
...
} LDR_DATA_TABLE_ENTRY, PLDR_DATA_TABLE_ENTRY;В рамках рассматриваемой задачи особый интерес представляют поля DllBase - в которой содержится указатель на адрес, по которому в память процесса загружена библиотека, а также BaseDllName - строка с ее именем.
Таким образом malware получает адреса DLL примерно следующим образом:
получает адрес
PEB;получает указатель на
PEB_LDR_DATA;переходит к
InMemoryOrderModuleList;начинает обход двусвязного списка DLL;
сравнивает поле
BaseDllNameструктурыLDR_DATA_TABLE_ENTRYс именем искомой библиотеки;получает
DllBase— адрес DLL в памяти.
После этого malware может приступить к парсингу таблицы экспорта библиотеки для поиска нужных Windows API.
Алгоритм динамического получения адреса экспортируемой функции внутри DLL
После получения адреса загруженной библиотеки malware необходимо найти адрес интересующей Windows API внутри нее.
Упрощенно процесс выглядит следующим образом:

Для начала необходимо получить адрес структуры исполняемого файла IMAGE_EXPORT_DIRECTORY, содержащий сведения об экспортируемых библиотекой функциях.
Общий формат PE-файла, которому соответствуют все исполняемые файлы windows, в том числе библиотеки, в подробном виде представлен на рисунке.

Для этого осуществляется парсинг PE-заголовка библиотеки в следующей последовательности:
Из структуры
IMAGE_DOS_HEADERполучаем значение поляe_lfanew— смещение до заголовкаIMAGE_NT_HEADERS;Из заголовка
IMAGE_NT_HEADERSполучаем указатель наIMAGE_OPTIONAL_HEADER;В
IMAGE_OPTIONAL_HEADERнаходится массив DataDirectory, содержащий ссылки на различные таблицы PE-файла (импорта, экспорта, ресурсов и др.);Переходим по адресу
DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]и получаем адрес структурыIMAGE_EXPORT_DIRECTORY.
Формат структуры IMAGE_EXPORT_DIRECTORY представлен ниже.
typedef struct IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name;
DWORD Base;
DWORD NumberOfFunctions;
DWORD NumberOfNames;
DWORD AddressOfFunctions;
DWORD AddressOfNames;
DWORD AddressOfNameOrdinals;
};Из структуры IMAGE_EXPORT_DIRECTORY полезны следующие поля:
NumberOfFunctions— число экспортируемых функций;AddressOfFunctions— RVA (Relative Virtual Address , адреса функций, относительно начала исполняемого файла) массива адресов экспортируемых функций;AddressOfNames— RVA массива адресов имен экспортируемых функций;AddressOfNameOrdinals— RVA массива ординалов экспортируемых функций.
Логика поиска искомой экспортируемой функции требует использования AddressOfFunctions, AddressOfNames, AddressOfNameOrdinals - массивы, в которых содержится RVA экспортируемых библиотекой функцией.
Стоит отметить, что получение адреса функции выполняется по ее ординалу — уникальному порядковому номеру в таблице экспорта. Это позволяет быстро определить адрес функции и избежать неоднозначности при работе с экспортируемыми именами.
За счет получения указанных выше массивов указателей из структуры IMAGE_EXPORT_DIRECTORY дальнейшие действия сводятся к циклу, в котором выполняются следующие действия:
Получить имя функции из
AddressOfNames;Сравнить его с искомым именем;
При совпадении получить ordinal из
AddressOfNameOrdinals;По ordinal получить RVA функции из
AddressOfFunctions;Вернуть адрес искомой Windows API.
API Name Hashing
На практике для усложнения анализа malware разработчики часто используют API Hashing — вместо хранения имен функций вычисляются их хеш-значения. В дальнейшем в ходе выполнения программы производится сравнение хеш значений имен функций, а не их имен.
Это позволяет:
убрать из бинаря строки с именами Windows API;
усложнить статичный анализ образца ВПО;
затруднить поиск интересующих функций в строках файла.
При этом могут использоваться различные алгоритмы хеширования:
ROR-based hash;
CRC32;
DJB2;
кастомные реализации.
На практике для API Hashing обычно используют простые алгоритмы, которые быстро вычисляются и имеют низкую вероятность коллизий.
Некоторые алгоритмы хеширования, например DJB2 или ROR-based hash, могут использовать дополнительное начальное значение (seed). В этом случае итоговый хеш зависит не только от имени функции, но и от выбранного seed, благодаря чему одинаковые имена API в разных образцах могут иметь разные хеш-значения. Это затрудняет поиск соответствий по заранее известным константам и требует восстановления алгоритма хеширования и используемого seed для конкретного сэмпла.
Dynamic API Resolve глазами реверсера
Теперь посмотрим, как данный механизм выглядит глазами исследователя ВПО.
Разбор проводился в IDA Pro.
В рамках исследуемого сэмпла производим переход к функции sub_401037 в докомпилированном виде.

Сразу видим следующие закономерности - функция sub_4014F3 вызывается часто, принимает в качестве аргументов два значения v7 - результат работы функции sub_40144B, а также некоторую константу, которая отличается при каждом вызове функции. Результаты выполнения функция sub_4014F3 кладутся в локальные переменные.
Проведем анализ локальной переменной v9, которая вычисляется в результате выполнения функции sub_4014F3(v7, 0x3D8CAE3).

В дальнейшем значение переменной v9 используется как адрес функции для ее последующего вызова. Поэтому сначала необходимо разобраться, какое именно значение возвращает функция sub_4014F3 и каким образом оно попадает в v9. Для этого перейдем в функцию sub_4014F3.

Первое, за что цепляется глаз — константы 0x3C и 0x88.
v6 = (a1 + *(*(a1 + 0x3C) + a1 + 0x88));0x3C — смещение до IMAGE_DOS_HEADER->e_lfanew, то есть переход к IMAGE_NT_HEADERS64.
0x88 — смещение до IMAGE_DIRECTORY_ENTRY_EXPORT для PE32+ (x64).
Для опытного исследователя подобные константы являются характерным признаком ручного парсинга PE-файла. Если в декомпилированном коде встречаются обращения по смещениям 0x3C (e_lfanew) и 0x88 (смещение до IMAGE_DIRECTORY_ENTRY_EXPORT от IMAGE_OPTIONAL_HEADER для формата PE32+), это уже повод проверить, не реализует ли функция Dynamic API Resolve.
Таким образом, можно сделать вывод, что a1 содержит базовый адрес загруженного в память PE-файла, а v6 является указателем на структуру IMAGE_EXPORT_DIRECTORY. Чтобы упростить дальнейший анализ, присвоим переменной v6 соответствующий тип в IDA. После этого псевдокод функции становится значительно понятнее и принимает следующий вид.

Таким образом удалось определить типы некоторых переменных:
v7- VA (Virtual Address, вычисляется сложениемImageBaseи RVA)AddressOfNames;v8- VAAddressOfFunctions;v9- число экспортируемых библиотекой функций (NumberOfNames);v10- VAAddressOfNameOrdinals.
Далее в рамках цикла в переменную i помещается указатель на начала массива, в котором содержится список указатели на имена экспортируемых библиотекой функций. После этого функция посимвольно перебирает имя API и вычисляет его хеш, который кладется в переменную v11. Затем хеш сравнивается с константой, переданной вторым аргументом функции:
if ((v11 & 0x7FFFFFFF) == a2)
break;При совпадении происходит выход из цикла, следовательно, аргумент a2 - хеш значение имени искомой экспортируемой библиотекой функцией.
В свою очередь при несовпадении хеша цикл повторяется, при этом указатель v7 инкрементируется и указывает на последующий адрес имени функции в AddressOfNames, также инкрементируется счетчик v5 - который будет использован вдальнейшем для определения ординала экспортируемой функции.
При успешном нахождении совпадения хеш значения имени экспортируемой функции функция sub_4014F3 возвращает адрес, вычисление которого производится относительно базового адреса загруженной в память библиотеки, передаваемой аргументом a1:
return a1 + *(v8 + 4LL * (v10 + 2LL * v5));Если учесть все данные, которые нам стали известны в результате проведенного анализа, можно сделать вывод о том, что в возвращаемом значении:
a1-ImageBase;v8- VAAddressOfFunctions;v10- VAAddressOfNameOrdinals;v5- индекс найденного имени функции (умножение на 2 производится, так как элементыAddressOfNameOrdinalsимеет размер 2 байта).
Осуществляется вычисление адреса экспортируемой функции по ее орденалу.
Таким образом можно сделать вывод, что sub_4014F3 выполняет поиск экспортируемой функции по хешу ее имени, передаваемой параметром a2 внутри DLL, адрес которой передается аргументом a1.
Остается определить, из какой библиотеки осуществляется поиск.
Возвращаемся в вызывающую функцию sub_401037.
v6 = sub_40144B(0xB9F5B9C);
v7 = v6;Результат работы функции sub_40144B сохраняется в переменной v7. В дальнейшем это значение используется как базовый адрес загруженной DLL.
Переходим в функцию sub_40144B.

В функции sub_40144B IDA смогла распознать работу со структурой PEB и обход списка загруженных модулей через InLoadOrderModuleList.
Далее внутри цикла наблюдаем практически тот же алгоритм вычисления хеша, что и в предыдущей функции.
После вычисления хеша выполняется сравнение:
if ((v7 & 0x7FFFFFFF) == a1)
return i[3].Flink;Следовательно, функция перебирает загруженные DLL, вычисляет хеш имени каждой библиотеки и возвращает адрес той, чей хеш совпал с переданным значением.
Таким образом:
sub_40144B()— поиск DLL по хешу имени;sub_4014F3()— поиск экспортируемой функции по хешу имени.
Именно такая комбинация очень часто встречается при реализации Dynamic API Resolve.
Если одновременно встречаются обход PEB, ручной парсинг Export Directory и последующий косвенный вызов функции (call r15, как представлено на скриншоте снизу), это практически всегда повод проверить гипотезу о реализации Dynamic API Resolve.

В рамках дальнейшего исследования удобно написать небольшой скрипт (или воспользоваться мощью ИИ), который будет принимать:
хеш имени DLL;
хеш имени функции.
Это позволит автоматически восстановить реальные имена вызываемых API и значительно ускорить дальнейший анализ поведения сэмпла ВПО.
Заключение
Dynamic API Resolve до сих пор остается одним из наиболее часто встречаемых методов сокрытия используемых Windows API в современных образцах ВПО. Несмотря на стремление усложнить статический анализ, реализация данной техники оставляет достаточно характерные паттерны: обход PEB, парсинг таблицы экспорта DLL и использование API Hashing. Понимание Windows Internals и практический опыт анализа различных семейств ВПО позволяет ускорить процесс выявления встроенных в сэмпл механизмов защиты, а также оптимизировать процесс восстановления исходной логики работы сэмпла.
PS
Если статья оказалась полезной, в Telegram-канале публикую материалы по Windows Internals, DFIR и reverse engineering, а также разбираю реальные кейсы анализа вредоносного ПО (meimnf).