昇腾社区 C++ 语言安全编程指导(建议稿)

说明

本指导基于C++语言制定而成,给参与Ascend开源社区项目的开发者提供安全编程指导。

规则并不是完美的,通过禁止在特定情况下有用的特性,可能会对代码实现造成影响。但是我们制定规则的目的是“为了大多数程序员可以得到更多的好处”。

参考本指导之前,希望您具有相应的C++语言基础能力,而不是通过该文档来学习C++语言。

  1. 了解C++语言的ISO标准;
  2. 熟知C++语言的基本语言特性,包括C++ 03/11/14/17/20相关特性;
  3. 了解C++语言的标准库;

如果希望改进某个规则,建议提交Issue并说明理由,经Ascend运营团队评审后可接纳并修改生效。

约定

规则:编程时必须遵守的约定(must)

建议:编程时应该遵守的约定(should)

本指导适用通用C++标准, 如果没有特定的标准版本,适用所有的版本(C++03/11/14/17/20)。

例外

无论是'规则'还是'建议',都必须理解该条目这么规定的原因,并努力遵守。 但是,有些规则和建议可能会有例外。

在不违背总体原则,经过充分考虑,有充足的理由的前提下,可以适当违背本指导中约定。 例外破坏了代码的一致性,请尽量避免。'规则'的例外应该是极少的。

适用范围

Ascend 社区所有开源仓


1. 安全编码

1.1 总体原则

规则 1.1.1 保证静态类型安全

C++应该是静态类型安全的,这样可以减少运行时的错误,提升代码的健壮性。但是由于C++存在下面的特性,会破坏C++静态类型安全,针对这部分特性要仔细处理:

  • 联合体
  • 类型转换
  • 缩窄转换
  • 类型退化
  • 范围错误
  • void* 类型指针

可以通过约束这些特性的使用,或者使用C++的新特性,例如std::variant(C++17)、std::span(C++20)等来解决这些问题,提升C++代码的健壮性。

规则 1.1.2 保证内存安全

C++语言的内存完全由程序员自己控制,所以在操作内存的时候必须保证内存安全,防止出现内存错误:

  • 内存越界访问
  • 释放以后继续访问内存
  • 解引用空指针
  • 内存没有初始化
  • 把指向局部变量的引用或者指针传递到了函数外部或者或者其他线程中
  • 申请的内存或者资源没有及时释放

建议使用更加安全的C++的特性,比如RAII,引用,智能指针等,来提升代码的健壮性。

规则 1.1.3 禁止使用编译器“未定义行为”

遵循ISO C++标准,标准中未定义的行为禁止使用。对于编译器实现的特性或者GCC等编译器提供的扩展特性也需要谨慎使用,这些特性会降低代码的可移植性。

1.2 类

规则 1.2.1 类的成员变量必须显式初始化

如果类有成员变量,没有定义构造函数,又没有定义默认构造函数,编译器将自动生成一个构造函数,但编译器生成的构造函数并不会对成员变量进行初始化,对象状态处于一种不确定性。

例外:

  • 如果类的成员变量具有默认构造函数,那么可以不需要显式初始化。
  • 如果类的设计目的就是将成员变量作为未初始内存块使用,则可以不初始化该成员变量。

示例:如下代码没有构造函数,私有数据成员无法初始化:

class Message {
public:
    void ProcessOutMsg()
    {
        //…
    }

private:
    unsigned int msgID_;
    unsigned int msgLength_;
    std::string someIdentifier_;
};

Message message;   // message成员变量没有初始化
message.ProcessOutMsg();   // 后续使用存在隐患

// 因此,有必要定义默认构造函数,如下:
class Message {
public:
    Message() : msgID_(0), msgLength_(0)
    {
    }

    void ProcessOutMsg()
    {
        // …
    }

private:
    unsigned int msgID_;
    unsigned int msgLength_;
    std::string someIdentifier_; // 具有默认构造函数,不需要显式初始化
};

1.3 表达式与语句

规则 1.3.1 确保有符号整数运算不溢出

