第 48 课:C/C++ 安全编码核查
# 第 48 课:C/C++ 安全编码核查
免责声明:本专栏仅用于安全工程学习研究,禁止使用本专栏介绍的技术做其他用途,否则后果自负,与本号无关。
前面的专栏主要讲“模块能做什么”。本课换一个角度:即使不讨论功能意图,C/C++ 代码本身也可能因为长度校验、内存释放、句柄生命周期、线程参数、权限掩码和错误处理不严谨而产生稳定性和安全问题。新手读 C/C++ 安全代码时,不要只看程序能不能跑,还要看失败路径是否可靠、资源是否释放、输入是否可信。

# 1. 本课阅读方法
本课围绕 6 类问题展开:
| 类别 | 新手要问的问题 |
|---|---|
| 长度校验 | memcpy 前有没有确认输入长度足够 |
| 内存生命周期 | new/delete、malloc/free 是否匹配 |
| 句柄释放 | Open* 后每条返回路径是否关闭 |
| 线程同步 | 线程参数和共享变量生命周期是否稳定 |
| 权限最小化 | 是否滥用 KEY_ALL_ACCESS、PROCESS_ALL_ACCESS |
| 错误处理 | 失败时是否清理资源并返回明确状态 |
# 2. 源码目录和文件
| 位置 | 作用 | 本课关注 |
|---|---|---|
主插件\启动管理\启动管理\StartupManager.cpp | 启动项命令处理 | 网络包长度与结构体复制 |
主插件\解密数据\解密数据\DecryptManger.cpp | 敏感数据格式化 | malloc 和 delete 不匹配 |
主插件\注入管理\注入管理\InjectManager.cpp | 远程进程操作 | 句柄释放、权限过宽 |
主插件\HPSocket\Buffer.cpp | 缓冲区实现 | 长度、内存重分配、指针移动 |
主插件\文件管理\文件管理\FileManager.cpp | 文件和注册表操作 | 宽权限、路径和错误处理 |
依赖说明: 本课不是讲某一个第三方库,而是从 C/C++ 资源管理角度横向看多个模块。涉及 HPSocket 缓冲区、Windows 句柄、注册表 API、内存分配函数和注入模块中的远程进程 API。所有判断都放进代码注释,例如长度来源、释放方式、句柄关闭路径和权限掩码,不再单独列独立栏目。
# 3. 长度校验:结构体复制前先判断 nSize

