0x00 前言
在php的开源框架或者第三方库中,基本都存在反序列化利用链的问题,当然这需要反序列化入口去触发
而针对这些框架中存在的pop链的问题,基本上就三种处理方式
- 不处理(单独的pop利用链并不能构成漏洞,案例:thinkphp框架)
- 在
__wakeup
方法中将关键变量置空(案例:YII框架) - 在
__wakeup
方法中抛出异常(案例:guzzlehttp)
在反序列化的利用中,任何一个魔术方法就可能会被利用到,但是作为入口,基本上只有三个
__wakeup
:反序列化时触发__destruct
:析构时触发__toString
:类对象被当成字符串使用时触发
在这三个利用,
最直接自然是__wakeup
方法,如果pop链以此为入口,基本上不会存在什么限制
如果一个入口为__destruct
方法,那么在执行过程中如果遇到致命错误,则不会再执行析构方法(php7,php5会继续执行),这也是guzzlehttp
组件中的修复方式
__toString
方法,同样不能在触发之前抛出致命错误
所以,针对于pop链的修复,一般都是基于__wakeup
操作的
那么__wakeup
存在哪些绕过的方式呢?
0x01 举例
第一种方式
- 影响版本 php5
当反序列化字符串中,表示属性个数的值大于真实属性个数时,会跳过__wakeup
函数的执行
此bug记录于php-bugs-72663
看下代码
这里就很明确,表示属性个数的值大于真实属性个数时,return 0
,自然也不会调用下面的__wakeup
方法
但是此时的对象rval
已经被创建,所以当程序结束时,依然会执行析构方法
在高版本中,修复方式如下,会先判断__wakeup
方法是否存在,如果存在就会先释放,再返回
场景测试代码
由于场景是针对于php5的,再php5中抛出异常,不会影响析构方法的执行如下
所以测试代码使用替换成员变量的值,测试代码如下,exit也并不会中断析构的执行
1 |
|
正常的payload
当属性个数不一致时
当php版本为7.x时,并没有反序列化出来对象,也就是上面提到的,个属性个数不一致的时候被释放掉了
第二种方式
- 由于是借用正常逻辑重新赋值,所以所有版本都可以
针对于这种在__wakeup
中,将关键变量置空的修复方式,还可以进行二次赋值,这也是来自之前看过的一个实例
利用引用的形式再次覆盖掉成员变量
1 |
|
当我们正常设置b的值
1 | test=O:1:"A":2:{s:1:"a";O:1:"B":2:{s:1:"c";N;s:1:"d";s:4:"test";}s:1:"b";s:4:"test";} |
如上面的代码中$a->b = &$b->c;
,成员变量b的值将会被重新覆盖
1 | test=O:1:"A":2:{s:1:"a";O:1:"B":2:{s:1:"c";N;s:1:"d";s:4:"test";}s:1:"b";R:3;} |
第三种方式
- 影响版本:php7、8
- 限制入口类不能存在
__wakeup
方法
这种方式主要就是在成员变量的长度不一致的时候,__destruct
方法会在__wakeup
方法之前被调用
测试代码
1 |
|
当长度不一致时
1 | test=O:1:"A":2:{s:4:"info";O:1:"B":1:{s:3:"end";s:10:"phpinfo();";}s:4:"end";s:1:"1";} |
正常解析时
这个去跟一下底层的实现
这里用到了php的垃圾回收机制
官方给出的解释是当结构体(zval)的指向计数(refcount)减少到非0时,才会进入垃圾缓冲区(buffer),当缓冲区到达临界值(默认是10000,PHP7.3的临界值触发是100)时,会触发垃圾回收算法,该算法对缓冲区的疑似垃圾结构体进行遍历,把每个结构体的指向计数(refcount)减1,然后判断其(refcount)是否为0,如果是的话,就判定这个结构体是垃圾,并销毁
通过调试还可以发现,反序列化的操作是从前往后一点点完成的,什么意思呢
比如如下序列化数据在进行反序列化时,如果A类中不存在__wakeup
方法(存在时属性不对,直接释放,上面提到了),这个时候A类以及info成员变量是可以成功反序列化的,只是在饭序列化end的时候,出现了错误,会报错Error at offset
,但是报这个错误程序并不会退出,会继续往下执行
1 | O:1:"A":2:{s:4:"info";O:1:"B":1:{s:3:"end";s:10:"phpinfo();";}s:4:"end";s:1:"1";} |
这里主要是通过php_var_unserialize函数进行反序列化的,接下来看看对比
当属性长度异常时,堆栈函数如下
1 | php_var_unserialize |
可以看到实例化类时,类的refcount计数为1,就是这个实例化出来的类会触发垃圾回收
由于长度不正确,反序列的结果就是错误的(此时info变量条用的B类反序列化时正常完成了的)
然后就会出现,经常碰到的那个错误
最后进入PHP_VAR_UNSERIALIZE_DESTROY结构体处理销毁
这里面包含了所有需要销毁的变量,自然也包括info属性的B类,但是B类是正常的
在var_destroy方法中,可以看到A类的refoubd计数是1,B类的refound是3
跟进i_zval_ptr_dtor方法
1 | static zend_always_inline void i_zval_ptr_dtor(zval *zval_ptr ZEND_FILE_LINE_DC) |
看一下GC_DELREF
结构体
1 |
|
所以A类会调用rc_dtor_func函数
1 | ZEND_API void ZEND_FASTCALL rc_dtor_func(zend_refcounted *p) |
调用A类的析构方法
此时已经执行完A类的析构方法了,但是上面的for循环此时的i值为0,当i=4的时候才会处理B类
所以此时的B类是未调用__wakeup
方法的,当i=4的时候,调用__wakeup
,但是A类的析构方法已经执行完成,且调用时的变量未被B类的__wakeup
方法覆盖
这是序列化数据错误的时候执行顺序时
1 | A->__destruct |
当反序列化数据正确的时候,走的不再是var_destroy
会进入else分支
计数标识为2
调用完这个循环,已经执行了B类的__wakeup
方法
然后就是程序的结束流程了,如下,就又到了上面析构的地方
1 | zval_ptr_dtor |
所以此时的执行顺序是
1 | B->__wakeup |
从这里还能看出的是
- 当A类出现错误,并不影响A类中成员变量是类对象的反序列化
比如
1 |
|
虽然反序列化出来的的A是false,但是B中的代码已经执行了
第四种方式
这种是针对guzz
测试代码
1 |
|
直接反序列化B类时,这里B类首先抛出异常,上面说提到,在php7版本中,抛出致命错误将不在进行析构
入口点修改为A类,间接调用B类时,B类不再析构,但是A类会进行析构,并且因为B类抛出异常并没有析构,所以B类的成员变量的值,都是反序列化出来的值,当A类对其调用时,可以直接调用
0x02 end
…