调试内存

本章简要介绍了 PHP 源代码的内存调试。它并不是一个完整的课程:内存调试并不难,但你需要一些经验,通过大量的实践,这也是你在设计任何 C 语言代码时可能必须做的。我们将在这里介绍一个非常著名的内存调试器:valgrind;以及如何在 PHP 中使用它来调试内存问题。

关于 valgrind 的简要说明

Valgrind 是一个著名的工具,在许多 Unix 环境下用于调试任何 C/C++ 编写的软件中许多常见的内存问题。Valgrind 是一个关于内存调试的多工具前端。最常用的底层工具名为 “memcheck”。它的工作原理是将 libc 的堆分配替换为自己的堆分配,并跟踪你对它们所做的操作。你可能还会对 “massif” 的用法感兴趣:它是一个内存跟踪器,有助于了解程序的一般堆内存使用情况。

您应该阅读 Valgrind 文档 以进一步了解。它写得很好,并包含一些有代表性的小示例。

为了进行内存分配替换,您需要通过 valgrind 运行要分析的程序(此处为 PHP),也就是说,启动的二进制文件将是 valgrind。

由于 valgrind 替换并跟踪所有 libc 的堆分配,它往往会大大减慢已调试程序的速度。在 PHP 的情况下,您会注意到这一点。虽然 PHP 的速度减慢并不那么明显,但仍然可以清楚地感觉到;如果您注意到了,请不要担心,这是正常的。

Valgrind 不是您可能使用的唯一工具,但却是最常见的工具。 Dr.MemoryLeakSanitizerElectric FenceAddressSanitizer 是其他常用工具。

开始之前

以下是获得良好内存调试经验所需的步骤,以便更容易发现缺陷并减少调试时间:

  • 您应该始终 使用 PHP 的调试版本。尝试在生产版本上调试内存是没有意义的。

  • 您应该始终在 USE_ZEND_ALLOC=0 环境中启动调试器。您可能已经在 Zend 内存管理器章节中了解到,此环境变量会为当前进程启动禁用 ZendMM。强烈建议在启动内存调试器时这样做。完全绕过 ZendMM 有助于理解 valgrind 生成的跟踪。

  • 还强烈建议在环境 ZEND_DONT_UNLOAD_MODULES=1 下启动内存调试器。这将防止 PHP 在进程结束时卸载扩展的 .so 文件。这是为了获得更好的 valgrind 报告跟踪;如果 PHP 在 valgrind 即将显示其错误时卸载了扩展,那么稍后的那些将是不完整的,因为从中获取信息的文件不再是进程内存映像的一部分。

  • 您可能需要一些 抑制。当您告诉 PHP 不要在进程结束时卸载其扩展时,您可能会在 valgrind 输出中得到误报。PHP 扩展会针对泄漏进行检查,如果您的平台上出现误报,您可以使用 类似这样 的抑制来阻止它们。您可以根据此类示例随意编写自己的文件。

  • Valgrind 显然比 Zend Memory Manager 更适合查找泄漏和其他内存相关问题。您应该始终在代码上运行 valgrind,这确实是每个 C 程序员必须执行的步骤。无论是因为遇到崩溃并想查找和调试它,还是因为质量工具似乎表面上没有显示任何不良信息,您都应运行它,valgrind 是指出隐藏缺陷的工具,随时可能一次或多次地向您喷薄而出。使用它,即使您认为代码似乎一切正常:您可能会感到惊讶。

你必须在程序中使用 valgrind(或任何内存调试器)。如果不对内存进行调试,就不可能对每一个强大的 C 程序都有 100% 的信心。内存错误会导致有害的安全问题和程序崩溃,通常是随机的,取决于许多参数。

内存泄漏检测示例

Starter

Valgrind 是一款全堆内存调试器。它还可以调试进程内存映射和函数栈。请在其文档中获取更多信息。

让我们来检测动态内存泄漏,并尝试使用最常见的简单方法:

PHP_RINIT_FUNCTION(pib)
{
    void *foo = emalloc(128);
}

上面的代码每次请求都会泄漏 128 字节,因为它没有为这样的缓冲区调用 efree()。由于是调用 emalloc(),因此要通过 Zend 内存管理器,该管理器稍后会像我们在 ZendMM 章节中看到的那样对泄漏发出警告。让我们看看 valgrind 是否也能注意到泄漏:

> ZEND_DONT_UNLOAD_MODULES=1 USE_ZEND_ALLOC=0 valgrind --leak-check=full --suppressions=/path/to/suppression
--show-reachable=yes --track-origins=yes ~/myphp/bin/php -dextension=pib.so /tmp/foo.php

我们使用 valgrind 启动一个 PHP-CLI 进程。我们假设这里有一个名为 “pib” 的扩展。下面是输出结果:

==28104== 128 bytes in 1 blocks are definitely lost in loss record 1 of 1
==28104==    at 0x4C2DB8F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==28104==    by 0xA3701E: __zend_malloc (zend_alloc.c:2820)
==28104==    by 0xA362E7: _emalloc (zend_alloc.c:2413)
==28104==    by 0xE896F99: zm_activate_pib (pib.c:1880)
==28104==    by 0xA79F1B: zend_activate_modules (zend_API.c:2537)
==28104==    by 0x9D31D3: php_request_startup (main.c:1673)
==28104==    by 0xB5909A: do_cli (php_cli.c:964)
==28104==    by 0xB5A423: main (php_cli.c:1381)

==28104== LEAK SUMMARY:
==28104==    definitely lost: 128 bytes in 1 blocks
==28104==    indirectly lost: 0 bytes in 0 blocks
==28104==    possibly lost: 0 bytes in 0 blocks
==28104==    still reachable: 0 bytes in 0 blocks
==28104==    suppressed: 7,883 bytes in 40 blocks

在我们的层面上,“肯定会失去(definitely lost)” 是我们必须关注的。

有关 memcheck 输出的不同字段的详细信息,请参阅其 文档

我们使用 USE_ZEND_ALLOC=0 来禁用并完全绕过 Zend Memory Manager。每次调用其 API(例如 emalloc())都会直接导致 libc 调用,就像我们在 calgrind 输出堆栈帧上看到的那样。

Valgrind 发现了我们的漏洞。

很简单,现在我们可以使用持久分配,也就是绕过 ZendMM 并使用传统 libc 的动态内存分配来产生泄漏。如:

PHP_RINIT_FUNCTION(pib)
{
    void *foo = malloc(128);
}

以下是报告全文:

==28758==    128 bytes in 1 blocks are definitely lost in loss record 1 of 1
==28758==    at 0x4C2DB8F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==28758==    by 0xE896F82: zm_activate_pib (pib.c:1880)
==28758==    by 0xA79F1B: zend_activate_modules (zend_API.c:2537)
==28758==    by 0x9D31D3: php_request_startup (main.c:1673)
==28758==    by 0xB5909A: do_cli (php_cli.c:964)
==28758==    by 0xB5A423: main (php_cli.c:1381)

也被抓到了。

Valgrind 确实能捕获一切。巨大的进程内存映射中每一小块被遗忘的字节都会被 valgrind 的眼睛报告出来。你无法通过。

更复杂的用例

这是一个更复杂的设置。你能发现下面代码中的泄漏吗?

static zend_array ar;

PHP_MINIT_FUNCTION(pib)
{
    zend_string *str;
    zval string;

    str = zend_string_init("yo", strlen("yo"), 1);
    ZVAL_STR(&string, str);

    zend_hash_init(&ar, 8, NULL, ZVAL_PTR_DTOR, 1);
    zend_hash_next_index_insert(&ar, &string);
}

这里有两个泄漏。首先,我们分配了一个 zend_string,但没有释放它。其次,我们分配了一个新的 zend_hash,但同样没有释放它。让我们用 valgrind 启动它,看看结果:

