第 8 课:主控端架构分析
# 第 8 课:主控端架构分析
免责声明:本专栏仅用于安全工程学习研究,禁止使用本专栏介绍的技术做其他用途,否则后果自负,与本号无关。
本课定位:第 7 课已经讲清楚主控界面布局,本课继续往下讲主控端内部架构。为了让新手读起来连续,本课从“一条连接的生命周期”讲起:监听端口、建立连接、创建
ClientContext、收包组包、回调主框架、上线入列表、打开功能窗口、界面命令发送、插件调度和断开清理。本文只用于安全工程学习、源码审计和企业检测设计,不提供任何功能复现或未授权使用步骤。
# 1. 本课先解决什么问题
主控端看起来是一个带列表和工具栏的 Windows 窗口,但源码里真正的结构更复杂。它至少包含五类逻辑:
| 逻辑 | 代表源码 | 新手要理解什么 |
|---|---|---|
| 界面控制台 | CMainFrame、CTabView、CQuickView、各类 Dialog | 主控如何显示连接、分组和功能窗口 |
| 连接上下文 | ClientContext | 一条连接的状态、缓冲区、窗口绑定和列表绑定都放在哪里 |
| 网络服务 | ISocketBase、CHpTcpServer、CHpUdpServer | TCP/UDP 监听和发送如何统一封装 |
| 协议分发 | TOKEN_*、COMMAND_* | 收到的字节流如何变成“上线、心跳、打开窗口、请求插件”等事件 |
| 插件和功能窗口 | SendDll、OnOpenSendDll、各类 OnOpen*dialog | 界面动作如何进入网络发送或插件调度 |
本课的目标是让读者能回答这些审计问题:
- 一条连接从哪里来,保存在哪里?
- 收到数据后,谁来判断“完整包”?
- 为什么有些数据交给
CMainFrame,有些数据交给某个功能窗口? - 上线信息如何进入分组页签和连接列表?
- 界面按钮如何变成网络发送?
- 插件请求为什么是高风险边界?
- 断开连接时有哪些资源需要清理?
# 2. 一条连接的完整生命周期
先看完整生命周期图。后面所有源码都按这张图展开。

主控端的一条连接大致经历这些阶段:
| 阶段 | 核心函数 | 说明 |
|---|---|---|
| 启动监听 | 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_pSocketBase 和 ClientContext 传给窗口 |
| 界面发命令 | SendSelectCommand / SendDll | 从选中行取连接对象并发送 |
| 断开清理 | OnClose / NC_CLIENT_DISCONNECT | 关闭窗口、移出列表、释放缓冲区 |
这个顺序比按文件读更适合新手。因为主控端不是一个线性函数,而是一组事件回调和窗口消息互相配合。
# 3. 本课涉及源码目录和文件
| 文件 | 本课用途 | 关键结构或函数 |
|---|---|---|
主控\Quick\macros.h | 定义连接上下文和窗口消息 | ClientContext、WM_NOTIFYPROC、WM_ADDTOMAINLIST |
主控\Quick\MainFrm.cpp | 主控主框架、回调中枢、协议分发、功能弹窗 | g_pSocketBase、NotifyProc、ProcessReceiveComplete、OnOpen*dialog |
主控\Quick\ISocketBase.h/.cpp | TCP/UDP 统一封装 | Addserver、Send、Shutdown |
主控\Quick\HpTcpServer.cpp | TCP 监听服务 | Initialize、OnAccept、OnReceive、OnClose |
主控\Quick\HpUdpServer.cpp | UDP 监听服务 | Initialize、OnHandShake、OnReceive、OnClose |
主控\Quick\TabView.cpp | 分组页签 | OnAddFindGroup |
主控\Quick\QuickView.cpp | 连接列表和界面命令发送 | OnAddtomainlist、SendSelectCommand、SendDll |
第 10 课会专门讲网络通信模型,第 11 课会讲协议和命令分发。本课只把主控端架构主线讲清楚。
# 4. 主控端五层架构
主控端可以抽象成五层:
| 层次 | 角色 | 代表对象 |
|---|---|---|
| UI 层 | 展示连接、分组、状态栏和功能窗口 | CMainFrame、CTabView、CQuickView、Dialog |
| 上下文层 | 保存一条连接的网络状态和界面绑定 | ClientContext |
| 网络层 | 监听、收包、发包、断开 | ISocketBase、CHpTcpServer、CHpUdpServer |
| 分发层 | 把网络事件转成业务事件 | NotifyProc、ProcessReceive、ProcessReceiveComplete |
| 功能层 | 文件、终端、注册表、屏幕、音频、插件等功能窗口 | CFileManagerDlg、CShellDlg、CRegeditDlg 等 |
这五层之间不是严格隔离的。源码里 ClientContext 同时被网络层、UI 层和功能窗口使用,这也是后面线程安全和生命周期风险的来源。
# 5. ClientContext 字段分区图
ClientContext 是本课最重要的结构。可以把它看成“一条连接的档案袋”:连接 ID、缓冲区、登录信息、当前窗口、列表项指针都放在里面。

