
Техника выполнения/внедрения кода с использованием манипуляции структурой модулей DLL в PEB
Скрытное выполнение кода путем изменения EntryPoint загруженных модулей во время выполнения.
Процессы Windows имеют различные модули, загруженные во время выполнения. Каждый из этих модулей имеет определенную функцию DllMain(), которая будет вызвана при создании/уничтожении процесса или потока (четыре возможных сценария).
Чтобы правильно вызывать эти функции в течение жизненного цикла процесса, функции загрузчика Windows (ntdll!Ldrp*) обращаются к списку записей, содержащих ключевые параметры (включая поле EntryPoint) для каждого модуля.
Перезаписывая этот EntryPoint для DLL, мы гарантируем, что выполнение кода будет перенаправлено в нужное нам место.
Это можно использовать как примитив для выполнения кода, так и для проксирования API, например, для выполнения определенных API с не подозрительным стеком вызовов, поскольку они будут вызваны легитимными функциями Windows.
Это также можно использовать для запуска выполнения в удаленном процессе, если атакующий имеет возможность читать и записывать память в целевом процессе. Аналогично Threadless Injection, это позволяет выполнять код в процессе без вызова классических API, связанных с выполнением (CreateRemoteThread, QueueUserAPC).
Загрузка/выгрузка модулей в процессе Windows — это сложная тема, которая сопряжена с множеством проблем, потенциальной нестабильностью, состояниями гонки и сбоями. Например, хорошо известное препятствие, связанное с выполнением кода в рамках функции DllMain(), заключается в том, что действует блокировка загрузчика, и мы выполняемся в потоке, который еще не полностью настроен или находится в процессе завершения.
Поэтому я постарался должным образом задокументировать, что возможно, а что нет. Например, хотя большинство обычных вызовов API можно выполнить, запуск полноценного beacon требует определенных условий в отдельном процессе, чтобы избежать взаимоблокировок, вызванных функциями, используемыми в wininet.dll или winhttp.dll.
Каждый процесс поддерживает список структур _LDR_DATA_TABLE_ENTRY во время выполнения. Эти структуры содержат множество деталей, относящихся к DLL, таких как ее EntryPoint (который мы будем перезаписывать), имя, некоторые хэши, временные метки, различные флаги и т. д. Некоторые из этих структур документированы, некоторые — нет.
Их можно просмотреть с помощью этой команды WinDbg:
dt nt!_LDR_DATA_TABLE_ENTRY 0xdeadbeef
0:006> dt nt!_LDR_DATA_TABLE_ENTRY 0x18d1c4838c0
ntdll!_LDR_DATA_TABLE_ENTRY
+0x000 InLoadOrderLinks : _LIST_ENTRY [ 0x0000018d`1c485de0 - 0x0000018d`1c4832b0 ]
+0x010 InMemoryOrderLinks : _LIST_ENTRY [ 0x0000018d`1c485df0 - 0x0000018d`1c4832c0 ]
+0x020 InInitializationOrderLinks : _LIST_ENTRY [ 0x0000018d`1c4832d0 - 0x0000018d`1c482c80 ]
+0x030 DllBase : 0x00007ffe`e87e0000 Void
+0x038 EntryPoint : 0x00007ffe`e8838d00 Void
+0x040 SizeOfImage : 0x2fe000
+0x048 FullDllName : _UNICODE_STRING "C:\WINDOWS\System32\KERNELBASE.dll"
+0x058 BaseDllName : _UNICODE_STRING "KERNELBASE.dll"
+0x068 FlagGroup : [4] "???"
+0x068 Flags : 0x8a2cc
+0x068 PackagedBinary : 0y0
+0x068 MarkedForRemoval : 0y0
+0x068 ImageDll : 0y1
(...)
+0x0e0 MappingInfoIndexNode : _RTL_BALANCED_NODE
+0x0f8 OriginalBase : 0x00007ffe`e87e0000
+0x100 LoadTime : _LARGE_INTEGER 0x01db5f86`2fa735fc
+0x108 BaseNameHashValue : 0x235bec4
+0x10c LoadReason : 0 ( LoadReasonStaticDependency )
+0x110 ImplicitPathOptions : 0x4000
+0x114 ReferenceCount : 1
+0x118 DependentLoadFlags : 0x800
+0x11c SigningLevel : 0 ''
Адрес структуры можно получить, обойдя дважды связанную структуру, на которую ссылается PEB процесса в структуре PEB_LDR_DATA.
dt nt!_PEB_LDR_DATA 0xb4b4c3c3
Обратите внимание на флаг DontCallForThreads. Как следует из названия, если этот флаг установлен, ОС НЕ будет вызывать DllMain() этого модуля для событий потока (т. е. DLL_THREAD_ATTACH или DLL_THREAD_DETACH).
При создании DLL необходимо следовать следующему шаблону, чтобы корректно взаимодействовать с функциями загрузчика ОС:
BOOL WINAPI DllMain(
HINSTANCE hinstDLL, // дескриптор модуля DLL
DWORD fdwReason, // причина вызова функции
LPVOID lpvReserved ) // зарезервировано
{
// Выполнение действий в зависимости от причины вызова.
switch( fdwReason )
{
case DLL_PROCESS_ATTACH:
// Инициализация один раз для каждого нового процесса.
// Возврат FALSE для неудачной загрузки DLL.
break;
case DLL_THREAD_ATTACH:
// Инициализация, специфичная для потока.
break;
case DLL_THREAD_DETACH:
// Очистка, специфичная для потока.
break;
case DLL_PROCESS_DETACH:
if (lpvReserved != nullptr)
{
break; // не выполнять очистку при завершении процесса
}
// Выполнение необходимой очистки.
break;
}
return TRUE; // Успешная DLL_PROCESS_ATTACH.
}
Как описано выше, методика временно перезаписывает EntryPoint DLL, чтобы перенаправить выполнение. Поскольку мы не контролируем ничего, кроме перенаправления выполнения, необходимо предпринять некоторые меры для обработки того, что мы хотим запустить, с какими аргументами и как получить возвращаемое значение.
Это делается путем определения структуры DATA_T в куче, таким образом, чтобы она оставалась доступной на протяжении всех шагов.
Эта структура определена следующим образом:
typedef struct _DATA_T {
// Манипуляции со структурами LDR
ULONG_PTR runner; // вредоносная точка входа для выполнения
ULONG_PTR bakOriginalBase; // резервная копия перезаписанного OriginalBase
ULONG_PTR bakEntryPoint; // резервная копия перезаписанного EntryPoint
HANDLE event; // событие, сигнализирующее о выполнении Runner
// вызов функции
ULONG_PTR ret; // возвращаемое значение
DWORD createThread; // запустить этот вызов API в новом потоке (требуется для wininet/winhttp)
ULONG_PTR function; // вызываемая Windows API
DWORD dwArgs; // количество аргументов
ULONG_PTR args[MAX_ARGS]; // массив аргументов
} DATA_T, * PDATA_T;
Для настройки выполнения API необходимо подготовить эти поля. Значение ret будет содержать возвращаемое значение после выполнения. event используется для синхронизации, чтобы сигнализировать о завершении выполнения. Все остальные поля являются входными и определяют, какой API вызывать (function), с какими аргументами (dwArgs и args[]), адрес функции Runner(), куда перенаправляется выполнение, и резервные копии перезаписанных исходных записей DLL (bakOriginalBase и bakEntryPoint).
Поле createThread должно быть установлено в 1 для тех сложных функций API, которые не будут работать в среде DllMain() (сюда входят многие библиотеки wininet и winhttp).
Вот пример настройки вызова MessageBoxA(), как показано в PoC:
pDataT->dwArgs = 4;
pDataT->runner = (ULONG_PTR)Runner;
pDataT->function = (ULONG_PTR)MessageBoxA;
pDataT->args[0] = (ULONG_PTR)0;
pDataT->args[1] = (ULONG_PTR)"Hello";
pDataT->args[2] = (ULONG_PTR)"LDRSHUFFLE";
pDataT->args[3] = (ULONG_PTR)MB_OKCANCEL;
pDataT->event = CreateEventA(NULL, FALSE, FALSE, "ExecEvt");
Функция UpdateLdr() отвечает за внесение соответствующего изменения в _LDR_DATA_TABLE_ENTRY целевого модуля.
RestoreLdr() восстановит эти изменения на более позднем этапе (вызывается из Runner()).
Эти функции по сути находят PEB и обходят структуры модулей, чтобы определить нужную DLL и ее поля. В заголовочных файлах я использую определения, используемые Batsec в его DarkLoadLibrary, и рекомендую читателям ознакомиться с этим проектом и соответствующим постом в блоге MDSec, чтобы воспользоваться его отличной работой по внутреннему устройству загрузки модулей в Windows.
Примечание: этот PoC загружает жертвенную DLL (SACRIFICIAL_DLL_NAME) и выполняет эти изменения в этой DLL. Однако вполне возможно изменить уже загруженную DLL. Фактически, именно такой подход используется для межпроцессного внедрения. Из соображений стабильности я рекомендую избегать изменения важных DLL, таких как ntdll или kernel32, которые также чаще проверяются решениями безопасности.
При создании или уничтожении потока выполнение перенаправляется в Runner(), который действует как поддельная DllMain() для модуля. Эта функция затем:
PDATA_T)RestoreLdr()) в исходное состояниеDllMain() (по сути, проксируя нормальный вызов DLL).На этом этапе "нормальное" выполнение ОС завершено. Затем оно продолжается с нашими полезными нагрузками:
DATA_T. Если этот API помечен для запуска в новом потоке (createThread = 1), вызов будет выполнен в новом потоке.pDataT->event), чтобы наш основной код знал, что вызов выполнен.Когда Windows в конечном итоге вызывает наш поддельный EntryPoint (который является адресом функции Runner()), стек вызовов выглядит следующим образом:

