第 14 课:插件 DLL 内存加载
# 第 14 课:插件 DLL 内存加载
免责声明:本专栏仅用于安全工程学习研究,禁止使用本专栏介绍的技术做其他用途,否则后果自负,与本号无关。
本课定位:防御分析、源码阅读、内存 PE 加载原理、企业检测工程。
本课只解释源码中的插件 DLL 内存加载机制、工程收益和防御检测点,不提供未授权环境中的复用步骤、规避思路或攻击流程。
第 13 课讲清楚了插件数据如何到达登录模块:主控先发插件元数据,登录模块判断本地缓存是否可用,必要时请求主控下发完整插件数据,然后写入 HKCU\Console\0/1,最后创建线程进入 Loop_DllManager。
本课只接着回答一个问题:Loop_DllManager 里的 MemoryLoadLibrary 到底做了什么?
# 1. 本课学习目标
| 问题 | 本课回答 |
|---|---|
内存加载和普通 LoadLibrary 有什么不同 | 普通加载输入是磁盘路径;内存加载输入是已经在内存里的 PE/DLL 字节 |
| 为什么项目要用内存加载 | 便于主控按需下发插件、缓存二进制数据、减少本地 DLL 路径依赖、运行前注入连接配置 |
MemoryModule 是什么 | 一个第三方内存 PE 加载器,来自 Joachim Bauch 的 MemoryModule,MPL 2.0 许可 |
| 内存加载做了哪些步骤 | 校验 PE 头、分配映像内存、复制节区、重定位、解析导入表、修正节权限、执行 TLS/入口、查导出函数 |
| 防御侧怎么检测 | 关联插件大包、注册表二进制缓存、内存 PE 映射、动态导出解析、功能插件新连接 |
# 2. 源码目录和文件
| 文件 | 作用 |
|---|---|
主插件\登录模块\登录模块\LoginManager.cpp | 登录模块接收插件数据、缓存插件、启动 Loop_DllManager |
主插件\登录模块\登录模块\LoginManager.h | DllSendData、DllData、加载模式等结构声明 |
主插件\登录模块\登录模块\MemoryModule.h | 内存加载器对外接口 |
主插件\登录模块\登录模块\MemoryModule.cpp | 内存 PE 加载器实现 |
MemoryModule 是第三方组件,不是项目作者从零实现的 PE Loader。它的用途是:把一段 DLL/EXE 字节当作 PE 映像加载到当前进程内存里,并提供类似 LoadLibrary / GetProcAddress / FreeLibrary 的接口。
# 3. 内存加载在整体链路中的位置