源码节选如下。
// 源码位置:主控\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;
};
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;
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++;
}
}
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;
}
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
这一段代码从界面配置进入网络服务对象,调用顺序可以用时序图理解:

读这张时序图时,按从左到右、从上到下的顺序看。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;
}
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;
}
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
这两段代码的防御分析:
| 观察点 | 说明 |
|---|---|
| 远端信息 | szAddress、usPort 是连接溯源基础 |
| 连接状态 | IsConnect = 666 是魔法值,后续 888 表示关闭 |
| 类型差异 | switchsocket 决定后续发送走 TCP 还是 UDP |
| 生命周期 | ClientContext 复用必须关注缓冲区和 UI 指针清理 |
| 检测点 | 新连接、握手、连接额外数据绑定、异常连接数 |
# 8. 主控程序的线程结构与网络连接结构
前面已经分别看了监听启动和 ClientContext 创建。这里把主控程序的线程结构、监听服务表、连接级上下文和 UI 对象放到一张结构图里看。这个视角适合源码审计:先分清“服务级对象”和“连接级对象”,再分析网络回调线程为什么可能碰到 UI 生命周期问题。
本节仍然只做防御分析,不讲未授权连接、攻击复现或规避检测步骤。我们关注的是主控端自身如何组织线程、连接和对象引用,以及这些结构在合法远程管理软件中应该怎样审计和加固。
# 8.1 没有被控连上时的结构
没有被控连接进入时,主控端已经可能完成了监听初始化,但还没有任何连接级 ClientContext。此时结构如下。

这张图里最容易混淆的是 g_servermap 和 ClientContext:g_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++;
}
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;
}
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;
}
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 成为中心对象:网络回调、主框架、连接列表和功能窗口都可能保存同一个指针。

连接级结构的核心字段在 macros.h 中。这里要把 switchsocket、m_server、m_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;
};
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;
}
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;
}
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_pSocketBase 和 ClientContext,调用统一发送入口;ISocketBase::Send 再根据 switchsocket 和 m_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;
}
}
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,而是线程结构和对象生命周期共同造成的。

NotifyProc 现在是在网络回调路径里直接分发的。源码里曾经有 PostMessage(WM_NOTIFYPROC, ...) 的注释路径,但当前实际代码直接调用 ProcessReceive、ProcessReceiveComplete 和断开清理分支。
// 源码位置:主控\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();
}
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_pSocketBase 和 ClientContext,列表项也把 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;
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;
}
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. 收包、组包和解包流程
收到网络数据后,主控端不会立刻当成业务命令处理。它先写入收包缓存,判断是否收齐完整包,再进入业务分发。

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;
}
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
这段代码的执行顺序:
- 根据连接 ID 找
ClientContext。 - 检查连接状态。
- 把网络字节写入
m_CompressionBuffer。 - 先触发
NC_RECEIVE,用于进度或流式窗口。 - 从缓存头部读取包长
nSize。 - 如果缓存长度足够,就把业务数据写入
m_DeCompressionBuffer。 - 触发
NC_RECEIVE_COMPLETE。 - 从收包缓存删除已处理的数据。
这段代码的防御关注点已经写在源码注释里:包长字段、缓冲区长度、解包密码和异常半包都会影响稳定性;主机和网络侧应重点关联固定长度头、周期性完整包、异常大包、重传行为,以及 NC_RECEIVE_COMPLETE 的触发条件。
收包链路涉及网络线程、连接上下文、主框架和功能窗口,时序关系如下:

