
原始的银狐远程控制软件代码中,存在着大量的C++编码问题,其中绝大多数都属于容易忽视的低级错误。正是这些错误导致了软件稳定性差,在运行时极易崩溃。
本次分析的代码基于4.0版本,是一个Visual Studio 2010工程,希望这些问题的剖析与修复方案,能对大家提高实际的C/C++开发水平有所帮助。
问题1:GetModuleFileName参数误用

如图所示,银狐代码中大量调用了Windows API GetModuleFileName 来获取当前程序所在路径。该函数的最后一个参数本应传递缓冲区能容纳的字符数量,而非字节数目。由于工程设置使用了Unicode字符集(一个字符占两个字节),图中传递字节数目的做法,在存放的路径较长时,极易造成内存越界,引发程序崩溃。
DWORD GetModuleFileNameW(
[in, optional] HMODULE hModule,
[out] LPWSTR lpFilename,
[in] DWORD nSize // 这个参数是字符数量,不是字节数目
);
修改方法:
将第三个参数改为字符数量。Windows提供了一个宏 ARRAYSIZE,可以在编译期间自动计算数组的字符长度(其实现原理是数组总字节数除以第一个元素的字节数)。因此,应将上述代码修改为:
TCHAR ExePath[MAX_PATH] = { 0 };
GetModuleFileName(NULL, ExePath, ARRAYSIZE(ExePath));
此类错误在银狐代码中至少存在十几处,尤其是在主控端代码中。
问题2:memcpy拷贝空字符串导致越界

这段代码节选自“系统管理”插件模块,你能看出标红处的问题吗?
这里使用 memcpy 将一个空字符串(宽字符 L".")拷贝到 lpBuffer + dwOffset 指向的内存位置,但拷贝长度却是 MAX_PATH * sizeof(TCHAR)。由于宽字符空串 _T("") 所指向的内存值就是 L'\0',这种“拷贝过多”的行为实际上会读取源地址之后未定义的内存区域,造成内存越界,其行为是未知的。
修改方法:
作者的原意可能是将 MAX_PATH * sizeof(TCHAR) 长度的内存区域置零。因此,这里应该直接使用 memset 函数进行清零操作。
memset(lpBuffer + dwOffset, 0, MAX_PATH * sizeof(TCHAR));
类似这样的代码在“系统管理”插件模块中随处可见。
问题3:剪贴板数据处理中的连环崩溃

这段问题代码位于“登录模块”的离线键盘记录功能中。短短一段逻辑,竟然隐藏着三个潜在的崩溃点:
- 错误计算数据长度:
GlobalSize 这个API返回的是当前剪贴板数据的字节数,即使剪贴板内容是宽字符。原代码将其乘以2,可能导致后续 wcscmp(lpstr, Clipboard_old) != 0 比较时,读取 lpstr 越界。
- memcpy可能越界:同理,
memcpy(Clipboard_old, lpstr, nPacketLen); 中的 nPacketLen 因乘以2而过大,读取 lpstr 时也可能越界。
- 格式化字符串未限制长度:使用
wsprintf(temp, _T("\r\n[剪切板:]%s\r\n"), lpstr); 格式化时,没有指定 temp 缓冲区的最大长度。如果 lpstr 不是以 \0 结尾的有效字符串(由于越界读取,这很可能发生),格式化函数会一直向后读取内存,导致严重越界。
真是十行代码,三个崩溃隐患。
完整修复后的代码如下:
if (GetTickCount() - m_dwLastCapture > 1500)
{
InterlockedExchange((LPLONG)&m_dwLastCapture, GetTickCount());
OpenClipboard(NULL);
HGLOBAL hglb = GetClipboardData(CF_UNICODETEXT);
if (hglb != NULL)
{
//int nPacketLen = int(GlobalSize(hglb)) * 2 + 2; // 错误写法
int nPacketLen = int(GlobalSize(hglb)); // 正确写法
LPCTSTR lpstr = (LPCTSTR)GlobalLock(hglb);
if (lpstr != NULL)
{
if (nPacketLen < sizeof(szClipboard_old)) // 判断长度
{
//if (wcscmp(lpstr, szClipboard_old) != 0) // 不安全的内容比较
if (memcmp((char*)lpstr, (char*)szClipboard_old, nPacketLen) != 0) // 安全的二进制比较
{
memcpy(szClipboard_old, lpstr, nPacketLen);
//wsprintf(szTemp, _T("\r\n[剪切板:]%s\r\n"), lpstr); // 不安全格式化
swprintf_s(szTemp, ARRAYSIZE(szTemp), _T("\r\n[剪切板:]%s\r\n"), lpstr); // 安全格式化
Input::SaveToFile(szTemp);
memset(szTemp, 0, sizeof(szTemp));
}
}
}
::GlobalUnlock(hglb);
}
::CloseClipboard();
}
问题4:GetWindowText参数错误与格式化风险

