《360 安全规则集合》简称《安规集》,是一套详细的 C/C++ 安全编程指南,由 360 集团质量工程部编著, 将编程时需要注意的问题总结成若干规则,可为制定编程规范提供依据,也可为代码审计或相关培训提供指导意见, 旨在提升软件产品的可靠性、健壮性、可移植性以及可维护性,从而提升软件产品的综合安全性能。
《安规集》面向软件开发、测试及相关管理人员,适用于桌面、服务端及嵌入式软件系统。
《安规集》的最新动态请参见:GitHub - Qihoo360/safe-rules: 详细的C/C++编程规范指南,由360质量工程部编著,适用于桌面、服务端及嵌入式软件系统。
可在 Apache-2.0 协议的许可范围内自由传播和使用《安规集》相关内容,同时也可以根据自身需求对某些规则进行适当的调整和取舍。
针对 C 和 C++ 语言,本文收录了 525 种需要重点关注的问题, 每个问题对应一条规则,可作为代码审计依据或编程规范条款,也可为相关培训提供指导意见。
本文是适用于不同应用场景的规则集合,读者可从中选取某个子集以适应自身需求。
规则分类
本文以 ISO/IEC 9899:2011、ISO/IEC 14882:2011 为依据,兼顾 C++17 及历史标准,将规则分为:
- Security:敏感信息防护与安全策略
- Resource:资源管理
- Precompile:预处理、宏、注释、文件
- Global:全局及命名空间作用域
- Type:类型设计与实现
- Declaration:声明
- Exception:异常
- Function:函数实现
- Control:流程控制
- Expression:表达式
- Literal:字面常量
- Cast:类型转换
- Buffer:缓冲区
- Pointer:指针
- Interruption:中断与信号处理
- Concurrency:异步与并发
- Style:样式与风格
规则组成
- 标识:规则的唯一标识,以“ID_”开头
- 标题:规则的定义和简要说明
- 说明:规则设立的原因、违反规则的后果
- 示例:例举符合规则(Compliant)的和违反规则(Non-compliant)的代码
- 相关:与当前规则有相关性的规则,可作为扩展阅读的线索
- 依据:规则依照的 ISO/IEC 标准条目
- 配置:某些规则的细节可灵活设置,审计工具可以此为参照实现定制化功能
- 参考:规则参考的其他规范条目,如 C++ Core Guidelines、MISRA、SEI CERT Coding Standards 等
其中“依据”以“标准名称:版本 章节编号(段落编号)-性质”的格式引用标准,“性质”一项分为:
- undefined:可使程序产生未定义的行为,其后果是不可预期的
- unspecified:可使程序产生未声明的行为,具体行为由编译器或环境定义,没有明确的文档
- implementation:可使程序产生由实现定义的行为,具体行为由编译器或环境定义,具有明确的文档,但不具备可移植性
- deprecated:已被废弃的或不建议继续使用的编程方式
没有特殊说明的规则同时适用于 C 语言和 C++ 语言,只适用于某一种语言的规则会有相关说明。
规则目录
- Security
- Resource
- 不可失去对已分配资源的控制
- 不可失去对已分配内存的控制
- 不可访问未初始化或已释放的资源
- 使资源接受对象化管理
- 资源的分配与回收方法应成对提供
- 资源的分配与回收方法应配套使用
- 不应在模块之间传递容器类对象
- 不应在模块之间传递非标准布局类型的对象
- 对象申请的资源应在析构函数中释放
- 对象被移动后应重置状态再使用
- 构造函数抛出异常需避免相关资源泄漏
- 不可重复释放资源
- 用 delete 释放对象需保证其类型完整
- 用 delete 释放对象不可多写中括号
- 用 delete 释放数组不可漏写中括号
- 不可释放非动态分配的内存
- 在一个表达式语句中最多使用一次 new
- 流式资源对象不应被复制
- 避免使用变长数组
- 避免使用在栈上动态分配内存的函数
- 局部数组不应过大
- 避免不必要的内存分配
- 避免分配大小为零的内存空间
- 避免动态内存分配
- 判断资源分配函数的返回值是否有效
- 在 C++ 代码中禁用 C 资源管理函数
- Precompile
- Include
- Macro-definition
- Macro-usage
- Directive
- Comment
- File
- Other
- Global
- Type
- Class
- 类的非常量数据成员均应为 private
- 类的非常量数据成员不应定义为 protected
- 类不应既有 public 数据成员又有 private 数据成员
- 有虚函数的基类应具有虚析构函数
- 避免多重继承自同一非虚基类
- 存在析构函数或拷贝赋值运算符时,不应缺少拷贝构造函数
- 存在拷贝构造函数或析构函数时,不应缺少拷贝赋值运算符
- 存在拷贝构造函数或拷贝赋值运算符时,不应缺少析构函数
- 存在任一拷贝、移动、析构相关的函数时,应定义所有相关函数
- 避免重复实现由默认拷贝、移动、析构函数完成的功能
- 可接受一个参数的构造函数需用 explicit 关键字限定
- 重载的类型转换运算符需用 explicit 关键字限定
- 不应过度使用 explicit 关键字
- 带模板的赋值运算符不应与拷贝或移动赋值运算符混淆
- 带模板的构造函数不应与拷贝或移动构造函数混淆
- 抽象类禁用拷贝和移动赋值运算符
- 数据成员的数量应在规定范围之内
- 数据成员之间的填充数据不应被忽视
- 常量成员函数不应返回数据成员的非常量指针或引用
- 类成员应按 public、protected、private 的顺序声明
- POD 类和非 POD 类应分别使用 struct 和 class 关键字定义
- 继承层次不应过深
- Enum
- Union
- Class
- Declaration
- Naming
- Qualifier
- Specifier
- Declarator
- Parameter
- Initializer
- Object
- Function
- Bitfield
- Complexity
- Old-style
- Other
- Exception
- 保证异常安全
- 处理所有异常
- 不应抛出过于宽泛的异常
- 不应捕获过于宽泛的异常
- 不应抛出非异常类型的对象
- 不应捕获非异常类型的对象
- 全局对象的初始化过程不可抛出异常
- 析构函数不可抛出异常
- 内存回收函数不可抛出异常
- 对象交换过程不可抛出异常
- 移动构造函数和移动赋值运算符不可抛出异常
- 异常类的拷贝构造函数不可抛出异常
- 异常类的构造函数和异常信息相关的函数不应抛出异常
- 与标准库相关的 hash 过程不应抛出异常
- 由 noexcept 标记的函数不可产生未处理的异常
- 避免异常类多重继承自同一非虚基类
- 通过引用捕获异常
- 捕获异常时不应产生对象切片问题
- 捕获异常后不应直接再次抛出异常
- 重新抛出异常时应使用空 throw 表达式(throw;)
- 不应在 catch 子句外使用空 throw 表达式(throw;)
- 不应抛出指针
- 不应抛出 NULL
- 不应抛出 nullptr
- 不应在模块之间传播异常
- 禁用动态异常说明
- 禁用 C++ 异常
- Function
- main 函数的返回类型只应为 int
- main 函数不应被调用、重载或被 inline、static 等关键字限定
- 参数名称在声明处和实现处应保持一致
- 多态类的对象作为参数时不应采用值传递的方式
- 不应存在未被使用的具名形式参数
- 形式参数不应被修改
- 复制成本高的参数不应按值传递
- 转发引用只应作为 std::forward 的参数
- 局部对象在使用前应被初始化
- 成员须在声明处或构造时初始化
- 基类对象构造完毕之前不可调用成员函数
- 在面向构造或析构函数体的 catch 子句中不可访问非静态成员
- 成员初始化应遵循声明的顺序
- 在构造函数中不应使用动态类型
- 在析构函数中不应使用动态类型
- 在析构函数中避免调用 exit 函数
- 拷贝构造函数应避免实现复制之外的功能
- 移动构造函数应避免实现数据移动之外的功能
- 拷贝赋值运算符应处理参数是自身对象时的情况
- 不应存在无效的写入操作
- 不应存在没有副作用的语句
- 不应存在得不到执行机会的代码
- 有返回值的函数其所有分枝都应显式返回
- 不可返回局部对象的地址或引用
- 不可返回临时对象的地址或引用
- 合理设置 lambda 表达式的捕获方式
- 函数返回值不应为右值引用
- 函数返回值不应为常量对象
- 函数返回值不应为基本类型的常量
- 被返回的表达式应与函数的返回类型一致
- 被返回的表达式不应为相同的常量
- 具有 noreturn 属性的函数不应返回
- 具有 noreturn 属性的函数返回类型只应为 void
- 由 atexit、at_quick_exit 指定的处理函数应正常返回
- 函数模板不应被特化
- 函数的退出点数量应在规定范围之内
- 函数的标签数量应在规定范围之内
- 函数的行数应在规定范围之内
- lambda 表达式的行数应在规定范围之内
- 函数参数的数量应在规定范围之内
- 不应定义复杂的内联函数
- 避免函数调用自身
- 不可递归调用析构函数
- 作用域及类型嵌套不应过深
- 汇编代码不应与普通代码混合
- 避免重复的函数实现
- Control
- If
- if 语句不应被分号隔断
- 在 if...else-if 分枝中不应有重复的条件
- 在 if...else-if 分枝中不应有被遮盖的条件
- if 分枝和 else 分枝的代码不应完全相同
- if...else-if 各分枝的代码不应完全相同
- if 分枝和隐含的 else 分枝代码不应完全相同
- 没有 else 子句的 if 语句与其后续代码相同是可疑的
- if 分枝和 else 分枝的起止语句不应相同
- if 语句作用域的范围不应有误
- 如果 if 关键字前面是右大括号,if 关键字应另起一行
- if 语句的条件不应为赋值表达式
- if 语句不应为空
- if...else-if 分枝数量应在规定范围之内
- if 分枝中的语句应该用大括号括起来
- 所有 if...else-if 分枝都应以 else 子句结束
- For
- While
- Do
- Switch
- switch 语句不应被分号隔断
- switch 语句不应为空
- case 标签的值不可超出 switch 条件的范围
- switch 语句中任何子句都应从属于某个 case 或 default 分枝
- case 和 default 标签应直接从属于 switch 语句
- 不应存在紧邻 default 标签的空 case 标签
- 不应存在内容完全相同的 case 分枝
- switch 语句的条件不应为 bool 型
- switch 语句不应只包含 default 标签
- switch 语句不应只包含一个 case 标签
- switch 语句分枝数量应在规定范围之内
- switch 语句应配有 default 分枝
- switch 语句的每个非空分枝都应该用无条件的 break 或 return 语句终止
- switch 语句应该用大括号括起来
- switch 语句不应嵌套
- Try-catch
- Jump
- If
- Expression
- Logic
- Evaluation
- Operator
- Assignment
- Comparison
- Call
- Sizeof
- Assertion
- Complexity
- Other
- Literal
- Cast
- 避免类型转换造成数据丢失
- 避免数据丢失造成类型转换失效
- 避免有符号整型与无符号整型相互转换
- 不应将负数转为无符号数
- 避免与 void* 相互转换
- 避免向下类型转换
- 指针与整数不应相互转换
- 类型转换不应去掉 const、volatile 等属性
- 不应转换无继承关系的指针或引用
- 不应转换无 public 继承关系的指针或引用
- 非 POD 类型的指针与基本类型的指针不应相互转换
- 不同的字符串类型之间不可直接转换
- 避免向对齐要求更严格的指针转换
- 避免转换指向数组的指针
- 避免转换函数指针
- 向下动态类型转换应使用 dynamic_cast
- 判断 dynamic_cast 转换是否成功
- 不应转换 new 表达式的类型
- 不应存在多余的类型转换
- 可用其他方式完成的转换不应使用 reinterpret_cast
- 合理使用 reinterpret_cast
- 在 C++ 代码中禁用 C 风格类型转换
- Buffer
- Pointer
- 避免空指针解引用
- 注意逻辑表达式内的空指针解引用
- 不可解引用未初始化的指针
- 不可解引用已失效的指针
- 避免指针运算的结果溢出
- 未指向同一数组的指针不可相减
- 未指向同一数组或同一对象的指针不可比较大小
- 未指向数组元素的指针不应与整数加减
- 避免无效的空指针检查
- 不应重复检查指针是否为空
- 不应使用非零常量对指针赋值
- 不应使用常量 0 表示空指针
- 在 C++ 代码中 NULL 和 nullptr 不应混用
- 在 C++ 代码中用 nullptr 代替 NULL
- 不应使用 false 对指针赋值
- 不应使用 '\0' 等字符常量对指针赋值
- 指针不应与 false 比较大小
- 指针不应与 '\0' 等字符常量比较大小
- 指针与空指针不应比较大小
- 不应判断 this 指针是否为空
- 禁用 delete this
- 释放指针后应将指针赋值为空或其他有效值
- 函数取地址时应显式使用 & 运算符
- 指针与整数的加减运算应使用数组下标的方式
- Interruption
- Concurrency
- Style
1 Security
敏感数据不可写入代码
代码中的敏感数据极易泄露,产品及相关运维、测试工具的代码均不可记录任何敏感数据。
示例:
/**
* My name is Rabbit
* My passphrase is Y2Fycm90 // Non-compliant
*/
#define PASSWORD "Y2Fycm90" // Non-compliant
const char* passcode = "Y2Fycm90"; // Non-compliant
将密码等敏感数据写入代码是非常不安全的,即使例中 Y2Fycm90 是实际密码的某种变换,聪明的读者也会很快将其破解。
敏感数据的界定是产品设计的重要环节。对具有高可靠性要求的客户端软件,不建议保存任何敏感数据,对于必须保存敏感数据的软件系统,则需要落实安全的存储机制以及相关的评审与测试。
敏感数据不可被系统外界感知
敏感数据出入软件系统时需采用有效的保护措施。
示例:
void foo(User* u) {
log("username: %s, password: %s", u->name, u->pw); // Non-compliant
}
显然,将敏感数据直接输出到界面、日志或其他外界可感知的介质中是不安全的,需避免敏感数据的有意外传,除此之外,还需要落实具体的保护措施。
保护措施包括但不限于:
- 避免用明文或弱加密方式传输敏感数据
- 避免敏感数据从内存交换到外存
- 避免敏感数据写入内存转储文件
- 应具备反调试机制,使外界无法获得程序的内部数据
- 应具备反注入机制,使外界无法篡改程序的行为
下面以 Windows 平台为例,给出阻止敏感数据从内存交换到外存的示例:
class SecretBuf {
size_t len = 0;
unsigned char* buf = nullptr;
public:
SecretBuf(size_t size) {
auto* tmp = (unsigned char*)VirtualAlloc(
0, size, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE
);
if (VirtualLock(tmp, size)) { // The key point
buf = tmp;
len = size;
} else {
VirtualFree(tmp, 0, MEM_RELEASE);
}
}
~SecretBuf() {
SecureZeroMemory(buf, len); // Clear the secret content
VirtualUnlock(buf, len);
VirtualFree(buf, 0, MEM_RELEASE);
len = 0;
buf = nullptr;
}
size_t size() const { return len; }
unsigned char* ptr() { return buf; }
const unsigned char* ptr() const { return buf; }
};
例中 SecretBuf 是一个缓冲区类,其申请的内存会被锁定在物理内存中,不会与外存交换,可在一定程度上防止其他进程的恶意嗅探,保障缓冲区内数据的安全。SecretBuf 在构造函数中通过 VirtualLock 锁定物理内存,在析构函数中通过 VirtualUnlock 解除锁定,解锁之前有必要清除数据,否则解锁之后残留数据仍有可能被交换到外存,进一步可参见 ID_unsafeCleanup。
SecretBuf 的使用方法如下:
void foo() {
SecretBuf buf(256);
if (buf.ptr()) {
.... // Do something secret using buf.ptr()
} else {
.... // Handle memory error
}
}
在 Linux 等系统中可参见如下有相似功能的接口:
int mlock(const void* addr, size_t len); // In <sys/mman.h>
int munlock(const void* addr, size_t len);
int mlockall(int flags);
int munlockall(void);
敏感数据在使用后应被有效清理
及时清理不再使用的敏感数据是重要的安全措施,且应保证清理过程不会因为编译器的优化而失效。
程序会反复利用内存,敏感数据可能会残留在未初始化的对象或对象之间的填充数据中,如果被存储到磁盘或传输到网络就会造成敏感信息的泄露,可参见 ID_secretLeak 和 ID_ignorePaddingData 的进一步讨论。
示例:
void foo() {
char password[8] = {};
....
memset(password, 0, sizeof(password)); // Non-compliant
}
示例代码调用 memset 覆盖敏感数据以达到清理目的,然而保存敏感信息的 password 为局部数组且 memset 之后没有再被引用,根据相关标准,编译器可将 memset 过程去掉,使敏感数据没有得到有效清理。C11 提供了 memset_s 函数以避免这种问题,某些平台和库也提供了相关支持,如 SecureZeroMemory、explicit_bzero、OPENSSL_cleanse 等不会被优化掉的函数。
在 C++ 代码中,可用 volatile 限定相关数据以避免编译器的优化,再用 std::fill_n 等方法清理,如:
void foo() {
char password[8] = {};
....
volatile char v_padding = 0;
volatile char* v_address = password;
std::fill_n(v_address, sizeof(password), v_padding); // Compliant
}
公共成员或全局对象不应记录敏感数据
公共成员、全局对象可被外部代码引用,如果存有敏感数据则可能会被误用或窃取。
示例:
extern string password; // Non-compliant
struct A {
string username;
string password; // Non-compliant
};
至少应将相关成员改为 private:
class A {
public:
.... // Interfaces for accessing passwords safely
private:
string username;
string password; // Compliant
};
敏感数据最好对引用者完全隐藏,避免被恶意分析、复制或序列化。使数据与接口进一步分离,可参见“Pimpl idiom”等模式。
与内存空间布局相关的信息不可被外界感知
函数、对象、缓冲区的地址以及相关内存区域的长度等信息不可被外界感知,否则会成为攻击者的线索。
示例:
int foo(int* p, int n) {
if (n >= some_value) {
log("buffer address: %p, size: %d", p, n); // Non-compliant
}
}
示例代码将缓冲区的地址和长度输出到日志是不安全的,这种代码多以调试为目的,不应将其编译到产品的正式版本中。
与网络地址相关的信息不应写入代码
在代码中记录网络地址不利于维护和移植,也容易暴露产品的网络结构,属于安全隐患。
示例:
string host = "10.16.25.93"; // Non-compliant
foo("172.16.10.36:8080"); // Non-compliant
bar("https://192.168.73.90"); // Non-compliant
应从配置文件中获取地址,并配以加密措施:
MyConf cfg;
string host = cfg.host(); // Compliant
foo(cfg.port()); // Compliant
bar(cfg.url()); // Compliant
特殊的 IP 地址可不受本规则限制,如:
0.0.0.0
255.255.255.255
127.0.0.1-127.255.255.255
预判用户输入造成的不良后果
须对用户输入的脚本、路径、资源请求等信息进行预判,对产生不良后果的输入予以拒绝。
示例:
Result foo() {
return sqlQuery(
"select * from db where key='%s'", userInput() // Non-compliant
);
}
设 userInput 返回用户输入的字符串,sqlQuery 将用户输入替换格式化占位符后执行 SQL 语句,如果用户输入“xxx' or 'x'='x”一类的字符串则相当于执行的是“select * from db where key='xxx' or 'x'='x'”,一个恒为真的条件使 where 限制失效,造成所有数据被返回,这是一种常见的攻击方式,称为“SQL 注入(SQL injection)”,对于 XPath、XQuery、LDAP 等脚本均需考虑这种问题,应在执行前判断用户输入的安全性。
又如:
string bar() {
return readFile(
"/myhome/mydata/" + userInput() // Non-compliant
);
}
这段代码意在将用户输入的路径限制在 /myhome/mydata 目录下,然而这么做是不安全的,如果用户输入带有“../”这种相对路径,则仍可绕过限制,这也是一种常见的攻击方式,称为“路径遍历(directory traversal)”,应在读取文件之前判断路径的安全性。
注意,“用户输入”不单指人的手工输入,源自环境变量、配置文件以及其他软硬件的输入均在此范围内。
对资源设定合理的访问权限
对资源设定合理的访问权限,避免为攻击者提供不应拥有的权限或能力。
权限的分类包括但不限于:
- 文件、数据库等资源的读写权限
- 计算、IO 过程的执行权限
- 软硬件资源的占用权限
权限设定是产品设计与实现的重要环节,需落实相关的评审与测试。
示例:
#include <stdio.h>
int main() {
umask(000); // Non-compliant
FILE* fp = fopen("bar", "w"); // Old method
....
fclose(fp);
}
例中 umask 函数开放了所有用户对文件的读写权限,这是很不安全的,进程之间不应直接通过文件通信,应实现安全的接口和交互机制。
由于历史原因,C 语言的 fopen 和 C++ 语言的 fstream 都不能确保文件只能被当前用户访问,C11 提供了 fopen_s,C++17 提供了 std::filesystem::permissions 以填补这方面的需求。
C11 fopen_s 简例:
#define __STDC_WANT_LIB_EXT1__ 1
#include <stdio.h>
int main() {
FILE* fp = NULL;
errno_t e = fopen_s(&fp, "bar", "w"); // Good
....
fclose(fp);
}
与 fopen 不同,fopen_s 可以不受 umask 等函数的影响,直接将文件的权限设为当前用户私有,其他用户不可访问,降低了文件被窃取或篡改的风险,是一种更安全的方法。
除此之外,如果需要对资源进行更精细的权限管理,可参见“access control list(ACL)”。
对用户落实有效的权限管理
需落实有效的权限管理,相关措施包括但不限于:
- 落实授权与认证机制,提供多因素认证
- 遵循最小特权原则,对资源和相关算法设置合理的访问或执行权限
- 避免仅在客户端认证而非服务端认证
- 检查请求是否符合用户的权限设定,拒绝无权限的请求
- 用户放弃某项权限后,应确保相关权限不再生效
- 遵循合理的“认证 - 执行”顺序,避免复杂度攻击或早期放大攻击
- 保证信道完整性,对相关用户进行充分的身份认证,避免中间人攻击
- 验证通信通道的源和目的地,拒绝非预期的请求和应答
- 避免攻击者使用重放攻击等手段绕过身份认证或干扰正常运营
- 避免不恰当地信任反向 DNS(关注 DNS Cache Poisoning)
- 避免过于严格且易触发的账户锁定机制,使攻击者通过锁定账户干扰正常运营
权限管理与安全直接相关,应落实严格的评审、测试以及攻防演练。
示例:
Result foo() {
auto req = getRequest();
auto res = sqlQuery(
"select * from db where key='%s'", req["key"] // Non-compliant
);
return res;
}
设例中 req 对应用户请求,sqlQuery 将请求中的 key 字段替换格式化占位符后执行查询,这个模式存在多种问题,应先判断用户是否具有读取数据库相关字段的权限,而且还应判断 req["key"] 的值是否安全,详见 ID_hijack。
又如:
void bar(User* user) {
auto buf = read_large_file();
if (is_admin(user)) { // Non-compliant
do_something(buf);
}
}
设例中 read_large_file 读取大型文件,is_admin 进行身份认证,在身份认证之前访问资源使得攻击者不必获取有效账号即可消耗系统资源,从而对系统造成干扰,所以应该在访问资源之前进行身份认证。
避免引用危险符号名称
弱加密、弱哈希、弱随机、不安全的协议等相关库、函数、类、宏、常量等名称不应出现在代码中。
这种危险符号名称主要来自:
- 低质量随机数生成算法,如 srand、rand 等
- 不再适用的哈希算法,如 MD2、MD4、MD5、MD6、RIPEMD 以及 SHA-1 等
- 非加密协议,如 HTTP、FTP 等
- 低版本的传输层安全协议,如 TLSv1.2 之前的版本
- 弱加密算法,如 DES、3DES 等
示例:
#include <openssl/md5.h> // Non-compliant, obsolete hash algorithm
const string myUrl = "http://foo/bar"; // Non-compliant, use https instead
void foo() {
MD5_CTX c; // Non-compliant
MD5_Init(&c); // Non-compliant, obsolete hash algorithm
....
}
void bar() {
srand(0); // Non-compliant, unsafe random seed
EVP_des_ecb(); // Non-compliant, unsafe encryption algorithm
....
}
避免使用危险接口
由于历史原因,有些系统接口甚至标准库函数存在缺陷,无法安全使用,也有一些接口的使用条件很苛刻,难以安全使用。
示例:
gets // The most dangerous function
mktemp // Every use of ‘mktemp’ is a security risk, use ‘mkstemp’ instead
getpass // Unsafe and not portable
crypt // Unsafe, exhaustive searches of the key space are possible
getpw // It may overflow the provided buffer, use ‘getpwuid’ instead
cuserid // Not portable and unreliable, use ‘getpwuid(geteuid())’ instead
chgrp // Prone to TOCTOU race conditions, use ‘fchgrp’ instead
chown // Prone to TOCTOU race conditions, use ‘fchown’ instead
chmod // Prone to TOCTOU race conditions, use ‘fchmod’ instead
SuspendThread // Forced suspension of a thread can cause many problems
TerminateThread // Forced termination of a thread can cause many problems
GlobalMemoryStatus // Return incorrect information, use ‘GlobalMemoryStatusEx’ instead
SetProcessWorkingSetSize // Cause adverse effects on other processes and the entire system
例中 gets 函数不检查缓冲区边界,无法安全使用;TerminateThread 等 Windows API 强制终止线程,线程持有的资源难以正确释放,极易导致泄漏或死锁等问题,应避免使用这类函数。
避免使用已过时的接口
避免使用在相关标准中已过时的接口,应改用更完善的替代方法以规避风险,提高可移植性。
对于过时的 C++ 标准库接口,本规则特化为 ID_obsoleteStdFunction。
示例:
asctime // Use ‘strftime’ instead
bcmp // Use ‘memcmp’ instead
bcopy // Use ‘memmove’ or ‘memcpy’ instead
bsd_signal // Use ‘sigaction’ instead
bzero // Use ‘memset’ instead
ctime // Use ‘strftime’ instead
gethostbyaddr // Use ‘getnameinfo’ instead
gethostbyname // Use ‘getaddrinfo’ instead
getwd // Use ‘getcwd’ instead
mktemp // Use ‘mkstemp’ instead
usleep // Use ‘nanosleep’ instead
utime // Use ‘utimensat’ instead
vfork // Use ‘fork’ instead
wcswcs // Use ‘wcsstr’ instead
pthread_attr_getstackaddr // Use ‘pthread_attr_getstack’ instead
pthread_attr_setstackaddr // Use ‘pthread_attr_setstack’ instead
CreateToolbarEx // Use ‘CreateWindowEx’ instead
InitCommonControls // Use ‘InitCommonControlsEx’ instead
NtQuerySystemTime // Use ‘GetSystemTimeAsFileTime’ instead
RegCreateKey // Use ‘RegCreateKeyEx’ instead
WinExec // Use ‘CreateProcess’ instead
例中 C89 引入的 ctime、asctime 等函数在 POSIX.1-2008 标准中已过时,应改用 strftime 函数;RegCreateKey 等 16 位 Windows API 在 32 和 64 位平台中不应再被使用。
避免除 0 等计算异常
除 0 等在数学上没有定义的运算、浮点异常、非法指令、段错误等问题称为“计算异常”,意味着严重的底层运行时错误,而且这种异常无法用语言层面的常规方法捕获。
示例:
int foo(int n) {
if (n) {
return 100 / n; // Compliant
}
return 200 / n; // Non-compliant, undefined behavior
}
整数除 0 往往会使程序崩溃,浮点数除 0 可以产生“Inf”或“NaN”等无效结果,在某些环境中也可以设置浮点异常使程序收到特定信号。
崩溃会使程序异常终止,无法或难以执行必要的善后工作。如果崩溃可由外部输入引起,会被攻击者利用从而迫使程序无法正常工作,具有高可靠性要求的服务类程序更应该注意这一点,可参见“拒绝服务攻击”。对于客户端程序,也要防止攻击者对崩溃产生的“core dump”进行恶意调试,避免泄露敏感数据,总之程序的健壮性与安全性是紧密相关的。
选择安全的异常处理方式
避免使用 errno 和与其相同的模式,应根据实际需求选择通过函数返回值或 C++ 异常机制来处理异常情况。
errno 被设定的位置和被读取的位置相距较远,不遵循固定的静态结构,极易误用,是不安全的异常处理方式,对异常情况的错误处理往往会成为业务漏洞,使攻击者轻易地实现其目的。
示例:
void foo() {
if (somecall() == FAILED) {
printf("somecall failed\n");
if (errno == SOME_VALUE) { // Non-compliant
....
}
}
}
例中 somecall 执行异常,通过 errno 获取异常信息,但 errno 的值会被 printf 修改,相应的异常处理也失去了意义。
又如:
void bar(const char* s) {
int i = atoi(s);
if (errno) { // Non-compliant
....
}
}
errno 并不能反映所有异常情况,atoi 等函数与 errno 无关,例中 errno 的值来自函数外部难以预料的位置,相应的异常处理也将是错误的。
不应产生或依赖未定义的行为
未定义的行为(undefined behavior)是指程序在语言标准中没有定义的行为,一般由错误的代码实现引起,可能是崩溃,也可能没有实质危害,这种行为的结果是不可预期的,不应使程序产生或依赖未定义的行为。
对未定义行为的介绍和约束是本规则集合的重要内容,将在后续章节中深入讨论,在附录中也有详细介绍。
示例:
int foo(int i) {
if (i + 1 <= i) // Determine overflow, but it’s undefined
.... // Handle overflow, may be invalid
else
return i + 1;
}
示例代码用 i + 1 <= i 判断是否溢出,但有符号整数溢出的后果是未定义的,这种判断可能是无效的,甚至某些编译器会认为 i + 1 <= i 恒为假而免去 if 分枝的内容,直接返回 i + 1。
应改为:
int foo(int i) {
if (i != INT_MAX) // Well defined
return i + 1;
else
.... // Handle overflow
}
不应依赖未声明的行为
语言标准允许程序的某些行为可由编译器自行定义,且无需提供文档说明,这种行为称为未声明的行为(unspecified behavior),具有不确定性,也会导致可移植性问题,故不应使程序依赖未声明的行为。
对未声明行为的介绍和约束是本规则集合的重要内容,将在后续章节中深入讨论。
示例:
const char* p = "ABC";
const char* q = "ABC";
assert(p == q); // Unspecified behavior
相同字符串常量的地址是否相同是未声明的,例中的断言可能会失效,而且要注意,未声明的行为即使在同一编译器的不同版本之间也可能会有差异。
避免依赖由实现定义的行为
语言标准允许程序的某些行为可由编译器自行定义,这种行为称为由实现定义的行为(implementation-defined behavior),虽然有文档支持,但也会增加移植或兼容等方面的成本。
示例:
- cstdlib、stdlib.h 中的 abort、exit、_Exit、quick_exit、getenv、system 等函数
- ctime、time.h 中的 clock 等函数
- csignal、signal.h 中的 signal 等函数
这些函数的行为取决于编译器、库或环境的生产厂家,同一个函数不同的厂家会有不同的实现,故称这种函数的行为是“由实现定义”的。有高可靠性要求的软件系统应避免使用这种函数,否则需明确各种实现上的具体差异,增加了移植、发布以及兼容性等多方面的成本。
#include <cstdlib>
void foo() {
abort(); // Non-compliant
}
调用 abort 函数会终止进程,但打开的流是否会被关闭,缓冲区内的数据是否会写入文件,临时文件是否会被清理则由实现定义。
保证组件的可靠性
导入库、配置、数据等组件时应判断其可靠性,对不受信任的组件予以拒绝。
示例:
利用数字签名判断 DLL 等动态库的可靠性,代码可参考“WinVerifyTrust”等 API 的使用。
保证第三方软件的可靠性
应检查引入的第三方代码是否可靠,避免由第三方软件引入安全风险。
示例:
利用“软件组成分析(SCA)”工具检查第三方库的安全性。
隔离非正式功能的代码
非正式功能的代码,如用于调试、测试的代码或历史遗留代码,在产品的发布版本中不应生效,否则会导致泄露信息或打破正常流程等非预期的结果。
示例:
User u = get_user_input();
if (authenticate(u) || u.name() == "debug") // Non-compliant, back door
{
.... // Login successful
}
示例代码进行了用户身份验证,但直接放过 debug 这种特殊用户名是不符合要求的。
启用平台和编译器提供的防御机制
针对一些常见攻击,平台和编译器会提供防御机制,如:
程序应利用这种机制加强自身的安全性,进一步可参见“security hardening”。
示例:
// In test.c
#include <stdio.h>
int main(void) {
printf("%p\n", main);
}
如果在 Linux 等平台上按如下方式编译:
cc test.c -o test
各函数的地址在虚拟内存中是固定的,易被攻击者猜中,进而施展攻击手段。
当平台启用了“ASLR”机制,再按如下方式编译:
cc test.c -o test -fPIE -pie
可使程序各结构的地址随机化,函数的地址在每次运行时均不相同,有效提高了攻击难度。
如无特殊原因,在编译程序时不应屏蔽这种防御机制,如:
cc test.c -o test -z execstack # Non-compliant, disable NX
cc test.c -o test -z norelro # Non-compliant, disable RELRO
cc test.c -o test -fno-stack-protector # Non-compliant, disable CANARY
如果必须屏蔽,应落实相关的评审与测试。
禁用不安全的编译选项
掩盖错误、不符合标准、屏蔽安全措施等不安全的编译选项应被禁用。
示例:
c++ test.cpp -o test -fpermissive -w # Non-compliant
c++ test.cpp -o test -fno-access-control # Non-compliant
c++ test.cpp -o test -ffast-math # Non-compliant
例中选项 -fpermissive 会使一些编译错误降为警告,-w 会隐藏警告,-fno-access-control 会打破语言规则,使类成员不再受 private、protected 等关键字限制,-ffast-math 虽然会提高程序的运算效率,但不再遵守相关 IEEE 或 ISO 标准,这种编译选项均不应使用。
2 Resource
不可失去对已分配资源的控制
对于动态分配的资源,其地址、句柄或描述符等标志性信息不可被遗失,否则资源无法被访问也无法被回收,这种问题称为“资源泄漏”,会导致资源耗尽或死锁等问题,使程序无法正常运行。
在资源被回收之前,记录其标志性信息的变量如果:
- 均被重新赋值
- 生命周期均已结束
- 所在线程均被终止
相关资源便失去了控制,无法再通过正常手段访问相关资源。
示例:
int fd;
fd = open("a", O_RDONLY); // Open a file descriptor
read(fd, buf1, 100);
fd = open("b", O_RDONLY); // Non-compliant, the previous descriptor is lost
read(fd, buf2, 100);
例中变量 fd 记录文件资源描述符,在回收资源之前对其重新赋值会导致资源泄漏。
不可失去对已分配内存的控制
动态分配的内存地址不可被遗失,否则相关内存无法被访问也无法被回收,这种问题称为“内存泄漏(memory leak)”,会导致可用内存被耗尽,使程序无法正常运行。
程序需要保证内存分配与回收之间的流程可达,且不可被异常中断,相关线程也不可在中途停止。
本规则是 ID_resourceLeak 的特化。
示例:
void foo(size_t n) {
void* p = malloc(n);
if (cond) {
return; // Non-compliant, ‘p’ is lost
}
....
free(p);
}
例中局部变量 p 记录已分配的内存地址,释放前在某种情况下函数返回,之后便再也无法访问到这块内存了,导致内存泄露。
又如:
void bar(size_t n) {
void* p = malloc(n);
if (n < 100) {
p = realloc(p, 100); // Non-compliant, ‘p’ may be lost
}
....
}
当 realloc 函数分配失败时会返回空指针,p 指向的原内存空间不会被释放,但 p 被赋值为空,导致内存泄露,这是一种常见错误。
不可访问未初始化或已释放的资源
访问未初始化或已释放的资源属于逻辑错误,会导致标准未定义的行为。
对于访问未初始化的局部对象,本规则特化为 ID_localInitialization;对于解引用未初始化或已被释放的指针,本规则特化为 ID_wildPtrDeref 、ID_danglingDeref。
示例:
void foo(const char* path, char buf[], size_t n) {
FILE* f;
if (path != NULL) {
f = fopen(path, "rb");
}
fread(buf, 1, n, f); // Non-compliant, ‘f’ may be invalid
fclose(f);
}
void bar(FILE* f, char buf[], size_t n) {
if (feof(f)) {
fclose(f);
}
fread(buf, 1, n, f); // Non-compliant, ‘f’ may be closed
}
使资源接受对象化管理
将资源托管于类的对象,使资源的生命周期协同于对象的生命周期,避免分散处理分配与回收等问题,是 C++ 程序设计中的重要方法。
动态申请的资源如果只能通过普通指针或变量访问,不受对象的构造和析构等机制控制,则称为“无主”资源,极易产生泄漏或死锁等问题。应尽量使用标准库提供的容器、智能指针以及资源对应的类,避免直接使用 new、delete 以及底层资源管理接口。
示例:
int* p = new int[8]; // Non-compliant, ownerless
.... // If any exception is thrown,
// or any wrong jump occurs, the memory leaks
struct X { int* p; };
X x;
x.p = new int[8]; // Non-compliant, no destructor, ‘x’ is not an owner
....
delete[] p; // Non-compliant, explicit delete
delete[] x.p; // Non-compliant
例中用不受析构函数控制的指针保存 new 表达式的结果,以及对应的 delete 表达式均不符合要求。
应将资源托管于类的对象:
class Mgr {
int* p;
public:
Mgr(size_t n): p(new int[n]) {}
~Mgr() { delete[] p; }
};
Mgr m(8); // Compliant, ‘m’ is the owner of the resource
例中 m 对象负责资源的分配与回收,称 m 对象拥有资源的所有权,相关资源的生命周期与对象的生命周期一致,有效避免了资源泄漏或错误回收等问题。针对成员的 new、delete 可不受本规则限制,但应优先使用容器或智能指针。
资源的所有权可以发生转移,但应保证转移前后均有对象负责管理资源,并且在转移过程中不会产生异常。进一步理解对象化管理方法,可参见“RAII(Resource Acquisition Is Initialization)”等机制。
另外,底层资源管理接口也不应直接在业务代码中使用,如:
void foo(const TCHAR* path) {
WIN32_FIND_DATA ffd;
HANDLE h = FindFirstFile(path, &ffd); // Non-compliant, ownerless
....
CloseHandle(h); // Is it right?
}
例中 FindFirstFile 是 Windows API,返回的资源句柄对应“无主”资源,需要显式回收。
应对其合理封装:
class MY_FIND_DATA
{
struct HANDLE_DELETER
{
using pointer = HANDLE;
void operator()(pointer p) { FindClose(p); }
};
WIN32_FIND_DATA ffd;
unique_ptr<HANDLE, HANDLE_DELETER> uptr;
public:
MY_FIND_DATA(const TCHAR* path): uptr(FindFirstFile(path, &ffd)) {}
....
HANDLE handle() { return uptr.get(); }
};
将 FindFirstFile 及其相关数据封装成一个类,由 unique_ptr 对象保存 FindFirstFile 的结果,FindClose 是资源的回收方法,将其作为 unique_ptr 对象的组成部分,使资源可以被自动回收。
资源的分配与回收方法应成对提供
资源的分配和回收方法应在同一库或主程序等可执行模块、类等逻辑模块中提供。
如果一个模块分配的资源需要另一个模块回收,会打破模块之间的独立性,增加维护成本,而且 so、dll、exe 等可执行模块一般都有独立的堆栈,跨模块的分配与回收往往会造成严重错误。
示例:
// In a.dll
int* foo() {
return (int*)malloc(1024);
}
// In b.dll
void bar() {
int* p = foo();
....
free(p); // Non-compliant, crash
}
例中 a.dll 分配的内存由 b.dll 释放,相当于混淆了不同堆栈中的数据,程序一般会崩溃。
应改为:
// In a.dll
int* foo_alloc() {
return (int*)malloc(1024);
}
void foo_dealloc(int* p) {
free(p);
}
// In b.dll
void bar() {
int* p = foo_alloc();
....
foo_dealloc(p); // Compliant
}
修正后 a.dll 成对提供分配回收函数,b.dll 配套使用这些函数,避免了冲突。
类等逻辑模块提供了分配方法,也应提供回收方法,如重载了 new 运算符,也应重载相应的 delete 运算符:
class A {
void* operator new(size_t); // Non-compliant, missing ‘operator delete’
};
class B {
void operator delete(void*); // Non-compliant, missing ‘operator new’
};
class C {
void* operator new(size_t); // Compliant
void operator delete(void*); // Compliant
};
资源的分配与回收方法应配套使用
使用了某种分配方法,就应使用与其配套的回收方法,否则会引发严重错误。
示例:
void foo() {
T* p = new T;
....
free(p); // Non-compliant, use ‘delete’ instead
}
void bar(size_t n) {
char* p = (char*)malloc(n);
....
delete[] p; // Non-compliant, use ‘free’ instead
}
不同的分配回收方法属于不同的资源管理体系,用 new 分配的资源应使用 delete 回收,malloc 分配的应使用 free 回收。
不应在模块之间传递容器类对象
在库或主程序等可执行模块之间传递容器类对象会造成分配回收方面的冲突。
与资源管理相关的对象,如流、字符串、智能指针以及自定义对象均不应在模块间传递。
不同的可执行模块往往具有独立的资源管理机制,跨模块的分配与回收会造成严重错误,而且不同的模块可能由不同的编译器生成,对同一对象的实现也可能存在冲突。
示例:
// In a.dll
void foo(vector<int>& v) {
v.reserve(100);
}
// In b.exe
int main() {
vector<int> v { // Allocation in b.exe
1, 2, 3
};
foo(v); // Non-compliant, reallocation in a.dll, crash
}
例中容器 v 的初始内存由 b.exe 分配,b.exe 与 a.dll 具有独立的堆栈,由于模板库的内联实现,reserve 函数会调用 a.dll 的内存管理函数重新分配 b.exe 中的内存,造成严重错误。
不应在模块之间传递非标准布局类型的对象
非标准布局类型的运行时特性依赖编译器的具体实现,在不同编译器生成的模块间传递这种类型的对象会导致运行时错误。
“标准布局(standard-layout)”类型的主要特点:
- 没有虚函数也没有虚基类
- 所有非静态数据成员均具有相同的访问权限
- 所有非静态数据成员和位域都在同一个类中声明
- 不存在相同类型的基类对象
- 没有非标准布局的基类
- 没有非标准布局和引用类型的非静态数据成员
除非模块均由同一编译器的同一版本生成,否则不具备上述特点的对象不应在模块之间传递。
示例:
// a.dll
class A {
....
public:
virtual void foo(); // Non standard-layout
};
void bar(A&);
// b.exe
int main() {
A a;
bar(a); // Non-compliant
}
设例中 a.dll 和 b.exe 由不同的编译器生成,b.exe 中定义的 a 对象被传递给了 a.dll 中定义的接口,由于存在虚函数,不同的编译器对 a 对象的内存布局会有不同的解读,从而造成冲突。
对象申请的资源应在析构函数中释放
对象在析构函数中释放自己申请的资源是 C++ 程序设计的重要原则,不可被遗忘,也不应要求用户释放。
示例:
class A {
int* p = nullptr;
public:
A(size_t n): p(new int[n]) {
}
~A() { // Non-compliant, must delete[] p
}
};
例中成员 p 与内存分配有关,但析构函数为空,不符合本规则要求。
对象被移动后应重置状态再使用
对象被移动后在逻辑上不再有效,如果没有通过清空数据或重新初始化等方法更新对象的状态,不应再使用该对象。
示例:
#include <vector>
using V = std::vector<int>;
void foo(V& a, V& b)
{
a = std::move(b); // After moving, the state of ‘b’ is unspecified
b.push_back(0); // Non-compliant
}
例中容器 b 的数据被移动到容器 a,可能是通过交换的方法实现的,也可能是通过其他方法实现的,标准容器被移动后的状态在 C++ 标准中是未声明的,程序不应依赖未声明的状态。
应改为:
void foo(V& a, V& b)
{
a = std::move(b);
b.clear(); // Clear
b.push_back(0); // Compliant
}
构造函数抛出异常需避免相关资源泄漏
构造函数抛出异常表示对象构造失败,不会再执行相关析构函数,需要保证已分配的资源被有效回收。
示例:
class A {
int *a, *b;
public:
A(size_t n):
a(new int[n]),
b(new int[n]) // The allocations may fail
{
if (sth_wrong) {
throw E(); // User exceptions
}
}
~A() { // May be invalid
delete[] a;
delete[] b;
}
};
例中内存分配可能会失败,抛出 bad_alloc 异常,在某种条件下还会抛出自定义的异常,任何一种异常被抛出析构函数就不会被执行,已分配的资源就无法被回收,但已构造完毕的对象还是会正常析构的,所以应采用对象化资源管理方法,使资源可以被自动回收。
可改为:
A::A(size_t n) {
// Use objects to hold resources
auto holder_a = make_unique<int[]>(n);
auto holder_b = make_unique<int[]>(n);
// Do the tasks that may throw exceptions
if (sth_wrong) {
throw E();
}
// Transfer ownership, make sure no exception is thrown
a = holder_a.release();
b = holder_b.release();
}
先用 unique_ptr 对象持有资源,完成可能抛出异常的事务之后,再将资源转移给相关成员,转移的过程不可抛出异常,这种模式可以保证异常安全,如果有异常抛出,资源均可被正常回收。对遵循 C++11 及之后标准的代码,建议用 make_unique 函数代替 new 运算符。
示例代码意在讨论一种通用模式,实际代码可采用更直接的方式:
class A {
vector<int> a, b; // Or use ‘unique_ptr’
public:
A(size_t n): a(n), b(n) { // Safe and brief
....
}
};
保证已分配的资源时刻有对象负责回收是重要的设计原则,可参见 ID_ownerlessResource 的进一步讨论。
注意,“未成功初始化的对象”在 C++ 语言中是不存在的,应避免相关逻辑错误,如:
struct T {
A() { throw CtorException(); }
};
void foo() {
T* p = nullptr;
try {
p = new T;
}
catch (CtorException&) {
delete p; // Logic error, ‘p’ is nullptr
return;
}
....
delete p;
}
例中 T 类型的对象在构造时抛出异常,而实际上 p 并不会指向一个未能成功初始化的对象,赋值被异常中断,catch 中的 p 仍然是一个空指针,new 表达式中抛出异常会自动回收已分配的内存。
不可重复释放资源
重复释放资源会导致标准未定义的行为。
由于多种原因,资源管理系统难以甚至无法预先判断资源是否已被回收,一旦重复释放资源,可能会直接破坏资源管理系统的数据结构,导致不可预期的错误。
示例:
void foo(const char* path) {
FILE* p = fopen(path, "r");
if (p) {
....
fclose(p);
}
fclose(p); // Non-compliant, closed twice, undefined behavior
}
用 delete 释放对象需保证其类型完整
如果用 delete 释放“不完整类型(incomplete type)”的对象,且对象的完整类型具有 non-trivial 析构函数,会导致标准未定义的行为。
示例:
struct T; // Forward declaration, the type is incomplete
void foo(T* p) {
delete p; // Non-compliant, undefined behavior
}
struct T {
~T(); // Non-trivial destructor
};
例中指针 p 被释放时,其类型是不完整的,如果指针的完整类型以及相关基类或非静态成员具有显式定义的非默认析构函数,即 non-trivial 析构函数,会导致未定义的行为,相关析构函数可能不会正确执行。
应保证指针的类型在释放前具有完整声明:
struct T {
~T();
};
void foo(T* p) {
delete p; // Compliant
}
用 delete 释放对象不可多写中括号
用 new 分配的对象应该用 delete 释放,不可用 delete[] 释放,否则导致标准未定义的行为。
示例:
auto* p = new X; // One object
....
delete[] p; // Non-compliant, use ‘delete p;’ instead
用 delete 释放数组不可漏写中括号
用 new[] 分配的数组应该用 delete[] 释放,不可漏写中括号,否则导致标准未定义的行为。
示例:
void foo(int n) {
auto* p = new X[n]; // n default-constructed X objects
....
delete p; // Non-compliant, use ‘delete[] p;’ instead
}
在某些环境中,可能只有第一个对象的析构函数被执行,其他对象的析构函数都没有被执行,如果对象与资源分配有关就会导致资源泄漏。
不可释放非动态分配的内存
释放非动态分配的内存会导致标准未定义的行为。
本规则是 ID_incompatibleDealloc 的特化。
示例:
void bar() {
int i;
....
free(&i); // Non-compliant, undefined behavior
}
在栈上分配的内存空间不需要显式回收,否则会导致严重的运行时错误。
在一个表达式语句中最多使用一次 new
由于子表达式的求值顺序存在很多未声明的情况,在表达式中多次显式分配资源易造成资源泄露。
示例:
fun(
shared_ptr<T>(new T),
shared_ptr<T>(new T) // Non-compliant, potential memory leak
);
例中 fun 函数的两个参数均包含 new 表达式,而参数的求值顺序在标准中是未声明的,出于优化等目的,可能会先为两个 T 类对象分配内存,之后再分别执行对象的构造函数,如果某个构造函数抛出异常,已分配的内存就无法回收了。
从 C++17 开始,参数的求值过程不再有所重叠,示例代码的问题在 C++17 后会有所缓解,但为了更广泛的适用性和兼容性,应避免在表达式中多次显式分配资源。
应改为:
shared_ptr<T> a{new T}; // Compliant
shared_ptr<T> b{new T}; // Compliant
fun(a, b);
这样即使构造函数抛出异常也会自动回收已分配的内存。
更好的方法是避免显式资源分配,用 make_shared、make_unique 等函数代替 new 运算符:
fun(
make_shared<T>(),
make_shared<T>() // Compliant, safe and brief
);
流式资源对象不应被复制
FILE 等流式对象不应被复制,如果存在多个副本会造成数据不一致的问题。
示例:
FILE f;
FILE* fp = fopen(path, "r");
f = *fp; // Non-compliant
memcpy(fp, &f, sizeof(*fp)); // Non-compliant
避免使用变长数组
使用变长数组(variable length array)可以在栈上动态分配内存,但分配失败时难以通过标准方法控制程序的行为。
变长数组由 C99 标准提出,不在 C++ 标准之内,在 C++ 代码中不应使用。
示例:
void foo(int n)
{
int a[n]; // Non-compliant, a variable length array
// Undefined behavior if n <= 0
....
}
例中数组 a 的长度为变量,其内存空间在运行时动态分配,如果 n 不是合理的正整数会导致未定义的行为。
另外,对于本应兼容的数组类型,如果长度不同也会导致未定义的行为,如:
void bar(int n)
{
int a[5];
typedef int t[n]; // Non-compliant, a variable length array type
t* p = &a; // Undefined behavior if n != 5
....
}
避免使用在栈上动态分配内存的函数
alloca、strdupa 等函数可以在栈上动态分配内存,但分配失败时难以通过标准方法控制程序的行为。
在栈上动态分配内存的函数可能效率更高,分配的内存也不用显式回收,但无法满足分配需求时会直接导致运行时错误,对其返回值的检查是无效的。应避免使用这种后果难以控制的函数,尤其在循环和递归调用过程中更不应使用这种函数,而且这种函数不是标准函数,依赖平台和编译器的具体实现。
示例:
#include <alloca.h> // Or use malloc.h in MSVC
void fun(size_t n) {
int* p = (int*)alloca(n * sizeof(int)); // Non-compliant
if (!p) {
return; // Invalid
}
....
}
例中 alloca 函数在栈上分配内存,如果 n 过大会使程序崩溃。
局部数组不应过大
局部数组在栈上分配空间,如果占用空间过大会导致栈溢出错误。
应关注具有较大数组的函数,评估其在运行时的最大资源消耗是否符合执行环境的要求。
示例:
void foo() {
int arr[1024][1024][1024]; // Non-compliant, too large
....
}
在栈上分配空间难以控制失败情况,如果条件允许可改在堆上分配:
void foo() {
int* arr = (int*)malloc(1024 * 1024 * 1024 * sizeof(int)); // Compliant
if (arr) {
.... // Normal procedure
} else {
.... // Handle allocation failures
}
}
避免不必要的内存分配
对单独的基本变量或只包含少量基本变量的对象不应使用动态内存分配。
示例:
bool* pb = new bool; // Non-compliant
char* pc = new char; // Non-compliant
内存分配的开销远大于变量的直接使用,而且还涉及到回收问题,是得不偿失的。
应改为:
bool b = false; // Compliant
char c = 0; // Compliant
用 new[] 分配数组时方括号被误写成小括号,或使用 unique_ptr 等智能指针时遗漏了数组括号也是常见笔误,如:
int* pi = new int(32); // Non-compliant
auto ui = make_unique<int>(32); // Non-compliant
应改为:
int* pi = new int[32]; // Compliant
auto ui = make_unique<int[]>(32); // Compliant
有时可能需要区分变量是否存在,用空指针表示不存在,并通过资源分配创建变量的方式属于低效实现,不妨改用变量的特殊值表示变量的状态,在 C++ 代码中也可使用 std::optional 实现相关功能。
避免分配大小为零的内存空间
当申请分配的内存空间大小为 0 时,malloc、calloc、realloc 等函数的行为是由实现定义的。
示例:
int n = user_input();
if (n >= 0) {
int* p = (int*)malloc(n * sizeof(int)); // Non-compliant
if (p == NULL)
log("Required too much memory"); // ‘n’ may also be zero
else
....
}
当例中 n 为 0 时,malloc 可能会分配元素个数为 0 的数组,也可能会返回空指针。
又如:
int* p = (int*)malloc(n * sizeof(int));
....
realloc(p, 0); // Non-compliant, use free(p) instead
C90 规定当 realloc 函数的长度参数为 0 时会释放内存,与 free(p) 相同,但在后续标准中废弃了这一特性,不应继续使用。
这种情况下 C++ 语言的 new 运算符会分配元素个数为 0 的数组,但这种数组往往没有实际价值,而且要注意,在 C 和 C++ 语言中元素个数为 0 的数组也需要被释放。
避免动态内存分配
标准库提供的动态内存分配方法,其算法或策略不在使用者的控制之内,很多细节是标准没有规定的,而且也是内存耗尽等问题的根源,有高可靠性要求的嵌入式系统应避免动态内存分配。
在内存资源有限的环境中,由于难以控制具体的分配策略,很可能会导致已分配的空间用不上,未分配的空间不够用的情况。而在资源充足的环境中,也应尽量避免动态分配,如果能在栈上创建对象,就不应采用动态分配的方式,以提高效率并降低资源管理的复杂性。
示例:
void foo() {
std::vector<int> v; // Non-compliant
....
}
例中 vector 容器使用了动态内存分配方法,容量的增长策略可能会导致内存空间的浪费,甚至使程序难以稳定运行。
判断资源分配函数的返回值是否有效
malloc 等函数在分配失败时返回空指针,如果不加判断直接使用会导致标准未定义的行为。
在有虚拟内存支持的平台中,正常的内存分配一般不会失败,但申请内存过多或有误时(如参数为负数)也会导致分配失败,而对于没有虚拟内存支持的或可用内存有限的嵌入式系统,检查分配资源是否成功是十分重要的,所以本规则应该作为代码编写的一般性要求。
库的实现更需要注意这一点,如果库由于分配失败而使程序直接崩溃,相当于干扰了主程序的决策权,很可能会造成难以排查的问题,对于有高可靠性要求的软件,在极端环境中的行为是需要明确设定的。
示例:
void foo(size_t n) {
char* p = (char*)malloc(n);
p[n - 1] = '\0'; // Non-compliant, check ‘p’ first
....
}
示例代码未检查 p 的有效性便直接使用是不符合要求的。
在 C++ 代码中禁用 C 资源管理函数
为了简化资源管理并避免潜在的错误,在 C++ 代码中不应直接使用分配、释放普通指针的函数,而应使用容器、智能指针和相关工厂函数。
示例:
void foo(size_t n) {
int* p = (int*)malloc(n * sizeof(int)); // Non-compliant
....
free(p); // Non-compliant
}
应改为:
void foo(size_t n) {
auto p = make_unique<int[]>(n); // Compliant
....
}
3 Precompile
Include
include 指令应符合标准格式
#include 后只应为 < 头文件路径 > 或 " 头文件路径 ",否则会导致标准未定义的行为。
示例:
#include <string.h> // Compliant
#include "string.h" // Compliant
#define HEADER "string.h"
#include HEADER // Compliant
#include stdlib.h // Non-compliant, undefined behavior
例中对 string.h 的引用符合标准,而对 stdlib.h 的引用会导致标准未定义的行为。
注意,由引号标识的头文件路径并非字符串常量,不应对其使用字符串常量的特性,如:
#include "stdlib" ".h" // Non-compliant, implementation defined
是否会将引号中的内容连接成一个路径是由实现定义的,这种代码是不可移植的。
另外,如下形式的代码也是不符合标准的:
#include L"foo" // Non-compliant
#include u"bar" // Non-compliant
#include U"baz" // Non-compliant
#include R"(..\foo\bar)" // Non-compliant
include 指令中禁用不合规的字符
字母、数字、下划线、点号之外的字符可能与文件系统存在冲突,也可能导致标准未定义的行为,不应出现在头文件和相关目录名称中。
示例:
#include <"foo"> // Non-compliant
#include <foo*> // Non-compliant
#include <foo'bar> // Non-compliant
#include <foo> // Compliant
#include <foo.h> // Compliant
#include <foo_bar> // Compliant
可以用 / 作为路径分隔符,但不应出现 // 或 /*,否则会导致未定义的行为,如:
#include <foo//bar.h> // Non-Compliant, undefined behavior
#include <foo/*bar.h> // Non-Compliant, undefined behavior
另外,某些平台的文件路径不区分大小写,建议头文件以小写字母命名以提高可移植性。
include 指令中不应使用反斜杠
如果在 include 指令中使用反斜杠,程序的行为在 C 和 C++03 标准中是未定义的,在 C++11 标准中是由实现定义的。
示例:
#include <foo\bar.h> // Non-compliant
#include "foo\\bar.h" // Non-compliant
#include <foo/bar.h> // Compliant
在有可移植性要求的代码中应避免使用反斜杠。
include 指令中不应使用绝对路径
绝对路径使代码过分依赖编译环境,意味着项目的编译设置不完善,应使用相对路径。
示例:
#include "C:\\foo\\bar.h" // Non-compliant
#include "/foo/bar.h" // Non-compliant
include 指令应位于文件的起始部分
include 指令之前的代码只应为预编译指令或注释,否则不利于阅读和维护。
用于包含模板实现文件的 include 指令可不受本规则限制,但相关文件的命名应与普通头文件有所区别。
示例:
#include "a.h" // Compliant
extern int i;
#include "b.h" // Non-compliant
extern
#include "c.h" // Non-compliant, undefined behavior
如果声明的一部分在头文件内,另一部分在头文件外,会导致标准未定义的行为。
禁用不合规的头文件
已过时的、无意义的或有不良副作用的头文件应禁用。
示例:
#include <tgmath.h> // Non-compliant
#include <setjmp.h> // Non-compliant
#include <iso646.h> // Non-compliant in C++
#include <stdbool.h> // Non-compliant in C++
#include <ciso646> // Non-compliant in C++
#include <cstdbool> // Non-compliant in C++
#include <ctgmath> // Non-compliant in C++
#include <ccomplex> // Non-compliant in C++
#include <cstdalign> // Non-compliant in C++
tgmath.h 和 ctgmath 会使用语言标准之外的技术实现某种重载效果,而且其中的部分函数名称会干扰其他标准库中的名称,setjmp.h 和 csetjmp 则包含危险的过程间跳转函数。
iso646.h、stdalign.h、stdbool.h 以及 ciso646、cstdalign、cstdbool 等头文件对 C++ 语言没有意义,ccomplex、cstdalign、cstdbool、ctgmath 等头文件在 C++17 标准中已过时,在 C++ 代码中不应使用这些头文件。
stdio.h、signal.h、time.h、fenv.h 等头文件含有较多标准未声明或由实现定义的内容,对有高可靠性要求的软件系统也不建议使用。
审计工具不妨通过配置设定不合规头文件的名称,如:
[ID_forbiddenHeader]
inC=tgmath.h|setjmp.h
inCpp=tgmath.h|ctgmath|setjmp.h|csetjmp
表示对 C 代码将 tgmath.h、setjmp.h 设为不合规,对 C++ 代码将 tgmath.h、ctgmath、setjmp.h、csetjmp 设为不合规。
C++ 代码不应引用 C 头文件
为了与 C 语言兼容,C++ 标准库也会提供 C 头文件,但在这种 C 头文件在 C++ 标准中是已过时的。
C 标准头文件均有对应的 C++ 版本,C++ 版本提供了更适合 C++ 代码的命名空间、模板以及函数重载等功能。C 标准不在 C++ 标准之内,在 C++ 代码中不建议使用 C 标准库的功能,如果确有必要,应使用 C++ 版本的头文件。
本规则是 ID_forbiddenHeader 的特化。
示例:
#include <assert.h> // Non-compliant, use <cassert>
#include <ctype.h> // Non-compliant, use <cctype>
#include <errno.h> // Non-compliant, use <cerrno>
#include <float.h> // Non-compliant, use <cfloat>
#include <limits.h> // Non-compliant, use <climits>
#include <locale.h> // Non-compliant, use <clocale>
#include <math.h> // Non-compliant, use <cmath>
#include <setjmp.h> // Non-compliant, use <csetjmp>
#include <signal.h> // Non-compliant, use <csignal>
#include <stdarg.h> // Non-compliant, use <cstdarg>
#include <stddef.h> // Non-compliant, use <cstddef>
#include <stdio.h> // Non-compliant, use <cstdio>
#include <stdlib.h> // Non-compliant, use <cstdlib>
#include <string.h> // Non-compliant, use <cstring>
#include <time.h> // Non-compliant, use <ctime>
#include <wchar.h> // Non-compliant, use <cwchar>
#include <wctype.h> // Non-compliant, use <cwctype>
源文件不应被包含
应将 .c、.cpp 等源文件作为独立的翻译单元,否则代码的管理和维护会变得困难。
示例:
#include "foo.c" // Non-compliant
#include "bar.cpp" // Non-compliant