8 / 52 Windows、主控与网络

第 8 课:主控端架构分析

# 第 8 课:主控端架构分析

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

本课定位:第 7 课已经讲清楚主控界面布局,本课继续往下讲主控端内部架构。为了让新手读起来连续,本课从“一条连接的生命周期”讲起:监听端口、建立连接、创建 ClientContext、收包组包、回调主框架、上线入列表、打开功能窗口、界面命令发送、插件调度和断开清理。本文只用于安全工程学习、源码审计和企业检测设计,不提供任何功能复现或未授权使用步骤。

# 1. 本课先解决什么问题

主控端看起来是一个带列表和工具栏的 Windows 窗口,但源码里真正的结构更复杂。它至少包含五类逻辑:

逻辑 代表源码 新手要理解什么
界面控制台 CMainFrameCTabViewCQuickView、各类 Dialog 主控如何显示连接、分组和功能窗口
连接上下文 ClientContext 一条连接的状态、缓冲区、窗口绑定和列表绑定都放在哪里
网络服务 ISocketBaseCHpTcpServerCHpUdpServer TCP/UDP 监听和发送如何统一封装
协议分发 TOKEN_*COMMAND_* 收到的字节流如何变成“上线、心跳、打开窗口、请求插件”等事件
插件和功能窗口 SendDllOnOpenSendDll、各类 OnOpen*dialog 界面动作如何进入网络发送或插件调度

本课的目标是让读者能回答这些审计问题:

  1. 一条连接从哪里来,保存在哪里?
  2. 收到数据后,谁来判断“完整包”?
  3. 为什么有些数据交给 CMainFrame,有些数据交给某个功能窗口?
  4. 上线信息如何进入分组页签和连接列表?
  5. 界面按钮如何变成网络发送?
  6. 插件请求为什么是高风险边界?
  7. 断开连接时有哪些资源需要清理?

# 2. 一条连接的完整生命周期

先看完整生命周期图。后面所有源码都按这张图展开。

第 8 课图 1:一条连接的完整生命周期

主控端的一条连接大致经历这些阶段:

阶段 核心函数 说明
启动监听 ISocketBase::Addserver 根据配置启动 TCP 或 UDP 监听
建立连接 CHpTcpServer::OnAccept / CHpUdpServer::OnHandShake 创建或复用 ClientContext
收到数据 OnReceive 把字节写入 m_CompressionBuffer
完整包到达 NC_RECEIVE_COMPLETE 解包到 m_DeCompressionBuffer
主框架分发 CMainFrame::NotifyProc 根据事件类型分发
上线入组 CTabView::OnAddFindGroup LOGININFO.Group 找分组
进入列表 CQuickView::OnAddtomainlist ClientContext 绑定到列表项
打开功能窗口 CMainFrame::OnOpen*dialog g_pSocketBaseClientContext 传给窗口
界面发命令 SendSelectCommand / SendDll 从选中行取连接对象并发送
断开清理 OnClose / NC_CLIENT_DISCONNECT 关闭窗口、移出列表、释放缓冲区

这个顺序比按文件读更适合新手。因为主控端不是一个线性函数,而是一组事件回调和窗口消息互相配合。

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

文件 本课用途 关键结构或函数
主控\Quick\macros.h 定义连接上下文和窗口消息 ClientContextWM_NOTIFYPROCWM_ADDTOMAINLIST
主控\Quick\MainFrm.cpp 主控主框架、回调中枢、协议分发、功能弹窗 g_pSocketBaseNotifyProcProcessReceiveCompleteOnOpen*dialog
主控\Quick\ISocketBase.h/.cpp TCP/UDP 统一封装 AddserverSendShutdown
主控\Quick\HpTcpServer.cpp TCP 监听服务 InitializeOnAcceptOnReceiveOnClose
主控\Quick\HpUdpServer.cpp UDP 监听服务 InitializeOnHandShakeOnReceiveOnClose
主控\Quick\TabView.cpp 分组页签 OnAddFindGroup
主控\Quick\QuickView.cpp 连接列表和界面命令发送 OnAddtomainlistSendSelectCommandSendDll

第 10 课会专门讲网络通信模型,第 11 课会讲协议和命令分发。本课只把主控端架构主线讲清楚。

# 4. 主控端五层架构

主控端可以抽象成五层:

层次 角色 代表对象
UI 层 展示连接、分组、状态栏和功能窗口 CMainFrameCTabViewCQuickViewDialog
上下文层 保存一条连接的网络状态和界面绑定 ClientContext
网络层 监听、收包、发包、断开 ISocketBaseCHpTcpServerCHpUdpServer
分发层 把网络事件转成业务事件 NotifyProcProcessReceiveProcessReceiveComplete
功能层 文件、终端、注册表、屏幕、音频、插件等功能窗口 CFileManagerDlgCShellDlgCRegeditDlg

这五层之间不是严格隔离的。源码里 ClientContext 同时被网络层、UI 层和功能窗口使用,这也是后面线程安全和生命周期风险的来源。

# 5. ClientContext 字段分区图

ClientContext 是本课最重要的结构。可以把它看成“一条连接的档案袋”:连接 ID、缓冲区、登录信息、当前窗口、列表项指针都放在里面。

第 8 课图 2:ClientContext 字段分区图

源码节选如下。

// 源码位置:主控\Quick\macros.h:222-274
struct ClientContext
{
    // 连接身份:用于定位 TCP/UDP 连接。
    ULONG_PTR m_Socket;
    e_socket switchsocket;
    PVOID m_server;
    TCHAR szAddress[20];
    USHORT usPort;

    // 收发缓冲:网络层写入,主框架读取。
    CBuffer m_WriteBuffer;
    CBuffer m_CompressionBuffer;    // 接收到的压缩/封装数据
    CBuffer m_DeCompressionBuffer;  // 解包后的业务数据

    // 功能窗口绑定:第一个值表示窗口类型,第二个值保存窗口对象地址。
    // 防御分析:把窗口指针放入 int 存在平台移植风险,尤其要关注 x64 场景。
    int m_Dialog[2];

    // 统计字段:可用于观察异常大量数据传输。
    int       m_allpack_rev;
    long long m_alldata_rev;
    int       m_allpack_send;
    long long m_alldata_send;

    // 协议状态:连接状态、通信密码、位数信息。
    int   IsConnect;
    byte  m_password[10];
    BOOL  bisx86;

    // 上线信息:列表展示、分组和后续功能窗口都依赖它。
    LOGININFO* LoginInfo;

    // 屏幕缩略图和 UI 绑定。
    byte* ScreenPicture;
    int PictureSize;
    void* pView;
    CXTPReportRecord* pRecord_old;
    CXTPReportRecordItem* Item_cmp_old_Context;
    CXTPReportRecordItem* Item_cmp_old_IsActive;
    CXTPReportRecordItem* Item_cmp_old_winodow;
    CXTPReportRecordItem* Item_cmp_old_m_Time;
};
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

# 6. 监听服务是怎么启动的

主控端全局创建了一个 ISocketBase 对象。

// 源码位置:主控\Quick\MainFrm.cpp:58
// 防御分析:主控端全局网络服务入口。
// 后续界面命令、功能窗口、插件调度都会通过它发送数据。
ISocketBase* g_pSocketBase = new ISocketBase;
1
2
3
4

主框架初始化时,会读取端口配置并调用 Addserver

