通过修改运行时已加载模块的 EntryPoint 实现隐蔽代码执行。
Windows 进程在运行时加载了各种模块。其中每个模块都定义了一个 DllMain() 函数,该函数会在进程或线程创建/销毁时被调用(共有四种可能的情形)。
为了在进程生命周期内正确调用这些函数,Windows 加载器函数(ntdll!Ldrp*)会引用一个条目列表,其中包含每个模块的关键参数(包括 EntryPoint 字段)。
通过覆写某个 DLL 的 EntryPoint,我们可以确保代码执行被重定向到我们选择的位置。
这既可以用作代码执行原语,也可以用于 API 代理,即:以不引起怀疑的调用栈来运行某些 API,因为它们将由合法的 Windows 函数调用。
这也可以用于在远程进程中触发执行,前提是攻击者能够在目标进程上读写内存。与 Threadless Injection 类似,它提供了在不调用与执行相关的经典 API(CreateRemoteThread、QueueUserAPC)的情况下在进程中执行代码的能力。
Windows 进程中的模块加载/卸载是一个复杂的主题,带来许多挑战,可能存在不稳定、竞态条件和崩溃。例如,一个与作为 DllMain() 函数的一部分运行代码相关的众所周知的障碍在于:存在加载器锁(Loader Lock),并且我们运行在一个尚未完全设置或正在终止过程中的线程中。
因此,我尝试恰当地记录哪些操作可行,哪些不可行。例如,虽然大多数常规 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 标志。顾名思义,如果设置了该标志,操作系统将不会针对线程事件(即 DLL_THREAD_ATTACH 或 DLL_THREAD_DETACH)调用该模块的 DllMain()。
在创建 DLL 时,必须遵循以下模板才能与操作系统加载器函数协同工作:
BOOL WINAPI DllMain(
HINSTANCE hinstDLL, // handle to DLL module
DWORD fdwReason, // reason for calling function
LPVOID lpvReserved ) // reserved
{
// Perform actions based on the reason for calling.
switch( fdwReason )
{
case DLL_PROCESS_ATTACH:
// Initialize once for each new process.
// Return FALSE to fail DLL load.
break;
case DLL_THREAD_ATTACH:
// Do thread-specific initialization.
break;
case DLL_THREAD_DETACH:
// Do thread-specific cleanup.
break;
case DLL_PROCESS_DETACH:
if (lpvReserved != nullptr)
{
break; // do not do cleanup if process termination scenario
}
// Perform any necessary cleanup.
break;
}
return TRUE; // Successful DLL_PROCESS_ATTACH.
}
如上所述,该技术临时覆写 DLL 的 EntryPoint 以重定向执行。由于我们只控制执行重定向,因此需要预先做一些安排来处理我们要运行什么、使用什么参数,以及如何取回返回值。
为此,在堆上定义一个 DATA_T 结构,使其在整个过程中保持可访问。
该结构定义如下:
typedef struct _DATA_T {
// LDR structures manipulation
ULONG_PTR runner; // malicious entry point to execute
ULONG_PTR bakOriginalBase; // backup of overwritten OriginalBase
ULONG_PTR bakEntryPoint; // backup of overwritten EntryPoint
HANDLE event; // event signalling that the Runner has executed
// function call
ULONG_PTR ret; // return value
DWORD createThread; // run this API call in a new thread (required for wininet/winhttp)
ULONG_PTR function; // Windows API to call
DWORD dwArgs; // number of args
ULONG_PTR args[MAX_ARGS]; // array of args
} DATA_T, * PDATA_T;
要设置 API 执行,需要准备好这些字段。ret 值用于在执行后收集返回值。event 用于同步,表示执行已完成。所有其他字段都是输入,定义要调用哪个 API(function)、使用哪些参数(dwArgs 和 args[])、执行重定向到的 Runner() 函数地址,以及被覆写的原始 DLL 条目的备份(bakOriginalBase 和 bakEntryPoint)。
对于这些在 DllMain() 设置中无法良好运行的复杂 API 函数,需要将 createThread 字段设置为 1(这包括许多 wininet 和 winhttp 库)。
以下是在 PoC 中可见的设置调用 MessageBoxA() 的示例:
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 调用。如果该 API 被标记为在新线程中运行(createThread = 1),则此调用将在新线程中执行。pDataT->event),以便我们的主代码知道调用已执行。当 Windows 最终调用我们的伪 EntryPoint(即 Runner() 函数地址)时,调用栈如下所示:

提供的 PoC 包含一个调用 MessageBoxA() 的示例。
它还包含一个使用 wininet 进行 HTTP 下载的演示。定义 HTTP 变量以启用该代码。

上述原则归结为在进程内存空间中进行读写,以便在未来的任意时间点触发代码执行。
稍作调整,这些读写操作就可以应用于远程进程,以覆写其某个 DLL 的 EntryPoint。
一个先决条件是能够在进程内存空间中读写,即:
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 在线程创建时调用该 DLL 的 EntryPoint。我们也不会选择列表中最先出现的 DLL,因为它们更容易受到安全产品的审查(ntdll.dll、kernel32.dll...)。
该 DLL 的详细信息存储在 PEBINJ_DATA 数据结构中。
shellcode(此处为 beacon)通过 InjectShellcodeToRemoteProcess() 写入远程进程空间。
两次 WriteProcessMemory() 调用覆写 DLL 的 EntryPoint,并将其备份到 OriginalBase 中,以便稍后恢复。
此时,下一个 DLL_THREAD_ATTACH 或 DLL_THREAD_DETACH 事件将导致 shellcode 被调用。在运行 beacon 的背景下,这会带来一定的限制和注意事项,下一节将详细介绍。
该技术导致在非常特定情况下执行 shellcode。加载器锁(Loader Lock)处于活动状态(因为操作系统认为它正在加载/卸载 DLL);线程要么正在创建,要么正在销毁;一般来说,可能存在线程同步问题、死锁等。
在测试过程中,观察到了两个挑战:
运行典型的 Cobalt Strike beacon 在使用 wininet.dll 或 winhttp.dll 中的 API 时会导致死锁。
在线程销毁时运行会导致稳定性问题,因为我们运行在一个正在被销毁的线程中。
为了提高稳定性,我们必须:
确保 beacon 将在新线程中运行。因此,UDRL 将在调用通常的 Cobalt Strike 反射 DLL 入口点之前执行 CreateThread。
只在线程创建时运行,而不是在线程消亡时运行。为此,我们确保当操作系统调用 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) {
// only run for a Thread creation event
if (fdwReason != DLL_THREAD_ATTACH) {
return TRUE;
}
...
}
这两个额外步骤已嵌入到 UDRL 的演示中。
VirtualAlloc
VirtualProtect
CreateThread
Sleep
MessageBoxA
InternetOpenW(需要使用 createThread = 1 运行)
InternetOpenUrlA(需要使用 createThread = 1 运行)