这张图要重点看两次回调边界:第一次是 NC_RECEIVE,表示“有字节进入”;第二次是 NC_RECEIVE_COMPLETE,表示“完整业务包已经组好”。前者常用于窗口中间状态,后者才适合进入主控级业务分发。审计时如果把这两个事件混成一个,就容易误判数据还没收齐时发生的行为。
# 10. NC_RECEIVE 与 NC_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();
}
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;
}
}
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_* 做主控级分发。
}
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_LOGIN、TOKEN_HEARTBEAT、TOKEN_CONDITION | 进入列表、保持连接、更新状态 |
| 功能窗口打开 | TOKEN_DRIVE_LIST、TOKEN_REGEDIT、TOKEN_SHELL_START | 触发 WM_OPEN*DIALOG |
| 屏幕与外设 | TOKEN_BITMAPINFO_*、TOKEN_WEBCAM_BITMAPINFO、TOKEN_AUDIO_START | 打开视觉/音频窗口 |
| 插件与版本 | TOKEN_GETVERSION、TOKEN_SENDLL、TOKEN_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;
}
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

先看分组处理。
// 源码位置:主控\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;
}
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;
}
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_pSocketBase 和 ClientContext 传进去。
// 源码位置:主控\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;
}
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_pSocketBase 和 ClientContext 后,就具备继续发送命令和接收回包的上下文;m_Dialog[0] 会影响后续回包分发;m_Dialog[1] = (int)dlg 在 x64 思路下存在指针截断风险,窗口关闭后还要避免网络线程继续调用旧指针。
# 16. 界面命令如何变成网络发送
第 7 课讲了界面入口。这里继续看界面命令如何进入发送边界。

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