Предоставленный PoC содержит пример вызова MessageBoxA().
Он также содержит демонстрацию загрузки HTTP с использованием wininet. Определите переменную HTTP, чтобы активировать этот код.

Описанные выше принципы сводятся к чтению и записи в адресное пространство процесса, чтобы вызвать выполнение кода в произвольный момент времени в будущем.
С некоторыми изменениями эти операции чтения и записи могут быть применены к удаленному процессу, чтобы перезаписать EntryPoint одной из его DLL.
Предварительным условием является возможность чтения и записи в адресное пространство процесса, то есть:
OpenProcess(PROCESS_VM_READ, FALSE, dwPid) и OpenProcess(PROCESS_VM_WRITE | PROCESS_VM_OPERATION, FALSE, PID)
В PoC представлен дополнительный проект под названием LdrInject, демонстрирующий, как выполнить эти шаги. Вкратце, он делает следующее:
в ReadPEB() он обходит список _LDR_DATA_TABLE_ENTRY в целевом процессе, чтобы определить подходящую DLL для перезаписи. Обратите внимание, что эта DLL должна иметь DontCallForThreads == 0, потому что мы хотим, чтобы Windows вызывала EntryPoint этой DLL при создании потока. Мы также не выбираем первые DLL в списке, так как они обычно чаще проверяются продуктами безопасности (ntdll.dll, kernel32.dll...).
детали этой DLL сохраняются в структуре данных PEBINJ_DATA.
шеллкод (в данном случае beacon) записывается в удаленное адресное пространство процесса с помощью InjectShellcodeToRemoteProcess()
два вызова WriteProcessMemory() перезаписывают EntryPoint DLL и сохраняют его резервную копию в OriginalBase, чтобы впоследствии его можно было восстановить.
В этот момент следующее событие DLL_THREAD_ATTACH или DLL_THREAD_DETACH приведет к вызову шеллкода. Это имеет определенные ограничения и оговорки в контексте запуска beacon, которые подробно описаны в следующем разделе.
Эта методика приводит к выполнению шеллкода в очень специфической ситуации. Блокировка загрузчика активна (так как ОС считает, что находится в процессе загрузки/выгрузки DLL); поток либо создается, либо уничтожается; и, как правило, существует вероятность проблем с синхронизацией потоков, взаимоблокировок и т. д.
Во время тестирования были замечены две проблемы:
запуск типичного Cobalt Strike beacon приводил к взаимоблокировке при использовании API в wininet.dll или winhttp.dll.
запуск при уничтожении потока вызывает проблемы со стабильностью, поскольку мы выполняемся в потоке, который находится в процессе уничтожения.
Чтобы повысить стабильность, необходимо:
убедиться, что beacon будет запущен в новом потоке. Поэтому UDRL будет вызывать CreateThread перед вызовом обычной точки входа рефлексивной DLL Cobalt Strike.
запускать только в создаваемом потоке, а не в умирающем. Для этого мы гарантируем, что когда EntryPoint вызывается ОС, причиной является fdwReason == DLL_THREAD_ATTACH:
winApi.CreateThread(NULL, 4096, (LPTHREAD_START_ROUTINE)&runner, (LPVOID)&ct_data, 0, &dwThId);
вместо обычного
((DLLMAIN)entryPoint)((HINSTANCE)loaderStart, 4, NULL);
ULONG_PTR __cdecl ReflectiveLoader(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) {
// запускать только при событии создания потока
if (fdwReason != DLL_THREAD_ATTACH) {
return TRUE;
}
...
}
Эти два дополнительных шага были встроены в демонстрацию UDRL.
VirtualAlloc
VirtualProtect
CreateThread
Sleep
MessageBoxA
InternetOpenW (требуется запуск с createThread = 1)
InternetOpenUrlA (требуется запуск с createThread = 1)