【描述】 有符号整数溢出是未定义的行为。出于安全考虑,对外部数据中的有符号整数值在如下场景中使用时,需要确保运算不会导致溢出:

  • 指针运算的整数操作数(指针偏移值)

  • 数组索引

  • 变长数组的长度(及长度运算表达式)

  • 内存拷贝的长度

  • 内存分配函数的参数

  • 循环判断条件

在精度低于int的整数类型上进行运算时,需要考虑整数提升。程序员还需要掌握整数转换规则,包括隐式转换规则,以便设计安全的算术运算。

1)加法

【错误代码示例】(加法)

如下代码示例中,参与加法运算的整数是外部数据,在使用前未做校验,可能出现整数溢出。

int numA = ... // 来自外部数据
int numB = ... // 来自外部数据
int sum = numA + numB;
...

【正确代码示例】(加法)

int numA = ... // 来自外部数据
int numB = ... // 来自外部数据
int sum = 0;
if (((numA > 0) && (numB > (INT_MAX - numA))) ||
 ((numA < 0) && (numB < (INT_MIN - numA)))) {
  ... // 错误处理
}
sum = numA + numB;
...

2)减法

【错误代码示例】(减法)

如下代码示例中,参与减法运算的整数是外部数据,在使用前未做校验,可能出现整数溢出,进而造成后续的内存复制操作出现缓冲区溢出。

unsigned char  *content = ... // 指向报文头的指针
size_t contentSize = ... // 缓冲区的总长度
int totalLen = ... // 报文总长度
int skipLen = ... // 从消息中解析出来的需要忽略的数据长度
// 用totalLen - skipLen 计算剩余数据长度,可能出现整数溢出
(void)memmove(content, content + skipLen, totalLen - skipLen);
...

【正确代码示例】(减法)

如下代码示例中,重构为使用size_t类型的变量表示数据长度,并校验外部数据长度是否在合法范围内。

unsigned char *content = ... //指向报文头的指针
size_t contentSize = ... // 缓冲区的总长度
size_t totalLen = ... // 报文总长度
size_t skipLen = ... // 从消息中解析出来的需要忽略的数据长度
if (skipLen >= totalLen || totalLen > contentSize) {
 ... // 错误处理
}

(void)memmove(content, content + skipLen, totalLen - skipLen);
...

3)乘法

【错误代码示例】(乘法)

如下代码示例中,内核代码对来自用户态的数值范围做了校验,但是由于opt是int类型,而校验条件中错误的使用了ULONG_MAX进行限制,导致整数溢出。

int opt = ... // 来自用户态
if ((opt < 0) || (opt > (ULONG_MAX / (60 * HZ)))) { // 错误的使用了ULONG_MAX做上限校验
 return -EINVAL;
}

... = opt * 60 * HZ; // 可能出现整数溢出
...

【正确代码示例】(乘法)

一种改进方案是将opt的类型修改为unsigned long类型,这种方案适用于修改了变量类型更符合业务逻辑的场景。

unsigned long opt = ... // 将类型重构为 unsigned long 类型。
if (opt > (ULONG_MAX / (60 * HZ))) {
 return -EINVAL;
}
... = opt * 60 * HZ;
...

另一种改进方案是将数值上限修改为INT_MAX。

int opt = ... // 来自用户态
if ((opt < 0) || (opt > (INT_MAX / (60 * HZ)))) { // 修改使用INT_MAX作为上限值
 return -EINVAL;
}
... = opt * 60 * HZ;

4)除法

【错误代码示例】(除法)

如下代码示例中,做除法运算前只检查了是否出现被零除的问题,缺少对数值范围的校验,可能出现整数溢出。

int numA =  ... // 来自外部数据
int numB =  ... // 来自外部数据
int result = 0;

if (numB == 0) {
 ... // 对除数为0的错误处理
}

result = numA / numB; // 可能出现整数溢出
...

【正确代码示例】(除法)

