正文
前言
在本文中,我们主要介绍了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关于反序列化模糊测试过程的文章(
www.evonide.com/fuzzing-uns…
)。我们在此仅举一例:
// 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)算法(官方文档:
php.net/manual/de/f…
)。垃圾回收机制默认是启用的,可以通过在php.ini配置文件中设置zend.enable_gc来触发。
在这里,我们已经假设各位读者具备了一些PHP的相关知识,包括内存管理、“zval”和“引用计数”等,如果有读者对这些名词不熟悉,可以首先阅读官方文档:
www.phpinternalsbook.com/zvals/basic…
;
www.phpinternalsbook.com/zvals/memor…
。
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”一文(
researcher.watson.ibm.com/researcher/…
),实现了一种垃圾回收算法。
2.2 触发垃圾回收
该算法的实现可以在“Zend/zend_gc.c”(
github.com/php/php-src…
)中找到。每当zval被销毁时(例如:在该zval上调用unset时),垃圾回收算法会检查其是否为数组或对象。除了数组和对象外,所有其他原始数据类型都不能包含循环引用。这一检查过程通过调用gc_zval_possible_root函数来实现。任何这种潜在的zval都被称为根(Root),并会被添加到一个名为gc_root_buffer的列表中。
然后,将会重复上述步骤,直至满足下述条件之一:
1、gc_collect_cycles()被手动调用(
php.net/manual/de/f…
);
2、垃圾存储空间将满。这也就意味着,在根缓冲区的位置已经存储了10000个zval,并且即将添加新的根。这里的10000是由“Zend/zend_gc.c”(
github.com/php/php-src…
)头部中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(
github.com/php/php-src…
)和一个额外的自定义例程来转储垃圾回收缓冲区的内容。
//.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的: