概述
C规则防止各种问题,包括内存泄漏,堆栈损坏,缓冲区溢出,使用后释放,未初始化变量,指针内存分配缺陷,未检查解引用NULL返回值,NULL指针的解引用,负整数的误用,如何处理函数调用返回值,函数返回指向局部堆栈变量的指针,在危险使用后对整数进行边界检查。
Array vs Singleton(数组和单例模式)
C/ C++中的类型系统不区分“指向一个对象的指针”和“指向对象数组的指针”。因此,很容易意外地将指针算术应用于仅指向单例对象的指针。数组vs单例涵盖了这种错误的许多情况。通常,当指针算术应用于单例指针时,结果要么是从内存中读取垃圾/意外值,要么写入意外内存并损坏它。
例子下面的示例包含一个缺陷,因为在基类指针上执行的算术假设对象是基类的大小,但原始数组的对象是派生类的大小:
class Base
{
public:
int x;
};
class Derived : public Base
{
public:
int y;
};
void f(Base *b)
{
b[1].x = 4;
}
Derived arr[3];
f(arr); // Defect
表达式b[1]
意味着从b
开始,偏移一个Base
对象的大小(而不是Derived
对象的大小),然后解引用这个偏移后的地址。如果b
事实上是指向数组的指针,这样做是可以访问数组下一个元素的。但由于f
函数接收到的是单个对象的指针,加上[1]
会导致访问未知或未分配的内存区域,因为b
并未指向一个连续的对象数组。
因此,b[1]
会导致未定义行为,也就是说,根据C++标准,这种操作可能导致任何结果,包括但不限于程序崩溃、产生随机值或静默失败。在实际执行时,由于b
被错误地当作数组指针使用,很可能会尝试去访问不属于当前对象的内存,从而引发错误。
Bad free(错误释放)
对于不指向动态分配内存的指针执行free
操作,其行为是未定义的,通常会导致内存损坏。
在C/C++编程中,malloc
、calloc
、realloc
等函数用于在堆上动态分配内存,分配的内存需要通过free
函数手动释放。相反,栈上分配的局部变量、全局变量或静态变量的内存,以及函数指针或数组的地址,它们的生命周期由编译器自动管理,不应该也不需要通过free
函数释放。
当错误地对非动态分配的内存调用free
函数时,可能会覆盖堆内存管理系统的重要数据结构,从而导致内存的混乱和不可预知的行为。最常见的后果之一是内存损坏,这通常会在程序稍后的时间点引发程序崩溃或数据丢失等严重问题。
Freeing an array type:
struct S { int a[4]; };
void fn(struct S *s)
{
int stackarray[3];
int *p = stackarray; // array_assign
free(p); // incorrect_free
free(s->a); // array_free
}
这段代码中的free(p)
和free(s->a)
都是错误的内存释放操作,原因在于stackarray和a
是在栈上分配的内存,不是通过malloc
、calloc
或realloc
等动态内存分配函数分配的
Freeing a function pointer:
int (*fnptr)(int);
void fn()
{
free(fnptr);
}
在C语言中,释放一个函数指针(如上述代码所示)是错误的,因为函数指针并不指向动态分配的内存区域。函数指针只是存储了一个函数的地址,这个地址是由编译器在编译时期确定的,并且存储在程序的代码段或只读数据段中,而非堆或栈上。
Bad sizeof(计算内存的错误)
C/C++中一些技术上合法但往往错误的sizeof
运算符用法。BAD_SIZEOF会识别出应用于下列情况的sizeof
运算符:
-
指针类型的函数参数:当
sizeof
运用于具有指针类型的函数参数时,可能错误地计算了指针自身的大小,而非指针所指向的数据类型的大小。 -
C++中的
this
指针:this
在C++中是一个隐含的指针类型函数参数,直接对其应用sizeof
将计算指针自身大小,而非对象实例的大小。 -
对象地址:直接对对象地址执行
sizeof
运算,可能并非计算该对象实际大小,尤其是当意图测量数组或动态分配内存区域大小时,容易出现错误。 -
指针算术表达式:在指针算术表达式上应用
sizeof
通常会得到指针自身的大小,而非经过偏移后内存区域的大小。
错误的大小值可能导致一系列问题,诸如分配的内存不足或过多,缓冲区溢出,初始化或复制不完整,以及逻辑不一致等。修复这些缺陷取决于代码本应完成的任务。如果sizeof
的用法确实有误,通常可以通过从sizeof
的操作数中移除一层间接寻址(例如,对指针解引用),或调整括号的使用方式来解决问题。
size_t SomeClass::getObjectSize() const
{
return sizeof(this); /* Defect */
}
this
在C++中是一个隐含的指针类型函数参数,直接对其应用sizeof
将计算指针自身大小,而非对象实例的大小。
short s;
memset(&s, 0, sizeof(&s)); /* Defect */
传递给sizeof
的参数是&s
(即short
类型的变量s
的地址),而不是s
本身。sizeof(&s)
会计算s
变量地址的大小,通常在32位系统上是4字节,在64位系统上是8字节,而不是short
类型的大小(通常为2字节)。
Forward NULL(检查到指针为NULL后,仍然在某个条件下错误地对NULL指针进行了解引用操作)
对NULL指针进行解引用操作将会导致程序崩溃。FORWARD_NULL检查包含以下三种情况:
-
先检查指针是否为NULL,然后在该指针为NULL的情况下沿某一路径对其进行解引用。这意味着即使之前已经检查过指针是否为空,但在后续代码中仍有可能在未确保指针非空的情况下解引用它。
-
将指针赋值为NULL,然后在未改变其值的情况下沿某一路径继续执行并对该指针进行解引用。这意味着在设置指针为NULL之后,没有重新赋予新的有效地址就直接使用了该指针。
-
在没有首先检查其是否为NULL的情况下,直接对
dynamic_cast
返回值进行解引用。dynamic_cast
在类型转换失败时会返回NULL(对于指针类型),如果知道返回值肯定不会为NULL,可以使用static_cast
进行类型转换以避免生成缺陷报告。但如果无法确保转换成功,则应在解引用前检查其是否为NULL。int forward_null_example1(int *p) { int x; if ( p == NULL ) { x = 0; } else { x = *p; } x += fn(); *p = x; // Defect: p is potentially NULL return 0; }
这个例子中,函数首先检查了传入的指针
p
是否为NULL。如果p
是NULL,则初始化变量x
为0;否则从p
指向的内存位置读取一个整数值赋给x
。然而,在执行到*p = x;
这一行时,无论之前是否检查了p
是否为NULL,这里都直接对p
进行了解引用和赋值操作,而此时并没有再次确认p
是否仍为NULL。因此,如果p
在初次检查后直至此行时仍然保持NULL状态,这将会触发NULL指针解引用的问题,导致程序崩溃。
Infinite loop(无限循环)
这些循环会导致程序挂起或崩溃。这类问题通常发生在循环控制变量没有按照循环条件中的关系运算符适当更新的情况下。
void foo(int x)
{
int i=0;
while (true)
{
if (i >= 10)
{
if (x == 55)
{ // x is never updated
break;
}
}
i++;
}
}
除非在进入循环之前将x设置为55,否则它将永远不会终止
for (i = 0; i < p->x[0].hi * p->x[0].lo; j++)
{
a += p->x[0].hi;
}
错误的循环控制变量(j而不是i)
char c = foo();
while (c != EOF)
{
if (c == 0x1c)
{
found = 1;
}
else
{
if (found)
{
return -1;
}
else
{
continue;
}
}
}
没有正确更新c。如果它的值不是EOF或0x1c(28),它将永远不会退出循环
NULL returns
许多未检查NULL返回值就直接解引用的情况。在开发过程中,有时程序员并未对函数返回值进行有效性检查,而直接以可能带来风险的方式使用这些返回值。对于任何可能返回NULL指针的函数,都需要在使用前先检查其是否为NULL,才能确保安全使用。若未能对可能为NULL的指针返回值进行检查,就进行解引用操作,将会导致由于NULL指针解引用而产生的程序崩溃。
void bad_malloc()
{
// malloc returns NULL on error
struct some_struct *x = (struct some_struct*)malloc(sizeof(*x));
// ERROR: memset dereferences possibly NULL pointer x
memset(x, 0, sizeof(*x));
}
在调用memset
之前,没有检查x
是否为NULL
。如果malloc
因为某种原因未能成功分配内存(例如,系统内存不足),x
就会被赋值为NULL
。在这种情况下,直接对NULL
指针x
进行解引用并调用memset
函数,将导致程序试图访问无效内存地址,从而引发运行时错误,如segmentation fault(段错误)。
Overrun static(静态变量/数组越界)
造成栈破坏和安全漏洞的最常见原因之一便是缓冲区溢出。当对栈上分配数组边界之外的内存进行操作时,会发生缓冲区溢出,导致内存损坏。这种损坏不仅会造成难以定位的内存一致性问题,还会产生安全漏洞,使得攻击者有可能夺取系统的控制权。
缓冲区溢出之所以常见,是因为诸如C和C++之类的编程语言本质上是不安全的。数组和指针引用并不会自动进行边界检查;程序员必须负责确保变量与逻辑边界之间的正确比较。而在程序的控制流涉及指针和索引在函数之间传递的情况下,要正确进行这些检查可能会变得相当困难。
void overrun()
{
struct some_struct vmax_mtd[2];
if (!vmax_mtd[1] && !vmax_mtd[2])
{ // Outside buffer access
iounmap((void *)iomapadr);
}
}
void overrun_pointer()
{
int buf[10];
int *x = &buff[1];
x[9] = 0;
buff[10]
}
在overrun()
函数中,对数组vmax_mtd
的访问超出了其声明的边界。数组vmax_mtd
声明为包含两个some_struct
元素的数组,其索引应该从0到1。然而,在条件判断中却试图访问vmax_mtd[1]
和vmax_mtd[2]
。
在overrun_pointer()
函数中,存在明显的缓冲区溢出问题。首先,指针x
指向数组buf
的第二个元素,即buf[1]
。然后,通过x[9] = 0;
对数组buf
的第10个元素进行写操作,这已经是数组的有效边界之外。对buff[10]
的访问,它也将导致缓冲区溢出。
Overrun dynamic(动态分配内存越界)
许多动态分配堆内存缓冲区越界访问的实例。不恰当的缓冲区访问可能导致动态分配的堆内存遭到破坏,进而引发进程崩溃、安全漏洞以及其他严重的系统问题。OVERRUN_DYNAMIC特指的是对动态分配内存缓冲区进行越界索引访问的情况。
void bad_heap()
{
int *buffer = (int *) malloc(10 * sizeof(int)); // 40 bytes
int i = 0;
for(; i <= 10; i++)
{ // Defect: writes buffer[10] and overruns memory
buffer[i] = i;
}
}
bad_heap
函数中存在一个堆内存越界(OVERRUN_DYNAMIC)的问题。在该函数中,首先通过malloc
函数动态分配了大小为10个整数(40字节)的内存缓冲区。然后使用一个循环对缓冲区进行初始化,但是循环条件设置错误,循环直到i
达到11时才结束,这意味着会对buffer[10]
进行写操作。
void test(int i)
{
int n;
char *p = malloc(n);
int y = n; // Valid indices are buffer[0] to buffer[y - 1]
p[y] = 'a'; // Defect: writing to buffer[y] overruns local buffer
}
在调用malloc分配内存时,变量n尚未初始化,因此分配的内存大小是不确定的。这会导致未定义的行为,因为不清楚分配了多少字节的内存。正确的做法是先初始化n,然后再进行内存分配。即使n被正确初始化,对p[y]的赋值也是一个错误,因为它试图访问索引y处的内存,而有效的索引范围应该是从0到y-1。这个操作会导致本地缓冲区的越界写入,产生未定义行为,可能导致程序崩溃或其他不可预测的问题。
struct s
{
int a; int b;
}s1;
void test()
{
int n, i;
struct s *p = malloc(n * sizeof(struct s));
if (i <= n) // "i" can be equal to n
p[i] = s1; // Defect: overrun of buffer p
}
在调用malloc分配结构体数组时,变量n同样未初始化。同样需要先初始化n再进行内存分配。即使进行了正确的内存分配,条件语句if (i <= n)允许在i等于n的情况下访问p[i]。在C语言中,结构体数组的索引应该从0到n-1。因此,当i等于n时,对p[i]的赋值操作会导致缓冲区越界,这也是一个缺陷。正确的做法是将条件改为if (i < n),确保不会访问超出分配内存范围的位置。
Resource leak(资源泄露)
这些问题通常发生在变量拥有某种资源(最常见的为新分配的内存)并离开其作用域时。小规模的内存泄露可能导致长期运行而不重启的进程出现问题,严重的内存泄露则可能导致进程崩溃。如果用户输入或来自网络的数据触发内存泄露,还可能发生拒绝服务攻击。
文件描述符或套接字泄露可能导致程序崩溃、拒绝服务,甚至无法打开更多的文件或套接字。操作系统对一个进程可以拥有的文件描述符和套接字数量有限制。一旦达到限制,进程必须先关闭一部分已打开的资源句柄,才能分配更多资源。如果进程泄露了这些句柄,除非进程终止,否则无法回收这些资源。
很多内存泄露出现在遇到错误条件并意外泄露内存的错误处理路径上。其中一些情况可以通过在函数中设立一个统一的退出标签来解决,所有错误退出路径均使用goto语句跳转至此标签。在这个退出标签处,可以根据需要释放资源。
避免内存泄露的一个常用技巧是使用内存池(arena),它会记住在此区域内分配的所有内存,直到单个释放点一次性释放全部内存。在合适的情况下,内存池分配器在速度和正确性方面具有显著优势。
int leak_example(int c)
{
void *p = malloc(10);
if(c)
return -1; // "p" is leaked
/* ... */
free(p);
return 0;
}
直接return -1 退出函数了,未释放内存。
void test(int c)
{
FILE *p = fopen("foo.c", "rb");
if(c)
{
return; // leaking file pointer "p"
}
fclose(p);
}
如果c>0直接return,未关闭文件。
void calls_fnptr()
{
char *p = strdup("memory");
void (*fnptr)(void *) = simple;
fnptr(p); // Defect
}
strdup
是一个在C标准库中并不正式存在的函数,但在许多Unix/Linux系统以及POSIX兼容的系统中作为扩展函数广泛使用。strdup
函数主要用于复制一个字符串,并分配新的内存空间来存放复制的内容。需要free手动释放内存。
Return local(返回局部变量)
在C和C++中,当函数退出时,所有的局部变量都会随着栈帧的移除而丢失,控制权返回到调用函数。在被调函数栈上分配的变量将不再有效;当调用新函数时,它们的内存区域会被覆盖。将指向局部栈变量的指针返回给调用函数可能会导致内存损坏和不一致的行为。
some_struct * basic_return_local(struct some_struct *b)
{
struct some_struct a(*b); // a is copy-constructed onto the stack
return &a; // Returns a pointer to local struct a
}
局部变量a
会在栈上被销毁,因此返回的指针将指向无效的内存区域。任何尝试通过返回的指针访问或修改结构体数据的操作都将导致未定义的行为,包括但不限于内存访问冲突、程序崩溃以及其他难以预测的问题。
Reverese inull(反转空指针检查)
由于对NULL指针进行解引用操作会导致进程崩溃,所以在解引用前进行NULL检查至关重要。如果程序员确信指针不可能为NULL,在这种情况下,解引用操作是安全的。然而,即便如此,事后进行NULL检查也是不必要的,并且应该移除,因为它暗示着指针可能存在为NULL的可能性。另一方面,如果指针可能为NULL,那么通过将NULL检查移动到解引用操作之前,就可以修正这个问题,确保程序的健壮性。
void basic_reverse_null(struct buf_t *request_buf)
{
*request_buf = some_function(); // Assignment dereference
if (request_buff == NULL) // NULL check AFTER dereference
return;
}
这种顺序存在风险,因为在解引用赋值操作之前并没有检查request_buf
是否为NULL。如果request_buf
原本就是NULL,那么对它的解引用操作会导致程序崩溃或产生未定义的行为。
Reverse negative(反转负数检查)
在开发过程中,经常忽视了在可能产生危险后果的操作前对整数进行正确的范围检查。对负整数处理不当可能导致难以察觉的问题,从内存损坏到安全漏洞等严重问题。程序员“认为”整数不可能为负数,在这种情况下,负数检查实际上是不必要的,应该被移除,因为它向其他程序员传达了整数可能为负数的误导信息。整数实际上可能为负数,在这种情况下,负数检查应在危险操作之前进行,以确保程序正确性和安全性。
void simple_reverse_neg(int some_signed_integer)
{
some_struct *x = kmalloc(some_signed_integer, GFP_KERNEL); /* Dangerous
integer use */
if (some_signed_integer < 0)
{ // Check after use
return error;
}
}
在使用可能为负数的整数some_signed_integer
进行内存分配之前,没有对其进行负数检查。最后也没有进行kfree操作。
eg:kmalloc
kmalloc 是 Linux 内核空间中的内存分配函数,只能在内核代码中使用。
它分配的内存位于内核地址空间,而且通常保证分配的内存是物理上连续的。
kmalloc 适合分配较小且需要物理连续性的内存块,如用于设备驱动程序中的 DMA 操作。
调用格式:void *kmalloc(size_t size, gfp_t flags);
释放内存时,使用 kfree(void *ptr); 函数。
Sizecheck
若指针指向的内存块过小,那么尝试使用这个指针时可能会超出其应有的边界范围,这可能会导致堆内存破坏、程序崩溃以及其他严重问题。
struct sizecheck_example_t
{
int n;
float f;
char s[4];
void *p;
};
struct sizecheck_example_t *sizecheck_example(void)
{
struct sizecheck_example_t *ptr;
ptr = (sizecheck_example_t *)malloc( sizeof( ptr ) );
return ptr;
}
ptr为指针,分配大小为指针大小,错误
正确的做法应该是:
ptr = (sizecheck_example_t *)malloc( sizeof( struct sizecheck_example_t ) );
这样就能正确地为struct sizecheck_example_t
结构体分配足够的内存空间,避免了由于分配的内存不足而导致的内存访问越界、数据损坏等问题。
Sizeof mismatch(计算的内存大小与实际需要分配或使用的内存大小不匹配)
指针与sizeof表达式之间疑似不匹配的组合。当指针和sizeof表达式一起出现时,sizeof表达式通常应代表指针所指向内存区域的大小。
struct buffer
{
char b[100];
};
void f(struct buffer *p)
{
p += sizeof(struct buffer); /* Defect: "sizeof(struct buffer)" should be
"1" */
}
这里的意图可能是想让指针p
指向当前结构体之后的位置,但由于错误地使用了sizeof(struct buffer)
,结果并不是预期的那样移动到下一个结构体的起始位置,而是移动了相当于整个结构体大小的字节数。
在C语言中,当你想要让指针指向同一个数组(或结构体中的连续数组成员)的下一个元素时,只需将其加1即可。每个元素的大小会被隐式地考虑在内。因此,正确的做法应该是:
p += 1;
Stack use(栈使用)
无论是通过间接调用(函数指针)还是直接或间接递归,都需要检查栈的使用情况。防止因栈空间耗尽导致的栈溢出问题,尤其针对那些具有较大局部变量、深层递归或间接调用等情况。
void stack_use_callee1(void)
{
char buf[1024]; // 1024 bytes of stack usage
char c; /* 4 bytes of stack usage,1 byte promoted to 4 byte alignment requirement */
}
void stack_use_callee2(void)
{
char buf[16384]; // Exceeds max single base use of 1024 bytes
}
void stack_use_callee3(void)
{
char buf[20000]; // Exceeds max single base use of 1024 bytes
}
在某些嵌入式系统或资源受限的环境中,栈空间是有限的,过多地使用栈空间可能导致栈溢出(stack overflow)
Missing break
指遗漏了中断当前循环或switch分支的break语句。这会导致程序在执行流上出现问题,无法正常跳出相应的循环或switch块,而是继续执行下一个case或循环迭代。
void doSomething(int what)
{
switch (what)
{
case 1:
foo();
break;
case 2:
bar();// Defect: Missing break statement in this case
case 3:
gorf();
break;
case 3:
foo();
// Correct: Some comment justifying the missing break
default:
break;
}
}
在上述代码中,存在一个“Missing break”的问题。在case 2:
分支里,执行了bar()
函数,但没有跟随break
语句。这会导致在执行完bar()
后,程序会继续执行下一个case
,即case 3:
里的gorf()
函数,即使输入的what
值仅仅是2。并且case3重复定义。
Unreliable cast of function pointer(不可靠的函数指针强制转换)
在C或C++中,不安全的函数指针转换通常指的是显式地将一个类型的函数指针强制转换为另一个类型,而两个函数的签名(包括返回类型和参数列表)并不完全匹配。这样做可能导致运行时错误,因为编译器无法确保转换后的函数指针被正确调用。
#include <math.h>
#include <stdio.h>
#define PI 3.142
double Calculate_Sum(int (*fptr)(double))
{
double sum = 0.0; double y;
for (int i = 0; i <= 100; i++)
{
y = (*fptr)(i*PI/100); sum += y;
}
return sum / 100;
}
int main(void)
{
double (*fp)(double); double sum;
fp = sin;
sum = Calculate_Sum(fp);
/* Defect: fp implicitly cast to int(*) (double) */
printf("sum(sin): %f\n", sum); return 0;
}
这里存在一个隐式的类型转换错误,即fp
从double (*)(double)
隐式转换为了int (*)(double)
。尽管在某些编译器和架构下,这种转换可能不会导致程序崩溃,但从类型安全的角度看,这是不推荐的,因为原始函数的返回类型信息丢失,可能会导致意外的结果。