如下代码示例中,按照最大允许值进行校验,防止整数溢出,在编程时可根据具体业务场景做更严格的值域校验。

int numA = ... // 来自外部数据
int numB = ... // 来自外部数据
int result = 0;

// 检查除数为0及除法溢出错误
if ((numB == 0) || ((numA == INT_MIN) && (numB == -1))) {
 ... // 错误处理
}

result = numA / numB;
...

5)求余数

【错误代码示例】(求余数)

int numA = ... // 来自外部数据
int numB = ... // 来自外部数据
int result = 0;
if (numB == 0) {
 ... // 对除数为0的错误处理
}

result = numA % numB; // 可能出现整数溢出
...
}

【正确代码示例】(求余数)

如下代码示例中,按照最大允许值进行校验,防止整数溢出。在编程时可根据具体业务场景做更严格的值域校验。

int numA =  ... // 来自外部数据
int numB =  ... // 来自外部数据
int result = 0;

// 检查除数为0及除法溢出错误
if ((numB == 0)  || ((numA == INT_MIN) && (numB == -1))) {
 ... // 错误处理
}

result = numA % numB;
...
}

6)一元减

当操作数等于有符号整数类型的最小值时,在二进制补码一元求反期间会发生溢出。

【错误代码示例】(一元减)

如下代码示例中,计算前未校验数值范围,可能出现整数溢出。

int numA = ... // 来自外部数据
int result = -numA; // 可能出现整数溢出
...

【正确代码示例】(一元减)

如下代码示例中,按照最大允许值进行校验,防止整数溢出。在编程时可根据具体业务场景做更严格的值域校验。

int numA =  ... // 来自外部数据
int result = 0;

if (numA == INT_MIN) {
 ... // 错误处理
}

result = -numA;
...
规则 1.3.2 确保无符号整数运算不回绕

【描述】

涉及无符号操作数的计算永远不会溢出,因为超出无符号整数类型表示范围的计算结果会按照(结果类型可表示的最大值 + 1)的数值取模。

这种行为更多时候被非正式地称为无符号整数回绕。

在精度低于int的整数类型上进行运算时,需要考虑整数提升。程序员还需要掌握整数转换规则,包括隐式转换规则,以便设计安全的算术运算。

出于安全考虑,对外部数据中的无符号整数值在如下场景中使用时,需要确保运算不会导致回绕:

  • 指针运算的整数操作数(指针偏移值)

  • 数组索引

  • 变长数组的长度(及长度运算表达式)

  • 内存拷贝的长度

  • 内存分配函数的参数

  • 循环判断条件

1)加法

【错误代码示例】(加法)

如下代码示例中,校验下一个子报文的长度加上已处理报文的长度是否超过了整体报文的最大长度,在校验条件中的加法运算可能会出现整数回绕,造成绕过该校验的问题。

size_t totalLen =  ... // 报文的总长度
size_t readLen = 0 // 记录已经处理报文的长度
...
size_t pktLen = ParsePktLen(); // 从网络报文中解析出来的下一个子报文的长度
if (readLen + pktLen > totalLen) { // 可能出现整数回绕
 ... // 错误处理
}

...
readLen += pktLen;
...

【正确代码示例】(加法)

由于readLen变量记录的是已经处理报文的长度,必然会小于totalLen,因此将代码中的加法运算修改为减法运算,避免条件绕过。

size_t totalLen = ... // 报文的总长度
size_t readLen = 0; // 记录已经处理报文的长度
...

size_t pktLen = ParsePktLen(); // 来自网络报文
if (pktLen > totalLen - readLen) {
 ... // 错误处理
}

...
readLen += pktLen;
...

2)减法

【错误代码示例】(减法)

如下代码示例中,校验len合法范围的运算可能会出现整数回绕,导致条件绕过。

size_t len = ... // 来自用户态输入
if (SCTP_SIZE_MAX - len < sizeof(SctpAuthBytes)) { // 减法操作可能出现整数回绕
 ... // 错误处理
}

... = kmalloc(sizeof(SctpAuthBytes) + len, gfp); // 可能出现整数回绕
...