==31316== 296 (264 direct, 32 indirect) bytes in 1 blocks are definitely lost in loss record 1 of 2
==32006==    by 0xA3701E: __zend_malloc (zend_alloc.c:2820)
==32006==    by 0xA814B2: zend_hash_real_init_ex (zend_hash.c:133)
==32006==    by 0xA816D2: zend_hash_check_init (zend_hash.c:161)
==32006==    by 0xA83552: _zend_hash_index_add_or_update_i (zend_hash.c:714)
==32006==    by 0xA83D58: _zend_hash_next_index_insert (zend_hash.c:841)
==32006==    by 0xE896AF4: zm_startup_pib (pib.c:1781)
==32006==    by 0xA774F7: zend_startup_module_ex (zend_API.c:1843)
==32006==    by 0xA77559: zend_startup_module_zval (zend_API.c:1858)
==32006==    by 0xA85AF5: zend_hash_apply (zend_hash.c:1508)
==32006==    by 0xA77B25: zend_startup_modules (zend_API.c:1969)

==31316== 32 bytes in 1 blocks are indirectly lost in loss record 2 of 2
==31316==    by 0xA3701E: __zend_malloc (zend_alloc.c:2820)
==31316==    by 0xE880B0D: zend_string_alloc (zend_string.h:122)
==31316==    by 0xE880B76: zend_string_init (zend_string.h:158)
==31316==    by 0xE896F9D: zm_activate_pib (pib.c:1781)
==31316==    by 0xA79F1B: zend_activate_modules (zend_API.c:2537)
==31316==    by 0x9D31D3: php_request_startup (main.c:1673)
==31316==    by 0xB5909A: do_cli (php_cli.c:964)
==31316==    by 0xB5A423: main (php_cli.c:1381)

==31316== LEAK SUMMARY:
==31316== definitely lost: 328 bytes in 2 blocks

正如预期的那样,两次泄漏都已报告。如您所见,valgrind 是准确的,它让您关注到需要关注的地方。

现在让我们修复它们:

PHP_MSHUTDOWN_FUNCTION(pib)
{
    zend_hash_destroy(&ar);
}

在 PHP 进程结束时,我们将在 MSHUTDOWN 中销毁持久化数组。在创建数组时,我们将 ZVAL_PTR_DTOR 作为析构函数传给了数组,因此数组会对插入的项目运行回调。这是 zval 销毁器,它将销毁分析其内容的 zvals。对于 IS_STRING 类型,析构函数将释放 zend_string,并在必要时释放它。完成。

正如你所看到的,PHP 和其他 C 语言程序一样,充满了嵌套指针。zend_string 被封装在一个 zval 中,它本身是 zend_array 的一部分。泄漏数组显然会同时泄漏 zvalzend_string,但 zval 并不是堆分配的(我们在堆栈上分配),因此没有泄漏报告。你应该习惯忘记释放/释放一个复合结构(如 zend_array)会导致大量泄漏的事实,因为通常情况下,结构嵌入结构,等等…​…​

缓冲区溢出/下溢检测

内存泄漏是不好的。它将导致你的程序或多或少触发一次 OOM,并且会大大降低主机的运行速度,因为随着时间的推移,主机的可用内存会越来越少。这就是内存泄漏综合症。

但还有更糟糕的情况:缓冲区越界访问。访问分配限制之外的指针是许多邪恶操作(如在机器上获取 root shell)的根源,因此应绝对防止这些操作。更糟糕的是,越界访问还经常导致内存损坏,导致程序崩溃。不过,这取决于目标机器的硬件、使用的编译器和选项、操作系统的内存布局、使用的 libc 等诸多因素。

因此,越界访问是非常令人讨厌的,它们就像 炸弹,可能会爆炸,也可能不会爆炸,可能是现在,也可能是一分钟后,或者如果你过于幸运,它们永远不会爆炸。