这里最需要关注的是发送边界。界面层可以选择连接、组织命令或选择插件文件,但真正把数据写向网络的是 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);
}
}
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 网络边界。
}
}
}
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;
}
}
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_MAIN 或 TASK_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;
}
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;
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_CompressionBuffer、m_DeCompressionBuffer、ScreenPicture |
| 是否避免悬空指针 | 窗口销毁后不能继续被网络线程调用 |
# 19. 线程安全问题:源码已经给出线索
NotifyProc 附近的源码注释提到:网络线程中直接操作界面元素,如果界面元素刚好销毁,可能导致崩溃。这是主控端架构里非常重要的问题。
安全工程角度建议:
| 问题 | 改造建议 |
|---|---|
| 网络线程直接更新 UI | 网络线程只投递消息,UI 线程统一更新窗口 |
m_Dialog[1] 保存裸指针 | 使用安全句柄、智能指针或窗口生命周期管理 |
指针保存到 int | 改成 intptr_t、DWORD_PTR 或明确的指针类型 |
| 上下文复用复杂 | 复用前集中清理,并写单元测试覆盖 |
| 魔法值状态 | 用枚举替代 666、888 |
| 功能窗口多处回调 | 建立统一窗口注册表和注销机制 |
这些建议不是“重构洁癖”,而是长时间运行的网络 UI 程序必须面对的稳定性和审计问题。
# 20. 企业检测模型
把主控端架构转成企业检测模型,可以分成五类。
| 检测层 | 关注点 | 数据源 |
|---|---|---|
| 主机侧 | 监听端口、进程路径、窗口标题、模块加载 | EDR、Sysmon、进程清单、防火墙日志 |
| 网络侧 | TCP/UDP 长连接、心跳、小包周期、大块插件传输 | NDR、代理日志、NetFlow、PCAP |
| 内存/模块侧 | 插件名、版本、位数、二进制块、异常可执行页 | EDR、内存取证、模块加载事件 |
| UI/日志侧 | 连接列表、上线日志、失败日志、功能窗口打开 | 程序日志、截图、注册表配置 |
| 源码审计侧 | ClientContext、NotifyProc、SendDll、OnOpenSendDll | 代码审计、静态规则 |
事件关联建议:
- 先从进程监听端口开始。
- 关联网络长连接和周期性心跳。
- 关联大块二进制传输和插件名/版本字段。
- 关联主控窗口、功能窗口和日志文本。
- 关联功能窗口对应的系统行为,例如文件、注册表、进程、屏幕或音频访问。
# 21. 合法软件改造建议
如果企业要把类似架构用于合法远程运维或内部管理工具,应至少做这些改造:
| 改造方向 | 建议 |
|---|---|
| 明确授权 | 被控端必须有用户可见提示、授权确认和会话记录 |
| 身份认证 | 使用标准双向认证,不依赖弱口令或隐藏字段 |
| 插件可信 | 插件签名校验、哈希校验、版本审计、来源控制 |
| 权限控制 | 高风险功能按角色授权,敏感动作二次确认 |
| 网络安全 | 使用 TLS、证书校验、最小开放端口和固定访问控制 |
| 审计日志 | 记录操作者、目标、时间、功能、结果和数据量 |
| 线程模型 | 网络线程投递消息,UI 线程统一处理窗口对象 |
| 数据最小化 | 屏幕、音频、摄像头、文件等敏感数据按需启用 |
| 应急处置 | 支持一键断开、会话导出、告警和禁用插件 |
# 22. 新手常见误区
| 误区 | 正确做法 |
|---|---|
只看 MainFrm.cpp | 同时看网络类、上下文结构和列表类 |
看到 TOKEN_* 就直接下结论 | 先确认是否已有功能窗口绑定 |
忽略 ClientContext | 它是连接、UI 和功能窗口的共同中心 |
混淆 NC_RECEIVE 和 NC_RECEIVE_COMPLETE | 前者是数据到达,后者是完整业务包 |
| 只关注 TCP | UDP 也有握手、心跳、上下文和分发 |
| 忽略断开清理 | 断开处理决定稳定性和取证准确性 |
| 把插件请求当普通命令 | 插件调度是高风险边界,必须单独审计 |
# 23. 本课小结
本课把主控端从“窗口程序”还原成“一条连接的生命周期”:
ISocketBase::Addserver启动 TCP/UDP 监听。OnAccept或OnHandShake创建ClientContext。OnReceive写入收包缓冲并判断完整包。NotifyProc把网络事件交给主框架。ProcessReceiveComplete根据窗口状态和TOKEN_*分发。TOKEN_LOGIN进入分组页签和连接列表。- 功能窗口通过
m_Dialog[0]和m_Dialog[1]绑定连接。 - 界面命令最终进入
g_pSocketBase->Send。 - 插件调度要单独做高风险审计。
- 断开连接时要关闭窗口、移出列表并释放缓冲区。
第 10 课会继续往网络通信模型深入,专门讲 HPSocket、TCP/UDP、连接回调、心跳和流量检测。
# 24. 合法练习题
- 画出从
ISocketBase::Addserver到CHpTcpServer::OnAccept的调用链,只写函数名、文件名和角色。 - 在
ClientContext中把字段分成 6 类:连接身份、收发缓冲、协议状态、上线信息、UI 绑定、功能窗口绑定。 - 对比
CHpTcpServer::OnAccept和CHpUdpServer::OnHandShake,写出 5 个相同点和 3 个不同点。 - 从
TOKEN_LOGIN开始追到CQuickView::OnAddtomainlist,写出完整调用链。 - 任选一个
g_pSocketBase->Send上游入口,写出“界面入口、连接对象、发送边界、检测点”,不要运行程序。 - 审计
NC_CLIENT_DISCONNECT的处理逻辑,列出需要释放或解绑的资源。 - 写一段不超过 300 字的安全改造建议,主题是“网络线程不要直接操作 UI”。
安全工程知识交流加wx:easy_coder,黑灰产勿扰,企业单位合作请出示有效证件。
本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。
社区交流
讨论与留言