【正确代码示例】(减法)

如下代码示例中,调整减法运算的位置(需要确保编译期间减法表达式的值不翻转),避免整数回绕问题。

size_t len = ... // 来自用户态输入
if (len > SCTP_SIZE_MAX - sizeof(SctpAuthBytes)) { // 确保编译期间减法表达式的值不翻转
 ... // 错误处理
}

... = kmalloc(sizeof(SctpAuthBytes) + len, gfp);
...

3)乘法

【错误代码示例】(乘法)

如下代码示例中,使用外部数据计算申请内存长度时未校验,可能出现整数回绕。

size_t width =  ... // 来自外部数据
size_t height =  ... // 来自外部数据
unsigned char  *buf = (unsigned char  *)malloc(width  * height);

无符号整数回绕可能导致分配的内存不足。

【正确代码示例】(乘法)

如下代码是一种解决方案,校验参与乘法运算的整数数值范围,确保不会出现整数回绕。

size_t width =  ... // 来自外部数据
size_t height =  ... // 来自外部数据
if (width == 0 || height == 0) {
 ... // 错误处理
}

if (width  > SIZE_MAX / height) {
 ... // 错误处理
}

unsigned char  *buf = (unsigned char  *)malloc(width  * height);

【相关软件CWE编号】 CWE-190

规则 1.3.3 确保除法和余数运算不会导致除零错误(被零除)

【描述】

整数的除法和取余运算的第二个操作数值为0会导致程序产生未定义的行为,因此使用时要确保整数的除法和余数运算不会导致除零错误(被零除,下同)。

1)除法

【错误代码示例】(除法)

有符号整数类型的除法运算如果限制不当,会导致溢出。

如下示例对有符号整数进行的除法运算做了防止溢出限制,确保不会导致溢出,但不能防止有符号操作数numA和numB之间的除法过程中出现除零错误:

int numA =  ... // 来自外部数据
int numB =  ... // 来自外部数据
int result = 0;

if ((numA == INT_MIN) && (numB == -1)) {
 ... // 错误处理
}

result = numA / numB; // 可能出现除零错误
...

【正确代码示例】(除法)

如下代码示例中,添加numB是否为0的校验,防止除零错误。

int numA =  ... // 来自外部数据
int numB =  ... // 来自外部数据
int result = 0;

if ((numB == 0) || ((numA == INT_MIN) && (numB == -1))) {
 ... // 错误处理
}

result = numA / numB;
...

2)取余

【错误代码示例】(求余数)

如下代码,同除法的错误代码示例一样,可能出现除零错误,因为许多平台以相同的指令实现求余数和除法运算。

int numA =  ... // 来自外部数据
int numB =  ... // 来自外部数据
int result = 0;

if ((numA == INT_MIN) && (numB == -1)) {
 ... // 错误处理
}

result = numA % numB; // 可能出现除零错误
...

【正确代码示例】(求余数)

如下代码示例中,添加numB是否为0的校验,防止除零错误。

int numA =  ... // 来自外部数据
int numB =  ... // 来自外部数据
int result = 0;

if ((numB == 0)  | | ((numA == INT_MIN) && (numB == -1))) {
 ... // 错误处理
}

result = numA % numB;
...
规则1.3.4 &&和||操作符的右侧操作数不要包含副作用

逻辑与(&&)、逻辑或(||)表达式中的右操作数是否被求值,取决于左操作数的求值结果,当左操作数的求值结果可以得出整个逻辑表达式的结果时,不会再计算右操作数的结果。如果右操作数包含副作用,则不能确定是否确实发生了副作用,因此,本条款中要求逻辑与(&&)、逻辑或(||)操作符的右操作数中不要含有副作用。

规则1.3.5 循环必须安全退出

在应用程序中,一个重复提供服务的逻辑循环应当设计退出机制,并且将资源正确释放后安全退出。退出条件的设计,除了让程序逻辑更加完整,也能通过实现优雅退出的代码,显式释放服务循环中分配的资源,避免资源泄露。

