拥抱php之CVE-2016-5771

拥抱php

PHP的gc机制:

  1. 为什么要有gc?

php是一个脚本语言,弱变量类型的语言,用户不用考虑变量内存的分配。一切都由php的vm提供,在<5.3一下的时候,php使用的是引用计数来实现的gc,但是没办法解决自身的引用如下:

$a = array(0=>$&a);
unset($a);

5.3以后引入了新的gc方法,标记法。这里有一个值得注意的是php5和7关于引用计数方式有点不太一样,php7保存在zval_value中,php5在分配zval的时候,实际上是_zval_gc_info结构,引用计数也保存在zval结构下。

typedef struct _zval_gc_info {
zval z;
union {
gc_root_buffer *buffered;
struct _zval_gc_info *next;
} u;
} zval_gc_info;

  1. gc的面对是谁?

这个问题开始确实迷惑我了,以为所有变量都会参与到gc的cycle里面,只有array 和 object 的引用计数在减少的时候才有可能加入gc的root-buffer里面。

  1. gc的root-buffer什么时候会增加? 即什么才算是疑似垃圾的变量?

一个zval可能被引用很多次,如果某个时刻它的ref等于0的时候,这个时候才会去考虑真正的去释放掉这块内存,那么疑似垃圾怎么来定义呢?看下面释放zval的过程

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);
}
}

这是一个分支结构,首先会ref--,如果说引用计数为0,那么就真的去释放掉这个zval,并且如果这个zval存在与gc的root-buffer里面话,也会把这个zval从root-buffer删掉,root-buffer是个双链表结构,每次都从gc_globals->roots插入,也相当于一个FILO的结构。

再看另外一个分支,即ref--后,引用计数不为零,这个时候会去判断是不是possible_root可能根, 实际上就是把这个zval考虑加入root-buffer,同时标紫,这个就得将细节标色法了,后面再说。root名根,即一个zval变量在root-buffer只能存在一个,这个也是用标记法来判断的,只有黑色的时候才能考虑去标紫。

这个时候比较清楚了,即变量引用计数减少时,且减少之后不为0,zval的变量类型为array或者为object的时候。

上面三个问题应该是在了解gc过程中比较常见的问题。具体来看CVE-2016-5771

<?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);

在理解整个漏洞形成过程中其实不太容易的,如果你对gc和serialize的过程不太理解的话。很显然从输出结果来看这是一个UAF,$outer_array被意外的释放掉了。那么反过来想,结合gc,又不是反序列的问题,那么肯定是在处理gc的时候$outer_array引用计数肯定被减少为0,被当成垃圾释放掉了。还必须得深入到gc_collect_cycles里面去看才行。

这个$outer_array的结构如下:

array(1) { //外层数组
[1]=>
object(ArrayObject)#1 (1) {
["storage":"ArrayObject":private]=>
&array(2) { //内层数组
[1]=>
*RECURSION* //对内层数组的引用
[2]=>
*RECURSION* //对外层数组的引用
}
}
}

这种情况只能动态调呗,先下个断在gc_collect_cycles,看一下此时gc_root_buffer的可能垃圾根

[0x7ffff7bb37b0] (refcount=2) array(1): {
1 => [0x7ffff7bb6188] (refcount=1) object(ArrayObject) #1
}
[0x7ffff7bb4dd0] (refcount=2,is_ref) array(2): {
1 => [0x7ffff7bb4dd0] (refcount=2,is_ref) array(2):
2 => [0x7ffff7bb37b0] (refcount=2) array(1):
}

一切都是正常的第一个外部数组,第二个内部数组。再去细看处理过程,这里其实可以直接定位到gc是如何标记ArrayObject内部子zval的。关注为什么外层数组自身只有一次的引用,却减少两次ref

首先看是如何获得ArrayObject内部子元素的

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);

这个get_gc是一个用来获取ArrayObject内部的属性的HashTable的handler,看看get_gc是如何工作的

/*
#0 spl_array_get_properties (object=0x7ffff7bb6028) at /root/php-src/ext/spl/spl_array.c:796
#1 0x00005555558609b2 in zend_std_get_gc (object=0x7ffff7bb6028, table=0x7fffffffa608, n=0x7fffffffa614) at /root/php-src/Zend/zend_object_handlers.c:121
*/

