在上一篇文章中,我们探讨了银狐远控源码中细粒度的 C++ 编码问题。本文将继续深入,聚焦于整个框架层面上的设计缺陷。相比编码细节问题,这些架构上的缺陷往往牵一发而动全身,修复起来不仅难度更大,工作量也更为可观。

特别申明:本文内容仅限于用作技术交流,请勿使用本文介绍的技术做任何其他用途。
让我们先从两个相对简单的问题入手,作为“开胃菜”。
问题一:服务列表与驱动服务列表的权限陷阱
在默认版本中,如果被控端以非管理员权限运行,当主控打开系统管理界面时,Win32服务和驱动服务列表将无法正常显示。


问题的根源在于获取服务列表时,请求的权限过高,导致非管理员权限的程序调用被系统拒绝。相关代码位于以下两处,均会因权限不足而调用失败。


在调用Windows API OpenSCManager 之后,紧接着是为了调用 QueryServiceConfig2 来查询服务的状态。实际上,这里只需要查询权限即可,完全不需要申请过高的权限。
修改方法:
只需将权限请求降至查询级别即可,将这两处代码分别修改如下:
sc = OpenSCManager(NULL, NULL, SC_MANAGER_CONNECT | SC_MANAGER_ENUMERATE_SERVICE);
...
sh = OpenService(sc, lpServices[i].lpServiceName, SERVICE_QUERY_CONFIG);
修改后,服务列表在非管理员权限下也能正常显示了:


操作服务的几个右键菜单修复方法与此类似,不再赘述。
问题二:视频查看功能的空指针隐患
当被控端没有摄像头设备时,如果在Release版本下启动视频查看功能,程序会发生崩溃。
在Debug版本中,主控绘制摄像头画面的逻辑如下,对 m_lpBMI 指针进行了判空操作,因此没有问题。

m_lpBMI 是一个指向 BITMAPINFO 对象的指针,表示当前摄像头的一帧画面数据。
然而,在Release版本中,绘制逻辑走的是 WM_PAINT 消息处理流程,代码如下。这里直接引用了 m_lpBMI->bmiHeader.biWidth,却没有进行判空保护。

修复方法:
最简单的修复方法是在 OnPaint 函数入口处,像Debug版本一样对 m_lpBMI 进行判空检查。或者,更好的做法是在逻辑上就避免在没有摄像头时进入绘制流程。
接下来的问题涉及框架核心,复杂度更高。
问题三:网络线程直接操作UI带来的崩溃风险
这是整个框架上一个典型的设计缺陷。以刷新文件管理列表为例,其调用堆栈揭示了问题的本质:
> Quick.exe!CFileManagerDlg::FixedRemoteFileList(unsigned char * pbBuffer=0x15830000, unsigned long dwBufferLen=1351) Line 969 C++ Symbols loaded.
Quick.exe!CFileManagerDlg::OnReceiveComplete() Line 648 C++ Symbols loaded.
Quick.exe!CMainFrame::ProcessReceiveComplete(ClientContext * pContext=0x10aeefb8) Line 1785 C++ Symbols loaded.
Quick.exe!CMainFrame::NotifyProc(ClientContext * pContext=0x10aeefb8, unsigned int nCode=5) Line 1727 C++ Symbols loaded.
Quick.exe!CHpTcpServer::OnReceive(ITcpServer * pSender=0x10c7269c, unsigned long dwConnID=3, int iLength=1366) Line 200 C++ Symbols loaded.
...
可以看到,网络接收线程直接穿透到了UI界面线程,调用界面元素的方法来更新数据。如果网络存在波动或延迟,而此时用户恰好关闭了对应的对话框,由于代码引用的界面对象已经失效,程序就会不可避免地崩溃。
这个问题在系统管理界面上尤为突出,因为其需要传输的数据量较大,收发需要一定时间。读者可以使用原始版本进行测试,反复快速切换、打开、关闭系统管理的各个子界面,几乎必然能触发崩溃。
虽然Windows允许在工作线程中操作UI元素,但这并非最佳实践。银狐主控大量使用了这种方式,却缺乏足够的保护措施,这是各个功能插件共同存在的隐患。观察下面的核心分发代码,就能发现几乎所有UI操作都在网络线程中被触及:
void CMainFrame::ProcessReceiveComplete(ClientContext* pContext)
{
//...省略部分代码...
// 交给窗口处理
if (pContext->m_Dialog[0] > 0)
{
switch (pContext->m_Dialog[0])
{
case SCREENSPY_DIF_DLG: //差异屏幕
((CDifScreenSpyDlg*)dlg)->OnReceiveComplete();
break;
case FILEMANAGER_DLG: //文件管理
((CFileManagerDlg*)dlg)->OnReceiveComplete();
break;
case MACHINE_DLG: //主机管理
((CMachineDlg*)dlg)->OnReceiveComplete();
break;
// ... 其他大量 case 分支 ...
default:
TRACE(" if (pContext->m_Dialog[0] > 0) 非法数据 %s");
break;
}
return;
}
//...省略部分代码...
switch (pContext->m_DeCompressionBuffer.GetBuffer(0)[0])
{
case TOKEN_DRIVE_LIST: // 驱动器列表
g_pFrame->PostMessage(WM_OPENMANAGERDIALOG, 0, (LPARAM)pContext);
break;
case TOKEN_SYSINFOLIST://系统管理
g_pFrame->PostMessage(WM_OPENSYSINFODIALOG, 0, (LPARAM)pContext);
break;
case TOKEN_REGEDIT: //注册表管理
g_pFrame->PostMessage(WM_OPENREGEDITDIALOG, 0, (LPARAM)pContext);
break;
// ... 其他大量 case 分支 ...
default:
TRACE("switch (pContext->m_DeCompressionBuffer.GetBuffer(0)[0]) 非法数据");
break;
}
return;
}
修复方法:
需要全面梳理业务逻辑,调整框架设计。正确的做法是让网络线程通过线程间通信机制(如PostMessage)将数据和事件通知给UI线程,由UI线程自己来安全地更新界面元素。这项修改涉及面广,工作量巨大。
上述代码还隐藏着另一个严重的问题。
问题四:连接对象生命周期的管理混乱
我们以处理注册表消息的这一行代码为例:
case TOKEN_REGEDIT: //注册表管理
g_pFrame->PostMessage(WM_OPENREGEDITDIALOG, 0, (LPARAM)pContext);
break;
你能看出这里的问题吗?
这里调用 PostMessage 向UI线程的消息队列投递 WM_OPENREGEDITDIALOG 消息,并附带了连接对象指针 pContext。UI线程对消息的处理是异步的。以下是消息处理的部分代码:

问题的核心在于:pContext 对象的创建与销毁是由网络线程控制的。假设在UI线程正在使用这个对象期间,网络连接突然断开,该对象被网络线程回收,那么UI线程后续的操作就会引用到一个无效的内存地址,导致未定义行为(可能崩溃或数据显示错乱)。
银狐的网络线程在回收 ClientContext 对象时,并未直接删除它,而是放回了一个空闲对象池中(见下方代码)。这虽然不一定立即引起访问违例崩溃,但极有可能导致后续数据显示混乱,因为被回收的对象可能很快被新的连接复用。
struct ClientContext
{
ULONG_PTR m_Socket;
// Store buffers
CBuffer m_WriteBuffer;
CBuffer m_CompressionBuffer; // 接收到的压缩的数据
CBuffer m_DeCompressionBuffer;
// ...其他结构省略...
};
// 从空闲池获取或新建 Context
EnHandleResult CHpTcpServer::OnAccept(ITcpServer* pSender, CONNID dwConnID, UINT_PTR soClient)
{
// ...其他代码省略...
m_clcs.lock();
if (!m_listFreePool.IsEmpty())
{
pContext = m_listFreePool.RemoveHead();
}
else
{
pContext = new(std::nothrow) ClientContext;
}
m_clcs.unlock();
// ...初始化 pContext ...
}
// 连接关闭时,回收 Context 到空闲池
EnHandleResult CHpTcpServer::OnClose(ITcpServer* pSender, CONNID dwConnID, EnSocketOperation enOperation, int iErrorCode)
{
// ...其他代码省略...
if (m_TcpServer->GetConnectionExtra(dwConnID, (PVOID*)&pContext) && pContext != nullptr)
m_TcpServer->SetConnectionExtra(dwConnID, NULL);
if (!pContext) return HR_OK;
pContext->IsConnect = 888;
m_pNotifyProc(pContext, NC_CLIENT_DISCONNECT);
MovetoFreePool(pContext); // 移入空闲池
return HR_OK;
}
修复思路:
解决此问题,一种方法是将 pContext 内的关键数据复制一份,传递给UI线程。这对于数据量小的场景可行,但对于远程桌面、代理映射等需要传递大量数据的场景,复制方式显得笨重,会消耗大量内存并可能产生内存碎片。更优的方案是采用引用计数或类似智能指针的机制来管理对象生命周期,或者设计一套基于内存池的双缓冲区交换机制。由于此类逻辑在代码中随处可见,修复工作量同样巨大。
问题五:分散且脆弱的网络协议处理逻辑
许多开发者曾想调整银狐的通讯协议格式,但最终望而却步。放弃的主要原因在于,银狐的网络封包与解包逻辑分散在代码的各个角落,总计有五百多处,耦合度极高。
最佳实践:
应当将收发包的协议处理逻辑收敛到统一的入口和出口。在入口处解包,在出口处封包。例如,对于被控端,可以定义如下统一的网络客户端接口:
class INetworkClient
{
public:
virtual BOOL Start(const wchar_t* host, USHORT port, bool autoReconnect) = 0;
virtual void Stop() = 0;
virtual void SetProtocolEncryptType(int encryptType) = 0;
virtual void SetAESKey(const wchar_t* pszAESKey) = 0;
virtual void EnableHeartbeat(int32_t heartbeatIntervalSec) = 0;
virtual void EnableHeartbeatLogs(bool enable) = 0;
// 统一的发送接口
virtual bool Send(std::string& contentBuffer, int8_t flags) = 0;
virtual bool Send(const char* contentBuffer, size_t contentSize, int8_t flags) = 0;
virtual bool SendPluginPacket(const GUID& guid, std::string& contentBuffer, int8_t flags) = 0;
virtual bool SendPluginPacket(const GUID& guid, const char* contentBuffer, size_t contentSize, int8_t flags) = 0;
virtual bool IsConnected() = 0;
virtual int64_t GetLastConnectedTime() = 0;
protected:
virtual ~INetworkClient() = default;
};
所有的网络封包操作(如添加协议头、加密、压缩)都封装在 Send 系列接口内部。这样,无论是调整协议格式,还是变更加密压缩算法,都只需在这一处修改即可,并且可以低成本地替换不同的网络底层实现,极大提升了代码的可维护性和可扩展性,是构建健壮 后端架构 的基础。
问题六:对UDP协议的“虚假”支持
看到这个标题,可能会有读者疑惑:实测银狐确实支持UDP连接啊?没错,银狐使用了HP-Socket库的UDP组件,其原理是利用Windows完成端口(IOCP)来统一处理UDP Socket。
看一下HP-Socket的简化启动逻辑:
BOOL CUdpServer::Start(LPCTSTR lpszBindAddress, USHORT usPort)
{
//...省略无关代码
if(CreateListenSocket(lpszBindAddress, usPort))
if(CreateCompletePort())
if(CreateWorkerThreads())
if(StartAccept())
{
m_enState = SS_STARTED;
m_evWait.Reset();
return TRUE;
}
//...省略无关代码
}
可以看到流程和TCP服务器类似,只是创建的Socket类型不同。
框架在收到UDP数据包后,其处理逻辑与TCP完全一致,包含了处理“粘包/半包”的循环:
EnHandleResult CHpUdpServer::OnReceive(IUdpServer* pSender, CONNID dwConnID, const BYTE* pData, int iLength)
{
ClientContext* pContext = NULL;
if ((!m_UdpServer->GetConnectionExtra(dwConnID, (PVOID*)&pContext)) && (pContext != nullptr) && (iLength <= 0))
return HR_ERROR;
if (pContext->IsConnect != 666) return HR_ERROR;
pContext->m_CompressionBuffer.Write((PBYTE)pData, iLength);
m_pNotifyProc(pContext, NC_RECEIVE);
// 检测数据大小 —— 此处模仿了TCP的粘包处理逻辑
while ((int)pContext->m_CompressionBuffer.GetBufferLen() > m_headerlength)
{
// ... 解包逻辑,与TCP相同 ...
}
return HR_OK;
}
这样写虽然不算错误,但却是不必要的,并且忽略了UDP的核心问题。
首先,UDP是数据报协议,数据在操作系统协议栈交付给应用层时本身就是完整的报文,不存在TCP的“粘包”和“半包”问题。因此,上面的循环解包逻辑在UDP场景下是多余的。
最大的问题在于:UDP协议本身缺少可靠传输保证,即没有包的重传、排序和去重机制。然而,银狐的业务逻辑(如远程桌面、文件传输)是建立在可靠、有序的数据流之上的,框架层却完全没有实现任何可靠性保障机制(如RUDP)。这使得这套UDP实现在本地回环测试中可能表现正常,但一旦放到真实的网络环境中(尤其是存在丢包、乱序的互联网),业务表现将极不稳定,基本不可用。
关于银狐远控的缺陷远不止于此。在长达一年多的维护过程中,我累计修复了超过100个问题,在GitHub上提交了多达568次记录。

当然,尽管这套源码存在诸多问题,但瑕不掩瑜。它仍然是学习C++开发、多线程与网络编程、安全工程以及大型项目实践的绝佳材料。为了更方便地研究和优化,我不仅修复了上述缺陷,还将整个工程从Visual Studio 2010升级至2022,补全并重新编译了所有依赖库,并移除了所有已知后门,使其成为一款可用于学习和测试的远程控制软件。