Valgrind 是一款内存调试器,因此能够检测来自任何内存区域(堆和栈)的任何越界访问。这与查找泄漏的 memcheck 工具是一样的。

让我们来看一个简单的例子:

PHP_MINIT_FUNCTION(pib)
{
    char *foo = malloc(16);
    foo[16] = 'a';
    foo[-1] = 'a';
}

这段代码分配了一个缓冲区,并故意在边界之外和边界之后各写入一个字节。现在,如果运行这样的代码,就会有类似二分之一的几率立即崩溃,然后随机崩溃。你也可能在 PHP 中创建了一个安全漏洞,但它可能不会被远程利用(这种行为并不常见)。

越界访问会导致未定义的行为。无法预测会发生什么,但可以肯定的是,它很糟糕(立即崩溃),或者很可怕(安全问题)。记住。

让我们询问 valgrind,使用与之前完全相同的命令行来启动它,除了输出之外没有任何变化:

==12802== Invalid write of size 1
==12802==    at 0xE896A98: zm_startup_pib (pib.c:1772)
==12802==    by 0xA774F7: zend_startup_module_ex (zend_API.c:1843)
==12802==    by 0xA77559: zend_startup_module_zval (zend_API.c:1858)
==12802==    by 0xA85AF5: zend_hash_apply (zend_hash.c:1508)
==12802==    by 0xA77B25: zend_startup_modules (zend_API.c:1969)
==12802==    by 0x9D4541: php_module_startup (main.c:2260)
==12802==    by 0xB5802F: php_cli_startup (php_cli.c:427)
==12802==    by 0xB5A367: main (php_cli.c:1348)
==12802==  Address 0xeb488f0 is 0 bytes after a block of size 16 alloc'd
==12802==    at 0x4C2DB8F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==12802==    by 0xE896A85: zm_startup_pib (pib.c:1771)
==12802==    by 0xA774F7: zend_startup_module_ex (zend_API.c:1843)
==12802==    by 0xA77559: zend_startup_module_zval (zend_API.c:1858)
==12802==    by 0xA85AF5: zend_hash_apply (zend_hash.c:1508)
==12802==    by 0xA77B25: zend_startup_modules (zend_API.c:1969)
==12802==    by 0x9D4541: php_module_startup (main.c:2260)
==12802==    by 0xB5802F: php_cli_startup (php_cli.c:427)
==12802==    by 0xB5A367: main (php_cli.c:1348)
==12802==
==12802== Invalid write of size 1
==12802==    at 0xE896AA6: zm_startup_pib (pib.c:1773)
==12802==    by 0xA774F7: zend_startup_module_ex (zend_API.c:1843)
==12802==    by 0xA77559: zend_startup_module_zval (zend_API.c:1858)
==12802==    by 0xA85AF5: zend_hash_apply (zend_hash.c:1508)
==12802==    by 0xA77B25: zend_startup_modules (zend_API.c:1969)
==12802==    by 0x9D4541: php_module_startup (main.c:2260)
==12802==    by 0xB5802F: php_cli_startup (php_cli.c:427)
==12802==    by 0xB5A367: main (php_cli.c:1348)
==12802==  Address 0xeb488df is 1 bytes before a block of size 16 alloc'd
==12802==    at 0x4C2DB8F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==12802==    by 0xE896A85: zm_startup_pib (pib.c:1771)
==12802==    by 0xA774F7: zend_startup_module_ex (zend_API.c:1843)
==12802==    by 0xA77559: zend_startup_module_zval (zend_API.c:1858)
==12802==    by 0xA85AF5: zend_hash_apply (zend_hash.c:1508)
==12802==    by 0xA77B25: zend_startup_modules (zend_API.c:1969)
==12802==    by 0x9D4541: php_module_startup (main.c:2260)
==12802==    by 0xB5802F: php_cli_startup (php_cli.c:427)
==12802==    by 0xB5A367: main (php_cli.c:1348)