把第 13 课和本课接起来,完整链路是:
主控功能窗口
-> COMMAND_DLLMAIN
-> 登录模块查 g_vecDllDatas / HKCU\Console\0/1
-> 缓存缺失时 TOKEN_SENDLL 请求插件数据
-> COMMAND_SENDLL 接收 DllSendData + DLL 数据
-> 写入注册表二进制缓存
-> _beginthreadex 进入 Loop_DllManager
-> MemoryLoadLibrary 加载内存中的 DLL 字节
-> MemoryGetProcAddress("Main")
-> 插件 Main(ip, port, istcp, false)
2
3
4
5
6
7
8
9
10
新手容易误解的一点是:MemoryLoadLibrary 不是“把 DLL 文件读进内存再调用 LoadLibrary”。普通 LoadLibrary 仍然需要一个文件路径,并由 Windows Loader 完成映射。这里的 MemoryLoadLibrary 输入已经是一段内存字节,它自己完成一部分 Windows Loader 的工作。
# 4. 为什么要做内存加载
先从工程收益说起。理解收益,才能理解为什么这类实现对企业安全也更敏感。
| 工程收益 | 在本项目里的表现 | 防御侧含义 |
|---|---|---|
| 按需分发插件 | 主控只在需要时发送具体插件 | 网络上会出现“命令后跟大块插件数据”的模式 |
| 减少磁盘 DLL 路径依赖 | Release 分支不要求目标机已有同名 DLL 文件 | 普通 DLL 加载日志可能不完整 |
| 支持本地缓存 | 插件数据写入 HKCU\Console\0/1 | 注册表大块二进制值成为关键证据 |
| 运行前改配置 | 插件数据加载前可写入 IP、端口、协议等配置 | 二进制数据中出现运行期配置替换痕迹 |
| 便于 x86/x64 区分 | 主控根据 bIsX64 返回对应插件 | 架构选择和插件版本协商应纳入检测 |
| 插件入口统一 | 加载后统一查找导出函数 Main | MemoryGetProcAddress("Main") 是稳定行为线索 |
这些收益本身不是恶意结论。很多合法软件也会使用插件化、动态加载、热更新。但在远控类源码中,它和长连接、命令分发、注册表二进制缓存、隐私采集或注入能力组合在一起时,风险等级会明显升高。
# 5. 入口:Loop_DllManager 如何触发内存加载
第 13 课已经讲过 COMMAND_DLLMAIN 和 COMMAND_SENDLL 如何进入 Loop_DllManager。这里重点看线程入口中的加载分支。
// 源码位置:主插件\登录模块\登录模块\LoginManager.cpp:8-53
// 防御分析注释:
// 1. pDllData 来自 g_vecDllDatas,里面保存插件元数据、插件二进制数据、主控地址和端口。
// 2. Debug 分支用 LoadLibrary 加载本地 DLL,便于开发者在 VS 中调试。
// 3. Release 分支用 MemoryLoadLibrary 加载内存里的 DLL 字节,普通模块加载日志可能看不到文件路径。
// 4. MemoryGetProcAddress 查找固定导出函数 Main,后续把主控连接参数传给插件。
// 5. 本课只解释防御性原理,不把这条链路作为可复用加载方法。
unsigned int __stdcall Loop_DllManager(void* pVoid)
{
DllData* pDllData = (DllData*)pVoid;
typedef void (*DLLMain)(TCHAR* ip, DWORD port, BOOL istcp, BOOL RunDllEntryProc);
DLLMain lpproc;
switch (pDllData->m_nDllLoadMode)
{
case DLL_MEMLOAD:
{
#ifdef _DEBUG
HMODULE hDllModule = ::LoadLibrary(pDllData->m_sendDllData->szDllName);
#else
HMEMORYMODULE hDllModule = ::MemoryLoadLibrary(
pDllData->m_pDllData,
pDllData->m_sendDllData->dllDataSize);
#endif
if (hDllModule != NULL)
{
#ifdef _DEBUG
lpproc = (DLLMain)::GetProcAddress(hDllModule, "Main");
#else
lpproc = (DLLMain)MemoryGetProcAddress(hDllModule, "Main");
#endif
if (lpproc != NULL)
{
(*lpproc)(
pDllData->m_strMasterHost,
pDllData->m_nMasterPort,
pDllData->m_bIsTcp,
false);
#ifdef _DEBUG
::FreeLibrary(hDllModule);
#else
MemoryFreeLibrary(hDllModule);
#endif
}
}
}
break;
}
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
这段代码把两种加载方式摆得很清楚:
| 分支 | 输入 | 加载器 | 查找入口 | 典型证据 |
|---|---|---|---|---|
| Debug | DLL 文件名 | Windows Loader | GetProcAddress | 本地 DLL 路径、模块加载事件 |
| Release | DLL 内存字节 | MemoryModule | MemoryGetProcAddress | 内存 PE、注册表二进制缓存、动态导出解析 |
# 6. 对外接口:MemoryModule.h
先看接口,再看实现。MemoryModule.h 暴露的几个函数和 Windows API 很像:加载、查导出、释放。
// 源码位置:主插件\登录模块\登录模块\MemoryModule.h:35-79
// 防御分析注释:
// 1. HMEMORYMODULE 是自定义模块句柄,不是 Windows 的 HMODULE。
// 2. MemoryLoadLibrary 输入是内存地址和大小,不是 DLL 文件路径。
// 3. MemoryLoadLibraryEx 允许传入自定义分配器、依赖 DLL 加载器和导出解析器。
// 4. MemoryGetProcAddress 模拟 GetProcAddress,从内存映像的导出表里找函数。
// 5. MemoryFreeLibrary 会调用 DllMain(DLL_PROCESS_DETACH) 并释放相关内存。
typedef void *HMEMORYMODULE;
typedef void *HCUSTOMMODULE;
typedef LPVOID (*CustomAllocFunc)(LPVOID, SIZE_T, DWORD, DWORD, void*);
typedef BOOL (*CustomFreeFunc)(LPVOID, SIZE_T, DWORD, void*);
typedef HCUSTOMMODULE (*CustomLoadLibraryFunc)(LPCSTR, void *);
typedef FARPROC (*CustomGetProcAddressFunc)(HCUSTOMMODULE, LPCSTR, void *);
typedef void (*CustomFreeLibraryFunc)(HCUSTOMMODULE, void *);
HMEMORYMODULE MemoryLoadLibrary(const void *, size_t);
HMEMORYMODULE MemoryLoadLibraryEx(
const void *,
size_t,
CustomAllocFunc,
CustomFreeFunc,
CustomLoadLibraryFunc,
CustomGetProcAddressFunc,
CustomFreeLibraryFunc,
void *);
FARPROC MemoryGetProcAddress(HMEMORYMODULE, LPCSTR);
void MemoryFreeLibrary(HMEMORYMODULE);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
MemoryLoadLibrary 只是一个默认包装函数,真正逻辑在 MemoryLoadLibraryEx。
// 源码位置:主插件\登录模块\登录模块\MemoryModule.cpp:539-547
// 防御分析注释:
// 1. 默认分配器最终调用 VirtualAlloc / VirtualFree。
// 2. 默认依赖解析器最终调用 LoadLibraryA / GetProcAddress。
// 3. 这说明内存加载并不等于完全不碰系统 Loader:依赖 DLL 仍可能走普通加载路径。
HMEMORYMODULE MemoryLoadLibrary(const void *data, size_t size)
{
return MemoryLoadLibraryEx(
data,
size,
MemoryDefaultAlloc,
MemoryDefaultFree,
MemoryDefaultLoadLibrary,
MemoryDefaultGetProcAddress,
MemoryDefaultFreeLibrary,
NULL);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 7. 内存中的 PE 布局

内存加载不是把文件内容原样拷贝到一块连续内存就结束。PE 文件在磁盘上的布局和映射到内存后的布局不同:
| 磁盘文件视角 | 内存映像视角 |
|---|---|
PointerToRawData 指向文件偏移 | VirtualAddress 指向映像内 RVA |
| 节数据按文件对齐 | 节数据按内存对齐 |
| 导入表只是名称和 thunk 信息 | IAT 需要被填成真实函数地址 |
| 原始 ImageBase 只是期望基址 | 实际基址可能不同,需要重定位 |
| 节权限来自 PE 节属性 | 加载后要用 VirtualProtect 设置页面权限 |
所以 MemoryLoadLibraryEx 要做的工作,本质上是在当前进程里“手动映射”一个 DLL。
# 8. 第一步:校验 DOS 头、NT 头和架构
MemoryLoadLibraryEx 首先确认输入字节至少像一个 PE 文件,并且架构和当前进程匹配。
// 源码位置:主插件\登录模块\登录模块\MemoryModule.cpp:544-613
// 防御分析注释:
// 1. CheckSize 防止读取越界,避免把短缓冲区当成 PE 解析。
// 2. IMAGE_DOS_SIGNATURE 对应 MZ,IMAGE_NT_SIGNATURE 对应 PE。
// 3. FileHeader.Machine 必须等于 HOST_MACHINE,否则 x86/x64 不匹配会被拒绝。
// 4. 如果企业侧扫描到内存中出现 MZ/PE 头,但没有对应磁盘模块,应重点关注。
HMEMORYMODULE MemoryLoadLibraryEx(const void *data, size_t size, ...)
{
PIMAGE_DOS_HEADER dos_header;
PIMAGE_NT_HEADERS old_header;
if (!CheckSize(size, sizeof(IMAGE_DOS_HEADER))) {
return NULL;
}
dos_header = (PIMAGE_DOS_HEADER)data;
if (dos_header->e_magic != IMAGE_DOS_SIGNATURE) {
SetLastError(ERROR_BAD_EXE_FORMAT);
return NULL;
}
if (!CheckSize(size, dos_header->e_lfanew + sizeof(IMAGE_NT_HEADERS))) {
return NULL;
}
old_header = (PIMAGE_NT_HEADERS)&((const unsigned char *)data)[dos_header->e_lfanew];
if (old_header->Signature != IMAGE_NT_SIGNATURE) {
SetLastError(ERROR_BAD_EXE_FORMAT);
return NULL;
}
if (old_header->FileHeader.Machine != HOST_MACHINE) {
SetLastError(ERROR_BAD_EXE_FORMAT);
return NULL;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
这一段和第 17 课的 x86/x64 双产物是连在一起的:主控根据被控位数下发对应插件,MemoryModule 又在本地校验 PE 的 Machine 字段,避免把错误架构的 DLL 映射进当前进程。
# 9. 第二步:分配映像内存
校验通过后,加载器会尝试按 PE 原始 ImageBase 分配内存。如果失败,再选择任意地址分配。任意地址分配成功后,后面就必须做重定位。
// 源码位置:主插件\登录模块\登录模块\MemoryModule.cpp:620-705
// 防御分析注释:
// 1. alignedImageSize 来自 OptionalHeader.SizeOfImage 按页面大小对齐后的结果。
// 2. 优先使用 old_header->OptionalHeader.ImageBase,是为了减少重定位需求。
// 3. 分配权限先是 PAGE_READWRITE,后续 FinalizeSections 会按节属性改成更细权限。
// 4. 内存侧检测可关注大块 MEM_RESERVE | MEM_COMMIT 分配后出现 PE 头和可执行节。
code = (unsigned char *)allocMemory(
(LPVOID)(old_header->OptionalHeader.ImageBase),
alignedImageSize,
MEM_RESERVE | MEM_COMMIT,
PAGE_READWRITE,
userdata);
if (code == NULL) {
code = (unsigned char *)allocMemory(
NULL,
alignedImageSize,
MEM_RESERVE | MEM_COMMIT,
PAGE_READWRITE,
userdata);
if (code == NULL) {
SetLastError(ERROR_OUTOFMEMORY);
return NULL;
}
}
result = (PMEMORYMODULE)HeapAlloc(
GetProcessHeap(),
HEAP_ZERO_MEMORY,
sizeof(MEMORYMODULE));
result->codeBase = code;
result->isDLL = (old_header->FileHeader.Characteristics & IMAGE_FILE_DLL) != 0;
result->alloc = allocMemory;
result->free = freeMemory;
result->loadLibrary = loadLibrary;
result->getProcAddress = getProcAddress;
result->freeLibrary = freeLibrary;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
这里的关键变量:
| 变量 | 含义 |
|---|---|
data | 输入的插件 DLL 字节 |
size | 输入字节长度,来自 DllSendData::dllDataSize |
old_header | 原始 PE 头,仍指向输入数据 |
code | 新分配的内存映像基址 |
result->codeBase | MemoryModule 后续使用的模块基址 |
# 10. 第三步:复制 PE 头和节区
映像内存分配好后,加载器先复制 PE 头,再把每个节的数据从文件偏移复制到内存中的 RVA 位置。
// 源码位置:主插件\登录模块\登录模块\MemoryModule.cpp:700-714
// 防御分析注释:
// 1. SizeOfHeaders 控制 PE 头复制范围。
// 2. result->headers 指向新内存中的 NT 头,不再指向原始输入数据。
// 3. ImageBase 被更新为实际分配出来的 code 地址。
// 4. CopySections 会把磁盘节布局转换成内存节布局。
headers = (unsigned char *)allocMemory(
code,
old_header->OptionalHeader.SizeOfHeaders,
MEM_COMMIT,
PAGE_READWRITE,
userdata);
memcpy(headers, dos_header, old_header->OptionalHeader.SizeOfHeaders);
result->headers = (PIMAGE_NT_HEADERS)&((const unsigned char *)headers)[dos_header->e_lfanew];
result->headers->OptionalHeader.ImageBase = (uintptr_t)code;
if (!CopySections((const unsigned char *)data, size, old_header, result)) {
goto error;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
CopySections 是新手理解 PE 映射的重点。
// 源码位置:主插件\登录模块\登录模块\MemoryModule.cpp:176-229
// 防御分析注释:
// 1. section->PointerToRawData 是节在输入 DLL 文件字节中的偏移。
// 2. section->VirtualAddress 是节映射到内存映像后的 RVA。
// 3. SizeOfRawData 为 0 的节可能是未初始化数据,加载器会分配并清零。
// 4. 所有节初始都用 PAGE_READWRITE,后面再根据节属性收紧权限。
static BOOL CopySections(
const unsigned char *data,
size_t size,
PIMAGE_NT_HEADERS old_headers,
PMEMORYMODULE module)
{
unsigned char *codeBase = module->codeBase;
PIMAGE_SECTION_HEADER section = IMAGE_FIRST_SECTION(module->headers);
for (int i = 0; i < module->headers->FileHeader.NumberOfSections; i++, section++) {
if (section->SizeOfRawData == 0) {
int section_size = old_headers->OptionalHeader.SectionAlignment;
unsigned char *dest = (unsigned char *)module->alloc(
codeBase + section->VirtualAddress,
section_size,
MEM_COMMIT,
PAGE_READWRITE,
module->userdata);
dest = codeBase + section->VirtualAddress;
memset(dest, 0, section_size);
continue;
}
if (!CheckSize(size, section->PointerToRawData + section->SizeOfRawData)) {
return FALSE;
}
unsigned char *dest = (unsigned char *)module->alloc(
codeBase + section->VirtualAddress,
section->SizeOfRawData,
MEM_COMMIT,
PAGE_READWRITE,
module->userdata);
dest = codeBase + section->VirtualAddress;
memcpy(dest, data + section->PointerToRawData, section->SizeOfRawData);
section->Misc.PhysicalAddress = (DWORD)((uintptr_t)dest & 0xffffffff);
}
return TRUE;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
防御侧看内存时,可以关注:内存区域里存在 PE 头,节边界与 PE 节表一致,但没有普通 LoadLibrary 对应的磁盘模块路径。
# 11. 第四步:修正重定位
如果实际加载基址和 PE 原始 ImageBase 不一致,代码或数据里写死的绝对地址就需要修正。PerformBaseRelocation 负责遍历重定位表。
// 源码位置:主插件\登录模块\登录模块\MemoryModule.cpp:382-436
// 防御分析注释:
// 1. directory 指向 IMAGE_DIRECTORY_ENTRY_BASERELOC,也就是重定位目录。
// 2. delta 是实际 ImageBase 与原始 ImageBase 的差值。
// 3. x86 常见 IMAGE_REL_BASED_HIGHLOW,x64 常见 IMAGE_REL_BASED_DIR64。
// 4. 如果没有重定位信息且基址又变化,加载通常会失败。
static BOOL PerformBaseRelocation(PMEMORYMODULE module, ptrdiff_t delta)
{
unsigned char *codeBase = module->codeBase;
PIMAGE_DATA_DIRECTORY directory =
GET_HEADER_DICTIONARY(module, IMAGE_DIRECTORY_ENTRY_BASERELOC);
if (directory->Size == 0) {
return (delta == 0);
}
PIMAGE_BASE_RELOCATION relocation =
(PIMAGE_BASE_RELOCATION)(codeBase + directory->VirtualAddress);
for (; relocation->VirtualAddress > 0; ) {
unsigned char *dest = codeBase + relocation->VirtualAddress;
unsigned short *relInfo =
(unsigned short*)OffsetPointer(relocation, IMAGE_SIZEOF_BASE_RELOCATION);
for (DWORD i = 0;
i < ((relocation->SizeOfBlock - IMAGE_SIZEOF_BASE_RELOCATION) / 2);
i++, relInfo++) {
int type = *relInfo >> 12;
int offset = *relInfo & 0xfff;
switch (type)
{
case IMAGE_REL_BASED_HIGHLOW:
*(DWORD *)(dest + offset) += (DWORD)delta;
break;
#ifdef _WIN64
case IMAGE_REL_BASED_DIR64:
*(ULONGLONG *)(dest + offset) += (ULONGLONG)delta;
break;
#endif
}
}
relocation =
(PIMAGE_BASE_RELOCATION)OffsetPointer(relocation, relocation->SizeOfBlock);
}
return TRUE;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
这也是 x86/x64 差异的一个具体落点:x64 下地址宽度是 64 位,因此重定位项需要处理 DIR64。
# 12. 第五步:解析导入表
DLL 里调用的外部 API 不能只靠函数名存在。加载器必须找到依赖 DLL,并把 IAT 填成真实函数地址。
// 源码位置:主插件\登录模块\登录模块\MemoryModule.cpp:439-493
// 防御分析注释:
// 1. IMAGE_DIRECTORY_ENTRY_IMPORT 指向导入表目录。
// 2. importDesc->Name 是依赖 DLL 名称,例如 kernel32.dll、user32.dll 等。
// 3. 默认 loadLibrary 回调最终调用 LoadLibraryA,所以依赖 DLL 仍会走系统加载路径。
// 4. getProcAddress 回调最终调用 GetProcAddress,把 IAT 项改成真实函数地址。
static BOOL BuildImportTable(PMEMORYMODULE module)
{
unsigned char *codeBase = module->codeBase;
PIMAGE_DATA_DIRECTORY directory =
GET_HEADER_DICTIONARY(module, IMAGE_DIRECTORY_ENTRY_IMPORT);
if (directory->Size == 0) {
return TRUE;
}
PIMAGE_IMPORT_DESCRIPTOR importDesc =
(PIMAGE_IMPORT_DESCRIPTOR)(codeBase + directory->VirtualAddress);
for (; importDesc->Name; importDesc++) {
HCUSTOMMODULE handle = module->loadLibrary(
(LPCSTR)(codeBase + importDesc->Name),
module->userdata);
uintptr_t *thunkRef;
FARPROC *funcRef;
if (importDesc->OriginalFirstThunk) {
thunkRef = (uintptr_t *)(codeBase + importDesc->OriginalFirstThunk);
funcRef = (FARPROC *)(codeBase + importDesc->FirstThunk);
} else {
thunkRef = (uintptr_t *)(codeBase + importDesc->FirstThunk);
funcRef = (FARPROC *)(codeBase + importDesc->FirstThunk);
}
for (; *thunkRef; thunkRef++, funcRef++) {
if (IMAGE_SNAP_BY_ORDINAL(*thunkRef)) {
*funcRef = module->getProcAddress(
handle,
(LPCSTR)IMAGE_ORDINAL(*thunkRef),
module->userdata);
} else {
PIMAGE_IMPORT_BY_NAME thunkData =
(PIMAGE_IMPORT_BY_NAME)(codeBase + (*thunkRef));
*funcRef = module->getProcAddress(
handle,
(LPCSTR)&thunkData->Name,
module->userdata);
}
}
}
return TRUE;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
这里能看出一个重要结论:内存加载并不意味着完全没有系统可见行为。依赖 DLL 的加载、API 解析、内存页权限变化、线程执行仍然可能产生检测线索。
# 13. 第六步:设置节权限
节区复制时先使用 PAGE_READWRITE,只是为了方便写入。真正执行前,FinalizeSections 会根据节属性设置权限。
// 源码位置:主插件\登录模块\登录模块\MemoryModule.cpp:237-305
// 防御分析注释:
// 1. ProtectionFlags 把“可执行、可读、可写”三种节属性转换成 Windows 页面权限。
// 2. .text 类代码节通常会变成 PAGE_EXECUTE_READ。
// 3. 同时可写又可执行的页面需要重点关注,但这里的最终权限取决于 PE 节属性。
// 4. 企业侧应关联 VirtualAlloc、VirtualProtect 和线程起始地址,而不是只看单点 API。
static int ProtectionFlags[2][2][2] = {
{
{PAGE_NOACCESS, PAGE_WRITECOPY},
{PAGE_READONLY, PAGE_READWRITE},
}, {
{PAGE_EXECUTE, PAGE_EXECUTE_WRITECOPY},
{PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE},
},
};
static BOOL FinalizeSection(PMEMORYMODULE module, PSECTIONFINALIZEDATA sectionData)
{
BOOL executable =
(sectionData->characteristics & IMAGE_SCN_MEM_EXECUTE) != 0;
BOOL readable =
(sectionData->characteristics & IMAGE_SCN_MEM_READ) != 0;
BOOL writeable =
(sectionData->characteristics & IMAGE_SCN_MEM_WRITE) != 0;
DWORD protect = ProtectionFlags[executable][readable][writeable];
DWORD oldProtect;
if (VirtualProtect(sectionData->address, sectionData->size, protect, &oldProtect) == 0) {
return FALSE;
}
return TRUE;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
这一步是防御侧非常关心的边界:一个原本可写的内存映像,在映射完成后被改成可执行权限。单独 VirtualProtect 未必恶意,但如果前面刚出现插件大包、PE 头、注册表缓存和加载线程,就应该提高优先级。
# 14. 第七步:执行 TLS 和 DLL 入口
PE 支持 TLS 回调。MemoryModule 会在主入口前先执行 TLS 回调,再调用 DLL 的入口点。
// 源码位置:主插件\登录模块\登录模块\MemoryModule.cpp:359-379、735-755
// 防御分析注释:
// 1. ExecuteTLS 会遍历 TLS 回调并传入 DLL_PROCESS_ATTACH。
// 2. AddressOfEntryPoint 是 PE 可选头中的入口 RVA。
// 3. 如果是 DLL,入口函数按 DllMain 形式调用。
// 4. 这意味着插件代码可能在导出函数 Main 被调用前就已有初始化行为。
static BOOL ExecuteTLS(PMEMORYMODULE module)
{
unsigned char *codeBase = module->codeBase;
PIMAGE_DATA_DIRECTORY directory =
GET_HEADER_DICTIONARY(module, IMAGE_DIRECTORY_ENTRY_TLS);
if (directory->VirtualAddress == 0) {
return TRUE;
}
PIMAGE_TLS_DIRECTORY tls =
(PIMAGE_TLS_DIRECTORY)(codeBase + directory->VirtualAddress);
PIMAGE_TLS_CALLBACK* callback =
(PIMAGE_TLS_CALLBACK *)tls->AddressOfCallBacks;
while (callback && *callback) {
(*callback)((LPVOID)codeBase, DLL_PROCESS_ATTACH, NULL);
callback++;
}
return TRUE;
}
if (result->headers->OptionalHeader.AddressOfEntryPoint != 0) {
if (result->isDLL) {
DllEntryProc DllEntry =
(DllEntryProc)(LPVOID)(code + result->headers->OptionalHeader.AddressOfEntryPoint);
BOOL successfull =
(*DllEntry)((HINSTANCE)code, DLL_PROCESS_ATTACH, 0);
result->initialized = TRUE;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
这也是为什么检测不能只盯 MemoryGetProcAddress("Main")。如果 DLL 里有 TLS 或 DllMain 初始化逻辑,行为可能出现在 Main 之前。
# 15. 第八步:查找导出函数 Main
内存模块加载完成后,登录模块调用 MemoryGetProcAddress(hDllModule, "Main")。这个函数不是问系统 Loader,而是自己解析内存映像里的导出表。
// 源码位置:主插件\登录模块\登录模块\MemoryModule.cpp:780-856
// 防御分析注释:
// 1. IMAGE_DIRECTORY_ENTRY_EXPORT 指向导出表。
// 2. AddressOfNames / AddressOfNameOrdinals / AddressOfFunctions 共同完成名字到函数 RVA 的映射。
// 3. 本工程固定查找 Main,说明所有功能插件约定了统一导出入口。
// 4. 企业侧可把固定导出名、内存 PE 和插件连接行为放在同一条检测链。
FARPROC MemoryGetProcAddress(HMEMORYMODULE mod, LPCSTR name)
{
PMEMORYMODULE module = (PMEMORYMODULE)mod;
unsigned char *codeBase = module->codeBase;
PIMAGE_DATA_DIRECTORY directory =
GET_HEADER_DICTIONARY(module, IMAGE_DIRECTORY_ENTRY_EXPORT);
if (directory->Size == 0) {
SetLastError(ERROR_PROC_NOT_FOUND);
return NULL;
}
PIMAGE_EXPORT_DIRECTORY exports =
(PIMAGE_EXPORT_DIRECTORY)(codeBase + directory->VirtualAddress);
DWORD *nameRef = (DWORD *)(codeBase + exports->AddressOfNames);
WORD *ordinal = (WORD *)(codeBase + exports->AddressOfNameOrdinals);
// 源码中会构建导出名表并用二分查找定位 name。
// 找到后,根据 AddressOfFunctions 中的 RVA 计算真实函数地址。
return (FARPROC)(LPVOID)(
codeBase + (*(DWORD *)(codeBase + exports->AddressOfFunctions + (idx * 4))));
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
对于本工程,Main 的函数签名在 Loop_DllManager 里已经写死:
// 源码位置:主插件\登录模块\登录模块\LoginManager.cpp:10-16
// 防御分析注释:
// 1. 所有被加载插件都要提供 Main(TCHAR*, DWORD, BOOL, BOOL) 这种入口约定。
// 2. ip/port/istcp 来自登录模块当前连接上下文。
// 3. RunDllEntryProc=false 表示插件不要求以 DLL 入口进程模式退出当前进程。
typedef void (*DLLMain)(
TCHAR* ip,
DWORD port,
BOOL istcp,
BOOL RunDllEntryProc);
2
3
4
5
6
7
8
9
10
# 16. 第九步:释放内存模块
插件入口返回后,Loop_DllManager 调用 MemoryFreeLibrary。它会调用 DLL 的 detach 入口,释放导出名表、依赖模块和映像内存。
// 源码位置:主插件\登录模块\登录模块\MemoryModule.cpp:858-888
// 防御分析注释:
// 1. initialized 为真时,先调用 DllMain(DLL_PROCESS_DETACH)。
// 2. nameExportsTable 是 MemoryGetProcAddress 为导出名查找建立的辅助表。
// 3. modules 保存 BuildImportTable 加载过的依赖 DLL 句柄。
// 4. codeBase 是整个内存映像的基址,最终会被释放。
void MemoryFreeLibrary(HMEMORYMODULE mod)
{
PMEMORYMODULE module = (PMEMORYMODULE)mod;
if (module == NULL) {
return;
}
if (module->initialized) {
DllEntryProc DllEntry =
(DllEntryProc)(LPVOID)(
module->codeBase +
module->headers->OptionalHeader.AddressOfEntryPoint);
(*DllEntry)((HINSTANCE)module->codeBase, DLL_PROCESS_DETACH, 0);
}
free(module->nameExportsTable);
// 源码后续还会释放依赖模块、映像内存和 MEMORYMODULE 结构。
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
释放并不意味着没有证据。网络包、注册表缓存、日志、内存分配事件、导入解析、插件自己的功能连接,都可能留下可关联证据。
# 17. 完整时序再串一次

从源码顺序看,内存加载可以压缩成下面这条链:
Loop_DllManager
-> MemoryLoadLibrary
-> MemoryLoadLibraryEx
-> 校验 MZ / PE / Machine
-> VirtualAlloc 映像内存
-> 复制 PE 头
-> CopySections
-> PerformBaseRelocation
-> BuildImportTable
-> FinalizeSections
-> ExecuteTLS
-> DllMain(DLL_PROCESS_ATTACH)
-> MemoryGetProcAddress("Main")
-> Main(ip, port, istcp, false)
-> MemoryFreeLibrary
2
3
4
5
6
7
8
9
10
11
12
13
14
15
这条链也是企业检测链:
插件命令
-> 大块插件数据
-> HKCU\Console\0/1 REG_BINARY
-> 工作线程
-> 内存 PE 映像
-> 导入表解析 / 节权限变化
-> 固定导出 Main
-> 功能插件连接主控
2
3
4
5
6
7
8
# 18. 内存加载的能力限制
内存加载不是万能的。对新手来说,理解限制比记住函数名更重要。
| 限制 | 说明 |
|---|---|
| 架构必须匹配 | x86 进程不能直接加载 x64 DLL,反之也一样 |
| 依赖仍要解析 | 依赖 DLL 缺失或导出缺失,BuildImportTable 会失败 |
| TLS 和入口会执行 | Main 之前可能已经执行初始化逻辑 |
| 不等于无痕 | 内存分配、权限变化、依赖加载、线程执行都可能被看到 |
| 稳定性依赖插件质量 | 插件导出函数签名不匹配、异常未处理、全局状态冲突都可能导致崩溃 |
| 合法软件要有签名和审计 | 合规插件体系应有签名校验、版本白名单、用户授权和完整日志 |
# 19. 企业检测点
| 层面 | 检测点 | 说明 |
|---|---|---|
| 网络侧 | COMMAND_DLLMAIN、TOKEN_SENDLL、大块二进制数据 | 插件加载前后的协议行为 |
| 注册表侧 | HKCU\Console\0/1 大块 REG_BINARY | 插件缓存位置 |
| 内存侧 | 内存中出现 MZ/PE 头、节区布局、可执行私有页 | 内存 PE 映像线索 |
| API 侧 | VirtualAlloc、VirtualProtect、LoadLibraryA、GetProcAddress | 手动映射和依赖解析组合 |
| 线程侧 | _beginthreadex -> Loop_DllManager | 插件加载线程入口 |
| 导出侧 | 固定导出名 Main | 插件入口约定 |
| 行为侧 | 加载后插件再次连接主控 | 功能插件独立通信链路 |
关联逻辑可以这样设计:
同一进程 5 分钟窗口内:
1. 收到插件命令或大块二进制网络数据
2. 写入 HKCU\Console\0/1 REG_BINARY
3. 创建工作线程
4. 出现内存 PE 或可执行私有页
5. 随后出现功能插件连接或隐私/系统管理行为
满足 3 条以上即可提升风险等级,满足 5 条应按高危远控插件加载处理。
2
3
4
5
6
7
8
# 20. 合法软件加固建议
如果企业确实需要插件式远程运维,不建议照搬这种隐式内存加载链路。更合规的做法是:
| 设计项 | 建议 |
|---|---|
| 插件来源 | 插件必须来自可信仓库,并做签名校验 |
| 插件版本 | 主控和被控都维护版本白名单 |
| 用户授权 | 涉及屏幕、摄像头、麦克风、键盘、文件的插件必须有用户可见提示 |
| 审计日志 | 记录操作者、审批单、目标主机、插件名、版本、哈希、时间和结果 |
| 加载方式 | 能用标准加载就优先标准加载,必须内存加载时要做强校验和可观测审计 |
| 失败处理 | 加载失败、导入失败、入口异常都要记录并回滚 |
| 检测集成 | 主动向 EDR/SIEM 输出插件加载事件,而不是让安全团队被动猜测 |
# 21. 常见误区
| 误区 | 正确理解 |
|---|---|
| 内存加载就是 shellcode | 不完全是。这里加载的是 PE/DLL 字节,需要处理 PE 头、节区、重定位和导入表 |
| 没有 DLL 文件就没有痕迹 | 仍有网络、注册表、内存、线程、依赖加载和功能行为证据 |
MemoryLoadLibrary 不会调用系统 API | 默认实现仍使用 VirtualAlloc、VirtualProtect、LoadLibraryA、GetProcAddress |
只要查 LoadLibrary 就够了 | Release 分支的主模块不是通过普通 LoadLibrary 路径加载 |
只看 Main 就够了 | TLS 回调和 DllMain 可能在 Main 前执行 |
# 22. 合法练习题
- 只阅读源码,画出
MemoryLoadLibraryEx的 8 个阶段,不运行任何插件。 - 在实验文档里解释
PointerToRawData和VirtualAddress的区别。 - 写一条检测思路:
HKCU\Console\0/1大块REG_BINARY写入后 5 分钟内出现内存 PE。 - 对比 Debug 和 Release 分支,说明为什么调试时能看到 DLL 路径,发布时更依赖内存侧检测。
- 设计一个合规插件加载事件字段表,至少包含插件名、版本、哈希、操作者、目标主机、加载结果和审批编号。
# 23. 本课小结
本课把第 13 课里的内存加载细节独立出来,按源码顺序讲清楚了 MemoryModule 的核心机制:校验 PE、分配映像、复制节区、修正重定位、解析导入表、设置节权限、执行 TLS/入口、查找 Main 并释放模块。
对企业安全工程来说,最重要的结论是:内存加载的工程收益来自“按需分发、减少磁盘路径依赖、缓存插件、运行期配置注入”,但它也会削弱传统基于文件路径的可见性。因此检测必须从单点 API 升级为链路关联,把网络、注册表、内存、线程、导出函数和插件后续行为放在一起看。
安全工程知识交流加wx:easy_coder,黑灰产勿扰,企业单位合作请出示有效证件。
本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。
社区交流
讨论与留言