Скрытая угроза: чем опасен DarkLoadLibrary и как выявить его использование при атаках
DarkLoadLibrary — пример инструмента, который демонстрирует, как злоумышленники эксплуатируют низкоуровневые механизмы Windows для обхода защитных систем. Этот DLL‑загрузчик позволяет корректно загрузить DLL в память и при этом избежать выполнения LoadImageNotifyRoutine. Понимание принципа работы DarkLoadLibrary позволит точнее настроить мониторинг аномалий в загрузке DLL, чтобы обнаруживать и предотвращать атаки со скрытой загрузкой вредоносного кода.
LoadImageNotifyRoutine — одна из специальных функций обратного вызова (Kernel Notify Routines). Этот механизм ядра Windows используется для передачи в драйвер уведомлений о загрузке образа (EXE/DLL) в память. Защитные решения часто регистрируют свою функцию обратного вызова с помощью PsSetLoadImageNotifyRoutine и получают информацию о том, каким процессом и какой образ был загружен. Например, Sysmon использует этот вызов для генерации событий EventID 6 (Driver Loaded) и EventID 7 (Image Loaded).
Для начала разберем процесс загрузки DLL в Windows.
Загрузка DLL в Windows
Функции загрузки библиотек реализованы в ntdll.dll. Основная, на которую стоит обратить внимание, — LdrLoadDll. Она используется при вызовах LoadLibrary*.
Ключевые этапы, которые выполняет загрузчик DLL:
- Проверка, загружен ли модуль в процесс.
- Поиск/создание секции модуля.
- Маппинг секции в память.
- Заполнение таблицы адресов импортов (резолв импорта).
- Выполнение релокации.
- Добавление данных о модуле в системные списки.
- Установка прав на участки памяти, хранящие PE‑секции.
- Выполнение функций обратного вызова TLS.
- Выполнение точки входа (
DLLMain), если определена.
Мы не будем подробно разбирать все шаги. Опишем те, которые помогут лучше понять работу инструмента, а именно: проверку уже загруженного модуля, работу с секциями и добавление в системные списки.
Проверка, загружен ли модуль в процесс
Перед началом загрузки вызывается LdrpFindOrPrepareLoadingModule, в которой с помощью функции LdrpFindLoadedDllByName проверяется, загружен ли модуль в процесс. Это определяется по его наличию в LdrpHashTable — хеш‑таблице загруженных модулей. Если DLL уже в процессе, функция возвращает LDR_DATA_TABLE_ENTRY, структуру, в которой хранится информация о загруженном модуле, и увеличивает счетчик ее загрузок (LDR_DATA_TABLE_ENTRY → DdagNode → LoadCount). Если DLL еще не загружалась, то LdrpFindOrPrepareLoadingModule выделяет память и готовит LDR_DATA_TABLE_ENTRY для нового модуля.
Поиск/создание секции модуля
Загрузчик не оперирует файлом модуля напрямую, не считывает и не сохраняет его данные в память. Вместо этого он использует такие объекты, как секции и представления.
Загрузчик вызывает NtCreateSection, и уже в ядре считывается файл и создается секция, где хранятся его данные. В этот вызов в качестве аргумента AllocationAttributes передается значение SEC_IMAGE.

