前言
在本文中, 我们主要介绍了 PHP 垃圾回收 (Garbage Collection) 算法中的两个 Use-After-Free 漏洞. 其中一个漏洞影响 PHP 5.3 以上版本, 在 5.6.23 版本中修复. 另外一个漏洞影响 PHP 5.3 以上版本和 PHP 7 所有版本, 分别在 5.6.23 和 7.0.8 版本中修复. 这些漏洞也可以通过 PHP 的反序列化函数远程利用. 特别要提及的是, 我们通过这些漏洞实现了 pornhub.com 网站的远程代码执行, 从而获得了总计 20000 美元的漏洞奖励, 同时每人获得了来自 Hackerone 互联网漏洞奖励的 1000 美元奖金. 在这里, 感谢 Dario Weißer 编写反序列化模糊测试程序, 并帮助我们确定了反序列化中的原始漏洞.
概述
我们在审计 Pornhub 的过程中, 发现了 PHP 垃圾回收算法中的两个严重缺陷, 当 PHP 的垃圾回收算法与其他特定的 PHP 对象进行交互时, 发现了两个重要的 Use-After-Free 漏洞. 这些漏洞的影响较为广泛, 可以利用反序列化漏洞来实现目标主机上的远程代码执行, 本文将对此进行讨论.
在对反序列化进行模糊测试并发现问题之后, 我们可以总结出两个 UAF 漏洞的 PoC. 如果关注如何发现这些潜在问题, 大家可以参阅 Dario 关于反序列化模糊测试过程的文章( https://www.evonide.com/fuzzing-unserialize ). 我们在此仅举一例:
- // POC of the ArrayObject GC vulnerability
- $serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:4;i:2;r:1;};m:a:0:{}}}';
- $outer_array = unserialize($serialized_string);
- gc_collect_cycles();
- $filler1 = "aaaa";
- $filler2 = "bbbb";
- var_dump($outer_array);
- // Result:
- // string(4) "bbbb"
针对这个示例, 我们通常期望如下输出:
- array(1) { // outer_array
- [1]=>
- object(ArrayObject)#1 (1) {
- ["storage":"ArrayObject":private]=>
- array(2) { // inner_array
- [1]=>
- // Reference to inner_array
- [2]=>
- // Reference to outer_array
- }
- }
- }
但实际上, 一旦该示例执行, 外部数组 (由 $outer_array 引用) 将会被释放, 并且 zval 将会被 $filler2 的 zval 覆盖, 导致没有输出 "bbbb".
根据这一示例, 我们产生了以下问题:
为什么外部数组完全被释放?
函数 gc_collect_cycles()在做什么, 是否真的有必要存在这个手动调用? 由于许多脚本和设置根本不会调用这个函数, 所以它对于远程利用来说是非常不方便的.
即使我们能够在反序列化过程中调用它, 但在上面这个例子的场景中, 还能正常工作吗?
这一切问题的根源, 似乎都在于 PHP 垃圾回收机制的 gc_collect_cycles 之中. 我们首先要对这一函数有更好的理解, 然后才能解答上述的所有问题.
PHP 的垃圾回收机制
在早期版本的 PHP 中, 存在循环引用内存泄露的问题, 因此, 在 PHP 5.3.0 版本中引入了垃圾回收 (GC) 算法(官方文档: http://php.net/manual/de/features.gc.collecting-cycles.php ). 垃圾回收机制默认是启用的, 可以通过在 php.ini 配置文件中设置 zend.enable_gc 来触发.
在这里, 我们已经假设各位读者具备了一些 PHP 的相关知识, 包括内存管理,"zval" 和 "引用计数" 等, 如果有读者对这些名词不熟悉, 可以首先阅读官方文档: http://www.phpinternalsbook.com/zvals/basic_structure.html ; http://www.phpinternalsbook.com/zvals/memory_management.html .
2.1 循环引用
要理解什么是循环引用, 请参见一下示例:
- //Simple circular reference example
- $test = array();
- $test[0] = &$test;
- unset($test);
由于 $test 引用其自身, 所以它的引用计数为 2, 即使我们没有设置 $test, 它的引用计数也会变为 1, 从而导致内存不再被释放, 造成内存泄露的问题. 为了解决这一问题, PHP 开发团队参考 IBM 发表的 "Concurrent Cycle Collection in Reference Counted Systems" 一文( http://researcher.watson.ibm.com/researcher/files/us-bacon/Bacon01Concurrent.pdf ), 实现了一种垃圾回收算法.
2.2 触发垃圾回收
该算法的实现可以在 "Zend/zend_gc.c"( https://github.com/php/php-src/blob/PHP-5.6.0/Zend/zend_gc.c )中找到. 每当 zval 被销毁时(例如: 在该 zval 上调用 unset 时), 垃圾回收算法会检查其是否为数组或对象. 除了数组和对象外, 所有其他原始数据类型都不能包含循环引用. 这一检查过程通过调用 gc_zval_possible_root 函数来实现. 任何这种潜在的 zval 都被称为根(Root), 并会被添加到一个名为 gc_root_buffer 的列表中.
然后, 将会重复上述步骤, 直至满足下述条件之一:
1,gc_collect_cycles()被手动调用( http://php.net/manual/de/function.gc-collect-cycles.php );
2, 垃圾存储空间将满. 这也就意味着, 在根缓冲区的位置已经存储了 10000 个 zval, 并且即将添加新的根. 这里的 10000 是由 "Zend/zend_gc.c"( https://github.com/php/php-src/blob/PHP-5.6.0/Zend/zend_gc.c )头部中 GC_ROOT_BUFFER_MAX_ENTRIES 所定义的默认限制. 当出现第 10001 个 zval 时, 将会再次调用 gc_zval_possible_root, 这时将会再次执行对 gc_collect_cycles 的调用以处理并刷新当前缓冲区, 从而可以再次存储新的元素.
2.3 循环收集的图形标记算法
垃圾回收算法实质上是一种图形标记算法(Graph Marking Algorithm), 其具体结构如下. 图形节点表示实际的 zval, 例如数组, 字符串或对象. 而边缘表示这些 zval 之间的连接或引用.
此外, 该算法主要使用以下颜色标记节点.
1, 紫色: 潜在的垃圾循环根. 该节点可以是循环引用循环的根. 最初添加到垃圾缓冲区的所有节点都会标记为紫色.
2, 灰色: 垃圾循环的潜在成员. 该节点可以是循环参考循环中的一部分.
3, 白色: 垃圾循环的成员. 一旦该算法终止, 这些节点应该被释放.
4, 黑色: 使用中或者已被释放. 这些节点在任何情况下都不应该被释放.
为了能更清晰地了解这个算法的详情, 我们接下来具体看看其实现方法. 整个垃圾回收过程都是在 gc_collect_cycles 中执行:
- "Zend/zend_gc.c"
- [...]
- ZEND_API int gc_collect_cycles(TSRMLS_D)
- {
- [...]
- gc_mark_roots(TSRMLS_C);
- gc_scan_roots(TSRMLS_C);
- gc_collect_roots(TSRMLS_C);
- [...]
- /* Free zvals */
- p = GC_G(free_list);
- while (p != FREE_LIST_END) {
- q = p->u.next;
- FREE_ZVAL_EX(&p->z);
- p = q;
- }
- [...]
- }
这个函数可以分为如下四个简化后的步骤:
1,gc_mark_roots(TSRMLS_C):
将 zval_mark_grey 应用于 gc_root_buffer 中所有紫色标记的元素. 其中, zval_mark_grey 针对给定的 zval 按照以下步骤进行:
(1) 如果给定的 zval 已经标记为灰色, 则返回;
(2) 将给定的 zval 标记为灰色;
(3) 当给定的 zval 是数组或对象时, 检索所有子 zval;
(4) 将所有子 zval 的引用计数减 1, 然后调用 zval_mark_grey.
总体来说, 这一步骤将根 zval 可达的其他 zval 都标记为灰色, 并且将这些 zval 的引用计数器减 1.
2,gc_scan_roots(TSRMLS_C):
将 zcal_scan 应用于 gc_root_buffer 中的所有元素. zval_scan 针对给定的 zval 执行以下操作:
(1) 如果给定的 zval 已经标记为非灰色的其他颜色, 则返回;
(2) 如果其引用计数大于 0, 则调用 zval_scan_black, 其中 zval_scan_black 会恢复此前 zval_mark_grey 对引用计数器执行的所有操作, 并将所有可达的 zval 标记为黑色;
(3) 否则, 将给定的 zval 标记为白色, 当给定的 zval 是数组或对象时检索其所有子 zval, 并调用 zval_scan.
总体来说, 通过这一步, 将会确定出来哪些已经被标记为灰色的 zval 现在应该被标记为黑色或白色.
3,gc_collect_roots(TSRMLS_C):
在这一步骤中, 针对所有标记为白色的 zval, 恢复其引用计数器, 并将它们添加到 gc_zval_to_free 列表中, 该列表相当于 gc_free_list.
4, 最后, 释放 gc_free_list 中包含的所有元素, 也就是释放所有标记为白色的元素.
通过上述算法, 会对循环引用的所有部分进行标记和释放, 具体方法就是先将其标记为白色, 然后进行收集, 最终释放它们. 通过对上述算法进行仔细分析, 我们发现其中有可能出现冲突, 具体如下:
1, 在步骤 1.4 中, zval_mark_grey 在实际检查 zval 是否已经标记为灰色之前, 就对其所有子 zval 的引用计数器进行了递减操作;
2, 由于 zval 引用计数器的暂时递减, 可能会导致一些影响(例如: 对已经递减的引用计数器再次进行检查, 或对其进行其他操作), 从而造成严重后果.
PoC 分析
根据我们现在已经掌握的垃圾回收相关知识, 重新回到漏洞示例. 我们首先回想如下的序列化字符串:
- //POC of the ArrayObject GC vulnerability
- $serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:4;i:2;r:1;};m:a:0:{}}}';
在使用 gdb 时, 我们可以使用标准的 PHP 5.6 .gdbinit( https://github.com/php/php-src/blob/PHP-5.6.23/.gdbinit )和一个额外的自定义例程来转储垃圾回收缓冲区的内容.
- //.gdbinit dumpgc
- define dumpgc
- set $current = gc_globals.roots.next
- printf "GC buffer content:n"
- while $current != &gc_globals.roots
- printzv $current.u.pz
- set $current = $current.next
- end
- end
此外, 我们现在可以在 gc_mark_roots 和 gc_scan_roots 上设置断点来查看所有相关引用计数器的状态.
此次分析的目标, 是为了解答为什么外部数组会完全被释放. 我们将 PHP 进程加载到 gdb 中, 并按照上文所述设置断点, 执行示例脚本.
- (gdb) r poc1.php
- [...]
- Breakpoint 1, gc_mark_roots () at [...]
- (gdb) dumpgc
- GC roots buffer content:
- [0x109f4b0] (refcount=2) array(1): { // outer_array
- 1 => [0x109d5c0] (refcount=1) object(ArrayObject) #1
- }
- [0x109ea20] (refcount=2,is_ref) array(2): { // inner_array
- 1 => [0x109ea20] (refcount=2,is_ref) array(2): // reference to inner_array
- 2 => [0x109f4b0] (refcount=2) array(1): // reference to outer_array
- }
在这里, 我们看到, 一旦反序列化完成, 两个数组 (inner_array 和 outer_array) 都会被添加到垃圾回收缓冲区中. 如果我们在 gc_scan_roots 处中断, 那么将会得到如下的引用计数器:
- (gdb) c
- [...]
- Breakpoint 2, gc_scan_roots () at [...]
- (gdb) dumpgc
- GC roots buffer content:
- [0x109f4b0] (refcount=0) array(1): { // outer_array
- 1 => [0x109d5c0] (refcount=0) object(ArrayObject) #1
- }
在这里, 我们确实看到了 gc_mark_roots 将所有引用计数器减为 0, 所以这些节点接下来会变为白色, 随后被释放. 但是, 我们有一个问题, 为什么引用计数器会首先变为 0 呢?
3.1 对意外行为的调试
接下来, 让我们逐步通过 gc_mark_roots 和 zval_mark_grey 探究其原因.
1,zval_mark_grey 将会在 outer_array 上调用(此时, outer_array 已经添加到垃圾回收缓冲区中);
2, 将 outer_array 标记为灰色, 并检索所有子项, 在这里, outer_array 只有一个子项, 即 "object(ArrayObject) #1"(引用计数器 = 1);
3, 将子项或 ArrayObject 的引用计数分别进行递减, 结果为 "object(ArrayObject) #1"(引用计数器 = 0);
4,zval_mark_grey 将会在此 ArrayObject 上被调用;
5, 这一对象会被标记为灰色, 其所有子项 (对 inner_array 的引用和对 outer_array 的引用) 都将被检索;
6, 两个子项的引用计数器, 即两个引用的 zval 将被递减, 目前 "outer_array"(引用计数器 = 1),"inner_array"(引用计数器 = 1);
7, 由于 outer_array 已经标记为灰色(步骤 2), 所以现在要在 outer_array 上调用 zval_mark_grey;
8, 在 inner_array 上调用 zval_mark_grey, 它将被标记为灰色, 并且其所有子项都将被检索, 同步骤 5 一样;
9, 两个子项的引用计数器再次被递减, 导致 "outer_array"(引用计数器 = 0),"inner_array"(引用计数器 = 0);
10, 最后, 由于不再需要访问 zval, 所以 zval_mark_grey 将终止.
在此过程中, 我们没有想到的是, inner_array 和 ArrayObject 中包含的引用分别递减了两次, 而实际上它们每个引用应该只递减一次. 另外, 其中的步骤 8 不应被执行, 因为这些元素在步骤 6 中已经被标记算法访问过.
经过探究我们发现, 标记算法假设每个元素只能有一个父元素, 而在上述过程中显然不满足这一预设条件. 那么, 为什么在我们的示例中, 一个元素可以作为两个不同父元素的子元素被返回呢?
3.2 造成子项有两个父节点的原因
要找到答案, 我们必须先看看是如何从父对象中检索到子 zval 的:
- "Zend/zend_gc.c"
- [...]
- static void zval_mark_grey(zval *pz TSRMLS_DC)
- {
- [...]
- if (Z_TYPE_P(pz) == IS_OBJECT && EG(objects_store).object_buckets) {
- if (EXPECTED(EG(objects_store).object_buckets[Z_OBJ_HANDLE_P(pz)].valid &&
- (get_gc = Z_OBJ_HANDLER_P(pz, get_gc)) != NULL)) {
- [...]
- HashTable *props = get_gc(pz, &table, &n TSRMLS_CC);
- [...]
- }
可以看出, 如果传递的 zval 是一个对象, 那么该函数就会调用特定于对象的 get_gc 处理程序. 这个处理程序应该返回一个哈希表, 其中包含所有的子 zval. 经过进一步调试后, 我们发现该过程将会调用 spl_array_get_properties:
- "ext/spl/spl_array.c"
- [...]
- static HashTable *spl_array_get_properties(zval *object TSRMLS_DC) /* {{{ */
- {
- [...]
- result = spl_array_get_hash_table(intern, 1 TSRMLS_CC);
- [...]
- return result;
- }
总之, 将会返回内部 ArrayObject 数组的哈希表. 然而, 问题发生的根源是这个哈希表在两个不同的上下文环境中使用, 分别是:
1, 当算法试图访问 ArrayObject zval 的子元素时;
2, 当算法试图访问 inner_array 的子项时.
大家可能现在能猜到, 在步骤 1 中缺少了一些东西. 由于返回 inner_array 哈希表的行为与访问 inner_array 的行为非常相似, 因此前者在步骤 1 中也应该标记为灰色, 从而保证在步骤 2 中不能再次对其进行访问.
那么, 接下来我们会问, 为什么 inner_array 在步骤 1 中没有被标记为灰色? 我们可以再次仔细阅读一下 zval_mark_grey 是如何检索子项的:
HashTable *props = get_gc(pz, &table, &n TSRMLS_CC);
该方法推测是负责调用对象的垃圾回收函数, 其垃圾回收函数类似于如下例子:
- "ext/spl/php_date.c"
- [...]
- static HashTable *date_object_get_gc(zval *object, zval ***table, int *n TSRMLS_DC)
- {
- *table = NULL;
- *n = 0;
- return zend_std_get_properties(object TSRMLS_CC);
- }
然而, 返回的哈希表应该只包含对象自身的属性. 实际上, 还有 zval 的参数, 会通过引用进行传递, 并作为第二个 "返回参数". 该 zval 应该包含该对象在其他上下文中所引用的所有 zval. 这一点, 可以以存储在 SplObjectStorage 中的所有对象 / zval 为例.
对于我们特定的 ArrayObject 场景, 我们希望 zval 表能够包含对 inner_array 的引用. 然而, 这一过程为什么要调用 spl_array_get_properties 而不是 spl_array_get_gc 呢?
3.3 缺少的垃圾回收函数及其导致的后果
问题的答案很简单, spl_array_get_gc 根本就不存在!
PHP 的开发人员并没有为 ArrayObjects 实现相应的垃圾回收函数. 尽管如此, 其实还是不能解释为什么 spl_array_get_properties 被调用. 为了进一步追溯其原因, 我们首先看看对象是如何初始化的:
- "Zend/zend_object_handlers.c"
- [...]
- ZEND_API HashTable *zend_std_get_gc(zval *object, zval ***table, int *n TSRMLS_DC) /* {{{ */
- {
- if (Z_OBJ_HANDLER_P(object, get_properties) != zend_std_get_properties) {
- *table = NULL;
- *n = 0;
- return Z_OBJ_HANDLER_P(object, get_properties)(object TSRMLS_CC);
- [...]
- }
处理遗漏的垃圾回收函数, 依靠于对象自身的 get_properties 方法(前提是该方法已定义).
现在, 我们终于找到了第一个问题的答案. 造成该漏洞的主要原因, 是 ArrayObject 缺少垃圾回收函数.
奇怪的是, 这个函数是在 PHP 7.1.0 alpha2 版本中又被引入( https://github.com/php/php-src/commit/4e03ba4a6ef4c16b53e49e32eb4992a797ae08a8 ). 因此, 只有 PHP 5.3 及以上版本和 7 以下的版本缺少此函数, 受到漏洞影响. 然而, 由于在不经过对反序列化进行调整的前提下, 我们无法触发这一漏洞, 因此还不能仅凭借此漏洞来实现远程代码执行. 截至目前, 我们将该漏洞称为 "双递减漏洞", 漏洞报告如下(CVE-2016-5771): https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2016-5771 .
小结
现在, 我们仍然需要回答开头提出的问题. 其中之一是, 是否有必要手动调用 gc_collect_cycles?
此外, 在发现了这一漏洞后, 是否可以有效将其利用在对网站的远程代码执行漏洞利用上?
我们将在下篇文章中具体分析, 敬请关注.
来源: https://juejin.im/entry/5b34979de51d4558bf7c4da0