两个无效写入都已被检测到,现在你的目标是跟踪并修复它们。

在这里,我们使用了一个写内存越界的示例,这是最糟糕的情况,因为你的写操作如果成功(可能会立即导致 SIGSEGV),就会覆盖该指针旁边的一些关键区域。由于我们使用 libc 的 malloc() 进行分配,我们将覆盖 libc 用来管理和跟踪分配的关键头块和尾块。这将导致崩溃,这取决于很多因素(平台、使用的 libc、编译方式等)。

Valgrind 还可能报告无效读取。这意味着你执行的内存读取操作超出了已分配指针的范围。比块覆盖更好的情况是,你仍然访问了不该访问的内存区域,而在这种情况下,又可能导致立即崩溃、稍后崩溃或永远不会崩溃?不要这样做。

一旦在 valgrind 的输出中看到 “Invalid”,那你就真的有大麻烦了。无论是无效读取还是无效写入,你的代码中都存在问题,你应该将此问题视为高风险:现在就修复它,真的。

以下是有关字符串连接的第二个示例:

char *foo = strdup("foo");
char *bar = strdup("bar");

char *foobar = malloc(strlen("foo") + strlen("bar"));

memcpy(foobar, foo, strlen(foo));
memcpy(foobar + strlen("foo"), bar, strlen(bar));

fprintf(stderr, "%s", foobar);

free(foo);
free(bar);
free(foobar);

你能发现问题所在吗?

让我们问问 valgrind:

==13935== Invalid read of size 1
==13935==    at 0x4C30F74: strlen (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==13935==    by 0x768203E: fputs (iofputs.c:33)
==13935==    by 0xE896B91: zm_startup_pib (pib.c:1779)
==13935==    by 0xA774F7: zend_startup_module_ex (zend_API.c:1843)
==13935==    by 0xA77559: zend_startup_module_zval (zend_API.c:1858)
==13935==    by 0xA85AF5: zend_hash_apply (zend_hash.c:1508)
==13935==    by 0xA77B25: zend_startup_modules (zend_API.c:1969)
==13935==    by 0x9D4541: php_module_startup (main.c:2260)
==13935==    by 0xB5802F: php_cli_startup (php_cli.c:427)
==13935==    by 0xB5A367: main (php_cli.c:1348)
==13935==  Address 0xeb48986 is 0 bytes after a block of size 6 alloc'd
==13935==    at 0x4C2DB8F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==13935==    by 0xE896B14: zm_startup_pib (pib.c:1774)
==13935==    by 0xA774F7: zend_startup_module_ex (zend_API.c:1843)
==13935==    by 0xA77559: zend_startup_module_zval (zend_API.c:1858)
==13935==    by 0xA85AF5: zend_hash_apply (zend_hash.c:1508)
==13935==    by 0xA77B25: zend_startup_modules (zend_API.c:1969)
==13935==    by 0x9D4541: php_module_startup (main.c:2260)
==13935==    by 0xB5802F: php_cli_startup (php_cli.c:427)
==13935==    by 0xB5A367: main (php_cli.c:1348)

第 1779 行指向 fprintf() 调用。该调用确实调用了 fputs(),而 fputs() 本身又调用了 strlen()(两者都来自 libc),而 strlen() 在这里读取的 1 个字节无效。

我们只是忘记了终止字符串的 \0。我们给 fprintf() 传递了一个无效字符串。然后,strlen() 会扫描缓冲区,直到找到 \0,由于我们忘了清零,它将扫描缓冲区的边界。我们很幸运,strlen() 只从末尾传递了一个字节。这可能会导致崩溃,因为我们并不知道下一个 \0 会在内存的哪个位置,那是随机的。

解决:

size_t len   = strlen("foo") + strlen("bar") + 1;   /* note the +1 for \0 */
char *foobar = malloc(len);

/* ... ... same code ... ... */

foobar[len - 1] = '\0'; /* terminate the string properly */

上面描述的错误是 C 语言中最常见的错误之一。它们被称为 “差一错误”:您忘记分配一个字节,但正是由于这一点,您会在代码中产生大量问题。

最后,我们再举一个例子来说明 “使用后无”(use-after-free)的情况。这也是 C 语言编程中一个非常常见的错误,其严重程度不亚于糟糕的内存访问:它会产生安全漏洞,导致非常恶劣的行为。很明显,valgrind 可以检测到 use-after-free。下面就是一个例子:

char *foo = strdup("foo");
free(foo);

memcpy(foo, "foo", sizeof("foo"));

这里又是一个与 PHP 无关的 PHP 场景。我们释放了一个指针,然后重新使用它。这是一个很大的错误。让我们问问 valgrind:

==14594== Invalid write of size 1
==14594==    at 0x4C3245C: memcpy@GLIBC_2.2.5 (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==14594==    by 0xE896AA1: zm_startup_pib (pib.c:1774)
==14594==    by 0xA774F7: zend_startup_module_ex (zend_API.c:1843)
==14594==    by 0xA77559: zend_startup_module_zval (zend_API.c:1858)
==14594==    by 0xA85AF5: zend_hash_apply (zend_hash.c:1508)
==14594==    by 0xA77B25: zend_startup_modules (zend_API.c:1969)
==14594==    by 0x9D4541: php_module_startup (main.c:2260)
==14594==    by 0xB5802F: php_cli_startup (php_cli.c:427)
==14594==    by 0xB5A367: main (php_cli.c:1348)
==14594==  Address 0xeb488e0 is 0 bytes inside a block of size 4 free'd
==14594==    at 0x4C2EDEB: free (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==14594==    by 0xE896A86: zm_startup_pib (pib.c:1772)
==14594==    by 0xA774F7: zend_startup_module_ex (zend_API.c:1843)
==14594==    by 0xA77559: zend_startup_module_zval (zend_API.c:1858)
==14594==    by 0xA85AF5: zend_hash_apply (zend_hash.c:1508)
==14594==    by 0xA77B25: zend_startup_modules (zend_API.c:1969)
==14594==    by 0x9D4541: php_module_startup (main.c:2260)
==14594==    by 0xB5802F: php_cli_startup (php_cli.c:427)
==14594==    by 0xB5A367: main (php_cli.c:1348)
==14594==  Block was alloc'd at
==14594==    at 0x4C2DB8F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==14594==    by 0x769E8D9: strdup (strdup.c:42)
==14594==    by 0xE896A70: zm_startup_pib (pib.c:1771)
==14594==    by 0xA774F7: zend_startup_module_ex (zend_API.c:1843)
==14594==    by 0xA77559: zend_startup_module_zval (zend_API.c:1858)
==14594==    by 0xA85AF5: zend_hash_apply (zend_hash.c:1508)
==14594==    by 0xA77B25: zend_startup_modules (zend_API.c:1969)
==14594==    by 0x9D4541: php_module_startup (main.c:2260)
==14594==    by 0xB5802F: php_cli_startup (php_cli.c:427)
==14594==    by 0xB5A367: main (php_cli.c:1348)

一切又都清晰了。

结论

在推向生产前使用内存调试器。正如你在本章中学到的,在计算中遗忘的一个小字节就可能导致可利用的安全漏洞。它还经常(非常经常)导致简单的崩溃。这意味着,你的酷炫扩展可能会导致整个(一组)服务器及其所有客户端瘫痪。

C 是一种非常严谨的编程语言。你需要对数十亿字节的内存进行编程,你必须安排这些内存来执行一些计算。但是,千万不要滥用这种巨大的能力:在最好的情况下(很少见),什么也不会发生;在更坏的情况下(很常见),你会在这里或那里随机崩溃;在最糟糕的情况下,你会在程序中制造一个漏洞,而这个漏洞恰好可以被远程利用…​…​

你有工具,又聪明,那就好好保护机器内存吧,真的。