PHP unserialize UAF 研究学习
当初还是Web狗的时候就想搞PHP PWN了,但是一直没行动起来,最近正好有机会,就学习了一下PHP 反序列化的UAF。
最开始我看的是CVE-2016-9137
[1],因为之前没接触过PHP相关的二进制漏洞,这UAF漏洞我看了半天却没看懂UAF在哪?怎么导致的?关于该漏洞的相关资料也都没搜到。
随后我又搜了一些PHP其他反序列化UAF的资料[2],然后选择从PCH-034(CVE-2015-6834)
开始入手。
我编译了5.6.12
版本的php,然后使用woboq来进行代码审计[3]。
该类型的文章暂时计划分为两篇,因为在我搞明白反序列化的uaf,我感觉php这么多反序列化的uaf的利用方式应该都大同小异,区别应该是在不同的函数中触发uaf。所以本章主要用来分析php 反序列化 uaf
的利用思路,之后打算研究更多的反序列化漏洞来验证我的想法,作为第二篇的内容。
学习思路分享
一般搞二进制相关的漏洞,我的套路都是先看看能不能找到触发漏洞的PoC,比如这次研究的是PCH-034
,可以找到PoC:
1 |
|
但是作为一个之前没接触过PHP PWN的人,虽然该PoC能触发Crash,但是我还是搞不懂为什么会这样,这样有什么用?
因为还找到了漏洞点代码:
1 | while(*p == ':') { |
但是,即使有源码可以让我审计,但是直接看漏洞点的代码对我来说还是一脸懵逼,因为PHP的数据结构我一点也不清楚,所以接下来我准备通过调试+源码审计(还有php源码中提供的.gdbinit)先搞懂PHP的数据结构。
从Zero开始的第一步————搞清楚数据结构
过程没啥好说的,这里我只说结果,我认为在研究php unserialize漏洞过程中需要了解的结构体:
php变量相关的数据结构
- php变量的结构体:
1 | typedef struct _zval_struct zval |
首先来看_zval_struct
的数据结构,其中type表示变量的类型,可以通过源码找到php所有的变量类型:
1 |
_zvalue_value
则是变量值的数据结构,在64位系统中,该结构体占0x10字节。
比如当type = 1
的时候_zvalue_value->lval
就表示一个整型变量的值,dval
则是浮点型变量
str
为字符串变量,占12字节,前8字节,val
为字符串指针,后4字节表示字符串长度。
HashTable *ht
是数组变量的结构体,zend_object_value obj
为对象变量的结构体。
所以,在zval
结构体中,value
值的具体含义取决于type
的值。
refcount__gc
变量涉及到了php的垃圾回收机制,该值表示该zval
结构体被引用的次数,如果该值被降为0的时候,将会触发垃圾回收机制,释放该zval
结构体,如果该结构体的值是指针,也会更着被释放。
is_ref__gc
则表示该变量是否引用了其他变量,可以简单的认为,当该值为1的时候,表示该变量是一个指针变量。但是在具体源码审计的过程中,并没有发现对该值有啥check机制,暂时认为不需要关注。
- 键值对类型变量数据结构
该数据结构指的是HashTable
,但是我没称为数组变量结构体,而是键值对变量结构,因为除了数组,对象的属性也是使用该结构。该结构如下:
1 | typedef struct _hashtable { |
其中nNumOfElements
表示数组元素的数量,数组的元素通过Bucket
结构体保存,通过pListHead/pListTail
形成双链表结构:
1 | typedef struct bucket { |
如果该数组为列表结构,那么ulong h
表示该数组的key
,如果为字典结构的数组,则char *arKey
表示该数组的key
,长度为uint nKeyLength
。
pDataPtr
为值的指针,一般都是zval
结构,但是有时候可能会有特殊用法,所以在结构体中被定义为void *
单纯的指针。
- 对象变量数据结构
1 | typedef struct _zend_object_value { |
_zend_object_handlers
从名字都能看出来,是一些handle
函数指针,这还是比较重要的,跟后续的一种利用思路相关。
handle
变量是一个整型,表示该对象的index
,对象变量一般都是全局保存。
有一个zend_executor_globals
结构体,在这个结构体中,有一个zend_objects_store objects_store;
用来储存对象。
1 | typedef struct _zend_objects_store { |
这里的object_buckets
就是一个对象数组,通过object_buckets[handle]
找到相对应的对象。
1 | typedef struct _zend_object_store_bucket { |
该对象的详细信息数据结构由void *object;
变量指向,在大部分情况下,其结构体为zend_object
1 | typedef struct _zend_object { |
zend_class_entry *ce;
该结构体储存了该对象的一些重要的数据,比如该对象的名字,还有一些函数地址之类的。
HashTable *properties
这个就是之前说的,对象的属性,通过键值对的形式储存在该变量中。
php全局变量相关数据结构
php还有一个储存全局信息的数据结构_zend_executor_globals
:
1 | // 太长了,只贴对研究利用有用的结构 |
try … catch
写过php/python
对try ... catch/except
相关的语法应该很熟悉了,php是用C写的,但是C却没相关的语法,那么php的该语法是如何实现的?我们可以在php源码中找到这样的代码:
1 | zend_try { |
然后我们找到相关的宏定义:
1 |
|
我在学习的时候看到这里我还是很迷惑,随后我又去看了setjmp/longjmp
这两个函数的作用,才搞懂了其原理。
这两个函数的具体功能这里不细说,大家自己去看文档。这里我说说使用C语言实现try...catch
的逻辑。
当setjmp
的参数__bailout
为空的时候,相当于是第一次执行setjmp
,这个时候会把一些寄存器信息储存到JMP_BUF
结构体中,__bailout
在栈上,并且有一个全局变量executor_globals.bailout
指向该地址。
第一次执行setjmp
的返回值为0,所以进入了try { xxx }
的分支,执行正常的代码,如果执行的代码中遇到了错误,将会执行zend_bailout
函数,或者zend_error
(在zend_error
函数中会执行zend_bailout
)函数,这样将会通过longjmp
函数跳回到SETJMP(__bailout)
,这个时候的setjmp
函数的返回值则是根据longjmp
的第二个参数决定,正常情况下是非0值,所以进入了catch { xxx }
逻辑分支。(还可以根据setjmp
的返回值来确定是什么类型的Exception
)
因为我测试的时候是使用php xxx.php
命令来执行php的,所以如果遇到错误异常,最终会跳回到php_execute_script
函数,在这个函数中,主体代码都在try { xx }
分支中,当遇到异常,则会进入zend_end_try()
,直接退出,并不会做错误输出啥的,一般错误输出都是通过zend_error
函数实现。
zend_execute_data
通过.gdbinit
我们能知道,可以通过_zend_execute_data
结构体来查看当前作用域的变量。具体的还是自行去看.gdbinit
吧,这里就不说了。
php堆管理数据结构
php的源码中,自行实现了一套堆管理系统,可以通过use_zend_alloc
环境变量来控制堆管理使用malloc
还是_zend_mm_alloc_int
。
1 | ZEND_API void *_emalloc(size_t size ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC) |
默认情况下是使用_zend_mm_alloc_int
来分配堆内存,具体的逻辑感兴趣的自行研究,这里只说一说几个关键的数据结构。
1 | typedef struct _zend_alloc_globals { |
需要关注的就是zend_mm_heap
结构体,可以类比为malloc
的main_area
。
1 | struct _zend_mm_heap { |
主要就是注意这个cache
结构,php自己实现的这套堆内存管理,也有按大小分不同的chunk,最小的chunk为0x20 - 0x21f
, _zend_mm_free_int
会把这个区间的堆内存释放后暂存到cache
里面。
cache
能存放64种大小的堆块,最小的从0x20开始,到最大的0x218,每种堆块之间相隔8字节。
1 | typedef struct _zend_mm_small_free_block { |
在分配/释放small chunk的逻辑里面,只处理size
(因为需要确定放到cache的哪个位置)和prev_free_block
(small chunk是一个单链表结构)。安全性比malloc
差的太多了,没有任何的check
机制。
php默认反序列化函数中数据结构
php默认的反序列化函数为php_var_unserialize
,在这个函数中有一个参数:php_unserialize_data_t *var_hash
:
1 | struct php_unserialize_data { |
一般情况下,php_unserialize_data
结构体的几个成员变量都是var_entries
结构:
1 | var_entries *var_hash = (*var_hashx)->first; |
php_var_unserialize
会把反序列化后生成的每一个zval
结构体,按照顺序,放入data[VAR_ENTRIES_MAX]
数组,而反序列化中的引用变量(R)就是通过这个结构体,找到它要引用的zval
变量。
PHP反序列化中的UAF
php的数据结构搞清楚了以后,就要开始研究UAF漏洞了,UAF漏洞在哪?是什么原因触发了该漏洞?我们通过PoC来分析一下:
1 | $fakezval = ptr2str(1122334455); |
下面的步骤的可以通过脑内调试,或者使用gdb调试来调理清楚:
- 生成一个数组zval变量,放入var_entries.data[0],引用index = 1
- 生成一个整型zval变量,放入var_entries.data[1],引用index = 2, 放入数组[0]的pDataPtr
- 生成一个对象zval变量,放入var_entries.data[2],引用index = 3, 放入数组[1]的pDataPtr
- 根据
SplDoublyLinkedList
对象的代码,生成一个整型zval变量(1234),设置为对象的flags属性的值,放入var_entries.data[3],引用index = 4 - 根据
SplDoublyLinkedList
对象的代码,生成一个整型zval变量(1),设置为对象的数组元素的值,放入var_entries.data[4],引用index = 5 - 对象
SplDoublyLinkedList
处理完毕,执行其__wakeup
函数 - 生成一个对象zval变量,放入var_entries.data[5],引用index = 6, 放入数组[2]的pDataPtr
- 对象
obj
的ryat
属性指向index = 3的SplDoublyLinkedList
对象zval - 对象
obj
处理完毕,执行__wakeup
函数,$this->ryat = 1;
。该逻辑位于zend_std_write_property
函数,属性ryat
的值为整型zval变量(1),ryat
之前的值将会进入到垃圾回收的流程。 - index = 3的对象将会进入到
_zval_dtor_func
函数,进入到以下流程:
1 | case IS_OBJECT: |
- 随后的大致流程为:
zend_objects_store_del_ref
->spl_dllist_object_free_storage
,SplDoublyLinkedList
对象数组的变量也被释放(index = 5) - 生成一个数组zval变量,放入var_entries.data[6], 引用index = 7,数组[0]的值指向了
index = 5
的变量,该变量在上述逻辑中已经被释放,但是仍然可以被引用。 - 生成一个字符串zval变量,因为一个zval的结构体大小为0x20字节,这个时候生成的字符串zval变量指向的字符串也是一个大小为0x20(不包括头部)字节的堆块,而这个堆块正好等于
index = 5
变量被释放的堆块。(在本例中是正好这种情况下index=5的堆块被分配给了字符串,但是实际情况中不一定,所以需要自己构造,可以对PoC进行调试,查看堆块的分配情况,对反序列化元素进行相应的调整。) - 这个时候就产生了UAF漏洞,一个被释放的堆块,仍然能被指针指向,并且可以重新覆盖该部分堆块的值。
PHP 反序列化 UAF漏洞的利用
只知道漏洞点在哪还不够,还得能被利用,要不然该漏洞点地位就跟bug一样了。
任意读
正常情况下的PHP程序都是保护全开的,除非有啥命令注入之类的漏洞,要不然在不知道地址的情况下是很难利用的。所以我们首先来研究怎么通过UAF漏洞来达到任意读的目的。
触发漏洞的逻辑上面说的很清楚了,在第13步的时候,我们已经知道0x20字节长度的字符串被分配的堆内存跟index = 5
的zval变量内存是一样的。在12步的时候,我们让一个数组的0索引指向index = 5
的zval变量,相当于也指向这个0x20字节的字符串,这让我们能构造index = 5
的zval变量的内容,zval的数据结构在前文已经进行了讲解,比如在PoC中:
1 | $fakezval = ptr2str(1122334455); |
该部分就是对index = 5
的zval进行伪造,在上例中,把zval的type设置为0x01,表示为一个整型变量,其值为1122334455
,所以我们能得到输出:
1 | $ php uafpoc.php |
除了整型,我们还可以把type设置为字符串,这样前8字节就表示字符串地址,后面4字节表示字符串长度,这样就能达到任意地址读的目的。但是这个时候有一个问题了,PHP保护全开,我们怎么能读到一个有效地址呢?如果进行爆破,读到无效地址,程序直接就crash了。
如下PoC:
1 | $exploit = 'a:3:{i:0;C:19:"SplDoublyLinkedList":17:{i:1234;:i:2;:i:4;}i:1;O:3:"obj":1:{s:4:"ryat";R:2;}i:2;a:1:{i:0;R:5;}}'; |
我们可以把字符串的变量给删了,让数组引用直接引用到被释放的堆块,再让SplDoublyLinkedList
对象的数组中增加一个zval变量,这样再被释放时,0x20大小的堆块将会被多释放一块,确保数组引用的释放的堆块的prev_free_block
能指向下一个堆块,这样我们就能获取到堆地址。
之后我们能对堆进行扫描,从而获取到PHP binary地址,然后通过\x7fELF
字符串来找到基地址,libc
之类的同理。
通过任意读,我们可以获取在内存中的binary,所以在后续的利用中都是假设能已知libc版本,PHP binary版本。(相当于要用ROP的时候,知道偏移。)
RCE1
第一种RCE的方法跟任意读的方法差不多,我们能把index = 5
的zval伪造成对象变量,之后在反序列化的后面再加一个obj
对象,让其ryat
属性指向index = 5
,也就是相当于指向我们伪造的对象变量。根据前面所说的第10步,会进入到_zval_dtor_func
函数,随后执行Z_OBJ_HT_P(zvalue)->del_ref(zvalue TSRMLS_CC);
我们来看看Z_OBJ_HT_P
的宏展开:
1 |
既然我能把zval伪造成一个对象变量,那么也能伪造其handlers
成员变量,根据之前研究的zend_object_handlers
的数据结构,我们只要找到一个地址A,让A + 8的值为system
的地址,那么Z_OBJ_HT_P(zvalue)->del_ref(zvalue)
就会变成system(zvalue)
。
找这个地址A也简单,我们只要创建一个字符串变量,其值为重复n次的system地址(长度就是n*8),然后通过任意读,来找到这个变量在堆上的位置。
system的地址我们可以首先通过任意读找到libc的地址,从而找到system地址。
随后zvalue的handle部分有8字节可以供我们伪造system要执行的字符串,但是该方法限制太大了,只能执行8字节的命令,比如sh /*/x;
。虽然限制大,但是这种方法最简单。
这种方法主要是能控制RIP,如果能找到能栈迁移的ROP,这种方法就最好用的,但是我暂时没找到。
任意写
第二种RCE的方法就比较麻烦了,不过在研究第二种RCE的方法前,我们先来研究一下任意写。
任意写1
第一种任意写的方法和任意读的大部分步骤相同,比较像是把任意读和rce1的步骤进行结合。
同样是把index = 5
的zval变量伪造成一个字符串变量,字符串的地址为你想要写的地址A。但是我们需要再反序列化数据之后再增加一个obj
对象,让其ryat
属性执行index = 5
的变量,根据之前的逻辑,将会执行到_zval_dtor_func
函数,因为该变量为字符串,所以将会执行:
1 | case IS_STRING: |
字符串的地址将会被释放,在前面将会,对于大小在0x21f
以内的堆块再被释放后会被放入cache
中。从这里我们能看出,这个地址A并不是能随意写,需要A - 0x10
的值,也就是size字段, < 0x21f,并且 >= 0x20。
如果满足上述条件,地址A将会被放入cache
中,随后我们再反序列化字符串后续增加一个相应长度的字符串,这个字符串被分配的地址就会是A。从而达到任意写的目的,有点的利用简洁明了,方便。缺点是,任意写的地址需要满足size字段的需求。
任意写2
第二种任意写可以说是在第一种任意写之上的拓展吧。
- 通过字符串zval,申请一块small大小范围内的堆块,比如0x100字节(不包括0x10字节的头部)。这块内存区域我们可以写入一段有特征的字符,这样我们可以通过任意读来确定该内存的地址,比如确定了该地址为A(不包括头部)。
- 伪造一个字符串zval变量,字符串指针指向
A + 0x10
。 - 随后把0x100大小的字符串还有伪造的zval都释放掉,因为两者大小不同,所以会放入不同的cache
- 这里需要注意一下,0x100大小的字符串,前0x10字节的值因为被设置为伪造的字符串变量的值的大小。所以我们能控制
A + 0x10
被释放后,放入哪个cache。 - 再次申请一个0x100字节大小的字符串变量,这样我们就能覆盖
A + 0x18
的值(prev_free_block
),该值可以为我们想任意写的地址B。再次申请的第二个相应大小的堆块,就是地址为B的内存区域。
RCE2
第二种RCE比第一种麻烦了很多,但是限制却小了。
前面说了一下在C语言中使用的try ... catch
,如果我们想办法在不触发try(zend_try)
的情况下,直接触发zend_bailout
,那么就会恢复到executor_globals.bailout
储存的状态下。
executor_globals
的地址储存在PHP binary的.bss段,所以我们能通过任意读来获取到,在知道executor_globals
地址的情况下,我们就能获取到bailout
的地址。
这个时候就有4个方向,使用任意写2,来修改bailout
地址的值,或者使用任意写2来修改executor_globals
上储存的bailout
的值。
还可以使用任意写1,尝试在bailout
地址上查找是否有合适的size值,如果有,使用这个方法是最方便快捷的。或者可以使用任意写1,在executor_globals
中bailout
的上方查找是否有合适的size
。使用这种方法时需要注册,不要随意修改executor_globals
结构体的中的其他值,要不然还没rce就报错退出了。我们可以先使用任意读,获取到executor_globals
上的值,然后只修改bailout
的值,再重新写入。
关于JMP_BUF
的结构,一个是要清楚其代表的寄存器顺序:
rbx | (r9 ^ x) << 0x11 (rbp = r9) |
---|---|
r12 | r13 |
r14 | r15 |
(r8 ^ x) << 0x11 (rsp = r8) | (rdx ^ x) << 0x11 (rip = rdx) |
<<
不是普通的左移符号,表示为循环左移,想要恢复寄存器的值,通过逆推需要循环右移(ror),其大致逻辑如下:
1 | def ror(num, r): |
主要问题还是x的问题,在一个进程中,x的值不会变,这个时候executor_globals.bailout
原本保存的寄存器信息为php_execute_script
函数中zend_try
执行setjmp
时保存的,所以我们可以首先获取到(rdx ^ x) << 0x11
的值,假设该值为A
,然后我们可以通过PHP的binary获取到php_execute_script
函数中call setjmp
的下句指令的地址,假设该值为B
。那么,我们可以得出(A >> 0x11) ^ B == x
。
关于JMP_BUF
的利用,以及有很多文章都写过了,这里也就不多说了。
总结
能控制RIP,RSP后,后续利用就简单了,使用ROP设置参数,然后执行命令,EXP没法放出来。
下篇文章打算讲多个反序列化UAF漏洞在实际生产环境中的利用,因为现在的研究都是直接使用php script.php
。
引用
PHP unserialize UAF 研究学习