static HashTable *spl_array_get_properties(zval *object TSRMLS_DC) /* {{{ */
{
...
result = spl_array_get_hash_table(intern, 1 TSRMLS_CC);
intern->nApplyCount--;
return result;
}

static inline HashTable *spl_array_get_hash_table(spl_array_object* intern, int check_std_props TSRMLS_DC) { /* {{{ */
...
} else {
return HASH_OF(intern->array);
}
} /* }}} */

这个intern->array就是我们的内层数组,那么其实返回就是这个array。这就变的有趣了,往下看如果返回是内层数组的话,(在php里面array就是HashTable),回到我们的标灰函数中

...
p = props->pListHead;
...
while (p != NULL) {
pz = *(zval**)p->pData;
if (Z_TYPE_P(pz) != IS_ARRAY || Z_ARRVAL_P(pz) != &EG(symbol_table)) {
pz->refcount__gc--;
}
if (p->pListNext == NULL) {
goto tail_call;
} else {
zval_mark_grey(pz TSRMLS_CC);
}
p = p->pListNext;
}
}

按照常理来说取对象里面的属性,也应该是一个HashTable,属性名为key,值为value。但是这里出现了歧义,这个内层数组按道理来说只能算一个属性值,但是这里的逻辑,他把内层数组当成了所以属性值的HashTable,这里产生了歧义,这里为什么造成了外层数组的引用计数递减了两次,真正原因在于被当成对象所有属性值HashTable的内层array没有开始标灰,就开始处理内部的元素了,这也导致了两次gc两次遍历内层数组,最后造成外层数组引用递减两次。再看poc


$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:{}}}';
------exchange----

这里值得注意的是,为什么需要构造成这样?如果说我们标横线的地方对两个数组的引用交换行不行?如下

$serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:1;i:2;r:4;};m:a:0:{}}}';

从结果上看是不可以的,外层ref被递减成了-1,难道gc让其递减了3次呢?题外话,这里是个技巧,其实在尝试去改poc,会让自己更快的理解整个漏洞。如果你也思考到这里的话,其实很简单,在标灰之前下个断。dumpgc一下

GC buffer content:
[0x7ffff7bb4c70] (refcount=2) array(2): {
1 => [0x7ffff7bb3650] (refcount=1) array(1):
2 => [0x7ffff7bb4c70] (refcount=2) array(2):
}
[0x7ffff7bb3650] (refcount=1) array(1): {
1 => [0x7ffff7bb6028] (refcount=2) object(ArrayObject) #1
}

可以看到其实并没有递减三次,开始的时候ref只为1,这很有意思, 这就涉及到php变量赋值的问题上,php5引用赋值,是有split过程的,具体在这里不阐述。如果你把R换成r,又发现其实是可以的,但是内存释放顺序上和前面又是不一样的,这提醒我们不仅需要关注gc,麻烦还在于unserialize上,为什么这些zval会出现在gc_root_buff里面那么肯定是在序列化的过程中引用计数发送了变化,如果你能很早的关注到这个问题,那么后面一些问题也能很好理解,序列化过程中会在一个var_hash的结构中保存生成zval的引用,为了后面的r或者R来支持引用,当然在后面的var_destory也需要释放掉这些引用,这就意味在反序列化过程中每个zval的ref的大小要比正常情况下要大一些。

综上,这个cve的精髓总结一下,如果用ArrayObject包含目标zval的引用,在精心的构造上,是可以造成二次递减的。我从一个最简单的应用,具体开始本文的分析。

array{ //1
0 =>ArrayObject{
&$1
}
1 => &$1
}

如果标灰从上述开始。你猜能递减几次? 为什么呢?

=>array_$1 #grey
=> ArrayObject_ref-1 #grey }
=> $1 }
=>ArrayObject_ref-1 } =====> ArrayObject_dec
=>ret }
=>$1_ref-1 }
=>ret }
=>$1_ref-1
=>ret

这个是从我脑子里画下来gc标灰的递归过程。按照深度遍历的顺序,可以印证一下,你发现这里ArrayObject却递减了两次,而外层数组正常递减。这里一个有意思的东西是,你发现ArrayObject里面的array可以是一个引用,这在后面非常重要,这也是这个例子要引进的最重要的东西。这里我们还需要主要到一个问题,当我们的目标zval递减为0之后,是否会被立即释放呢?答案是不一定。如果目标zval处于某个ref不为0的zval内部,而且这个zval也被gc处理过,那么在标灰紧接着的第二步,标白的过程中会恢复这个ref不为0的zval,意味着内部的子节点引用计数都会被恢复。显然ArrayObject包含着我们目标zval的引用,所以我们必须考虑这个情况。