Таким образом система понимает, что необходимо создать секцию именно для образа. В результате ядро выполнит дополнительные операции с ней, в частности проверит валидность PE‑файла и разместит его PE‑секции по виртуальным адресам.
Загрузчик сначала пытается найти секцию модуля в KnownDll — списке DLL, для которых система уже создала секции при своем старте и переиспользует их во всех процессах. Если там секция не найдена, загрузчик обнаруживает файл модуля на диске и открывает, а дескриптор файла передается в вызов NtCreateSection.
Маппинг секции в память
Чтобы получить доступ к данным полученной секции, создается ее представление.
Именно в функции NtMapViewOfSection для секции, которая создана с AllocationAttributes, равным SEC_IMAGE, и вызывается LoadImageNotifyRoutine. NtMapViewOfSection возвращает в аргументе BaseAddress базовый адрес в памяти процесса, с которым дальше работает загрузчик.
Вставка в системные списки
Как уже было упомянуто, информация о загруженных модулях хранится в структуре LDR_DATA_TABLE_ENTRY. Чтобы система «видела» библиотеку, указатель на эту структуру добавляется в различные системные списки.
Так, после маппинга указатель на структуру с помощью функций ntdll!LdrpInsertDataTableEntry и ntdll!LdrpInsertModuleToIndex вставляется в следующие сущности:
InLoadOrderModuleList,InMemoryOrderModuleList— основные двусвязные списки, используемые для перечисления.LdrpHashTable— хеш‑таблица, хранящая 32 двусвязных списка и использующаяся для быстрого поиска по имени (например, вGetModuleHandle).LdrpModuleBaseAddressIndex— дерево, используемое для поиска по базовому адресу (например, вGetProcAddress).LdrpMappingInfoIndex— дерево, используемое для поиска по характеристикам маппинга (используется на этапе поиска уже загруженных модулей).
До вызова DLLMain в функции ntdll!LdrpInitializeNode вставляется в третий основной двусвязный список InInitializationOrderLinks.
Особенности DarkLoadLibrary
DarkLoadLibrary самостоятельно реализует этапы загрузки, описанные выше, но вместо использования NtCreateSection → NtMapViewOfSection размещает модуль в памяти, выделенной через NtAllocateVirtualMemory. Благодаря этому не активируются вызовы LoadImageNotifyRoutine. В результате СЗИ не получают телеметрию о загрузке модуля в процесс. А те средства защиты, которые опираются на LoadImageNotifyRoutine как на сигнал для установки перехватчиков функций (хуков), не установят их.
На первый взгляд, непонятно, зачем такие сложности, если уже есть техника отраженной загрузки DLL (Reflective DLL Load). Тем более для чего добавлять информацию о библиотеке в системные списки, тогда как ВПО обычно стремится ее оттуда убрать?
Главное отличие — в том, что с помощью DarkLoadLibrary можно загрузить DLL из файла, в частности системную DLL (за исключением стандартно загружаемых ntdll.dll, kernel32.dll и т. п.). Это позволяет ВПО использовать стандартные API‑функции, избежав уведомлений о загрузке соответствующего модуля и возможных хуков. Например, такой способ загрузки использует C2‑фреймворк NightHawk (v0.2.1).
Реализация в NightHawk
NightHawk перехватывает функции NtOpenSection, NtCreateSection и NtMapViewOfSecton, используемые при вызове LdrLoadDll, с помощью Detours.


NtOpenSection вызывается из функции ntdll!LdrpFindKnownDll, которая пытается найти библиотеку в списке KnownDll и вернуть дескриптор ее секции. Перехватчик проверяет, есть ли запрашиваемая DLL в списке на загрузку через DarkLoadLibrary. Если есть, возвращает ошибку с кодом 0xC0000135 (объект не найден), тем самым предотвращая загрузку из списка KnownDll.
NtCreateSection вызывается из функции ntdll!LdrpMapDllNtFileName. Если аргумент AllocationAttributes равен SEC_IMAGE и имя файла, для которого создается секция, есть в списке на загрузку через DarkLoadLibrary, для этой библиотеки перехватчик создает и возвращает дескриптор секции со значением вида <адрес указателя на имя библиотеки> | 0x8000000000000000 (например, 0x8000020803e96a50).
Дескриптор передается в функцию NtMapViewOfSecton, вызываемую из ntdll!LdrpMinimalMapModule. Если он имеет «высокий» номер (то есть выдуманный перехватчиком NtCreateSection), то файл библиотеки читается и записывается в выделенную с помощью NtAllocateVirtualMemory память. Адрес участка памяти передается в выходной аргумент BaseAddress. После выхода из функции процесс загрузки возвращается системе и продолжается как для любой другой библиотеки. NightHawk даже не нужно реализовывать остальные шаги самостоятельно, ОС Windows сделает все сама: разрешение импортов, релокацию, добавление в системные списки и т. д.
Детектирование
Нахождение модуля в куче
Если после загрузки DLL не отвязывается из системных таблиц, можно перечислить модули процесса и обнаружить те, которые находятся в памяти кучи («низкие» адреса в отличие от легитимно загруженных модулей).