1.4 资源管理

规则 1.4.1 外部数据作为数组索引或者内存操作长度时,需要校验其合法性
规则 1.4.2 内存申请前,必须对申请内存大小进行合法性校验
规则 1.4.3 禁止将局部变量的地址传递到其作用域外
规则 1.4.4 自定义new/delete操作符需要配对定义,且行为与被替换的操作符一致
规则 1.4.5 使用恰当的方式处理new操作符的内存分配错误

1.5 错误处理

规则 1.5.1 禁止从析构函数中抛出异常

析构函数默认自带noexcept属性,如果析构函数抛出异常,会直接导致std::terminate。自C++11起,允许析构函数被标记为noexcept(false),但即便如此,如果析构函数在stack unwinding的过程中被调用(例如另一个异常抛出,导致栈上的局部变量被析构),结果也是std::terminate,而析构函数最大的作用就是在无论正常返回还是抛出异常的情况下都能清理局部变量。因此,让析构函数抛出异常一般都是不好的。

1.6 标准库

规则 1.6.1 禁止从空指针创建std::string
规则 1.6.2 确保用于字符串操作的缓冲区有足够的空间容纳字符数据和结束符,并且字符串以null结束符结束
规则 1.6.3 外部数据用于容器索引或迭代器时必须确保在有效范围内
规则 1.6.4 调用格式化输入/输出函数时,使用有效的格式字符串
规则 1.6.5 调用格式化输入/输出函数时,禁止format参数受外部数据控制
规则 1.6.6 禁用程序与线程的退出函数和atexit函数
规则 1.6.7 禁止调用kill、TerminateProcess函数直接终止其他进程
规则 1.6.8 禁止在信号处理例程中调用非异步安全函数

1.7 函数使用

规则 1.7.1 禁止直接使用外部数据拼接SQL命令
规则 1.7.2 禁止使用非安全退出函数
{
  kill(...);            // 调用kill强行终止其他进程(如kill -9),会导致其他进程的资源得不到清理。
  TerminateProcess();   // 调用TerminateProcess函数强行终止其他进程,会导致其他进程的资源得不到清理。
  pthread_exit();       // 严禁在线程内主动终止自身线程,线程函数在执行完毕后会自动、安全地退出;
  ExitThread();         // 严禁在线程内主动终止自身线程,线程函数在执行完毕后会自动、安全地退出;
  exit();               // main函数以外,禁止任何地方调用,程序应该安全退出;
  ExitProcess();        // main函数以外,禁止任何地方调用,程序应该安全退出;
  abort();              // 禁用,abort会导致程序立即退出,资源得不到清理;
}

1.8 内存

规则 1.8.1 严禁使用string类存储敏感信息

string类是C++内部定义的字符串管理类,如果口令等敏感信息通过string进行操作,在程序运行过程中,敏感信息可 能会散落到内存的各个地方,并且无法清0。

以下代码,Foo函数中获取密码,保存到string变量password中,随后传递给VerifyPassword函数,在这个过程中, password实际上在内存中出现了2份。

int VerifyPassword(string password) {
  //...
}
int Foo() {
  string password = GetPassword();
  VerifyPassword(password);
  ...
}

应该使用char或unsigned char保存敏感信息,如下代码:

int VerifyPassword(const char *password) {
  //...
}
int Foo() {
  char password[MAX_PASSWORD] = {0};
  GetPassword(password, sizeof(password));
  VerifyPassword(password);
  ...
}
规则 1.8.2 内存中的敏感信息使用完毕后立即清0

口令、密钥等敏感信息使用完毕后立即清0,避免被攻击者获取。

1.9 文件

规则 1.9.1 外部文件路径使用前必须进行规范化并校验

1.10 其他

规则 1.10.1 禁用rand函数产生用于安全用途的伪随机数

C标准库rand()函数生成的是伪随机数,请使用/dev/random生成随机数。

规则 1.10.2 禁止在发布版本中输出对象或函数的地址