再就上面这个例子我们再继续研究。现在针对上面例子做一个改进:

  1. 让外层array_ref递减为0
  2. 保证ArrayObject也为0。(这一步就是上面提到的包含关系)

先做第一个改进,让array_ref递减为0,上面例子可能你看不出来什么,如果我们再加一个array的引用如下:

array{ //1
0 =>ArrayObject{
&$1
}
1 => &$1
2 => &$1
}

那么这个时候的递减过程就变成这样了:

=>array_$1 #grey
=> ArrayObject_ref-1 #grey }
=> $1 }
=>ArrayObject_ref-1 } =====> ArrayObject_dec
=>ret }
=>$1_ref-1 }
=>ret }
=>$1_ref-1 }
=>ret }
=>$1_ref-1
=>ret
=>$1_ref-1

看出来了吗?这个ArrayObject_dec结构多减了一次外层数组的引用,相当于增加了一个array的引用,递减了两次。此时外层数组的引用已经被递减成0了,相当于完成我们的第一个目标。现在去着手第二个目标,让ArrayObject的引用也变0,此时的ArrayObject递减了2次,所以此刻它的ref应该为-1,似乎在目前看来我们无法做到让它变成0.因为你单纯加ArrayObjec的引用只会减的更多。由此下面进入另个一个思考过程:)

从前面的poc来看,必须得触发gc才行,前面是通过gc_collect_cycles()来触发的,如果你想要远程触发这个漏洞的话,你可能做不到调用这个函数,最多就只是一个unserialize()在等着你。非手工的触发gc,gc的默认机制是当存储的垃圾可能根达到阀值以后触发,这个默认值一般是10000。

有没有办法通过unserialize()来制造垃圾的可能根呢?那是肯定的,你不用去细想就会有一处,就是在最后unserialize()结束的时候使用var_destory()来删除unserialize()过程中产生多余的zval引用的时候。几乎每一个创建zval都会涉及到,这样来说只要创建够多的zval,那么在这一步就会触发gc。

但是这其中是有问题的,仔细想的话,会产生一个矛盾的现象。

  • 考虑ArrayObject的ref如何变成0?
    我需要调整ArrayObject的ref,新增的ArrayObject引用肯定不能再放在目标array里面,这样只会减的更多的,那需要放在目标数组的外面,这样就能单纯的增加ArrayObject的引用,用来调整前面多的递减。

    问题来了,把ArrayObject放目标数组外面,外面怎么理解呢? 相当于有分支结构了。那么目标数组肯定又是某个zval的子节点了,如果是某个zval的子节点,那么在var_destory处理过程中处理目标数组的引用之前,肯定已经处理过这个zval的引用了。又回到了原来之前的问题下,如果目标数组在某个ref不为0的zval下,目标array的ref是会被恢复的。又开始循环了,我们得跳出这个圈子。

细细想来出现上面的问题的原因在于,目标array的父节点被当做垃圾可能根,这就导致在gc的时候目标array_ref间接被恢复。通过var_destory来触发gc的时间对于现在的情况来说太晚了,能不能更早一点,单纯的只把我们的目标array放到gc的root_buffer里面呢?

那么在var_destory之前有没有办法去减少某个zval的引用呢,来填充gc的root_buffer?答案是肯定有,unserialize过程是允许下面的写法的:

$a = "a:3:{i:0;a:0:{}i:0;a:0:{}i:0;a:0:{}}";
unseralize($a);

在创建array的时候,会先拿到key,通过key去array所在的HashTable中找对应的bucket,所以这里是存在相同key值的bucket的update过程,这一步会减少旧的bucket里面zval的引用。如果目标数组index和垃圾值的index一样,只要垃圾值够多,就能触发gc,并且直接把目标array放到了gc_root_buffer里面。那么gc的root-buffer里面只会存在目标数组zval和垃圾值的zval和其他一些无关紧要的zval,这样的情况就是我们的理想情况。