Побочные события, где можно получить CallTrace (доступ к памяти)
Если модуль был отвязан от системных таблиц, можно попытаться определить использование такого метода загрузки по косвенным признакам.
Рассмотрим на примере событий доступа к процессу. Например, попытаемся сделать дамп процесса LSASS.exe с помощью WinAPI‑функции MiniDumpWriteDump, которая вызвана из библиотеки, загруженной с помощью DarkLoadLibrary. Мы увидим, что вызов пришел из unbacked‑памяти (область памяти, которая существует только в оперативной памяти и не имеет соответствующего файла на жестком диске), но смещения в CallTrace все равно будут соответствовать функции MiniDumpWriteDump. На скриншоте можно увидеть два CallTrace: первый в случае стандартного дампа процесса, где явно виден вызов MiniDumpWriteDump из dbgcore.dll, а второй — дамп процесса, когда dbgcore.dll загружена через DarkLoadLibrary.

Этот факт позволяет обнаруживать DarkLoadLibrary с достаточной степенью уверенности. Очевидно, что, в зависимости от версии системы и версии файла dbgcore.dll/dbghelp.dll, смещения будут меняться. Есть как минимум два способа получить их список:
- Скачать все возможные версии библиотеки для всех возможных версий ОС из центра обновлений Windows, проанализировать потоки выполнения функции
MiniDumpWriteDumpв них и посчитать офсеты. - Если есть достаточный объем телеметрии, можно найти события доступа к процессу от процесса
werfault.exe, забрать из событий CallTrace и вычленить из них все комбинации офсетов.
Таким образом можно составить запрос для поиска попыток дампа процессов с помощью MiniDumpWriteDump:
dev_os_type:windows AND
event_type:processaccess AND
proc_calltrace:(CreateToolhelp32Snapshot AND UNKNOWN) AND
(
(
proc_calltrace.keyword:/ntdll.dll!ZwOpenProcess.*CreateToolhelp32Snapshot\+0x[0-9a-f]+.0x[0-9a-f]+8\(UNKNOWN\).0x[0-9a-f]+5\(UNKNOWN\).0x[0-9a-f]+2\(UNKNOWN\).0x[0-9a-f]+2\(UNKNOWN\).0x[0-9a-f]+4\(UNKNOWN\).*/
)
OR
(
proc_calltrace:(Module32FirstW) AND
proc_calltrace.keyword:/ntdll.dll!ZwOpenProcess.*CreateToolhelp32Snapshot\+0x[0-9a-f]+.0x[0-9a-f]+1\(UNKNOWN\).0x[0-9a-f]+5\(UNKNOWN\).0x[0-9a-f]+5\(UNKNOWN\).0x[0-9a-f]+[e2a]\(UNKNOWN\).0x[0-9a-f]+b\(UNKNOWN\).*/
)
OR
(
proc_calltrace:(Sleep) AND
proc_calltrace.keyword:/ntdll.dll!ZwOpenProcess.*CreateToolhelp32Snapshot\+0x[0-9a-f]+.0x[0-9a-f]+f\(UNKNOWN\).0x[0-9a-f]+5\(UNKNOWN\).0x[0-9a-f]+0\(UNKNOWN\).0x[0-9a-f]+a\(UNKNOWN\).0x[0-9a-f]+1\(UNKNOWN\).*/
)
OR
(
proc_calltrace:(timeEndPeriod) AND
proc_calltrace.keyword:/ntdll.dll!ZwOpenProcess.*CreateToolhelp32Snapshot\+0x[0-9a-f]+.0x[0-9a-f]+7\(UNKNOWN\).0x[0-9a-f]+5\(UNKNOWN\).0x[0-9a-f]+6\(UNKNOWN\).0x[0-9a-f]+6\(UNKNOWN\).0x[0-9a-f]+7\(UNKNOWN\).*/
)
OR
(
proc_calltrace:(SetXStateFeaturesMask) AND
proc_calltrace.keyword:/ntdll.dll!ZwOpenProcess.*CreateToolhelp32Snapshot\+0x[0-9a-f]+.0x[0-9a-f]+f\(UNKNOWN\).0x[0-9a-f]+5\(UNKNOWN\).0x[0-9a-f]+2\(UNKNOWN\).0x[0-9a-f]+8\(UNKNOWN\).0x[0-9a-f]+c\(UNKNOWN\).*/
)
OR
(
proc_calltrace:(GetExitCodeProcess) AND
proc_calltrace.keyword:/ntdll.dll!ZwOpenProcess.*CreateToolhelp32Snapshot\+0x[0-9a-f]+.0x[0-9a-f]+7\(UNKNOWN\).0x[0-9a-f]+5\(UNKNOWN\).0x[0-9a-f]+6\(UNKNOWN\).0x[0-9a-f]+f\(UNKNOWN\).0x[0-9a-f]+c\(UNKNOWN\).*/
)
OR
(
proc_calltrace:(CreateFileMappingW) AND
proc_calltrace.keyword:/ntdll.dll!ZwOpenProcess.*CreateToolhelp32Snapshot\+0x[0-9a-f]+.0x[0-9a-f]+7\(UNKNOWN\).0x[0-9a-f]+5\(UNKNOWN\).0x[0-9a-f]+7\(UNKNOWN\).0x[0-9a-f]+f\(UNKNOWN\).0x[0-9a-f]+3\(UNKNOWN\).*/
)
OR
(
proc_calltrace:(GetModuleFileNameW) AND
proc_calltrace.keyword:/ntdll.dll!ZwOpenProcess.*CreateToolhelp32Snapshot\+0x[0-9a-f]+.0x[0-9a-f]+8\(UNKNOWN\).0x[0-9a-f]+5\(UNKNOWN\).0x[0-9a-f]+7\(UNKNOWN\).0x[0-9a-f]+1\(UNKNOWN\).0x[0-9a-f]+3\(UNKNOWN\).*/
)
OR
(
proc_calltrace:(lstrcmpA) AND
proc_calltrace.keyword:/ntdll.dll!ZwOpenProcess.*CreateToolhelp32Snapshot\+0x[0-9a-f]+.0x[0-9a-f]+7\(UNKNOWN\).0x[0-9a-f]+5\(UNKNOWN\).0x[0-9a-f]+e\(UNKNOWN\).0x[0-9a-f]+5\(UNKNOWN\).0x[0-9a-f]+7\(UNKNOWN\).*/
)
OR
(
proc_calltrace:(CreateThreadpoolWork) AND
proc_calltrace.keyword:/ntdll.dll!ZwOpenProcess.*CreateToolhelp32Snapshot\+0x[0-9a-f]+.0x[0-9a-f]+a\(UNKNOWN\).0x[0-9a-f]+5\(UNKNOWN\).0x[0-9a-f]+8\(UNKNOWN\).0x[0-9a-f]+8\(UNKNOWN\).0x[0-9a-f]+8\(UNKNOWN\).*/
)
OR
(
proc_calltrace:(_llseek) AND
proc_calltrace.keyword:/ntdll.dll!ZwOpenProcess.*CreateToolhelp32Snapshot\+0x[0-9a-f]+.0x[0-9a-f]+f\(UNKNOWN\).0x[0-9a-f]+5\(UNKNOWN\).0x[0-9a-f]+2\(UNKNOWN\).0x[0-9a-f]+8\(UNKNOWN\).0x[0-9a-f]+c\(UNKNOWN\).*/
)
OR
(
proc_calltrace:(MapViewOfFileEx) AND
proc_calltrace.keyword:/ntdll.dll!ZwOpenProcess.*CreateToolhelp32Snapshot\+0x[0-9a-f]+.0x[0-9a-f]+7\(UNKNOWN\).0x[0-9a-f]+5\(UNKNOWN\).0x[0-9a-f]+b\(UNKNOWN\).0x[0-9a-f]+b\(UNKNOWN\).0x[0-9a-f]+a\(UNKNOWN\).*/
)
OR
(
proc_calltrace:(Process32NextW) AND
proc_calltrace.keyword:/ntdll.dll!ZwOpenProcess.*CreateToolhelp32Snapshot\+0x[0-9a-f]+.0x[0-9a-f]+2\(UNKNOWN\).0x[0-9a-f]+5\(UNKNOWN\).0x[0-9a-f]+c\(UNKNOWN\).0x[0-9a-f]+8\(UNKNOWN\).0x[0-9a-f]+8\(UNKNOWN\).*/
)
OR
(
proc_calltrace:(AreShortNamesEnabled) AND
proc_calltrace.keyword:/ntdll.dll!ZwOpenProcess.*CreateToolhelp32Snapshot\+0x[0-9a-f]+.0x[0-9a-f]+a\(UNKNOWN\).0x[0-9a-f]+5\(UNKNOWN\).0x[0-9a-f]+8\(UNKNOWN\).0x[0-9a-f]+8\(UNKNOWN\).0x[0-9a-f]+8\(UNKNOWN\).*/
)
OR
(
proc_calltrace:(GetModuleHandleW) AND
proc_calltrace.keyword:/ntdll.dll!ZwOpenProcess.*CreateToolhelp32Snapshot\+0x[0-9a-f]+.0x[0-9a-f]+1\(UNKNOWN\).0x[0-9a-f]+f\(UNKNOWN\).0x[0-9a-f]+a\(UNKNOWN\).0x[0-9a-f]+d\(UNKNOWN\).0x[0-9a-f]+9\(UNKNOWN\).*/
)
OR
(
proc_calltrace:(K32GetModuleFileNameExW) AND
proc_calltrace.keyword:/ntdll.dll!ZwOpenProcess.*CreateToolhelp32Snapshot\+0x[0-9a-f]+.0x[0-9a-f]+9\(UNKNOWN\).0x[0-9a-f]+3\(UNKNOWN\).0x[0-9a-f]+0\(UNKNOWN\).0x[0-9a-f]+2\(UNKNOWN\).0x[0-9a-f]+2\(UNKNOWN\).*/
)
OR
(
proc_calltrace:(GetPrivateProfileStringW) AND
proc_calltrace.keyword:/ntdll.dll!ZwOpenProcess.*CreateToolhelp32Snapshot\+0x[0-9a-f]+.0x[0-9a-f]+d\(UNKNOWN\).0x[0-9a-f]+4\(UNKNOWN\).0x[0-9a-f]+c\(UNKNOWN\).0x[0-9a-f]+d\(UNKNOWN\).0x[0-9a-f]+8\(UNKNOWN\).*/
)
OR
(
proc_calltrace:(Process32FirstW) AND
proc_calltrace.keyword:/ntdll.dll!ZwOpenProcess.*CreateToolhelp32Snapshot\+0x[0-9a-f]+.0x[0-9a-f]+4\(UNKNOWN\).0x[0-9a-f]+d\(UNKNOWN\).0x[0-9a-f]+5\(UNKNOWN\).0x[0-9a-f]+b\(UNKNOWN\).0x[0-9a-f]+2\(UNKNOWN\).*/
)
)
На основе этих данных также можно составить менее широкий запрос для поиска подозрительных открытий процесса системным процессом из неизвестного модуля:
dev_os_type:windows AND
event_type:processaccess AND
proc_file_path:("\\Windows\\System32\\" OR "\\Windows\\SysWOW64\\") AND
(
proc_calltrace:(*OpenProcess* AND UNKNOWN) AND
proc_calltrace.keyword:/ntdll.dll!ZwOpenProcess.*KernelBase.dll!OpenProcess\+0x[0-9a-f]+.(0x[0-9a-f]+\(UNKNOWN\).?)+/ AND
-proc_calltrace.keyword:/ntdll.dll!ZwOpenProcess.*KernelBase.dll!OpenProcess\+0x[0-9a-f]+.0x7ff[0-9a-f]+\(UNKNOWN\).*/
)
- DarkLoadLibrary иллюстрирует распространенный вектор атак — эксплуатацию низкоуровневых механизмов Windows для обхода средств защиты.
- Детальное понимание работы DarkLoadLibrary позволяет выявить признаки подозрительной активности при загрузке DLL.
- На основе этих признаков можно выстроить эффективный мониторинг, который позволит своевременно обнаруживать и пресекать атаки со скрытой загрузкой вредоносного кода.