内存泄漏 与 malloc chunk
我为什么写这篇文章
在我暑期实习期间 debug 一个内存泄漏的问题时,我发现我使用的其中一个 API return 了一个裸指针,从而把这个目标的 ownership 转移给了调用者(我)。换言之,我现在需要负责在代码运行完毕之后手动 delete 掉这个指针。尽管这是一个 非常糟糕的工程实践,我开始对内存泄漏是如何产生的,以及 delete[] 是如何删除内存的产生了兴趣。
在做了一些研究与实验后,我写下了这篇文章。本文将试图回答三组问题:
- 什么是内存泄漏?
- 对象是如何在 堆 (heap) 上被分配的?
delete[]如何知道它需要释放哪块内存? - 我们如 何预防内存泄漏?
Stack Overflow 上的问题 "How does delete[] 'know' the size of the operand array?" 其实已经大致回答了我们的第二个问题,但我还是决定更深入地探讨一下实际的内存空间是什么样的。
巧合的是,我和朋友 @gzhding 刚好在最近的一次 CTF 比赛中合作了一道 堆利用 (heap exploitation) 的题目。因为这份经历,我学会了如何使用 gdb 调试并查看堆上的内存,以借其管中窥豹。
注:我先写成了本文的英文版,之后才试图将其译回中文。因此如有可能的话,请以英文阅读本文,以避免一些因为翻译质量导致的语句不顺与理解困难。
什么是内存泄漏
我们知道 C++ 能够在堆上动态地分配内存。一个常见的例子是使用 new[] 创建数组,以及 delete[] 删除数组。
当我们在内存中创建了一个数组(即分配了一段内存用以存储这个对象)而又忘记删除它时,内存泄漏 就会发生。当指向这段内存的指针超出作用域 (scope) 时,正在运行的代码就丢失了对被分配的内存的知识。在最坏的情况下,如果内存泄漏在一个循环中发生,新分配的内存能够持续地堆积而不被释放,最终使得电脑变慢甚至崩溃。
PoC
以下有一段简单的 Proof of Concept (PoC) 代码。其中的 main() 函数调用了 memory_leak() 函数,后者又创建了一个由 26 个 char 组成的数组,并将大写英文字母填入它们。
void memory_leak() {
// Always delete pointers created by new to avoid memory leaks!
char *arr = new char[26];
for (int i = 0; i < 26; i++) {
arr[i] = char(65 + i); // 65 is the ascii of 'A'
}
// The memory area is not freed!
// delete[] arr;
}
int main() {
memory_leak();
return 0;
}
因为 delete[] 语句已经被注释掉,当函数 memory_leak() return 时,指针 arr 会超出作用域 (scope) 并导致这一内存区域被泄漏。
初探内存
我使用了 GEF (GDB Enhanced Features) 而不是原生 GDB 以获取经过美化的输出以及诸如 heap 一类的额外功能。
让我们以 g++ -g3 memory_leak.cpp -o memory_leak 来编译这个程序(-g3 flag 会在编译时保存程序的调试信息)并使用 gdb 来验证这一内存泄漏。
我们将会在 memory_leak() 函数的最后打一个断点,并运行程序直到其触发断点。
$ gdb memory_leak
gef➤ b 11
Breakpoint 1 at 0x1179: file memory_leak.cpp, line 11.
gef➤ r
[...]
─────────────────────────────────────────────────────────────── source:memory_leak.cpp+11 ────
6 arr[i] = char(65 + i); // 65 is the ascii of 'A'
7 }
8
9 // The memory area is not freed!
10 // delete[] arr;
●→ 11 }
12
13 int main() {
14 memory_leak();
15 return 0;
16 }
───────────────────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "memory_leak", stopped 0x555555555179 in memory_leak (), reason: BREAKPOINT
─────────────────────────────────────────────────────────────────────────────────── trace ────
[#0] 0x555555555179 → memory_leak()
[#1] 0x555555555186 → main()
──────────────────────────────────────────────────────────────────────────────────────────────
gef➤ info locals
arr = 0x55555556aeb0 "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
gef➤ x/8xw 0x55555556aeb0
0x55555556aeb0: 0x44434241 0x48474645 0x4c4b4a49 0x504f4e4d
0x55555556aec0: 0x54535251 0x58575655 0x00005a59 0x00000000
在程序触发断点后,我们打印出指针 arr 指向的地址及这块内存的内容。注意内存是以 小端序 存储的,因此 0x44 (D) 排在 0x43 (C),0x42 (B),以及 0x41 (A) 之前。
现在,让我们继续运行这个程序,直到函数 memory_leak() 运行完毕返回至 main()。
gef➤ finish
[...]
─────────────────────────────────────────────────────────────── source:memory_leak.cpp+15 ────
10 // delete[] arr;
● 11 }
12
13 int main() {
14 memory_leak();
→ 15 return 0;
16 }
───────────────────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "memory_leak", stopped 0x555555555186 in main (), reason: TEMPORARY BREAKPOINT
─────────────────────────────────────────────────────────────────────────────────── trace ────
[#0] 0x555555555186 → main()
──────────────────────────────────────────────────────────────────────────────────────────────
gef➤ info locals
No locals.
gef➤ x/8xw 0x55555556aeb0
0x55555556aeb0: 0x44434241 0x48474645 0x4c4b4a49 0x504f4e4d
0x55555556aec0: 0x54535251 0x58575655 0x00005a59 0x00000000
既然 memory_leak() return 了,我们就丢失了指向内存地址 0x55555556aeb0 的指针 arr。但当我们打印出内存区域时,发现这些数据仍然存储在内存中,没有(也不会)被释放。这就是内存泄漏。
利用 Valgrind 进行验证
此外,我们能够使用如 Valgrind 一样的自动化工具来检查内存泄漏。
$ valgrind --leak-check=full ./memory_leak
==382643== Memcheck, a memory error detector
==382643== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==382643== Using Valgrind-3.17.0 and LibVEX; rerun with -h for copyright info
==382643== Command: ./memory_leak
==382643==
==382643==
==382643== HEAP SUMMARY:
==382643== in use at exit: 26 bytes in 1 blocks
==382643== total heap usage: 2 allocs, 1 frees, 72,730 bytes allocated
==382643==
==382643== 26 bytes in 1 blocks are definitely lost in loss record 1 of 1
==382643== at 0x484021F: operator new[](unsigned long) (vg_replace_malloc.c:579)
==382643== by 0x10914A: memory_leak() (memory_leak.cpp:3)
==382643== by 0x109185: main (memory_leak.cpp:14)
==382643==
==382643== LEAK SUMMARY:
==382643== definitely lost: 26 bytes in 1 blocks
==382643== indirectly lost: 0 bytes in 0 blocks
==382643== possibly lost: 0 bytes in 0 blocks
==382643== still reachable: 0 bytes in 0 blocks
==382643== suppressed: 0 bytes in 0 blocks
==382643==
==382643== For lists of detected and suppressed errors, rerun with: -s
==382643== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)
对象是如何在堆 (heap) 上被分配的
为了更好地理解内存泄漏背后的机制,我们需要了解 C++ 是如何分配以及释放内存的。换言之,new 与 delete 是如何工作的。让我们一起深入进 GNU 的 libstdc++ 实现(g++ 默认使用的库)的源码。
new 与 delete 是如何工作的
因为 new 与 delete 操作符仅仅是 C++ 标准中定义的 interface,它们拥有不同的实现。我在此处将使用 GNU 在 gcc 11.2 版本中提供的 libstdc++ 的 源码。
new[] 和 delete[] 只是对 new 和 delete 的封装
有意思的是,从 operator new[] 的实现(源码)来看,new[] 在 stdlibc++ 中只是 new 的一个别名。
_GLIBCXX_WEAK_DEFINITION void*
operator new[] (std::size_t sz) _GLIBCXX_THROW (std::bad_alloc)
{
return ::operator new(sz);
}
对 delete[](源码)而言亦是如此,它不过是 delete 的别名。
根据 GNU stdlibc++ 的实现来看,似乎混合使用 new[] 与 new,以及 delete[] 与 delete 是完全可以接受的。
但是,你应当避免这么做,因为这种行为是取决于实现的。根据 C++ Working Paper,使用 new 和 delete 而不是 new[] 和 delete[] 会导致未定义的行为,这会使调试变得一团糟。
而 new 和 delete 不过是对 malloc 和 free 的封 装
让我们接下来看看 new 的 源码。它也只是一个对 C 中的 malloc 加上一些错误处理的封装,并会在最后给调用者 return 一个 malloc 返回的原始指针。
_GLIBCXX_WEAK_DEFINITION void *
operator new (std::size_t sz) _GLIBCXX_THROW (std::bad_alloc)
{
void *p;
/* malloc (0) is unpredictable; avoid it. */
if (__builtin_expect (sz == 0, false))
sz = 1;
while ((p = malloc (sz)) == 0)
{
new_handler handler = std::get_new_handler ();
if (! handler)
_GLIBCXX_THROW_OR_ABORT(bad_alloc());
handler ();
}
return p;
}
delete(源码)更加简单,直接调用了 C 中的 free。
_GLIBCXX_WEAK_DEFINITION void
operator delete(void* ptr) noexcept
{
std::free(ptr);
}
这样一来,我们似乎需要一路深入到 C 标准库中对 malloc 与 free 的实现才能知道在数组的创建与销毁背后究竟发生了什么。
然而,我们不会涵盖与 malloc 相关的全部内容(这些内容本身就足够撑起另外一篇文章了),我们将主要关注 malloc 如何组织它分配的内存空间(答案:在堆上构建 malloc_chunk)以及 free 是如何知道去释放哪块内存的。