华为C&C++语言编程规范
1.基本要求
1.1 变量
规则1.1.1:指针变量、表示资源描述符的变量、BOOL变量声明必须赋予初值
变量声明赋予初值,可以避免由于编程人员的疏忽导致的变量未初始化引用。
示例:
SOCKET s = INVALID_SOCKET;
unsigned char *msg = NULL;
BOOL success = FALSE;
int fd = -1;
以下代码,由于变量声明未赋予初值,在最后free的时候出错。
char *message; // 错误!必须声明为 char *message = NULL;
...
if (condition) {
message = (char *)malloc(len);
...
}
...
if (message != NULL) {
free(message); //如果condition未满足,会造成free未初始化的内存。
}
规则1.1.2:指向资源句柄或描述符的变量,在资源释放后立即赋予新值
资源释放后,对应的变量应该立即赋予新值,防止后续又被重新引用。如果释放语句刚好在变量作用域的最后一句,可以不进行赋值。
示例:
SOCKET s = INVALID_SOCKET;
unsigned char *msg = NULL;
int fd = -1;
...
closesocket(s);
s = INVALID_SOCKET;
...
free(msg);
msg = (unsigned char *)malloc(...); //msg变量又被赋予新值
...
close(fd);
fd = -1;
...
规则1.1.3:类的成员变量必须在构造函数中赋予初值
变量声明赋予初值,可以避免由于编程人员的疏忽导致的变量未初始化引用。
示例:
class CMsg {
public:
CMsg();
~CMsg();
protected:
int size;
unsigned char *msg;
};
CMsg::CMsg()
{
size = 0;
msg = NULL;
}
规则1.1.4:严禁对指针变量进行sizeof操作
编码人员往往由于粗心,将指针当做数组进行sizeof操作,导致实际的执行结果与预期不符。 下面的代码,buffer和path分别是指针和数组,编码人员想对这2个内存进行清0操作,但由于编码人员的疏忽,第5行代码,将内存大小误写成了sizeof,与预期不符。
char *buffer = (char *)malloc(size);
char path[MAX_PATH] = {0};
...
memset(path, 0, sizeof(path));
memset(buffer, 0, sizeof(buffer));
如果要判断当前的指针类型大小,请使用sizeof(char *)的方式。
建议1.1.1:尽量使用const
在变量声明前加const关键字,表示该变量不可被修改,这样就可以利用编译器进行类型检查,将代码的权限降到更低。
例如下面是不好的定义:
float pi = 3.14159f;
应当这样定义:
const float PI = 3.14159f;
建议1.1.2:全局变量的访问如果涉及多个线程,需要考虑多线程竞争条件问题
应该尽可能减少全局变量的使用,如果多个线程会访问到该全局变量,则访问过程必须加锁。 以下代码中,g_list是全局变量,对链表进行搜索操作时,在while循环语句的前后加锁。
ItemList *g_list = NULL;
ItemList *SearchList(const char *name)
{
Lock();
ItemList *p = g_list;
while (p != NULL)
{
if (strcmp(p->name, name) == 0)
{
break;
}
p = p->next;
}
UnLock();
return p;
}
性能敏感的代码,请考虑采用原子操作或者无锁算法。
建议1.1.3:同一个函数内,局部变量所占用的空间不要过大
程序在运行期间,函数内的局部变量保存在栈中,栈的大小是有限的。如果申请过大的静态数组,可能导致出现运行出错。 建议在申请静态数组的时候,大小不超过0x1000。 下面的代码,buff申请过大,导致栈空间不够,程序发生stackoverflow异常。
#define MAX_BUFF 0x1000000
int Foo()
{
char buff[MAX_BUFF] = {0};
...
}
1.2 断言(ASSERT)
断言是一种除错机制,用于验证代码是否符合编码人员的预期。编码人员在开发期间应该对函数的参数、代码中间执行结果合理地使用断言机制,确保程序的缺陷尽量在测试阶段被发现。 断言被触发后,说明程序出现了不应该出现的严重错误,程序会立即提示错误,并终止执行。 断言必须用宏进行定义,只在调试版本有效,最终发布版本不允许出现assert函数,例如:
#include <assert.h>
#ifdef DEBUG
#define ASSERT(f) assert(f)
#else
#define ASSERT(f) ((void)0)
#endif
下面的函数VerifyUser,上层调用者会保证传进来的参数是合法的字符串,不可能出现传递非法参数的情况。因
此,在该函数的开头,加上4个ASSERT进行校验。
BOOL VerifyUser(const char *userName, const char *password)
{
ASSERT(userName != NULL);
ASSERT(strlen(userName) > 0);
ASSERT(password != NULL);
ASSERT(strlen(password) > 0);
…
}
以下的switch,由于不可能出现default的情况,所以在default处直接调用ASSERT:
enum {
COLOR_RED = 1,
COLOR_GREEN,
COLOR_BLUE
};
…
switch (color) {
case COLOR_RED:
…
case COLOR_GREEN:
…
case COLOR_BLUE:
…
default: {
ASSERT(0);
}
}
以下代码,SendMsg是CMsg类的成员函数,socketID是成员变量,在调用SendMsg的时候必须保证socketID已经
被初始化,因此在此处用ASSERT判断socketID的合法性。
CMsg::CMsg()
{
socketID = INVALID_SOCKET;
}
int CMsg::SendMsg(const char *msg, int len)
{
ASSERT(socketID != INVALID_SOCKET);
…
ret = send(socketID, msg, len, 0);
…
}
在linux内核中定义ASSERT宏,可以采用如下方式:
#ifdef DEBUG
#define ASSERT(f) BUG_ON(!(f))
#else
#define ASSERT(f) ((void)0)
#endif
规则1.2.1:断言必须使用宏定义,禁止直接调用系统提供的assert()
断言只能在调试版使用,断言被触发后,程序会立即退出,因此严禁在正式发布版本使用断言,请通过编译选项进行控制。 错误用法如:
int Foo(int *array, int size)
{
assert(array != NULL);
…
}
规则1.2.2:运行时可能会导致的错误,严禁使用断言
断言不能用于校验程序在运行期间可能导致的错误。 以下代码的所有ASSERT的用法是错误的。
- FILE *fp = fopen(path, “r”);
- ASSERT(fp != NULL); //文件有可能打开失败
- char *str = (char *)malloc(MAX_LINE);
- ASSERT(str != NULL); //内存有可能分配失败
- ReadLine(fp, str);
- char *p = strstr(str, ‘age=’);
- ASSERT(p != NULL); //文件中不一定存在该字符串
- int age = atoi(p+4);
- ASSERT(age > 0); //文件内容不一定符合预期
建议1.2.1:不要将多条语句放在同一个断言中
为了更加准确地发现错误的位置,每一条断言只校验一个条件。 下面的断言同时校验多个条件,在断言触发的时候,无法判断到底是哪一个条件导致的错误:
int Foo(int *array, int size)
{
ASSERT(array != NULL && size > 0 && size < MAX_SIZE);
…
}
应该将每个条件分开:
int Foo(int *array, int size)
{
ASSERT(array != NULL);
ASSERT(size > 0);
ASSERT(size < MAX_SIZE);
…
}
1.3 函数
规则1.3.1:数组作为函数参数时,必须同时将其长度作为函数的参数
通过函数参数传递数组或一块内存进行写操作时,函数参数必须同时传递数组元素个数或所传递的内存块大小,否则函数在使用数组下标或访问内存偏移时,无法判断下标或偏移的合法范围,产生越界访问的漏洞。 以下代码中,函数ParseMsg不知道msg的范围,容易产生内存越界访问漏洞。
int ParseMsg(BYTE *msg)
{
…
}
…
size_t len = …
BYTE *msg = (BYTE *)malloc(len); //此处分配的内存块等同于字节数组
…
ParseMsg(msg);
…
正确的做法是将msg的大小作为参数传递到ParseMsg中,如下代码:
int ParseMsg(BYTE *msg, size_t msgLen)
{
ASSERT(msg != NULL);
ASSERT(msgLen != 0);
…
}
…
size_t len = …
BYTE *msg = (BYTE *)malloc(len);
…
ParseMsg(msg, len);
…
规则1.3.3:不对内容进行修改的指针型参数,定义为const
如果参数是指针型参数,且内容不会被修改,请定义为const类型。
int Foo(const char *filePath)
{
…
int fd = open(filePath, …);
…
}
建议1.3.2:字符串或指针作为函数参数时,请检查参数是否为NULL
如果字符串或者指针作为函数参数,为了防止空指针引用错误,在引用前必须确保该参数不为NULL,如果上层调用者已经保证了该参数不可能为NULL,在调用本函数时,在函数开始处可以加ASSERT进行校验。 例如下面的代码,因为BYTE *p有可能为NULL,因此在使用前需要进行判断。
int Foo(int *p, int count)
{
if (p != NULL && count > 0) {
int c = p[0];
}
…
int Foo2()
{
int *arr = …
int count = …
Foo(arr, count);
…
}
下面的代码,由于p的合法性由调用者保证,对于Foo函数,不可能出现p为NULL的情况,因此加上ASSERT进行校
验。
int Foo(int *p, int count)
{
ASSERT(p != NULL); //ASSERT is added to verify p.
ASSERT(count > 0);
int c = p[0];
…
}
int Foo2()
{
int *arr = …
int count = …
…
if (arr != NULL && count > 0) {
Foo(arr, count);
}
…
1.4 异常机制
规则1.4.1:禁用C++异常机制
严禁使用C++的异常机制,所有的错误都应该通过错误值在函数之间传递并做相应的判断, 而不应该通过异常机制进行错误处理。 编码人员必须完全掌控整个编码过程,建立攻击者思维,增强安全编码意识,主动把握有可能出错的环节。而使用C++异常机制进行错误处理,会削弱编码人员的安全意识。 异常机制会打乱程序的正常执行流程,使程序结构更加复杂,原先申请的资源可能会得不到有效清理。 异常机制导致代码的复用性降低,使用了异常机制的代码,不能直接给不使用异常机制的代码复用。 异常机制在实现上依赖于编译器、操作系统、处理器,使用异常机制,导致程序执行性能降低。 在二进制层面,程序被加载后,异常处理函数增加了程序的被攻击面,攻击者可以通过覆盖异常处理函数地址,达到攻击的效果。 例外: 在接管C++语言本身抛出的异常(例如new失败、STL)、第三方库(例如IDL)抛出的异常时,可以使用异常机制,例如:
int len = …;
char *p = NULL;
try {
p = new char[len];
}
catch (bad_alloc) {
…
abort();
}
1.5 类
规则1.5.1:构造函数内不能做任何有可能失败的操作
构造函数没有返回值,不能做错误判断,因此在构造函数内,不能做任何有可能失败的操作。 下面的代码中,
open、new、ConnectServer都有可能失败,这些操作不应该放在构造函数内。
CFoo::CFoo()
{
int fd = open(…);
char *str = new char[…];
BOOL b = ConnectServer(…);
…
}
规则1.5.2:严禁在构造函数中创建线程
构造函数内仅作成员变量的初始化工作,其他的操作通过成员函数完成。
规则1.5.3:如果类的公共接口中返回类的私有数据地址,则必须加const类型
class CMsg {
public:
CMsg();
~CMsg();
Const unsigned char *GetMsg();
protected:
int size;
unsigned char *msg;
};
CMsg::CMsg()
{
size = 0;
msg = NULL;
}
const unsigned char *CMsg::GetMsg()
{
return msg;
}
建议1.5.4:尽量避免定义public成员
类成员进行定义的时候,需要考虑类的功能,尽量减少对外接口的暴露。
1.6 安全退出
规则1.6.1:禁用atexit函数
atexit函数注册若干个有限的函数,当exit被调用后,自动调用由atexit事先注册的函数。 当资源不再使用后,编码人员应该立即主动地进行清理,而不应该在最终程序退出后通过事先注册的例程被动地清理。
例外: 作为服务维测监控功能,为定位程序异常退出原因的模块,可以作为例外使用atexit()函数。
规则1.6.2:严禁调用kill、TerminateProcess函数终止其他进程
调用kill、TerminateProcess等函数强行终止其他进程(如kill -9),会导致其他进程的资源得不到清理。 对于进程间通信,应该主动发送一个停止命令,通知对方进程安全退出。 当发送给对方进程退出信号后,在等待一定时间内如果对方进程仍然未退出,可以调用kill、TerminateProcess函数。
if (WaitForRemoteProcessExit(…) == TIME_OUT) {
kill(…); //目标进程在限定时间内仍然未退出,强行结束目标进程
}
规则1.6.3:禁用pthread_exit、ExitThread函数
严禁在线程内主动终止自身线程,线程函数在执行完毕后会自动、安全地退出。主动终止自身线程的操作,不仅导致代码复用性变差,同时容易导致资源泄漏错误。
建议1.6.1:禁用exit、ExitProcess函数(main函数除外)
程序应该安全退出,除了main函数以外,禁止任何地方调用exit、ExitProcess函数退出进程。直接退出进程会导致代码的复用性降低,资源得不到有效地清理。 程序应该通过错误值传递的机制进行错误处理。 以下代码加载文件,加载过程中如果出错,直接调用exit退出:
void LoadFile(const char filePath)
{
FILE fp = fopen(filePath, “rt”);
if (fp == NULL) {
exit(0);
}
…
}
正确的做法应该通过错误值传递机制,例如:
BOOL LoadFile(const char filePath)
{
BOOL ret = FALSE;
FILE fp = fopen(filePath, “rt”);
if (fp != NULL) {
…
}
…
return ret;
}
建议1.6.2:禁用abort函数
abort会导致程序立即退出,资源得不到清理。 例外: 只有发生致命错误,程序无法继续执行的时候,在错误处理函数中使用abort退出程序,例如:
void FatalError(int sig)
{
abort();
}
int main(int argc, char *argv[])
{
signal(SIGSEGV, FatalError);
…
}
2.字符串/数组操作
规则2.1:确保有足够的存储空间
部分字符串处理函数由于设计时安全考虑不足,或者存在一些隐含的目的缓冲区长度要求,容易被误用,导致缓冲区写溢出。典型函数如itoa,realpath。 以下的代码,试图将数字转为字符串,但是目标存储空间的长度不足。
int num = …
char str[8] = {0};
itoa(num, str, 10); // 10进制整数的最大存储长度是12个字节
以下的代码,试图将路径标准化,但是目标存储空间的长度不足。
char resolvedPath[100] = {0};
realpath(path, resolvedPath); //realpath函数的存储缓冲区长度是由PATH_MAX常量定义,或是由
_PC_PATH_MAX系统值配置的,通常都大于100字节
以下的代码,在对外部数据进行解析并将内容保存到name中,考虑了name的大小,是正确的做法。
char *msg = GetMsg();
…
char name[MAX_NAME] = {0};
int i=0;
//必须考虑msg不包含预期的字符’\n’
while (*msg != ‘\0’ && *msg != ‘\n’ && i < sizeof(name) - 1) {
name[i++] = *msg++;
}
name[i] = ‘\0’; //保证最后有’\0’
规则2.2:对字符串进行存储操作,确保字符串有’\0’结束符
对字符串进行存储操作,必须确保字符串有’\0’结束符,否则在后续的调用strlen等操作中,可能会导致内存越界访问漏洞。
规则2.3:外部数据作为数组索引时必须确保在数组大小范围内
外部数据作为数组索引对内存进行访问时,必须对数据的大小进行严格的校验,否则为导致严重的错误。 下面的代码,通过if语句判断offset的合法性:
int Foo(BYTE *buffer, int size)
{
…
int offset = ReadIntFromMsg();
if (offset >= 0 && offset < size) {
BYTE c = buffer[offset];
…
}
…
}
规则2.4:外部输入作为内存操作相关函数的复制长度时,需要校验其合法性
在调用内存操作相关的函数时(例如memcpy、memmove、memcpy_s、memmove_s等),如果复制长度外部可控,则必须校验其合法性,否则容易导致内存溢出。 下例中,循环长度来自设备外部报文,由于没有校验大小,可造成缓冲区溢出:
-
typedef struct BigIntType {
-
unsigned int length;
-
char val[MAX_INT_DIGITS];
-
}BigInt;
-
BigInt *AsnOctsToBigInt(const AsnOcts *asnOcts)
-
{
-
BigInt *bigNumber = NULL;
-
…
-
for (i = 0; i < asnOcts->octetLen; i++) {
-
bigNumber->val[i] = asnOcts->octs[i];
-
}
-
…
-
}
规则2.5:调用格式化函数时,禁止format参数由外部可控
调用格式化函数时,如果format参数由外部可控,会造成字符串格式化漏洞。 这些格式化函数有:
- char *msg = GetMsg();
- …
- printf(msg);
推荐做法:
- char *msg = GetMsg();
- …
- printf(“%s\n”, msg);
3.整数
规则3.1:整数之间运算时必须严格检查,确保不会出现溢出、反转、除0
在计算机中,整数存储的长度是固定的(例如32位或64位),当整数之间进行运算时,可能会超过这个最大固定长度,导致整数溢出或反转,使得实际计算结果与预期结果不符。 如果涉及到除法或者求余操作,必须确保除数不为0。
错误示例1:
- size_t width = ReadByte();
- size_t height = ReadByte();
- size_t total = width * height; //可能整数溢出
- void *bitmaps = malloc(total);
推荐做法1:
- size_t width = ReadByte();
- size_t height = ReadByte();
- if (width == 0 || height == 0 || width > MAX_WIDTH || height > MAX_HEIGHT) {
- //error
- …
- }
- size_t total = width * height; // MAX_WIDTH * MAX_HEIGHT 不会溢出
- void *bitmaps = malloc(total);
错误示例2:
- size_t a = ReadByte();
- size_t b = 1000 / a; //a可能是0
- size_t c = 1000 % a; //a可能是0
- …
推荐做法2:
- size_t a = ReadByte();
- if (a == 0) {
- //error
- …
- }
- size_t b = 1000 / a; //a不可能是0
- size_t c = 1000 % a; //a不可能是0
- …
规则3.2:整型表达式比较或赋值为一种更大类型之前必须用这种更大类型对它进行求值
由于整数在运算过程中可能溢出,当运算结果赋值给比他更大类型,或者和比他更大类型进行比较时,会导致实际结果与预期结果不符。 请观察以下二个代码及其输出:
- int main(int argc, char *argv[])
- {
- unsigned int a = 0x10000000;
- unsigned long long b = a * 0xab;
- printf(“b = %llX\n”, b);
- return 0;
- }
输出: b = B0000000
规则3.3:禁止对有符号整数进行位操作符运算
位操作符(~、>>、<<、&、^、|)应该只用于无符号整型操作数。 错误示例:
-
int data = ReadByte();
-
int a = data >> 24;
推荐做法:(为简化示例代码,此处假设ReadByte函数实际不存在返回值小于0的情况) -
unsigned int data = (unsigned int)ReadByte();
-
unsigned int a = data >> 24;
规则3.4:禁止整数与指针间的互相转化
指针的大小随着平台的不同而不同,强行进行整数与指针间的互相转化,降低了程序的兼容性,在转换过程中可能引起指针高位信息的丢失。
错误示例:
- char *ptr = …;
- unsigned int number = (unsigned int)ptr;
推荐做法:
- char *ptr = …;
- uintptr_t number = (uintptr_t)ptr;
规则3.5:禁止对指针进行逻辑或位运算(&&、||、!、~、>>、<<、&、^、|)
对指针进行逻辑运算,会导致指针的性质改变,可能产生内存非法访问的问题。 下面是错误的用法:
- BOOL dealName(const char *nameA, const char *nameB)
- {
- …
- if (nameA)
- …
- if (!nameB)
- …
- }
下面是正确的用法:
- BOOL dealName(const char *nameA, const char *nameB)
- {
- …
- if (nameA != NULL)
- …
- if (nameB == NULL)
- …
- }
例外: 为检查地址对齐而对地址指针进行的位运算可以作为例外。
规则3.6:循环次数如果受外部数据控制,需要校验其合法性
如下示例中,由于循环条件受外部输入的报文内容控制,可进入死循环:
- unsigned char *FindAttr(unsigned char type, unsigned char *msg, size_t inputMsgLen)
- {
- …
- msgLength = ntohs(*(unsigned short *)&msg[RD_LEA_PKT_LENGTH]);
- …
- while (msgLength != 0) {
- attrType = msg[0];
- attrLength = msg[RD_LEA_PKT_LENGTH];
- …
- msgLength -= attrLength;
- msg += attrLength;
- }
- …
- }
此例中,需要检查报文的实际可读长度,报文内容提供的循环增量(避免为0),以防止缓冲区溢出。
4.内存
规则4.1:内存申请前,必须对申请内存大小进行合法性校验
内存申请的大小可能来自于外部数据,必须检查其合法性,防止过多地、非法地申请内存。不能申请0长度的内
存。 例如:
- int Foo(int size)
- {
- if (size <= 0) {
- //error
- …
- }
- …
- char *msg = (char *)malloc(size);
- …
- }
规则4.2:内存分配后必须判断是否成功
- char *msg = (char *)malloc(size);
- if (msg != NULL) {
- …
- }
规则4.3:禁止引用未初始化的内存
malloc、new分配出来的内存没有被初始化为0,要确保内存被引用前是被初始化的。 以下代码使用malloc申请内
存,在使用前没有初始化:
- int *CalcMetrixColomn( int **metrix ,int *param, size_t size )
- {
- int *result = NULL;
- …
- size_t bufSize = size * sizeof(int);
- …
- result = (int *)malloc(bufSize);
- …
- result[0] += metrix[0][0] * param[0];
- …
- return result;
- }
规则4.3:禁止引用未初始化的内存
以下代码使用memset_s()对分配出来的内存清零。
- int *CalcMetrixColomn(int **metrix ,int *param, size_t size)
- {
- int *result = NULL;
- …
- size_t bufSize = size * sizeof(int);
- …
- result = (int *)malloc(bufSize);
- …
- int ret = memset_s(result, bufSize, 0, bufSize); //【修改】确保内存被初始化后才被引
用 - …
- result[0] += metrix[0][0] * param[0];
- …
- return result;
- }
规则4.4:内存释放之后立即赋予新值
悬挂指针可能会导致双重释放(double-free)以及访问已释放内存的危险。消除悬挂指针以及消除众多与内存相
关危险的一个最为有效地方法就是当指针使用完后将其置新值。 如果一个指针释放后能够马上离开作用域,因为它
已经不能被再次访问,因此可以无需对其赋予新值。
示例:
- char *message = NULL;
- …
- message = (char *)malloc(len);
- …
- if (…) {
- free(message); //在这个分支内对内存进行了释放
- message = NULL; //释放后将指针赋值为NULL
- }
- …
- if (message != NULL) {
- free(message);
- message = NULL;
- }
规则4.5:禁止使用realloc()函数
realloc()原型如下:
- void *realloc(void *ptr, size_t size);
随着参数的不同,其行为也是不同。 1) 当ptr不为NULL,且size不为0时,该函数会重新调整内存大小,并将新
的内存指针返回,并保证最小的size的内容不变; 2) 参数ptr为NULL,但size不为0,那么行为等同于
malloc(size); 3) 参数size为0,则realloc的行为等同于free(ptr)。 由此可见,一个简单的C函数,却被赋予了3种
行为,这不是一个设计良好的函数。虽然在编码中提供了一些便利性,但是却极易引发各种bug。
5.不安全函数
6.文件输入/输出
规则5.1:创建文件时必须显式指定合适的文件访问权限
创建文件时,如果不显式指定合适访问权限,可能会让未经授权的用户访问该文件。 下列代码没有显式配置文件的访问权限。
int fd = open(fileName, O_CREAT | O_WRONLY); //【错误】缺少访问权限设置
推荐做法:
int fd = open(fileName, O_CREAT | O_WRONLY, S_IRUSR|S_IWUSR);