利用GCC 11检测内存管理错误,第1部分:理解动态内存分配
内存管理错误是C和C++程序中最难发现的错误之一,因此,它们也是攻击者最喜欢的攻击目标。同时,这些错误还很难调试,因为它们涉及到程序中的三个不同的地方(分配内存、使用所分配内存以及释放内存),这些地方通常相距甚远,并且经常被指针的使用所掩盖。在这篇由两部分组成的文章中,我们将研究GCC 11的增强功能,这些增强功能有助于检测这些影响动态分配内存的错误。另外,David Malcolm在他的文章static analysis updates in GCC11中介绍了GCC静态分析器的相关改进。
在这篇文章中,我包含了一些示例代码的链接,以方便喜欢亲自动手实验的人使用。读者可以在每个示例的源代码上方找到这些链接。
内存分配策略概述
下面,让我们了解一下主要类型的内存管理错误。C和C++的内存分配策略分为四大类,具体如下所示:
· 自动类型:这种策略是在函数的堆栈中为对象分配内存。除了非标准的、不鼓励使用的、但仍然广泛使用的alloca()函数外,自动对象在声明时就被分配内存。并且,它们通常通过名称进行引用,除非它们以引用的方式传递给其他函数,或者用指针指向数组中的元素。顾名思义,自动对象在声明它们的代码块的末尾被自动释放。由alloca()分配的内存,将在函数返回时释放。
· 动态类型:这种策略通过明确调用内存分配函数在堆上为对象分配内存空间。为了避免内存耗尽,动态分配的对象必须通过显式调用内存释放函数来释放内存。
· 静态类型:这种策略为命名的对象分配的内存空间,在程序的生存期内将一直存在。由于它们永远不会超出作用域,所以它们在程序执行过程中永远不会被释放。
· 线程类型:和静态类型相似,只是生存期仅限于线程的执行时间。这个策略中的对象会在创建它们的线程终止时自动释放。
在这四种政策中,动态分配的内存不仅最容易出现问题,并且这些问题还非常的隐蔽。所以,这种形式的内存分配就是本文探讨的主题。可以肯定的是,很多错误也与自动分配策略有关(例如读取未初始化的内存空间,或者在局部变量的作用域之外,使用在其生存期内获得的指针对其进行的访问),对于这些问题,我们将单独用一篇文章进行介绍。
GCC 11中的新命令行选项
在深入探讨GCC 11可以检测的动态内存管理错误的细节之前,让我们先来总结一下与内存检测相关的命令行选项。需要注意的是,这里介绍的所有选项都是默认启用的。尽管它们在启用优化功能的情况下表现最好,但这不是必须的。
GCC 11提供了两个新的选项,并显著增强了一个已可用于多个版本的选项:
· -Wmismatched-dealloc选项,用于控制常见的内存分配函数和内存释放函数调用之间不匹配的警告。这个选项在GCC 11中是新增的。
· -Wmismatched-new-delete选项,用于控制C++中操作符new()和操作符delete()之间不匹配的警告。这个选项在GCC 11中也是新增的。
· -Wfree-nonheap-object选项,用于控制因非法释放动态内存指针(即,并非由动态内存分配函数返回的指针)而引发的警告。这个选项在GCC 11中得到了增强。
动态内存管理函数
对于C语言而言,最著名的动态内存管理函数就是calloc()、malloc()、realloc()和free()函数。除了这些C89函数之外,C99还引入了aligned_alloc()函数。同时,POSIX还引入了一些自己的内存分配函数,包括strdup()、strndup()和tempnam()函数,等等。实际上,C库的实现也经常提供自己的扩展。例如,FreeBSD、Linux和Solaris都定义了一个名为reallocarray()的函数,它是Calloc()和realloc()的混合体。所有这些内存分配函数返回的指针都必须传递给free()函数,才能将这些内存进行释放。
除了动态分配原始内存的函数外,还有几个标准API可以用来分配和释放其他资源。例如,fopen()、fdopen()以及POSIX的open_memstream()函数,可以用来创建并初始化FILE对象,但是,它们必须通过调用fclose()来释放相应的资源;同时,popen()函数也可以用来创建FILE,但必须通过调用pclose()来释放相关的资源。同样,POSIX的newlocale()和diplocale()函数可以用于创建语言环境(locale),但是,它们必须通过调用freelocale()来进行销毁。
最后,许多第三方库和程序都定义了它们自己的函数,以分配原始内存或初始化驻留在已分配内存中的各种类型的对象。这些函数通常将对象的指针返回给它们的调用方。对于这些函数分配的内存空间,最简单的释放方式是直接调用free()函数,但大多数API都不是通过这种方式释放内存的,而是由调用方将它们交由相应的释放函数来完成销毁和释放工作。
不过,所有这些API都有一个共同的主题:内存分配函数都会返回一个指针,用来访问对象;重要的是,这个指针最终必须被传递给相应的释放函数。例如,malloc()函数返回的指针最终必须传递给free(),而fopen()函数返回的指针则最终必须传递给fclose()函数。因此,将fopen()函数返回的指针传递给free()函数就是一个错误,同样,将malloc()返回的指针传递给fclose()函数也是一个错误。此外,在C++中,一个特定形式的运算符new()返回的指针(无论是普通的指针,还是数组指针),必须由相应形式的运算符delete()来删除,而不能通过调用free()或realloc()函数来删除。
内存的分配函数与释放函数必须配对使用
对于使用特定的内存分配函数分配的内存空间,如果调用“错误的”释放函数进行释放的话,通常会导致内存损坏。这时,代码可能在调用处立即崩溃,有时会返回有用的信息,或者可能会返回到调用方,并在稍后的某个时间崩溃,也就是在调用释放函数之外的地方发生崩溃。此外,释放函数也可能根本不会崩溃,而是覆盖了一些数据,导致将来发生不可预知的行为。当然,我们希望能够预防这些错误,如果无法防止的话,还是设法尽早检测出这些错误,而不是临近产品发布才发现这些问题——最好是在代码开发期间,在它们被提交到代码库之前发现问题。这里的挑战在于,如何让我们的工具(编译器或静态分析器)知道哪些内存分配函数分配的对象,必须用哪些内存释放函数来释放。
对于标准函数子集来说,其语义和关联可以而且经常被植入工具本身。例如,GCC知道标准的C和C++动态内存管理函数的效果,以及哪些函数与哪些函数相配,但它对
属性malloc
这里所说的属性malloc,严格来说是指在GCC 11中的增强版本的属性malloc。在传统的形式下,该属性不需要参数,只是让GCC知道具有该属性的函数将返回动态分配的内存,如malloc()函数。这个属性被GCC用来对返回的内存内容进行别名假设,并生成更有效的代码。实际上,GCC 11对属性malloc进行了扩展,使其可以接受一个或两个参数:为释放所分配的对象而调用的释放函数的名称,以及一个参数下标(可选),也就是调用释放函数时,相应的指针必须传递到这个参数位置。这样的话,同一个内存分配函数就可以与任意数量的释放函数配对使用了。例如,下面的声明指定fclose()函数为fopen()、fdopen()、fmemopen()和tmpfile()的函数的释放函数,而pclose()函数则是popen()函数唯一的释放函数。
int fclose (FILE*); int pclose (FILE*); __attribute__ ((malloc (fclose, 1)))) FILE* fdopen (int); __attribute__ ((malloc (fclose, 1)))) FILE* fopen (const char*, const char*); __attribute__ ((malloc (fclose, 1)))) FILE* fmemopen (void *, size_t, const char *); __attribute__ ((malloc (pclose, 1)))) FILE* popen (const char*, const char*); __attribute__ ((malloc (fclose, 1)))) FILE* tmpfile (void);
理想情况下,< stdio.h >和其他C库头文件中的声明都应该用attributeemalloc进行修饰。当前,针对Linux系统上glibc库的补丁已经提交,但还没有被批准。在这之前,我们可以在自己的头文件中加入如前所述的声明,以实现同样的检测。此外,完整的补丁也可以从sourceware.org下载。
GCC本身及其集成的静态分析器都使用了新的属性来发出类似的警告。不过,静态分析器可以检测到更多的问题,代价是编译时间会更长一些。
检测不匹配的释放函数
GCC 11中的一些警告使用了新属性malloc来检测各种内存管理错误。比如,-Wmismatched-dealloc选项可以用来控制释放函数收到来自不匹配的内存分配函数返回的参数时是否发送警告信息。例如,考虑到上一节的声明,以下函数中对fclose()的调用就会被诊断出来,因为传递给它的指针是从一个与之不匹配的内存分配函数返回的,该函数为popen()。下面,我们以popen_pclose为例进行演示:
void test_popen_fclose (void) { FILE *f = popen ("/bin/ls"); // use f fclose (f); }
下面是编译器给出的警告信息:
In function 'test_popen_fclose': warning: 'fclose' called on pointer returned from a mismatched allocation function [-Wmismatched-dealloc] 21 | fclose (f); | ^~~~~~~~~~ note: returned from 'popen' 19 | FILE *f = popen ("/bin/ls", "r"); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
在本文的后半部分中,我们将为大家演示可用于检测动态内存分配错误的选项。最后,我们将介绍可能导致误报或漏报的各种情况。
利用GCC 11检测内存管理错误,第2部分:内存释放函数
本文的前半部分描述了C和C++中的动态内存分配机制,以及GCC 11的部分新特性,利用这些特性,可帮助我们检测动态内存分配函数中的相关错误。接下来,我们继续介绍GCC 11中的相关特性,并解释了检测机制可能在哪些情况下会出现误报或漏报的报告。
new操作符和delete操作符之间的错配
C++的一个特点是,对运算符new()的特定形式及其重载的调用必须与运算符delete()的相应形式及其重载相匹配。C++为这些运算符提供了许多不同的形式,而GCC对此都烂熟于心。但除此之外,C++程序还可以对这些运算符进行重载,这种情况下,必须确保它们的搭配相互一致。否则,试图通过错误的形式来释放对象占用的资源时,很容易导致潜在的错误。此外,以标量形式声明操作符的类,也应该以数组形式声明“操作符对”(详情请参见R.15: Always overload matched allocation/deallocation pairs)。
例如,假设我们定义了一个管理自己内存的类。我们定义了操作符new()和操作符delete()的成员形式,但没有定义操作符的数组形式。接下来,我们分配了一个类的对象数组,然后,我们试图将其删除,具体如下面的代码所示。这时,GCC 11就能检测出这个错误,并发出了一个-Wmismatched-new-delete警告,指出了这个错误。因为运算符new()和运算符delete()被认为是特殊的运算符,所以,即使没有malloc属性也会发生这种情况。下面,我们用测试用例array_new_delete进行演示:
下面是编译器给出的警告信息:
In function 'void f(A*)', inlined from 'void test_array_new_delete()': warning: 'static void A::operator delete(void*)' called on pointer returned from a mismatched allocation function [-Wmismatched-new-delete] 11 | delete p; | ^ In function 'void test_array_new_delete()': note: returned from 'void* operator new [](long unsigned int)' 16 | A *p = new A[2]; | ^
注意这个例子是如何用一个并不指向所分配数组起始位置的指针来调用f()函数的,而警告仍然能够检测到它并不是一个有效的内存释放函数的参数。即使该警告在没有优化的情况下仍然有效,也必须使用-O2选项编译该示例,才能让GCC找到这个错误。这是因为new和delete表达式的调用位于不同的函数中,GCC必须内联这些函数才能检测到不匹配。
删除尚未分配内存的对象
另一种动态内存管理方面的错误,是试图释放一个还没有动态分配内存的对象。例如,将一个命名对象(如局部变量)的地址传递给一个内存释放函数,就是一个错误。通常情况下,调用内存释放函数时就会崩溃,但未必总是如此。具体会发生什么情况,往往取决于对象所指向的内存的内容。实际上,GCC早就实现了一个警告选项,专门用于检测这些类型的错误:-Wfree-nonheap-object。但在GCC 11之前,这个选项只能检测出与free()函数有关的明显错误——基本上就是把一个命名变量的地址传给该函数。不过,GCC 11在这方面已经得到了改善,现在,它已经能够针对每个已知的C或C++内存释放函数的调用进行相应的检测。除了free()之外,这些函数还包括realloc()函数,以及在C++中,delete()运算符的所有非置换形式。此外,对于用户定义的、具有malloc属性的内存释放函数的调用,GCC 11也会进行相应的检查。下面,我们以free_declared为例进行演示:
下面是编译器给出的警告信息:
In function 'test_free_declared': warning: 'free' called on unallocated object 'buf' [-Wfree-nonheap-object] 9 | free (p); | ^~~~~~~~ note: declared here 7 | char buf[32], *p = buf; | ^~~
属性alloc_size
除了上面两种形式的malloc属性外,还有一个属性可以帮助GCC发现内存管理错误,即alloc_size属性。该属性用于通知GCC,内存分配函数的哪些参数用于指定待分配内存对象的大小。例如,malloc()和calloc()函数就是这样隐式声明的:
__attribute__ ((malloc, malloc (free, 1), alloc_size (1))) void* malloc (size_t); __attribute__ ((malloc, malloc (free, 1), alloc_size (1, 2))) void* calloc (size_t, size_t);
使用alloc_size属性可以帮助GCC发现对已分配内存的越界访问。下面,我们以my_alloc_free为例,演示如何在用户定义的分配器中使用该属性:
void my_free (void*); __attribute__ ((malloc, malloc (my_free, 1), alloc_size (1))) void* my_alloc (int); void* f (void) { int *p = (int*)my_alloc (8); memset (p, 0, 8 * sizeof *p); return p; }
下面是编译器给出的警告信息:
In function 'test_memset_overflow': warning: 'memset forming offset [8, 31] is out of the bounds [0, 8] [-Warray-bounds] 9 | memset (p, 0, 8 * sizeof *p); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~
局限性
作为一个新特性,GCC 11对动态内存管理错误的检测并不完美:这些警告很容易出现误报和漏报。
误报
误报通常是由GCC无法确定某些代码路径不可达引起的。特别是-Wfree-nonheap-object选项,尤其容易出现这个问题,因为它会根据某些条件使用已声明的数组或动态分配的缓冲区,然后根据其他不同但等价的条件来释放这些缓冲区。如果GCC无法证明这两个条件是等价的,它就可能发出警告。GCC bug 54202说明了导致这种情况的潜在原因。值得注意的是,这个错误是在2012年提交的,针对GCC版本是4.7。所以,这个警告已经很老了,但最初的实现只检测最简单的错误,所以出现误报的情况很少。但是,由于GCC 11增强了警告的实现,也就是对所有已知的释放函数的全部调用都进行检查,所以,这类误报会更频繁地出现,因为这是与发现的错误的数量成正比的。另外,Bug 54202的测试用例可以在Compiler Explorer上找到:
typedef struct Data { int refcount; } Data; extern const Data shared_null; Data *allocate() { return (Data *)(&shared_null); } void dispose (Data *d) { if (d->refcount == 0) free (d); } void f (void) { Data *d = allocate(); dispose (d); }
下面是编译器的警告信息:
In function 'dispose', inlined from 'f': warning: attempt to free a non-heap object 'shared_null' [-Wfree-nonheap-object] 18 | free(d) | ^~~~~~~
我们当然不希望出现误报的情况太多,所以,我们计划在未来的更新中进一步减少这种情况。在此之前,我们建议使用#pragma GCC diagnostic来禁用该警告。
漏报
类似地,但在更大程度上,有条件地试图释放一个未分配内存的对象的错误代码可能根本就不会被诊断出来。由于GCC分析功能的局限性,这些漏报是极为常见的,而且一般来说是不可避免的。之所以出现这种情况,一个主要原因是,分析的准确性和深度取总体上取决于代码的优化,特别是内联优化。除了只有在优化的情况下才能启用外,内联还受到一些限制,目的是在速度和空间效率之间取得最佳平衡。对某一特定函数的调用是否被内联到其调用方中,取决于其对调用方的好处。在内部,所谓的“好处”是由GCC伪指令中的函数大小决定的。这个约束是由-finline-limit=选项控制的。如果将该选项设置为一个适当的高值,就会导致翻译单元中定义的大多数函数都变成内联,并将其函数体暴露出来,以供分析之用(对于将要发布的代码,不建议修改这个限制)。此外,由-flto选项启用的链接时间优化(LTO),会对跨翻译单元边界的内联函数进行同样的分析。
也就是说,我们意识到有一类漏报情况,它们并不受内联启发式方法的影响,因此我们希望在未来的版本中能解决其中的一些问题。下面的代码就是一个解决上述局限性的例子,尽管处理起来有些难度。因为对f()的调用可能会覆盖g()在*p中存储的值,在这些情况下发出警告将是一个误报。如果不想让f()函数修改传递给它的对象,那么,在声明该函数时,可以让其接受一个const void*参数,这似乎是一个不错的解决办法。但是,由于移除对象的常量性并不是一个错误,所以GCC必须保守地假设该函数实际上可能会这样做。为了生成正确的代码,这种假设是必要的,但是警告可以合理地做出更强的假设,尽管对于严格正确(但明显有问题)的程序来说,要付出一些误报的代价。实现这些更严格的假设,是未来版本考虑的改进措施之一。
void f (void*); void g (int n) { char a[8]; char *p = 8 < n ? malloc (n) : a; *p = n; f (p + 1); // might change *p if (*p < 8) free (p); // missing warning }
小结
现在,GCC 11提供了许多开箱即用的功能,可以用于寻找多种内存管理错误,并且无需对程序源代码进行任何修改。但是,为了在定义了自己的内存管理例程的代码中最大限度地利用这些功能,我们最好给这些函数加上malloc和alloc_size的属性。即使没有进行优化,GCC也会检查所有对内存分配和释放函数的调用,但在没有优化的情况下,检测只限于函数体的范围;通过优化,内存错误检测将扩展到内联到其调用方的函数。另外,提高内联限制可以提高分析效果,LTO也是如此。虽然GCC静态分析器也进行同样的检查,但它只检查同一翻译单元中的所有函数,而不检查内联函数。
本文翻译自https://developers.redhat.com/blog/2021/04/30/detecting-memory-management-bugs-with-gcc-11-part-1-understanding-dynamic-allocation/ 与 https://developers.redhat.com/blog/2021/05/05/detecting-memory-management-bugs-with-gcc-11-part-2-deallocation-functions/如若转载,请注明原文地址