现在找到了合理的触发gc的方式,但是我们的ArrayObject_ref目前为止还是没有为0,现在情况变了,前面是发生在unserialize之后,现在是处于的unserialize的过程里面,如果现在增加一个ArrayObject的引用相当于增加2个,即ref+2,因为var_hash里面会保存一个ArrayObject的引用。

增加一个ArrayObject的引用 ref+2,但目前来看我们的例子里面ArrayObject只多递减了一次,我们必须得考虑这两者之间的数量级关系。

现在再来看我们的例子,需要略微改变一下:

array{
0=>array{ //2 $2_ref=8
0 =>ArrayObject{ //$3_ref=2 |
&$2 | =====>ArrayObject_dec
} |
1 => &$2
2 => &$2
},
0=>array{},
0=>array{},
0=>array{},
... //garbage
}

现在的情况要比之前的复杂,其中各个zval的引用都翻倍了。如果断在var_destory前面,你可以看见此刻的ref实际上是多少,如上。

这时候如果再通过gc标灰,ArrayObject_ref能递减成0,而目标数组$2却只能递减4次,这远远不够,如果这个时候还是像前面的单纯增加目标数组的引用显然已经不行了,现在加一个$2, ref直接+2,2增1减,效果不理想。

我们还是得增加$2的引用,但是得让它递减的更多。如果我再加一个新的ArrayObject_dec的结构呢?

array{
0=>array{ //2 $2_ref=8
0 =>ArrayObject{ //$3_ref=2 |
&$2 | =====>ArrayObject_dec
} |
1 => &$2,
2 => &$2,
3 =>ArrayObject{ // |
&$2 | =====>ArrayObject_dec
} |
},
0=>array{},
0=>array{},
0=>array{},
... //garbage
}

看起来递减的效果要比单纯增加$2的引用要好。2增2减,现在刚好持平了。现在我们可以列个表达式,来算一算需要多少个ArrayObject_dec 和 $2, 分别设为x ,y :

ref_1 = (x+y+1)*2 //目标数组总引用数
ref_2 = 2 //单个ArrayObject的引用数
dec_1 = (x+1)y //目标数组递减的引用数
dec_2 = (x+1) //单个ArrayObject的引用递减数

2 - (x+1) == 2n //ArrayObject引用递减以后必须为负偶数
(x+1)y == (x+y+1)*2 //目标数组引用递减为0

数量关系如上所示:
当x = 1 ; ... 不成立
当x = 3 ; y = 4;

我就不往下算了,下面还有很多适合的条件。这里取需要3个ArrayObject_dec 和 4个$2。如下:

array{
0=>array{ //2
1 =>ArrayObject{ //3 |
&$2 | =====>ArrayObject_dec
} |
2 =>ArrayObject{ //7 |
&$2 | =====>ArrayObject_dec
} |
3 =>ArrayObject{ //11 |
&$2 | =====>ArrayObject_dec
} |
4 => &$2,
5 => &$2,
6 => &$2,
7 => &$2,
},
1=>array{ //inc ArrayObject_ref
0 => &$3,
1 => &$7,
2 => &$11,
},
0=>array{},
0=>array{},
0=>array{},
... //garbage
}

此时ArrayObject_ref会被递减成-2,这里需要在后面增加ArrayObject的引用使其正好为0,现在这个情况下目标数组可以被完美释放。如果你不确定的话,可以在gc_collect_cycles下个断,调一下看看目标array是否被加入了gc的freelist。接着我们需要去思考被释放的目标数组,会被如何重用。

php的内存管理和linux的slub有那么一点相似,但你只需要知道相同的size的chunk和malloc的fastbin一样是FIFO链表结构。

那么在这里释放顺序对于我们来说是比较重要的,再谈GC,gc标灰以后,再把ref不为0的zval全部恢复,这其中就包括子zval也会被恢复,再将ref=0的节点标白,最后再次变量收集白色节点,放到free_list,free_list也是个FIFO结构。

放进free_list按照遍历的顺序,最先的应该是目标数组,再接着3个ArrayObject。接着依次释放free_list中zval的内部元素,最后再释放zval。那么目标数组的zval则是最后释放的。

我们就先把眼光局限在这4块sizeof(zval_gc_info)的chunk上即size为32的chunk上,在php里面说chunk似乎不太准确,mmap分配的才叫chunk,这里我们干脆称它们为obj。