// 源码位置:主控\Quick\MainFrm.cpp:770-796
// 防御分析:遍历监听配置,按 IP、协议和端口启动服务。
// 企业检测应记录监听端口、协议类型、启动成功/失败日志。
for (int i = 0; i < m_num; i++)
{
    memcpy(m_portinfo, B_portinfo + i * sizeof(portinfo) + site, sizeof(portinfo));
    m_serverstartdate.ip.Format(_T("%s"), m_portinfo->ip);
    m_serverstartdate.m_net.Format(_T("%s"), m_portinfo->m_net);
    m_serverstartdate.port.Format(_T("%s"), m_portinfo->port);

    if (!g_pSocketBase->Addserver(NotifyProc, this, &m_serverstartdate))
    {
        g_pLogView->InsertLogItem(_T("失败"), m_portinfo->ip);
    }
    else
    {
        m_nAddListen++;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

ISocketBase::Addserver 根据协议创建 TCP 或 UDP 服务。

// 源码位置:主控\Quick\ISocketBase.cpp:8-43
bool ISocketBase::Addserver(
    NOTIFYPROC pNotifyProc,
    CMainFrame* pFrame,
    serverstartdate* m_serverstartdate)
{
    // m_serverstartdate 保存界面层传入的监听配置。
    // S_socket 是本次监听服务对象,后续会进入 g_servermap 做生命周期管理。
    // pNotifyProc 是网络层回调主框架的入口,应与监听端口、协议一起记录。
    Ssocket* S_socket = new Ssocket;
    _tcscpy_s(S_socket->m_ip, m_serverstartdate->ip.GetBuffer());
    S_socket->m_port = _ttoi(m_serverstartdate->port);

    if (m_serverstartdate->m_net.Compare(_T("TCP")) == 0)
    {
        // TCP 路径:创建 CHpTcpServer,并注册主框架回调 NotifyProc。
        CHpTcpServer* m_iocpServer = new CHpTcpServer;
        S_socket->socketserver = m_iocpServer;
        S_socket->m_e_socket = tcp;
        S_socket->runok = m_iocpServer->Initialize(
            pNotifyProc, 99999, S_socket->m_ip, S_socket->m_port);
    }
    else
    {
        // UDP 路径:创建 CHpUdpServer,并注册同一个 NotifyProc。
        CHpUdpServer* m_udpServer = new CHpUdpServer;
        S_socket->socketserver = m_udpServer;
        S_socket->m_e_socket = udp;
        S_socket->runok = m_udpServer->Initialize(
            pNotifyProc, 99999, S_socket->m_ip, S_socket->m_port);
    }

    // 防御分析:服务对象进入服务表,后续用于删除、关闭和发送路径定位。
    g_servermap.insert(MAKE_PAIR(ServerMap, (int)S_socket->socketserver, S_socket));
    return S_socket->runok;
}
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

这一段代码从界面配置进入网络服务对象,调用顺序可以用时序图理解:

第 8 课图 6:监听接入时序图

读这张时序图时,按从左到右、从上到下的顺序看。CMainFrame 负责接收界面配置,ISocketBase 负责选择 TCP 或 UDP 服务实现,底层服务对象建立监听后,再在连接进入时创建 ClientContext。对安全工程学习者来说,关键不是记住每个类名,而是知道“监听配置、连接上下文、回调函数”这三件事绑定在同一条链路上。

# 7. TCP 和 UDP 创建上下文的差异

TCP 和 UDP 都会创建 ClientContext,但入口不同。

对照项 TCP UDP
服务类 CHpTcpServer CHpUdpServer
上下文创建入口 OnAccept OnHandShake
连接类型字段 switchsocket = tcp switchsocket = udp
绑定方式 SetConnectionExtra(dwConnID, pContext) SetConnectionExtra(dwConnID, pContext)
静默清理 依赖关闭事件 额外有心跳清理线程

TCP 的上下文创建:

// 源码位置:主控\Quick\HpTcpServer.cpp:63-99
EnHandleResult CHpTcpServer::OnAccept(
    ITcpServer* pSender,
    CONNID dwConnID,
    UINT_PTR soClient)
{
    ClientContext* pContext = NULL;

    // 防御分析:优先从空闲池复用,复用前必须彻底清理旧数据。
    m_clcs.lock();
    if (!m_listFreePool.IsEmpty())
        pContext = m_listFreePool.RemoveHead();
    else
        pContext = new(std::nothrow) ClientContext;
    m_clcs.unlock();

    if (pContext == NULL)
        return HR_ERROR;

    // 防御分析:初始化连接上下文。
    ZeroMemory(pContext, sizeof(ClientContext));
    pContext->m_Socket = dwConnID;
    pContext->switchsocket = tcp;
    pContext->m_server = this;
    pContext->IsConnect = 666;
    pContext->LoginInfo = NULL;
    pContext->ScreenPicture = NULL;

    // 防御分析:记录远端地址和端口。
    int iAddressLen = sizeof(pContext->szAddress) / sizeof(TCHAR);
    pSender->GetRemoteAddress(dwConnID, pContext->szAddress, iAddressLen, pContext->usPort);

    // 防御分析:把连接 ID 和 ClientContext 绑定起来。
    if (!m_TcpServer->SetConnectionExtra(dwConnID, pContext))
        return HR_ERROR;

    return HR_OK;
}
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

UDP 的上下文创建:

// 源码位置:主控\Quick\HpUdpServer.cpp:91-149
EnHandleResult CHpUdpServer::OnHandShake(IUdpServer* pSender, CONNID dwConnID)
{
    ClientContext* pContext = NULL;

    // 防御分析:UDP 在握手阶段创建上下文。
    m_clcs.lock();
    if (!m_listFreePool.IsEmpty())
        pContext = m_listFreePool.RemoveHead();
    else
        pContext = new(std::nothrow) ClientContext;
    m_clcs.unlock();

    if (pContext == NULL)
        return HR_ERROR;

    ZeroMemory(pContext, sizeof(ClientContext));
    pContext->m_Socket = dwConnID;
    pContext->switchsocket = udp;
    pContext->m_server = this;
    pContext->IsConnect = 666;
    pContext->LoginInfo = NULL;

    int iAddressLen = sizeof(pContext->szAddress) / sizeof(TCHAR);
    pSender->GetRemoteAddress(dwConnID, pContext->szAddress, iAddressLen, pContext->usPort);

    if (!m_UdpServer->SetConnectionExtra(dwConnID, pContext))
        return HR_ERROR;

    return HR_OK;
}
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

这两段代码的防御分析:

观察点 说明
远端信息 szAddressusPort 是连接溯源基础
连接状态 IsConnect = 666 是魔法值,后续 888 表示关闭
类型差异 switchsocket 决定后续发送走 TCP 还是 UDP
生命周期 ClientContext 复用必须关注缓冲区和 UI 指针清理
检测点 新连接、握手、连接额外数据绑定、异常连接数

# 8. 主控程序的线程结构与网络连接结构

前面已经分别看了监听启动和 ClientContext 创建。这里把主控程序的线程结构、监听服务表、连接级上下文和 UI 对象放到一张结构图里看。这个视角适合源码审计:先分清“服务级对象”和“连接级对象”,再分析网络回调线程为什么可能碰到 UI 生命周期问题。

本节仍然只做防御分析,不讲未授权连接、攻击复现或规避检测步骤。我们关注的是主控端自身如何组织线程、连接和对象引用,以及这些结构在合法远程管理软件中应该怎样审计和加固。

# 8.1 没有被控连上时的结构

没有被控连接进入时,主控端已经可能完成了监听初始化,但还没有任何连接级 ClientContext。此时结构如下。

第 8 课补图:没有被控连上时的主控结构

这张图里最容易混淆的是 g_servermapClientContextg_servermap 保存的是监听服务对象,不是一条具体连接;ClientContext 要等 TCP OnAccept 或 UDP OnHandShake 之后才会创建或复用。

// 源码位置:主控\Quick\MainFrm.cpp:58,770-796
// 防御分析:g_pSocketBase 是主控端全局网络入口。
// 它不是某一条连接,而是统一管理 TCP/UDP 监听服务、发送和关闭的对象。
// 审计时应把它看成“网络边界入口”:界面命令和功能窗口最终都会回到这里发送。
ISocketBase* g_pSocketBase = new ISocketBase;

// 源码位置:主控\Quick\MainFrm.cpp:770-796
// 防御分析:主框架读取端口配置后,把 NotifyProc 注册给网络层。
// 此时只是启动监听;如果还没有远端连接进来,就不会产生连接级 ClientContext。
// 这里应记录监听 IP、协议、端口和启动结果,便于后续主机侧与网络侧关联。
for (int i = 0; i < m_num; i++)
{
    memcpy(m_portinfo, B_portinfo + i * sizeof(portinfo) + site, sizeof(portinfo));
    m_serverstartdate.ip.Format(_T("%s"), m_portinfo->ip);
    m_serverstartdate.m_net.Format(_T("%s"), m_portinfo->m_net);
    m_serverstartdate.port.Format(_T("%s"), m_portinfo->port);

    // 防御分析:NotifyProc 在这里被传入 TCP/UDP 服务对象。
    // 后续网络回调线程会通过这个函数指针回到 CMainFrame。
    if (!g_pSocketBase->Addserver(NotifyProc, this, &m_serverstartdate))
        g_pLogView->InsertLogItem(_T("失败"), m_portinfo->ip);
    else
        m_nAddListen++;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

ISocketBase::Addserver 负责把界面层的监听配置转成 TCP 或 UDP 服务对象,并把服务对象放入 g_servermap。注意:这里保存的是 Ssocket*,它描述监听服务;还没有保存任何远端连接上下文。

// 源码位置:主控\Quick\ISocketBase.cpp:8-48
bool ISocketBase::Addserver(
    NOTIFYPROC pNotifyProc,
    CMainFrame* pFrame,
    serverstartdate* m_serverstartdate)
{
    // 防御分析:S_socket 是“监听服务级”对象。
    // 它记录本次监听的 IP、端口、协议类型和底层服务指针。
    // 它不是 ClientContext,也不代表某一台已上线主机。
    Ssocket* S_socket = new Ssocket;
    _tcscpy_s(S_socket->m_ip, m_serverstartdate->ip.GetBuffer());
    S_socket->m_stop = FALSE;
    S_socket->m_port = _ttoi(m_serverstartdate->port);

    if (m_serverstartdate->m_net.Compare(_T("TCP")) == 0)
    {
        // 防御分析:TCP 监听路径。
        // CHpTcpServer 内部持有 m_TcpServer,它才是 HPSocket 的 TCP Pull Server 对象。
        CHpTcpServer* m_iocpServer = new CHpTcpServer;
        S_socket->socketserver = m_iocpServer;
        S_socket->m_e_socket = tcp;
        S_socket->runok = m_iocpServer->Initialize(
            pNotifyProc, 99999, S_socket->m_ip, S_socket->m_port);
    }
    else
    {
        // 防御分析:UDP 监听路径。
        // CHpUdpServer 内部持有 m_UdpServer,并额外启动静默连接清理线程。
        CHpUdpServer* m_udpServer = new CHpUdpServer;
        S_socket->socketserver = m_udpServer;
        S_socket->m_e_socket = udp;
        S_socket->runok = m_udpServer->Initialize(
            pNotifyProc, 99999, S_socket->m_ip, S_socket->m_port);
    }

    // 防御分析:g_servermap 保存监听服务表。
    // key 使用服务对象地址强转 int,x64 审计时要注意指针截断风险。
    // value 是 Ssocket*,用于后续删除监听、Shutdown 和界面展示监听状态。
    g_servermap.insert(MAKE_PAIR(ServerMap, (int)S_socket->socketserver, S_socket));
    return S_socket->runok;
}
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

TCP 和 UDP 初始化都保存 m_pNotifyProc,但 UDP 还会启动心跳清理线程。

// 源码位置:主控\Quick\HpTcpServer.cpp:26-49
BOOL CHpTcpServer::Initialize(
    NOTIFYPROC pNotifyProc,
    int nMaxConnections,
    TCHAR* ip,
    int nPort)
{
    // 防御分析:把主框架回调保存到服务对象。
    // HPSocket 后续触发 OnReceive/OnClose 时,服务对象会通过 m_pNotifyProc 回到 NotifyProc。
    m_pNotifyProc = pNotifyProc;

    // 防御分析:m_TcpServer 是实际的 HPSocket TCP 服务。
    // Start 成功只代表监听已启动,不代表存在已上线 ClientContext。
    return m_TcpServer->Start(_T("0.0.0.0"), nPort);
}

// 源码位置:主控\Quick\HpUdpServer.cpp:33-82
BOOL CHpUdpServer::Initialize(
    NOTIFYPROC pNotifyProc,
    int nMaxConnections,
    TCHAR* ip,
    int nPort)
{
    // 防御分析:UDP 路径同样保存 NotifyProc。
    // UDP 使用 ARQ 服务对象 m_UdpServer,连接语义依赖握手和静默连接检测。
    m_pNotifyProc = pNotifyProc;
    BOOL ret = m_UdpServer->Start(_T("0.0.0.0"), nPort);

    if (ret)
    {
        // 防御分析:UDP 额外启动 ThreadHeartbeat。
        // 这个线程周期性调用 DisconnectSilenceConnections,可能触发断开回调。
        // 审计生命周期时要把它当成网络侧后台线程,而不是 UI 线程。
        hThreadHeartWorker = (HANDLE)_beginthreadex(
            NULL, 0, ThreadHeartbeat, (void*)this, 0, &nThreadID);
    }

    return ret;
}
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

所以,“没有被控连上时”的主控结构可以总结为:

对象 是否存在 审计含义
g_pSocketBase 存在 全局网络入口已经可被 UI 和功能代码调用
g_servermap 可能存在监听项 记录 TCP/UDP 服务,不记录单条连接
CHpTcpServer::m_TcpServer 监听 TCP 时存在 HPSocket TCP 服务对象已经启动
CHpUdpServer::m_UdpServer 监听 UDP 时存在 HPSocket UDP/ARQ 服务对象已经启动
ThreadHeartbeat UDP 启动成功后存在 后台清理静默 UDP 连接
ClientContext 通常不存在 还没有连接级状态、列表项或功能窗口绑定
SetConnectionExtra 尚未调用 连接 ID 还没有绑定上下文

# 8.2 有被控连上时的结构

有连接进入后,结构会从“监听服务级”进入“连接级”。这时 ClientContext 成为中心对象:网络回调、主框架、连接列表和功能窗口都可能保存同一个指针。

第 8 课补图:有被控连上时的主控结构

连接级结构的核心字段在 macros.h 中。这里要把 switchsocketm_serverm_Socket 和 UI 绑定字段放在一起看。

// 源码位置:主控\Quick\macros.h:79-88,222-274
// 防御分析:switchsocket 只记录 TCP/UDP 类型。
// 发送、断开和部分状态判断会根据它选择 CHpTcpServer 或 CHpUdpServer。
enum e_socket
{
    tcp,
    udp,
};

struct ClientContext
{
    // 防御分析:m_Socket 保存 HPSocket 的 CONNID。
    // 它需要和 SetConnectionExtra 建立的连接额外数据对应起来。
    ULONG_PTR m_Socket;

    // 防御分析:收发缓冲区由网络回调线程写入或清理,
    // 主框架和功能窗口读取解包后的业务数据。
    CBuffer m_WriteBuffer;
    CBuffer m_CompressionBuffer;
    CBuffer m_DeCompressionBuffer;

    // 防御分析:m_Dialog[0] 是功能窗口类型,m_Dialog[1] 是窗口对象地址。
    // 这里用 int 保存窗口指针,x64 审计时要关注截断和悬空指针风险。
    int m_Dialog[2];

    // 防御分析:m_clcs_send_rec_close 是连接级收、发、关锁。
    // 它不能自动保护 UI 窗口生命周期,也不能替代 UI 线程消息投递。
    int IsConnect;
    CLCS m_clcs_send_rec_close;

    // 防御分析:switchsocket 决定发送分支;m_server 指向 CHpTcpServer 或 CHpUdpServer。
    // m_server 不是 HPSocket 的 m_TcpServer/m_UdpServer,而是本项目封装的服务对象。
    e_socket switchsocket;
    PVOID m_server;

    // 防御分析:登录信息、列表项和屏幕缩略图都是 UI/业务层引用点。
    // 断开、替换上线或上下文复用时必须统一解绑。
    LOGININFO* LoginInfo;
    void* pView;
    CXTPReportRecord* pRecord_old;
    CXTPReportRecordItem* Item_cmp_old_Context;
    CXTPReportRecordItem* Item_cmp_old_IsActive;
    CXTPReportRecordItem* Item_cmp_old_winodow;
    CXTPReportRecordItem* Item_cmp_old_m_Time;
};
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

TCP 连接在 OnAccept 中创建或复用 ClientContext,UDP 连接在 OnHandShake 中做同样的事。两条路径最后都调用 SetConnectionExtra

// 源码位置:主控\Quick\HpTcpServer.cpp:63-99
EnHandleResult CHpTcpServer::OnAccept(
    ITcpServer* pSender,
    CONNID dwConnID,
    UINT_PTR soClient)
{
    // 防御分析:从空闲池取旧上下文或新建上下文。
    // 空闲池复用可以减少分配,但也放大了清理不完整造成的生命周期风险。
    ClientContext* pContext = NULL;
    m_clcs.lock();
    if (!m_listFreePool.IsEmpty())
        pContext = m_listFreePool.RemoveHead();
    else
        pContext = new(std::nothrow) ClientContext;
    m_clcs.unlock();

    // 防御分析:连接级字段初始化。
    // switchsocket 和 m_server 是后续 Send/Disconnect 的关键分派依据。
    ZeroMemory(pContext, sizeof(ClientContext));
    pContext->m_Socket = dwConnID;
    pContext->switchsocket = tcp;
    pContext->m_server = this;
    pContext->IsConnect = 666;

    // 防御分析:把 HPSocket 的连接 ID 绑定到 ClientContext。
    // 之后 OnReceive/OnClose 会通过 GetConnectionExtra 取回同一个指针。
    if (!m_TcpServer->SetConnectionExtra(dwConnID, pContext))
        return HR_ERROR;

    return HR_OK;
}

// 源码位置:主控\Quick\HpUdpServer.cpp:91-149
EnHandleResult CHpUdpServer::OnHandShake(IUdpServer* pSender, CONNID dwConnID)
{
    // 防御分析:UDP 在握手完成后才建立连接级上下文。
    // 结构和 TCP 类似,只是 switchsocket 与 m_server 指向 UDP 服务。
    ClientContext* pContext = NULL;
    m_clcs.lock();
    if (!m_listFreePool.IsEmpty())
        pContext = m_listFreePool.RemoveHead();
    else
        pContext = new(std::nothrow) ClientContext;
    m_clcs.unlock();

    ZeroMemory(pContext, sizeof(ClientContext));
    pContext->m_Socket = dwConnID;
    pContext->switchsocket = udp;
    pContext->m_server = this;
    pContext->IsConnect = 666;

    // 防御分析:UDP 也使用 SetConnectionExtra 绑定连接上下文。
    // 因此后续收包分发逻辑可以和 TCP 共用 NotifyProc。
    if (!m_UdpServer->SetConnectionExtra(dwConnID, pContext))
        return HR_ERROR;

    return HR_OK;
}
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

收包时,TCP/UDP 服务先用 GetConnectionExtra 取回 ClientContext,再写入缓冲区,最后调用 m_pNotifyProc。这就是“网络回调到 NotifyProc”的关键路径。

// 源码位置:主控\Quick\HpTcpServer.cpp:111-204
EnHandleResult CHpTcpServer::OnReceive(
    ITcpServer* pSender,
    CONNID dwConnID,
    int iLength)
{
    // 防御分析:根据连接 ID 取回之前 SetConnectionExtra 绑定的上下文。
    // 如果这里拿到空指针或已关闭上下文,后续缓冲区写入和 UI 分发都不安全。
    ClientContext* pContext = NULL;
    if (!m_TcpServer->GetConnectionExtra(dwConnID, (PVOID*)&pContext))
        return HR_ERROR;

    // 防御分析:收到字节后先进入连接级收包缓存。
    // 此时不一定是完整业务包,所以先触发 NC_RECEIVE。
    pContext->m_CompressionBuffer.Write((PBYTE)pData, iLength);
    m_pNotifyProc(pContext, NC_RECEIVE);

    // 防御分析:当缓冲区满足完整包长度后,写入解包缓存并触发完整包事件。
    // ProcessReceiveComplete 才会按 TOKEN_* 或功能窗口类型做业务分发。
    pContext->m_DeCompressionBuffer.Write(
        pContext->m_CompressionBuffer.GetBuffer(m_headerlength),
        nSize - m_headerlength,
        1,
        pContext->m_password);
    m_pNotifyProc(pContext, NC_RECEIVE_COMPLETE);

    return HR_OK;
}
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

发送方向刚好反过来:UI 或功能窗口持有 g_pSocketBaseClientContext,调用统一发送入口;ISocketBase::Send 再根据 switchsocketm_server 找到具体服务对象。

// 源码位置:主控\Quick\ISocketBase.cpp:54-67
void ISocketBase::Send(ClientContext* pContext, LPBYTE lpData, UINT nSize)
{
    // 防御分析:pContext 是连接级对象,不能只判断非空。
    // 审计时还要关注 IsConnect、窗口生命周期和上下文是否已被放回空闲池。
    if (!pContext)
        return;

    // 防御分析:switchsocket 决定发送走 TCP 还是 UDP。
    // m_server 是 CHpTcpServer/CHpUdpServer 的封装对象指针。
    // 这说明发送路径并不依赖 g_servermap 查找单条连接。
    switch (pContext->switchsocket)
    {
    case tcp:
        ((CHpTcpServer*)pContext->m_server)->Send(pContext, lpData, nSize);
        break;
    case udp:
        ((CHpUdpServer*)pContext->m_server)->Send(pContext, lpData, nSize);
        break;
    default:
        break;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

把这几条关系放在一起,可以得到一个比较稳定的审计模型:

关系 含义 审计重点
g_pSocketBase -> g_servermap 管理监听服务 端口、协议、服务对象生命周期
Ssocket -> CHpTcpServer/CHpUdpServer 记录监听服务实现 TCP/UDP 初始化和关闭
CHp*Server -> m_TcpServer/m_UdpServer HPSocket 实际服务对象 网络回调来源
CONNID -> SetConnectionExtra -> ClientContext 连接 ID 到上下文 单连接状态、缓冲区、远端信息
ClientContext.switchsocket + m_server 发送分派 发送边界和断开边界
m_pNotifyProc -> CMainFrame::NotifyProc 网络事件回到主框架 线程模型和 UI 访问风险
ClientContext -> CQuickView/Dialog UI 和功能窗口引用连接 悬空指针、重复上线、断开清理

# 8.3 网络回调线程、UI线程和生命周期风险

主控端源码已经在 NotifyProc 附近写明一个重要问题:网络线程中直接操作界面元素,如果窗口刚好被关闭,主控可能崩溃。这个问题不是单个函数的小 bug,而是线程结构和对象生命周期共同造成的。

第 8 课补图:主控线程与网络连接关系

NotifyProc 现在是在网络回调路径里直接分发的。源码里曾经有 PostMessage(WM_NOTIFYPROC, ...) 的注释路径,但当前实际代码直接调用 ProcessReceiveProcessReceiveComplete 和断开清理分支。

// 源码位置:主控\Quick\MainFrm.cpp:1685-1777
void CALLBACK CMainFrame::NotifyProc(ClientContext* pContext, UINT nCode)
{
    // 防御分析:这是连接级收、发、关锁。
    // 它可以降低同一连接上收包、发送、关闭互相穿插的概率,
    // 但它不能保证 CDialog、CQuickView、CXTPReportRecord 等 UI 对象仍然有效。
    pContext->m_clcs_send_rec_close.lock();

    // 源码位置:主控\Quick\MainFrm.cpp:1689-1712 附近
    // 防御分析:源码注释明确指出这里有设计缺陷:
    // 网络线程中直接操作界面元素;如果对话框刚关闭,可能触发崩溃。
    // 合法软件应优先把网络事件投递到 UI 线程,由 UI 线程统一访问窗口对象。
    // ::PostMessage(((CQuickApp*)AfxGetApp())->GetMainWnd()->m_hWnd,
    //     WM_NOTIFYPROC, (WPARAM)nCode, (LPARAM)pContext);

    switch (nCode)
    {
    case NC_CLIENT_DISCONNECT:
        // 防御分析:断开时可能关闭功能窗口或从列表移除。
        // 这些对象属于 UI 生命周期,直接从网络线程访问存在悬空指针风险。
        break;

    case NC_RECEIVE:
        // 防御分析:有字节到达后可能调用功能窗口 OnReceive。
        g_pFrame->ProcessReceive(pContext);
        break;

    case NC_RECEIVE_COMPLETE:
        // 防御分析:完整包到达后可能进入 TOKEN_* 分发或功能窗口 OnReceiveComplete。
        g_pFrame->ProcessReceiveComplete(pContext);
        break;
    }

    pContext->m_clcs_send_rec_close.unlock();
}
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

功能窗口和列表项进一步扩大了同一个 ClientContext 的引用范围。窗口创建时保存 g_pSocketBaseClientContext,列表项也把 ClientContext 存入 SetItemData

// 源码位置:主控\Quick\MainFrm.cpp:2487-2580
LRESULT CMainFrame::OnOpenmanagerdialog(WPARAM wParam, LPARAM lParam)
{
    // 防御分析:lParam 是当前连接的 ClientContext。
    // 该指针来自网络回调分发路径,后续会被功能窗口长期持有。
    ClientContext* pContext = (ClientContext*)lParam;

    // 防御分析:功能窗口同时获得 g_pSocketBase 和 pContext。
    // 这意味着窗口既可以通过上下文识别目标连接,也可以通过全局网络入口发送数据。
    CFileManagerDlg* dlg = new CFileManagerDlg(this, g_pSocketBase, pContext);

    // 防御分析:m_Dialog[0] 影响后续回包路由。
    // m_Dialog[1] 保存窗口对象地址;当前代码使用 int,x64 和窗口销毁场景都需要重点审计。
    pContext->m_Dialog[0] = FILEMANAGER_DLG;
    pContext->m_Dialog[1] = (int)dlg;

    dlg->Create(IDD_FILE, GetDesktopWindow());
    dlg->ShowWindow(SW_SHOW);
    return 0;
}

// 源码位置:主控\Quick\QuickView.cpp:1518-1668
// 防御分析:连接列表第 0 项通过 SetItemData 保存 ClientContext。
// 用户从 UI 选择连接、发送命令、打开功能窗口时,都会从列表项取回这个指针。
// 因此断开连接或重复上线替换时,列表项必须和网络上下文同步解绑。
pContext->Item_cmp_old_Context->SetItemData((DWORD_PTR)pContext);
pContext->pView = this;
pContext->m_bIsMainSocket = 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

断开连接会解除 HPSocket 的连接额外数据,然后触发 NC_CLIENT_DISCONNECT,最后把 ClientContext 放回空闲池。这里最关键的是:网络层解除的是 CONNID -> ClientContext 绑定,但 UI 层可能仍然保存着同一个指针。

// 源码位置:主控\Quick\HpTcpServer.cpp:215-241
EnHandleResult CHpTcpServer::OnClose(
    ITcpServer* pSender,
    CONNID dwConnID,
    EnSocketOperation enOperation,
    int iErrorCode)
{
    ClientContext* pContext = NULL;

    // 防御分析:解除 HPSocket 连接 ID 到 ClientContext 的绑定。
    // 这一步只影响网络库的连接表,不会自动清理功能窗口和列表项里的裸指针。
    if (m_TcpServer->GetConnectionExtra(dwConnID, (PVOID*)&pContext)
        && pContext != nullptr)
        m_TcpServer->SetConnectionExtra(dwConnID, NULL);

    if (!pContext)
        return HR_OK;

    // 防御分析:连接状态改为关闭,并通知主框架做 UI 层清理。
    // 如果 NotifyProc 仍在网络线程中直接访问 UI,就会和窗口销毁形成竞态。
    pContext->IsConnect = 888;
    m_pNotifyProc(pContext, NC_CLIENT_DISCONNECT);

    // 防御分析:上下文放回空闲池前释放缓冲区和部分 UI 回指。
    // 审计时要确认 LoginInfo、m_Dialog、列表项和功能窗口是否已经同步解绑。
    MovetoFreePool(pContext);
    return HR_OK;
}
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

线程安全和生命周期风险可以这样归纳:

风险点 源码表现 防御审计建议
网络线程直接访问 UI NotifyProc 直接调用窗口或列表处理函数 网络线程只投递消息,UI 线程统一处理
功能窗口持有裸指针 m_Dialog[1] = (int)dlg 使用窗口句柄、intptr_t 或明确生命周期注册表
列表项保存上下文 SetItemData((DWORD_PTR)pContext) 断开和替换上线时清空或标记失效
上下文复用 MovetoFreePool(pContext) 复用前集中清理所有缓冲区、窗口指针和登录信息
服务表指针键 g_servermap.insert((int)socketserver, ...) x64 下避免指针截断,改用 uintptr_t 或指针类型 key
UDP 后台线程 ThreadHeartbeat 清理静默连接 Shutdown 时等待线程退出,避免与连接关闭并发冲突
连接状态魔法值 IsConnect = 666/888 用枚举和状态转换日志替代魔法值

从防御角度看,这一节的结论很直接:g_servermap 是监听服务表,SetConnectionExtra 是连接级绑定,ClientContext 是网络层和 UI 层共同引用的中心对象。只要 ClientContext 被多个线程和多个窗口共享,就必须把断开、窗口关闭、列表移除、上下文复用放到同一个生命周期模型里审计。

# 9. 收包、组包和解包流程

收到网络数据后,主控端不会立刻当成业务命令处理。它先写入收包缓存,判断是否收齐完整包,再进入业务分发。

第 8 课图 3:收包缓冲区到 NotifyProc 的流程

TCP 收包节选:

// 源码位置:主控\Quick\HpTcpServer.cpp:111-204
EnHandleResult CHpTcpServer::OnReceive(
    ITcpServer* pSender,
    CONNID dwConnID,
    int iLength)
{
    ClientContext* pContext = NULL;
    if (!m_TcpServer->GetConnectionExtra(dwConnID, (PVOID*)&pContext))
        return HR_ERROR;

    if (pContext->IsConnect != 666)
        return HR_ERROR;

    // 防御分析:从网络库读取字节,写入收包缓存。
    PBYTE pData = new BYTE[iLength];
    m_TcpServer->Fetch(dwConnID, pData, iLength);
    pContext->m_CompressionBuffer.Write((PBYTE)pData, iLength);

    // 防御分析:通知“有数据到达”,但此时未必形成完整业务包。
    m_pNotifyProc(pContext, NC_RECEIVE);

    // 防御分析:检查缓存是否超过包头长度。
    while ((int)pContext->m_CompressionBuffer.GetBufferLen() > m_headerlength)
    {
        int nSize = 0;
        CopyMemory(&nSize, pContext->m_CompressionBuffer.GetBuffer(0), sizeof(int));

        if ((nSize > 0) &&
            ((int)pContext->m_CompressionBuffer.GetBufferLen() >= nSize))
        {
            // 防御分析:完整包解到业务缓存。
            pContext->m_DeCompressionBuffer.ClearBuffer();
            pContext->m_DeCompressionBuffer.Write(
                pContext->m_CompressionBuffer.GetBuffer(m_headerlength),
                nSize - m_headerlength,
                1,
                pContext->m_password);

            // 防御分析:通知主框架“完整业务包已就绪”。
            m_pNotifyProc(pContext, NC_RECEIVE_COMPLETE);
            pContext->m_CompressionBuffer.Delete(nSize);
        }
        else
        {
            break;
        }
    }

    return HR_OK;
}
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

这段代码的执行顺序:

  1. 根据连接 ID 找 ClientContext
  2. 检查连接状态。
  3. 把网络字节写入 m_CompressionBuffer
  4. 先触发 NC_RECEIVE,用于进度或流式窗口。
  5. 从缓存头部读取包长 nSize
  6. 如果缓存长度足够,就把业务数据写入 m_DeCompressionBuffer
  7. 触发 NC_RECEIVE_COMPLETE
  8. 从收包缓存删除已处理的数据。

这段代码的防御关注点已经写在源码注释里:包长字段、缓冲区长度、解包密码和异常半包都会影响稳定性;主机和网络侧应重点关联固定长度头、周期性完整包、异常大包、重传行为,以及 NC_RECEIVE_COMPLETE 的触发条件。

收包链路涉及网络线程、连接上下文、主框架和功能窗口,时序关系如下:

第 8 课图 7:收包分发时序图

这张图要重点看两次回调边界:第一次是 NC_RECEIVE,表示“有字节进入”;第二次是 NC_RECEIVE_COMPLETE,表示“完整业务包已经组好”。前者常用于窗口中间状态,后者才适合进入主控级业务分发。审计时如果把这两个事件混成一个,就容易误判数据还没收齐时发生的行为。

# 10. NC_RECEIVENC_RECEIVE_COMPLETE 的区别

这两个事件经常被新手混淆:

回调码 触发时机 主框架处理函数 常见用途
NC_RECEIVE 收到字节后立即触发 ProcessReceive 进度、流式数据、窗口中间状态
NC_RECEIVE_COMPLETE 收齐完整业务包后触发 ProcessReceiveComplete 按窗口类型或 TOKEN_* 分发

NotifyProc 是这两个事件的中枢。

// 源码位置:主控\Quick\MainFrm.cpp:1685-1777
void CALLBACK CMainFrame::NotifyProc(ClientContext* pContext, UINT nCode)
{
    // pContext 是连接上下文,包含 socket、缓冲区、窗口状态和登录信息。
    // nCode 是网络层事件码,决定当前是断开、收包、完整包还是高风险 shellcode 分支。
    // pContext->m_Dialog[0] / m_Dialog[1] 与功能窗口绑定,窗口销毁后仍回调会造成稳定性风险。
    // 防御分析:连接级锁,保护收包、发送和关闭过程。
    pContext->m_clcs_send_rec_close.lock();

    // 防御分析:源码注释中明确指出一个架构缺陷:
    // 网络线程中可能直接操作界面元素,如果窗口已销毁,可能崩溃。
    // 合法软件应把网络事件投递到 UI 线程处理。

    switch (nCode)
    {
    case NC_CLIENT_DISCONNECT:
        // 防御分析:连接断开后关闭功能窗口或从列表移除。
        break;

    case NC_RECEIVE:
        g_pFrame->ProcessReceive(pContext);
        break;

    case NC_RECEIVE_COMPLETE:
        g_pFrame->ProcessReceiveComplete(pContext);
        break;

    case NC_SEND_SHELCODE_32:
    case NC_SEND_SHELCODE_64:
        // 防御分析:高风险链路,本课只标出分发点。
        // 详细防御性原理已在第 18 课讨论,不提供复用步骤。
        break;
    }

    pContext->m_clcs_send_rec_close.unlock();
}
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

# 11. ProcessReceive:有窗口时交给窗口处理

如果某个功能窗口已经打开,pContext->m_Dialog[0] 会记录窗口类型。此时数据通常不再由主框架解释,而是交给对应窗口。

// 源码位置:主控\Quick\MainFrm.cpp:2037-2104
void CMainFrame::ProcessReceive(ClientContext* pContext)
{
    if ((pContext == NULL))
        return;

    // 防御分析:m_Dialog[1] 保存当前功能窗口对象。
    CDialog* dlg = (CDialog*)pContext->m_Dialog[1];

    if (pContext->m_Dialog[0] > 0)
    {
        switch (pContext->m_Dialog[0])
        {
        case FILEMANAGER_DLG:
            ((CFileManagerDlg*)dlg)->OnReceive();
            break;
        case SCREENSPY_DIF_DLG:
            ((CDifScreenSpyDlg*)dlg)->OnReceive();
            break;
        case WEBCAM_DLG:
            ((CWebCamDlg*)dlg)->OnReceive();
            break;
        case AUDIO_DLG:
            ((CAudioDlg*)dlg)->OnReceive();
            break;
        case SHELL_DLG:
            ((CShellDlg*)dlg)->OnReceive();
            break;
        case REGEDIT_DLG:
            ((CRegeditDlg*)dlg)->OnReceive();
            break;
        default:
            break;
        }
        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

窗口类型分发表:

m_Dialog[0] 类型 代表窗口 防御关注点
FILEMANAGER_DLG 文件管理 文件读写、路径、传输量
SHELL_DLG 终端 命令执行风险、父子进程、命令行
REGEDIT_DLG 注册表 注册表枚举、写入、删除
WEBCAM_DLG 视频查看 摄像头访问、隐私合规
AUDIO_DLG 音频 麦克风访问、音频流量
PROXY_DLG 代理 转发连接、异常端口、内网流量

这里的关键理解是:m_Dialog[0] 决定数据由谁解释。没有窗口时,主框架看 TOKEN_*;有窗口时,具体窗口继续解释数据。

# 12. ProcessReceiveComplete:完整业务包的主控级分发

完整包进入 ProcessReceiveComplete 后,主框架先处理心跳和已打开窗口,再处理主控级 TOKEN_*

// 源码位置:主控\Quick\MainFrm.cpp:1786-1870
void CMainFrame::ProcessReceiveComplete(ClientContext* pContext)
{
    if ((pContext == NULL))
        return;

    CDialog* dlg = (CDialog*)pContext->m_Dialog[1];

    // 防御分析:心跳包用于主控和连接之间的忙闲状态确认。
    if (pContext->m_DeCompressionBuffer.GetBuffer(0)[0] == TOKEN_HEARTBEAT)
    {
        BYTE bToken[2] = { TOKEN_HEARTBEAT, m_bBusy ? 0 : 1 };
        g_pSocketBase->Send(pContext, (LPBYTE)&bToken, 2);
        return;
    }

    // 防御分析:如果窗口已绑定,完整业务包交给对应窗口。
    if (pContext->m_Dialog[0] > 0)
    {
        switch (pContext->m_Dialog[0])
        {
        case FILEMANAGER_DLG:
            ((CFileManagerDlg*)dlg)->OnReceiveComplete();
            break;
        case KEYBOARD_DLG:
            ((CKeyBoardDlg*)dlg)->OnReceiveComplete();
            break;
        case SHELL_DLG:
            ((CShellDlg*)dlg)->OnReceiveComplete();
            break;
        case REGEDIT_DLG:
            ((CRegeditDlg*)dlg)->OnReceiveComplete();
            break;
        case PROXY_DLG:
            ((CProxyMapDlg*)dlg)->OnReceiveComplete();
            break;
        default:
            break;
        }
        return;
    }

    // 防御分析:没有窗口绑定时,后续按 TOKEN_* 做主控级分发。
}
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

这段代码的检测思路:

检测对象 说明
心跳 固定小包、固定周期、长连接维持
窗口分发 功能窗口打开后数据流向发生变化
异常 m_Dialog[1] 无效但仍调用窗口函数,可能崩溃

# 13. TOKEN_* 在本课中的简化分类

本课不深入协议细节,只把主控级 TOKEN_* 按架构角色分组:

分类 代表令牌 架构作用
上线与状态 TOKEN_LOGINTOKEN_HEARTBEATTOKEN_CONDITION 进入列表、保持连接、更新状态
功能窗口打开 TOKEN_DRIVE_LISTTOKEN_REGEDITTOKEN_SHELL_START 触发 WM_OPEN*DIALOG
屏幕与外设 TOKEN_BITMAPINFO_*TOKEN_WEBCAM_BITMAPINFOTOKEN_AUDIO_START 打开视觉/音频窗口
插件与版本 TOKEN_GETVERSIONTOKEN_SENDLLTOKEN_GETAUTODLL 插件版本和数据发送
错误和反馈 TOKEN_ERROR 更新列表反馈或日志

源码节选:

// 源码位置:主控\Quick\MainFrm.cpp:1872-2035
// 首字节来自 m_DeCompressionBuffer.GetBuffer(0)[0]。
// 它是完整业务包的 TOKEN,后续会直接驱动窗口打开、插件发送或高风险能力。
// 因此要把 TOKEN 分布、窗口消息和插件请求放在同一条链路里分析。
switch (pContext->m_DeCompressionBuffer.GetBuffer(0)[0])
{
case TOKEN_LOGIN:
    // 防御分析:上线包进入分组页签。
    g_pTabView->SendMessage(WM_ADDFINDGROUP, 0, (LPARAM)pContext);
    break;

case TOKEN_GETVERSION:
    // 防御分析:版本协商入口。
    OnOpenSendVersion(pContext);
    break;

case TOKEN_SENDLL:
    // 防御分析:插件数据请求入口,是高风险审计点。
    OnOpenSendDll(pContext);
    break;

case TOKEN_DRIVE_LIST:
    // 防御分析:文件管理窗口入口。
    g_pFrame->PostMessage(WM_OPENMANAGERDIALOG, 0, (LPARAM)pContext);
    break;

case TOKEN_REGEDIT:
    // 防御分析:注册表窗口入口。
    g_pFrame->PostMessage(WM_OPENREGEDITDIALOG, 0, (LPARAM)pContext);
    break;

case TOKEN_PROXY_START:
    // 防御分析:代理窗口入口,应关联转发流量。
    g_pFrame->PostMessage(WM_OPENPROXYDIALOG, 0, (LPARAM)pContext);
    break;

case TOKEN_MONITOR:
    // 防御分析:屏幕监控入口,应关联周期性图像数据。
    g_pScreenMonitorDlg->PostMessage(WM_MONITOR_CLIENT, 0, (LPARAM)pContext);
    break;

default:
    TRACE("switch token 非法数据");
    break;
}
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

# 14. 上线包如何进入分组和连接列表

上线包的链路是本课最适合新手理解的部分:

TOKEN_LOGIN -> WM_ADDFINDGROUP -> CTabView::OnAddFindGroup -> WM_ADDTOMAINLIST -> CQuickView::OnAddtomainlist

第 8 课图 4:上线包进入分组和连接列表

先看分组处理。

// 源码位置:主控\Quick\TabView.cpp:183-254
LRESULT CTabView::OnAddFindGroup(WPARAM wParam, LPARAM lParam)
{
    ClientContext* pContext = (ClientContext*)lParam;
    if (pContext == NULL)
        return -1;

    if (!pContext->LoginInfo)
    {
        // 防御分析:从解包缓冲区复制 LOGININFO。
        pContext->LoginInfo = new LOGININFO;
        if (pContext->m_DeCompressionBuffer.GetBufferLen() == sizeof(LOGININFO))
        {
            memcpy(pContext->LoginInfo,
                pContext->m_DeCompressionBuffer.GetBuffer(),
                sizeof(LOGININFO));
        }
        else
        {
            return -1;
        }
    }

    // 防御分析:无分组时放入默认分组。
    if (lstrlen(pContext->LoginInfo->Group) == NULL)
    {
        lstrcpy(pContext->LoginInfo->Group, _T("默认"));
    }

    // 防御分析:找到已有分组后,把连接投递给对应 CQuickView。
    int nTabs = m_wndTabControl.GetItemCount();
    for (int i = 0; i < nTabs; i++)
    {
        CString strGroupName = m_wndTabControl.GetItem(i)->GetCaption();
        if (strGroupName == pContext->LoginInfo->Group)
        {
            CQuickView* pView = DYNAMIC_DOWNCAST(
                CQuickView,
                CWnd::FromHandle(m_wndTabControl.GetItem(i)->GetHandle()));
            pView->PostMessage(WM_ADDTOMAINLIST, 0, (LPARAM)pContext);
            return 0;
        }
    }

    // 防御分析:没有分组时创建新分组,再进入列表。
    AddGroup(pContext->LoginInfo->Group);
    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

再看连接列表绑定。下面是防御性节选,保留关键链路。

// 源码位置:主控\Quick\QuickView.cpp:1518-1668
LRESULT CQuickView::OnAddtomainlist(WPARAM wParam, LPARAM lParam)
{
    ClientContext* pContext = (ClientContext*)lParam;
    if (pContext == NULL || pContext->LoginInfo == NULL)
        return -1;

    // 防御分析:按硬件标识查重,非 Debug 模式下重复上线会替换旧连接。
    auto iter = m_ClienListDate.find(pContext->LoginInfo->szHWID);
    if (iter != m_ClienListDate.end())
    {
        CXTPReportRecord* pRecord = iter->second;
        pContext->pRecord_old = pRecord;
        pContext->Item_cmp_old_Context = pRecord->GetItem(0);

        // 防御分析:把列表项重新绑定到新的 ClientContext。
        pContext->Item_cmp_old_Context->SetItemData((DWORD_PTR)pContext);
        pContext->pView = this;
        pContext->m_bIsMainSocket = TRUE;

        // 防御分析:更新在线状态、活动窗口、系统信息等列表字段。
        pContext->Item_cmp_old_IsActive =
            MainChangeItem(pRecord, pContext->LoginInfo->UserActive, -1, 2);
        pContext->Item_cmp_old_winodow =
            MainChangeItem(pRecord, pContext->LoginInfo->Window, -1, 3);
    }
    else
    {
        // 防御分析:新上线对象会创建新的列表记录。
        CXTPReportRecord* pRecord = new CXTPReportRecord();
        pContext->Item_cmp_old_Context =
            pRecord->AddItem(new CXTPReportRecordItemNumber());

        // 防御分析:列表第 0 项绑定 ClientContext,后续界面命令靠它找连接。
        pContext->Item_cmp_old_Context->SetItemData((DWORD_PTR)pContext);
        pContext->pView = this;
        pContext->m_bIsMainSocket = TRUE;
        m_ClienListDate.insert(MAKE_PAIR(
            ClienListDate, pContext->LoginInfo->szHWID, pRecord));
    }

    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

这一段的审计重点:

重点 说明
LOGININFO 来源 来自 m_DeCompressionBuffer,应校验长度和字段
分组逻辑 Group 为空时进入默认分组
去重逻辑 szHWID 影响重复上线统计
列表绑定 SetItemData((DWORD_PTR)pContext) 是界面到连接的桥
清理风险 替换旧连接时要释放旧 LoginInfo、缩略图和 UI 指针

# 15. 功能窗口如何绑定连接

当某个 TOKEN_* 触发功能窗口,主框架会创建对应对话框,并把 g_pSocketBaseClientContext 传进去。

// 源码位置:主控\Quick\MainFrm.cpp:2487-2580
LRESULT CMainFrame::OnOpenmanagerdialog(WPARAM wParam, LPARAM lParam)
{
    // 防御分析:lParam 是当前连接上下文。
    ClientContext* pContext = (ClientContext*)lParam;

    // 防御分析:功能窗口持有网络发送入口和连接对象。
    CFileManagerDlg* dlg = new CFileManagerDlg(this, g_pSocketBase, pContext);

    // 防御分析:记录当前连接绑定的窗口类型和窗口对象。
    pContext->m_Dialog[0] = FILEMANAGER_DLG;
    pContext->m_Dialog[1] = (int)dlg;

    dlg->Create(IDD_FILE, GetDesktopWindow());
    dlg->ShowWindow(SW_SHOW);
    return 0;
}

LRESULT CMainFrame::OnOpenregeditdialog(WPARAM wParam, LPARAM lParam)
{
    ClientContext* pContext = (ClientContext*)lParam;
    CRegeditDlg* dlg = new CRegeditDlg(this, g_pSocketBase, pContext);
    pContext->m_Dialog[0] = REGEDIT_DLG;
    pContext->m_Dialog[1] = (int)dlg;
    dlg->Create(IDD_REGEDIT, GetDesktopWindow());
    dlg->ShowWindow(SW_SHOW);
    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

这段窗口创建代码要在源码注释中关注三件事:功能窗口拿到 g_pSocketBaseClientContext 后,就具备继续发送命令和接收回包的上下文;m_Dialog[0] 会影响后续回包分发;m_Dialog[1] = (int)dlg 在 x64 思路下存在指针截断风险,窗口关闭后还要避免网络线程继续调用旧指针。

# 16. 界面命令如何变成网络发送

第 7 课讲了界面入口。这里继续看界面命令如何进入发送边界。

第 8 课图 5:界面命令到发送边界

界面命令进入网络发送前,也可以拆成一张时序图:

第 8 课图 8:界面命令发送时序图

这里最需要关注的是发送边界。界面层可以选择连接、组织命令或选择插件文件,但真正把数据写向网络的是 SocketBase 相关发送接口。合法软件改造时,应在这个边界增加权限校验、命令白名单、日志记录和数据大小限制。

普通命令发送:

// 源码位置:主控\Quick\QuickView.cpp:1751-1766
void CQuickView::SendSelectCommand(PBYTE pData, UINT nSize)
{
    // 防御分析:取当前选中的连接列表行。
    CXTPReportSelectedRows* selerows = wndReport->GetSelectedRows();
    int nCnt = selerows->GetCount();

    for (int Tmpi = 0; Tmpi < nCnt; Tmpi++)
    {
        CXTPReportRow* pRow_old = selerows->GetAt(Tmpi);
        if (pRow_old->IsGroupRow()) continue;

        // 防御分析:列表第 0 项保存 ClientContext。
        CXTPReportRecordItem* Item_cmp_old =
            pRow_old->GetRecord()->GetItem(0);
        ClientContext* pContext =
            (ClientContext*)Item_cmp_old->GetItemData();

        // 防御分析:统一发送边界。
        // 这里开始,界面意图变成网络行为。
        if (pContext)
            g_pSocketBase->Send(pContext, pData, nSize);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

插件请求入口只做防御性说明,不展开可复用的缓冲区构造。

// 源码位置:主控\Quick\QuickView.cpp:1768-1807
void CQuickView::SendDll(LPCTSTR lpDllName, SendTaskType sendTaskType)
{
    CXTPReportSelectedRows* selerows = wndReport->GetSelectedRows();
    int nCnt = selerows->GetCount();
    if (nCnt < 1)
    {
        log_警告("没有选择发送成员");
        return;
    }

    for (int Tmpi = 0; Tmpi < nCnt; Tmpi++)
    {
        CXTPReportRow* pRow_old = selerows->GetAt(Tmpi);
        if (pRow_old->IsGroupRow()) continue;

        ClientContext* pContext =
            (ClientContext*)pRow_old->GetRecord()->GetItem(0)->GetItemData();

        if (pContext)
        {
            // 防御分析:这里会根据插件名、任务类型、目标位数构造插件请求。
            // 安全化节选:省略可直接复用的高风险细节,仅保留防御分析所需的插件请求边界。
            // 审计结论:该路径最终进入 g_pSocketBase->Send 网络边界。
        }
    }
}
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

检测模型:

阶段 记录内容
UI 入口 按钮 ID、菜单项、处理函数
连接选择 选中了哪些 ClientContext
命令类型 普通命令还是插件请求
发送边界 g_pSocketBase->Send 调用时间、目标连接、数据大小
后续回包 是否出现窗口打开、插件请求、错误反馈或大块数据

# 17. 插件调度链路为什么高风险

插件调度不是普通界面行为。它可能涉及版本协商、位数判断、二进制数据传输和动态加载。第 12 课会深入讲插件化设计,本课先看主控端的防御性链路。

// 源码位置:主控\Quick\MainFrm.cpp:2369-2450
void CMainFrame::OnOpenSendDll(ClientContext* pContext)
{
    // 防御分析:从完整业务包中取出 DllSendData。
    // 该结构包含插件名、版本、位数、任务类型等信息。
    DllSendData* pDllSendData =
        (DllSendData*)pContext->m_DeCompressionBuffer.GetBuffer(1);

    if (pContext->m_DeCompressionBuffer.GetBufferLen()
        != (sizeof(DllSendData) + 1))
    {
        log_信息("OnOpenSendDll 版本不同");
        return;
    }

    CString strFileName = pDllSendData->szDllName;

    switch (pDllSendData->sendTaskType)
    {
    case TASK_MAIN:
        // 防御分析:主插件请求,从主插件表查找对应位数和版本。
        // 安全化节选:省略可直接复用的高风险细节,仅保留防御分析所需的插件数据发送边界。
        break;

    case TASK_PLUG:
        // 防御分析:扩展插件请求,从插件管理视图插件表查找。
        // 审计时要记录插件来源、版本、位数和数据大小。
        break;

    default:
        break;
    }
}
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

企业侧重点:

检测点 说明
插件名 DllSendData.szDllName
任务类型 TASK_MAINTASK_PLUG
目标位数 x86/x64 影响插件选择
版本字段 szVersion 用于版本协商
数据大小 大块二进制传输是重要网络特征
来源表 主插件表或插件管理视图插件表
缺失控制 如果没有签名、哈希校验和权限控制,风险很高

# 18. 断开连接与资源清理

生命周期最后一步是断开清理。TCP 和 UDP 的关闭逻辑类似。

// 源码位置:主控\Quick\HpTcpServer.cpp:215-227
EnHandleResult CHpTcpServer::OnClose(
    ITcpServer* pSender,
    CONNID dwConnID,
    EnSocketOperation enOperation,
    int iErrorCode)
{
    ClientContext* pContext = NULL;

    // 防御分析:解除连接 ID 和 ClientContext 的绑定。
    if (m_TcpServer->GetConnectionExtra(dwConnID, (PVOID*)&pContext)
        && pContext != nullptr)
        m_TcpServer->SetConnectionExtra(dwConnID, NULL);

    if (!pContext)
        return HR_OK;

    // 防御分析:连接状态改为关闭魔法值。
    pContext->IsConnect = 888;

    // 防御分析:通知主框架关闭窗口、移出列表或释放 UI 资源。
    m_pNotifyProc(pContext, NC_CLIENT_DISCONNECT);

    // 防御分析:上下文进入空闲池复用前必须清理缓冲区和指针。
    MovetoFreePool(pContext);
    return HR_OK;
}
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

NotifyProc 中断开事件会进一步处理功能窗口和连接列表:

// 源码位置:主控\Quick\MainFrm.cpp:1700-1768
case NC_CLIENT_DISCONNECT:
{
    if (!pContext->m_bIsMainSocket)
    {
        // 防御分析:功能连接断开时关闭对应窗口。
        switch (pContext->m_Dialog[0])
        {
        case FILEMANAGER_DLG:
        case SCREENSPY_DIF_DLG:
        case WEBCAM_DLG:
        case AUDIO_DLG:
        case SHELL_DLG:
        case PROXY_DLG:
        case REGEDIT_DLG:
            ::PostMessage(((CDialog*)pContext->m_Dialog[1])->GetSafeHwnd(),
                WM_CLOSE, NULL, NULL);
            break;
        }
    }
    else if (pContext->pView && pContext->m_bIsMainSocket)
    {
        // 防御分析:主连接断开时,从连接列表移除并释放缩略图。
        SAFE_DELETE_AR(pContext->ScreenPicture);
        if (pContext->LoginInfo)
            ((CView*)(pContext->pView))->PostMessage(
                WM_REMOVEFROMLIST, 0, (LPARAM)(pContext->LoginInfo));
    }
}
break;
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

断开清理的检查项:

检查项 说明
是否解除连接绑定 SetConnectionExtra(dwConnID, NULL)
是否修改连接状态 IsConnect = 888
是否通知主框架 NC_CLIENT_DISCONNECT
是否关闭功能窗口 PostMessage(WM_CLOSE)
是否移出列表 WM_REMOVEFROMLIST
是否释放缓存 m_CompressionBufferm_DeCompressionBufferScreenPicture
是否避免悬空指针 窗口销毁后不能继续被网络线程调用

# 19. 线程安全问题:源码已经给出线索

NotifyProc 附近的源码注释提到:网络线程中直接操作界面元素,如果界面元素刚好销毁,可能导致崩溃。这是主控端架构里非常重要的问题。

安全工程角度建议:

问题 改造建议
网络线程直接更新 UI 网络线程只投递消息,UI 线程统一更新窗口
m_Dialog[1] 保存裸指针 使用安全句柄、智能指针或窗口生命周期管理
指针保存到 int 改成 intptr_tDWORD_PTR 或明确的指针类型
上下文复用复杂 复用前集中清理,并写单元测试覆盖
魔法值状态 用枚举替代 666888
功能窗口多处回调 建立统一窗口注册表和注销机制

这些建议不是“重构洁癖”,而是长时间运行的网络 UI 程序必须面对的稳定性和审计问题。

# 20. 企业检测模型

把主控端架构转成企业检测模型,可以分成五类。

检测层 关注点 数据源
主机侧 监听端口、进程路径、窗口标题、模块加载 EDR、Sysmon、进程清单、防火墙日志
网络侧 TCP/UDP 长连接、心跳、小包周期、大块插件传输 NDR、代理日志、NetFlow、PCAP
内存/模块侧 插件名、版本、位数、二进制块、异常可执行页 EDR、内存取证、模块加载事件
UI/日志侧 连接列表、上线日志、失败日志、功能窗口打开 程序日志、截图、注册表配置
源码审计侧 ClientContextNotifyProcSendDllOnOpenSendDll 代码审计、静态规则

事件关联建议:

  1. 先从进程监听端口开始。
  2. 关联网络长连接和周期性心跳。
  3. 关联大块二进制传输和插件名/版本字段。
  4. 关联主控窗口、功能窗口和日志文本。
  5. 关联功能窗口对应的系统行为,例如文件、注册表、进程、屏幕或音频访问。

# 21. 合法软件改造建议

如果企业要把类似架构用于合法远程运维或内部管理工具,应至少做这些改造:

改造方向 建议
明确授权 被控端必须有用户可见提示、授权确认和会话记录
身份认证 使用标准双向认证,不依赖弱口令或隐藏字段
插件可信 插件签名校验、哈希校验、版本审计、来源控制
权限控制 高风险功能按角色授权,敏感动作二次确认
网络安全 使用 TLS、证书校验、最小开放端口和固定访问控制
审计日志 记录操作者、目标、时间、功能、结果和数据量
线程模型 网络线程投递消息,UI 线程统一处理窗口对象
数据最小化 屏幕、音频、摄像头、文件等敏感数据按需启用
应急处置 支持一键断开、会话导出、告警和禁用插件

# 22. 新手常见误区

误区 正确做法
只看 MainFrm.cpp 同时看网络类、上下文结构和列表类
看到 TOKEN_* 就直接下结论 先确认是否已有功能窗口绑定
忽略 ClientContext 它是连接、UI 和功能窗口的共同中心
混淆 NC_RECEIVENC_RECEIVE_COMPLETE 前者是数据到达,后者是完整业务包
只关注 TCP UDP 也有握手、心跳、上下文和分发
忽略断开清理 断开处理决定稳定性和取证准确性
把插件请求当普通命令 插件调度是高风险边界,必须单独审计

# 23. 本课小结

本课把主控端从“窗口程序”还原成“一条连接的生命周期”:

  1. ISocketBase::Addserver 启动 TCP/UDP 监听。
  2. OnAcceptOnHandShake 创建 ClientContext
  3. OnReceive 写入收包缓冲并判断完整包。
  4. NotifyProc 把网络事件交给主框架。
  5. ProcessReceiveComplete 根据窗口状态和 TOKEN_* 分发。
  6. TOKEN_LOGIN 进入分组页签和连接列表。
  7. 功能窗口通过 m_Dialog[0]m_Dialog[1] 绑定连接。
  8. 界面命令最终进入 g_pSocketBase->Send
  9. 插件调度要单独做高风险审计。
  10. 断开连接时要关闭窗口、移出列表并释放缓冲区。

第 10 课会继续往网络通信模型深入,专门讲 HPSocket、TCP/UDP、连接回调、心跳和流量检测。

# 24. 合法练习题

  1. 画出从 ISocketBase::AddserverCHpTcpServer::OnAccept 的调用链,只写函数名、文件名和角色。
  2. ClientContext 中把字段分成 6 类:连接身份、收发缓冲、协议状态、上线信息、UI 绑定、功能窗口绑定。
  3. 对比 CHpTcpServer::OnAcceptCHpUdpServer::OnHandShake,写出 5 个相同点和 3 个不同点。
  4. TOKEN_LOGIN 开始追到 CQuickView::OnAddtomainlist,写出完整调用链。
  5. 任选一个 g_pSocketBase->Send 上游入口,写出“界面入口、连接对象、发送边界、检测点”,不要运行程序。
  6. 审计 NC_CLIENT_DISCONNECT 的处理逻辑,列出需要释放或解绑的资源。
  7. 写一段不超过 300 字的安全改造建议,主题是“网络线程不要直接操作 UI”。

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

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

社区交流

讨论与留言

前往 GitHub Issues →

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

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