网络包是不可信输入。任何从 lpBuffer 里直接 memcpy 出结构体的代码,都应该先判断 nSize 是否足够。下面这段代码把 lpBuffer 复制到 StartupRequest,但节选中没有看到复制前的长度判断。
// 源码位置:主插件\启动管理\启动管理\StartupManager.cpp,约121-128 case COMMAND_STARTUP_REGISTRY_REQUEST:
{
StartupRequest startupRequest;
memcpy(&startupRequest, lpBuffer, sizeof(startupRequest));
bool success = SetRegistryStart(startupRequest.enable);
OnHandleStartupResponse(
COMMAND_STARTUP_REGISTRY_RESPONSE,
startupRequest.enable,
success);
// 防御分析:从网络包复制结构体前,应确认 nSize >= sizeof(StartupRequest) // 否则异常包可能造成越界读或错误解析}
2
3
4
5
6
7
8
9
10
11
12
13
同类问题在多个分支中出现。
// 源码位置:主插件\启动管理\启动管理\StartupManager.cpp,约131-161 case COMMAND_STARTUP_FOLDER_REQUEST:
{
StartupRequest startupRequest;
memcpy(&startupRequest, lpBuffer, sizeof(startupRequest));
bool success = CopyOrRemoveFromStartupDir(startupRequest.enable);
}
break;
case COMMAND_STARTUP_SERVICE_REQUEST:
{
StartupRequest startupRequest;
memcpy(&startupRequest, lpBuffer, sizeof(startupRequest));
bool success = AddOrDeleteService(startupRequest.enable);
}
break;
// 防御分析:相同解析模式重复出现时,应抽成统一的安全解析函数,
// 先校验长度,再解析字段,再执行业务逻辑?
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
建议写法思路
| 步骤 | 要求 |
|---|---|
| 先判断 token | 只确认命令类型,不读取后续字段 |
| 再判断长度 | nSize >= sizeof(结构体) |
| 再复制结构体 | 使用明确的结构体版本 |
| 再检查字段 | 布尔、枚举、长度字段都要限制范围 |
| 最后执行 | 业务函数不直接接收原始网络缓冲区 |
# 4. 内存生命周期:申请和释放必须匹配

下面这段代码使用 malloc 申请内存,但释放函数使用 delete。这属于释放方式不匹配,可能引发未定义行为。
// 源码位置:主插件\解密数据\解密数据\DecryptManger.cpp,约132-172 char* CDecryptManger::GetCookiesChar(vector<BrowserData>* pPass, int* memLen)
{
int size = 1;
for (vector<BrowserData>::iterator iter = (*pPass).begin(); iter != (*pPass).end(); iter++)
{
size += int((*iter).bro_name.size()) + 17;
size += int((*iter).bro_url.size()) + 17;
size += int((*iter).user_name.size()) + 17;
size += int((*iter).pass_word.size()) + 17;
}
char* pCharPass = (char*)malloc(sizeof(char) * size);
memset(pCharPass, 0, size);
// 防御分析:这里的关键不是 pCharPass 这个变量名,而是生命周期配对 // malloc 申请的内存应free 释放;new delete,new[] delete[] // VirtualAlloc VirtualFree。更稳妥的工程写法是使用 std::string // std::vector<char> 或智能指针,让释放动作随对象生命周期自动发生 return pCharPass;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 源码位置:主插件\解密数据\解密数据\DecryptManger.cpp,约230-238 void CDecryptManger::DeleteMem(char** pChromeC)
{
if (*pChromeC != nullptr)
{
delete *pChromeC;
}
// 防御分析:这个函数和 GetCookiesChar 形成一条完整证据链 // 上游malloc 申请,下游却delete 释放,释放方式不匹配 // 这种问题不需要单独列成表格,直接在申请点和释放点标注即可 // 修复方向是:要么改成 free(*pChromeC),要么把申请和释放都改为 RAII}
2
3
4
5
6
7
8
# 5. 句柄生命周期和过宽权。
注入管理模块中使用 OpenProcess(PROCESS_ALL_ACCESS)。从代码质量看,过宽权限会扩大风险;从资源管理看,成功打开的句柄需要保证所有路径释放。
// 源码位置:主插件\注入管理\注入管理\InjectManager.cpp,约948-963 HANDLE hProc = OpenProcess(
PROCESS_ALL_ACCESS,
FALSE,
m_sinjectmode.dwProcessID);
if (hProc == NULL)
{
SendError(_T("打开进程失败"));
return;
}
LPBYTE lpAddress = new BYTE[dwSize];
if (lpAddress == NULL)
{
SendError(_T("VirtualAlloc Error"));
CloseHandle(hFile);
return;
}
// 防御分析:这里打开了目标进程句柄// 后续每条失败路径都应关闭 hProc,权限也应改成最小需要集合?
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 源码位置:主插件\注入管理\注入管理\InjectManager.cpp,约970-979 LPVOID RemotlpAddress = VirtualAllocEx(
hProc,
NULL,
dwSize,
MEM_COMMIT,
PAGE_EXECUTE_READWRITE);
WriteProcessMemory(hProc, RemotlpAddress, lpAddress, dwSize, NULL);
HANDLE ThreadShellCode = CreateRemoteThread(
hProc,
NULL,
0,
(LPTHREAD_START_ROUTINE)RemotlpAddress,
NULL,
0,
NULL);
if (ThreadShellCode)
CloseHandle(ThreadShellCode);
// 防御分析:这段只用于说明资源和权限问题// 远程进程操作属于高风险行为,企业代码中应默认禁止?
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 6. 缓冲区管理:重分配和指针移动
缓冲区类使用 VirtualAlloc、CopyMemory、MoveMemory 管理数据。新手读这类代码时,要重点看长度来源、指针边界和异常路径。
// 源码位置:主插件\HPSocket\Buffer.cpp,约36-50 BOOL CBuffer::Write(PBYTE pData, UINT nSize, BOOL bXORrecoder, byte* password)
{
ReAllocateBuffer(nSize + GetBufferLen());
CopyMemory(m_pPtr, pData, nSize);
if (bXORrecoder)
{
for (int i = 0, j = 0; i < (int)nSize; i++)
{
((char*)m_pPtr)[i] ^= (password[j++]) % 456 + 54;
if (i % (10) == 0)
j = 0;
}
}
// 防御分析:nSize 来自外部调用者,调用者必须保证长度可信 // 更稳妥的实现应检ReAllocateBuffer 返回值}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 源码位置:主插件\HPSocket\Buffer.cpp,约74-94 if (nSize > GetMemSize())
return 0;
if (nSize > GetBufferLen())
nSize = GetBufferLen();
if (nSize)
{
CopyMemory(pData, m_pBase, nSize);
MoveMemory(m_pBase, m_pBase + nSize, GetMemSize() - nSize);
m_pPtr -= nSize;
}
DeAllocateBuffer(GetBufferLen());
// 防御分析:移动内存时应使用有效数据长度,而不是总分配大小// 缓冲区代码建议配套单元测试覆盖边界长度?
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 7. 权限最小化:不要默ALL_ACCESS
文件管理中打开注册表使用 KEY_ALL_ACCESS。如果只是查询文件关联,不需要这么宽的权限。
// 源码位置:主插件\文件管理\文件管理\FileManager.cpp,约480-500 if (RegOpenKeyEx(
HKEY_CLASSES_ROOT,
lpExt,
0L,
KEY_ALL_ACCESS,
&hKey) != ERROR_SUCCESS)
return false;
RegQueryValue(hKey, NULL, strTemp, &nSize);
RegCloseKey(hKey);
// 防御分析:读取注册表时应优先使用 KEY_READ// KEY_ALL_ACCESS 会扩大权限面,也会提高被安全产品拦截的概率?
2
3
4
5
6
7
8
9
10
11
12
13
# 8. 更具体的修复写法
本课前面指出了几个问题,这里给出更贴近工程的修复方向。下面代码是防御性示例,用来说明如何把“先校验、再解析、最后执行”的思路固化到代码里。
// 示例位置:可用于 StartupManager.cpp 中解StartupRequest // 防御分析:网络包进入业务函数前,先做长度和命令校验// 这样业务函数拿到的是可信结构体,而不是原lpBufferbool TryReadStartupRequest(
const BYTE* lpBuffer,
UINT nSize,
BYTE expectedCommand,
StartupRequest* outRequest)
{
if (lpBuffer == NULL || outRequest == NULL)
return false;
if (nSize < 1 + sizeof(StartupRequest))
return false;
if (lpBuffer[0] != expectedCommand)
return false;
memcpy(outRequest, lpBuffer + 1, sizeof(StartupRequest));
// 防御分析:布尔字段也要归一化,避免异常值进入业务分支 outRequest->enable = outRequest->enable ? TRUE : FALSE;
return true;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
句柄释放也可以用小型封装降低失败路径遗漏。重点不是引入复杂框架,而是确保 CloseHandle 不依赖每个 return 分支手工记忆。
// 示例位置:可用于涉及 HANDLE 的管理类或局部工具
// 防御分析:ScopedHandle 用析构函数释放句柄,减少失败路径泄漏
// 这类封装适合文件、进程、线程、服务控制管理器等 Windows 句柄
class ScopedHandle
{
public:
explicit ScopedHandle(HANDLE value = NULL) : handle(value) {}
~ScopedHandle()
{
if (handle != NULL && handle != INVALID_HANDLE_VALUE)
CloseHandle(handle);
}
HANDLE get() const { return handle; }
private:
HANDLE handle;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
最小权限可以按“当前函数真正需要什么”来拆。例如只读取注册表值时使用 KEY_READ,只写指定值时使用 KEY_SET_VALUE,不要默认使用 KEY_ALL_ACCESS。
| 原始写法 | 更具体的替代 | 说明 |
|---|---|---|
KEY_ALL_ACCESS 读取文件关联 | KEY_READ | 只读场景不需要创建、删除、写入权限 |
PROCESS_ALL_ACCESS 查询进程 | PROCESS_QUERY_LIMITED_INFORMATION | 查询信息不应申请全权限 |
CreateFile(..., GENERIC_READ | GENERIC_WRITE) | 按实际动作拆分 | 只读、只写、追加写分别限制 |
直接 memcpy 结构 | TryRead* 解析函数 | 统一处理长度、版本、字段范 |
# 9. 企业检测点
| 层面 | 检测点 | 说明 |
|---|---|---|
| 静态扫 | memcpy、CopyMemory、strcat_s、wsprintf | 检查长度来 |
| 内存 | new、malloc、VirtualAlloc | 检查释放方式和失败路径 |
| 句柄 | OpenProcess、CreateFile、RegOpenKeyEx | 检查权限和关闭路径 |
| 线程 | _beginthreadex、CreateThread | 检查参数生命周期 |
| 权限 | ALL_ACCESS 类掩码 | 优先改成最小权限 |
# 10. 合法练习:
从任意一个插件中找 3 处
memcpy,说明它们的长度来源是否可信?把
malloc/delete不匹配的问题改写为std::string或std::vector<char>的设计说明?列出
KEY_READ和KEY_ALL_ACCESS的差异,并说明为什么读取注册表不应使用后者?设计一个“网络包安全解析函数”的伪代码:输入缓冲区、长度、结构体版本,输出解析结果。
安全工程知识交流加wx:easy_coder,黑灰产勿扰,企业单位合作请出示有效证件。
本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。
社区交流
讨论与留言