这个时候释放以后,你可以在_emalloc()下个断,可以很方便的跟踪4个obj被释放以后的去向,如果填充的垃圾数目够多,那么重新申请的过程应该如下:

i:0; a:0:{} i:0; a:0:{}
obj obj obj obj

是个4obj会按照FIFO的顺序,依次分配给这四个zval,a:0:{}好理解空数组,i:0表示的是数组里面key值,也是一个类型为long的zval。如果我们在目标数组以外再使用这4obj的引用即目标数组和ArrayObject的引用,就能得到不一样的zval引用,地址指向相同,内容发生了变化。

那么如何去利用这个过程呢?最好的情况是我们能伪造zval,如果伪造一个string类型的zval,那么我们就可以leak任意地址的数据,如何伪造假的string类型的zval呢?

i:999;s:4:"aaaa";

看上面这种情况,第一个表示key的zval,第二个是一个string类型的zval,到这里面已经分配出去2个obj了,当使用string的zval用来存储字符串,会根据字符串的大小去申请内存,我们可以控制字符串的长度,那么我们就可以申请到这第三个obj,再通过这第三个obj弄一个fake_zval。我们看一下zval的结构:

struct _zval_struct{
zvalue_value value;
zend_uint refcount__gc;
zend_uchar type;
zend_uchar is_ref__gc;
}

union _zvalue_value{
long lval;
double dval;
struct {
char *val;
int len;
} str;
HashTable *ht;
zend_object_value obj;
zend_ast *ast;
}

zval的结构大小为24,这里指的是x64的情况下,zvalue的结构也列出来了。所以这里我们是完全能控制一个zval结构的。细节的地方就是注意对齐。虽然这个地方我们能通过修改str.val 和 str.lenleak任意地址。但是问题是去哪里读?至少我们得知道php的elf地址吧!

对于这个问题都有比较通用的方法就是在堆上找残留text节或者data节或者bss节上的指针。这个时候需要变换一下思路。得让_efree()给我们设置fake_string_zval上的str.val.

按照上面的思路,我们得让我们的fake_string_zval二次释放。这个时候我想到了一个东西,array的index除了可以数字以外,还可以用字符串。而且在unserialize()处理array中是会把index值放在一个zval里面的,同时后面var_destory()会将其释放。

这个时候我们的fake_string_zval的str.val就变成了堆上一地址。通过调整str.len遍历堆上的内容,堆上肯定有Hashtable的结构,这过程生成很多array和object,他们都包含有HashTable的结构,有HashTable结构代表什么呢?HashTable里面有一个pDestructor的函数指针通常是指向_zval_ptr_dtor用来释放zval的函数。

这样就能拿到一个php二进制里面的地址,下面leak elf和符号表这里不再叙述,就这个地方我出了一道题,如下:

$flag="lalalalllalala";
echo(unserialize(base64_decode($_POST['az'])));

这道题就是这么简单,如果你了解前面整个流程,这道题其实很简单。这里你需要做的就是leak这个$flag变量,那么你得知道它放在哪?你需要大概了解一下php的vm是怎么运转的。整体上VM可以分配编译器和执行器,编译器的功能就是把php代码转换成opcode_array,执行器的功能就是去执行每一条opcode,上面的$flag相当于赋值是一个常量,关于常量是直接储存在zend_op_array->literals,这是一个结构体数组指针,我们只需要去遍历它就可以找到flag。

接下来问题就是怎么找到opcode_array这个结构,执行器的执行单元就是opcode_array,所以是可能存在多个opcode_array,用户的自定义函数调用就涉及到多个opcode_array的切换,显然本题没有用户自定义的函数调用,相应于变量域的切换,所以只有一个opcode_array结构,执行器的相关结构都存储在executor_globals这个全局变量上。executor_globals->active_op_array保存着当前正在执行的opcode_array。有了opcode_array根据前面的流程你就能找到flag。

关于executor_globals符号地址获取,具体看exp是比较常规的leak方法。本文主要重点在阐述
CVE-2016-5771利用,题目的讲解是其次,也看的出来了解该cve的利用以后,其实题目是非常非常的简单。该cve从原理上来说,是有一些难度,我每次看它也会有不一样的体会。但是我想难度更大的是在作者是怎样发现它?这是我最为感兴趣的。