变量声明赋予初值,可以避免由于编程人员的疏忽导致的变量未初始化引用。
示例:
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未初始化的内存。
}
资源释放后,对应的变量应该立即赋予新值,防止后续又被重新引用。如果释放语句刚好在变量作用域的最后一句,可以不进行赋值。
示例:
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;
…
变量声明赋予初值,可以避免由于编程人员的疏忽导致的变量未初始化引用。
示例:
class CMsg {
public:
CMsg();
~CMsg();
protected:
int size;
unsigned char *msg;
};
CMsg::CMsg()
{
size = 0;
msg = NULL;
}
编码人员往往由于粗心,将指针当做数组进行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 *)的方式。
在变量声明前加const关键字,表示该变量不可被修改,这样就可以利用编译器进行类型检查,将代码的权限降到更低。
例如下面是不好的定义:
float pi = 3.14159f;
应当这样定义:
const float PI = 3.14159f;
应该尽可能减少全局变量的使用,如果多个线程会访问到该全局变量,则访问过程必须加锁。 以下代码中,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;
}
性能敏感的代码,请考虑采用原子操作或者无锁算法。
程序在运行期间,函数内的局部变量保存在栈中,栈的大小是有限的。如果申请过大的静态数组,可能导致出现运行出错。 建议在申请静态数组的时候,大小不超过0x1000。 下面的代码,buff申请过大,导致栈空间不够,程序发生stackoverflow异常。
#define MAX_BUFF 0x1000000
int Foo()
{
char buff[MAX_BUFF] = {0};
…
}
断言是一种除错机制,用于验证代码是否符合编码人员的预期。编码人员在开发期间应该对函数的参数、代码中间执行结果合理地使用断言机制,确保程序的缺陷尽量在测试阶段被发现。 断言被触发后,说明程序出现了不应该出现的严重错误,程序会立即提示错误,并终止执行。 断言必须用宏进行定义,只在调试版本有效,最终发布版本不允许出现assert函数,例如:
#include
#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
断言只能在调试版使用,断言被触发后,程序会立即退出,因此严禁在正式发布版本使用断言,请通过编译选项进行控制。 错误用法如:
int Foo(int *array, int size)
{
assert(array != NULL);
…
}
断言不能用于校验程序在运行期间可能导致的错误。 以下代码的所有ASSERT的用法是错误的。
为了更加准确地发现错误的位置,每一条断言只校验一个条件。 下面的断言同时校验多个条件,在断言触发的时候,无法判断到底是哪一个条件导致的错误:
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);
…
}
通过函数参数传递数组或一块内存进行写操作时,函数参数必须同时传递数组元素个数或所传递的内存块大小,否则函数在使用数组下标或访问内存偏移时,无法判断下标或偏移的合法范围,产生越界访问的漏洞。 以下代码中,函数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);
…
如果参数是指针型参数,且内容不会被修改,请定义为const类型。
int Foo(const char *filePath)
{
…
int fd = open(filePath, …);
…
}
如果字符串或者指针作为函数参数,为了防止空指针引用错误,在引用前必须确保该参数不为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);
}
…
严禁使用C++的异常机制,所有的错误都应该通过错误值在函数之间传递并做相应的判断, 而不应该通过异常机制进行错误处理。 编码人员必须完全掌控整个编码过程,建立攻击者思维,增强安全编码意识,主动把握有可能出错的环节。而使用C++异常机制进行错误处理,会削弱编码人员的安全意识。 异常机制会打乱程序的正常执行流程,使程序结构更加复杂,原先申请的资源可能会得不到有效清理。 异常机制导致代码的复用性降低,使用了异常机制的代码,不能直接给不使用异常机制的代码复用。 异常机制在实现上依赖于编译器、操作系统、处理器,使用异常机制,导致程序执行性能降低。 在二进制层面,程序被加载后,异常处理函数增加了程序的被攻击面,攻击者可以通过覆盖异常处理函数地址,达到攻击的效果。 例外: 在接管C++语言本身抛出的异常(例如new失败、STL)、第三方库(例如IDL)抛出的异常时,可以使用异常机制,例如:
int len = …;
char *p = NULL;
try {
p = new char[len];
}
catch (bad_alloc) {
…
abort();
}
构造函数没有返回值,不能做错误判断,因此在构造函数内,不能做任何有可能失败的操作。 下面的代码中,
open、new、ConnectServer都有可能失败,这些操作不应该放在构造函数内。
CFoo::CFoo()
{
int fd = open(…);
char *str = new char[…];
BOOL b = ConnectServer(…);
…
}
构造函数内仅作成员变量的初始化工作,其他的操作通过成员函数完成。
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;
}
类成员进行定义的时候,需要考虑类的功能,尽量减少对外接口的暴露。
atexit函数注册若干个有限的函数,当exit被调用后,自动调用由atexit事先注册的函数。 当资源不再使用后,编码人员应该立即主动地进行清理,而不应该在最终程序退出后通过事先注册的例程被动地清理。
例外: 作为服务维测监控功能,为定位程序异常退出原因的模块,可以作为例外使用atexit()函数。
调用kill、TerminateProcess等函数强行终止其他进程(如kill -9),会导致其他进程的资源得不到清理。 对于进程间通信,应该主动发送一个停止命令,通知对方进程安全退出。 当发送给对方进程退出信号后,在等待一定时间内如果对方进程仍然未退出,可以调用kill、TerminateProcess函数。
if (WaitForRemoteProcessExit(…) == TIME_OUT) {
kill(…); //目标进程在限定时间内仍然未退出,强行结束目标进程
}
严禁在线程内主动终止自身线程,线程函数在执行完毕后会自动、安全地退出。主动终止自身线程的操作,不仅导致代码复用性变差,同时容易导致资源泄漏错误。
程序应该安全退出,除了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;
}
abort会导致程序立即退出,资源得不到清理。 例外: 只有发生致命错误,程序无法继续执行的时候,在错误处理函数中使用abort退出程序,例如:
void FatalError(int sig)
{
abort();
}
int main(int argc, char *argv[])
{
signal(SIGSEGV, FatalError);
…
}
部分字符串处理函数由于设计时安全考虑不足,或者存在一些隐含的目的缓冲区长度要求,容易被误用,导致缓冲区写溢出。典型函数如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’
对字符串进行存储操作,必须确保字符串有’\0’结束符,否则在后续的调用strlen等操作中,可能会导致内存越界访问漏洞。
外部数据作为数组索引对内存进行访问时,必须对数据的大小进行严格的校验,否则为导致严重的错误。 下面的代码,通过if语句判断offset的合法性:
int Foo(BYTE *buffer, int size)
{
…
int offset = ReadIntFromMsg();
if (offset >= 0 && offset < size) {
BYTE c = buffer[offset];
…
}
…
}
在调用内存操作相关的函数时(例如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];
}
…
}
调用格式化函数时,如果format参数由外部可控,会造成字符串格式化漏洞。 这些格式化函数有:
推荐做法:
在计算机中,整数存储的长度是固定的(例如32位或64位),当整数之间进行运算时,可能会超过这个最大固定长度,导致整数溢出或反转,使得实际计算结果与预期结果不符。 如果涉及到除法或者求余操作,必须确保除数不为0。
错误示例1:
推荐做法1:
错误示例2:
推荐做法2:
由于整数在运算过程中可能溢出,当运算结果赋值给比他更大类型,或者和比他更大类型进行比较时,会导致实际结果与预期结果不符。 请观察以下二个代码及其输出:
输出: b = B0000000
位操作符(~、>>、<<、&、^、|)应该只用于无符号整型操作数。 错误示例:
int data = ReadByte();
int a = data >> 24;
推荐做法:(为简化示例代码,此处假设ReadByte函数实际不存在返回值小于0的情况)
unsigned int data = (unsigned int)ReadByte();
unsigned int a = data >> 24;
指针的大小随着平台的不同而不同,强行进行整数与指针间的互相转化,降低了程序的兼容性,在转换过程中可能引起指针高位信息的丢失。
错误示例:
推荐做法:
对指针进行逻辑运算,会导致指针的性质改变,可能产生内存非法访问的问题。 下面是错误的用法:
下面是正确的用法:
如下示例中,由于循环条件受外部输入的报文内容控制,可进入死循环:
内存申请的大小可能来自于外部数据,必须检查其合法性,防止过多地、非法地申请内存。不能申请0长度的内
存。 例如:
malloc、new分配出来的内存没有被初始化为0,要确保内存被引用前是被初始化的。 以下代码使用malloc申请内
存,在使用前没有初始化:
以下代码使用memset_s()对分配出来的内存清零。
悬挂指针可能会导致双重释放(double-free)以及访问已释放内存的危险。消除悬挂指针以及消除众多与内存相
关危险的一个最为有效地方法就是当指针使用完后将其置新值。 如果一个指针释放后能够马上离开作用域,因为它
已经不能被再次访问,因此可以无需对其赋予新值。
示例:
realloc()原型如下:
随着参数的不同,其行为也是不同。 1) 当ptr不为NULL,且size不为0时,该函数会重新调整内存大小,并将新
的内存指针返回,并保证最小的size的内容不变; 2) 参数ptr为NULL,但size不为0,那么行为等同于
malloc(size); 3) 参数size为0,则realloc的行为等同于free(ptr)。 由此可见,一个简单的C函数,却被赋予了3种
行为,这不是一个设计良好的函数。虽然在编码中提供了一些便利性,但是却极易引发各种bug。
创建文件时,如果不显式指定合适访问权限,可能会让未经授权的用户访问该文件。 下列代码没有显式配置文件的访问权限。
int fd = open(fileName, O_CREAT | O_WRONLY); //【错误】缺少访问权限设置
推荐做法:
int fd = open(fileName, O_CREAT | O_WRONLY, S_IRUSR|S_IWUSR);