这段代码同样位于“登录模块”的离线键盘记录功能,负责获取被控机器上当前活动窗口的标题。
与问题一类似,GetWindowText 的最后一个参数应该传入缓冲区能容纳的字符数量(包括结尾的 L'\0'),但代码中传入了缓冲区的字节数目,这相当于允许的长度翻了一倍。如果用户电脑上的活动窗口标题较长,就会造成内存溢出,导致被控端崩溃。
修改方法:
BOOL Input::IsWindowsFocusChange()
{
memset(WindowCaption, 0, sizeof(WindowCaption));
hFocus = GetForegroundWindow();
GetWindowText(hFocus, WindowCaption, ARRAYSIZE(WindowCaption)); // 使用ARRAYSIZE
WindowCaption[ARRAYSIZE(WindowCaption)-1] = 0; // 确保终止符
//... 省略无关代码
}
下面的代码段:
if (lstrlen(WindowCaption) > 0)
{
SYSTEMTIME s;
GetLocalTime(&s);
wsprintf(temp, _T("\r\n[标题:]%s\r\n[时间:]%d-%d-%d %d:%d:%d\r\n"), WindowCaption, s.wYear, s.wMonth, s.wDay, s.wHour, s.wMinute, s.wSecond);
SaveToFile(temp);
memset(temp, 0, sizeof(temp));
memset(WindowCaption, 0, sizeof(WindowCaption));
ReturnFlag = TRUE;
}
其中调用 wsprintf 进行格式化存在与问题三相同的内存越界风险,当窗口标题较长时,temp 缓冲区可能被写爆。这也解释了为什么被控端在切换不同窗口时,有时会意外掉线(实际上是程序崩溃退出了)。
问题5:CreateStreamOnHGlobal内存管理不当

这段代码位于主控端的远程屏幕相关模块,存在一个较为隐蔽的内存管理问题,如果不熟悉相关Windows API,很难发现。其现象是启动远程屏幕时,主控端偶现崩溃。
这个问题是使用Visual Studio 2022集成的Google Address Sanitizer工具排查出来的,该工具能高效定位C/C++内存问题。
解决方法:
查阅MSDN可知,CreateStreamOnHGlobal 函数的第二个参数 fDeleteOnRelease 如果指定为 TRUE,则在调用返回的 IStream 接口的 Release 方法时,会自动释放第一个参数 hGlobal 所指向的内存。在本例中,hGlobal 是由 GlobalAlloc(GMEM_MOVEABLE, ...) 分配的可移动内存。
问题在于,代码中创建的OLE流对象(pStream 和 pOutStream)可能只占用了 GlobalAlloc 分配的部分内存或对其进行封装。当 fDeleteOnRelease 为 TRUE 时,流对象释放时会尝试释放整个 hGlobal 内存块,这可能与后续显式调用 GlobalFree(hGlobal) 产生冲突或访问违规,尤其是在多线程或复杂调用路径下。
因此,解决方案是将两处调用 CreateStreamOnHGlobal 函数时的第二个参数改为 FALSE,即禁止流对象自动释放全局内存,由我们手动管理 GlobalFree。
修改后代码如下:
//显示截图窗口
void CMainFrame::OnOpenDesktop(ClientContext* pContext)
{
//...省略无关代码...
HGLOBAL hGlobal = GlobalAlloc(GMEM_MOVEABLE, pContext->m_DeCompressionBuffer.GetBufferLen() - 1);
void* pData = GlobalLock(hGlobal);
memcpy(pData, pContext->m_DeCompressionBuffer.GetBuffer(1), pContext->m_DeCompressionBuffer.GetBufferLen() - 1);
GlobalUnlock(hGlobal);
IStream* pStream = NULL;
if (CreateStreamOnHGlobal(hGlobal, FALSE, &pStream) == S_OK) // 改为FALSE
{
CImage image;
if (SUCCEEDED(image.Load(pStream)))
{
IStream* pOutStream = NULL;
if (CreateStreamOnHGlobal(NULL, FALSE, &pOutStream) == S_OK) // 改为FALSE
{
image.Save(Ttime);
}
}
pStream->Release();
}
GlobalFree(hGlobal); // 手动释放
//...省略无关代码...
}
问题6:UDP断线重连失效的线程同步Bug

如图所示,当被控端使用UDP模式连接主控时,一旦发生断线,被控端将永远无法重新连接成功。这意味着所有依赖网络连接的模块(如上线、登录、键盘记录、远程屏幕等)都会因此失效。
这段逻辑的原理是:发起UDP连接后,会调用 WaitForSingleObject 等待一个内核事件对象 m_hEvent_run 受信(以此标志连接成功)。连接成功时,会调用 SetEvent 设置 m_hEvent_run 为受信状态。如下图所示:

随后,代码会调用 CUdpSocket::run_event_loop() 进入事件循环等待。
问题根源:
关键就在 CUdpSocket::run_event_loop() 函数内部,它关闭了 m_hEvent_run 这个事件对象句柄,同时被关闭的还有 m_hEvent。

这样一来,当发生断线需要重连时,再次调用 CUdpSocket::Connect 函数。由于 m_hEvent_run 的句柄已被关闭,WaitForSingleObject 会立即返回 WAIT_FAILED 状态,导致连接函数永远返回失败。

解决方法:
有两种思路可以解决此问题:
- 方法一:连接成功后,在
CUdpSocket::run_event_loop() 函数中不要调用 CloseHandle(m_hEvent_run); 来关闭这个事件对象,使其在程序生命周期内持续有效。
- 方法二:在每次尝试重连时,先判断事件句柄是否有效,若无效则重新调用
CreateEvent 函数创建新的 m_hEvent_run 和 m_hEvent 对象。
修复这个Bug需要对Windows多线程同步和Socket编程有深入的理解。这类成体系的知识往往需要在实际工作中长期积累和总结。
为了更彻底地排查和优化这套代码,除了修复上述问题,我还将工程从Visual Studio 2010升级到了Visual Studio 2022,补全并重新编译了所有依赖库,并移除了所有潜在的后门代码,使其成为一款更稳定、可用于学习研究的远程控制软件示例。
尽管这套初始源码存在不少缺陷,但它仍然是深入学习C/C++、Windows系统编程、网络通信、安全工程以及大型项目实战的宝贵材料。在像云栈社区这样的技术论坛中,与同行交流此类复杂项目的调试和优化经验,对开发者的成长大有裨益。