新春杯2026web方向(除java)+域渗透wp
字数 3499
更新时间 2026-04-15 13:49:53

CTF题目“让人变幸运的魔法”PHP反序列化字符逃逸解析

1. 题目概览

题目提供了一段PHP代码,存在一个反序列化字符逃逸漏洞。核心功能是:用户通过POST传入spell参数,代码会将其序列化,然后经过一个magic()函数处理,最后反序列化执行。目标是构造一个spell参数,使其经过处理后能够触发反序列化链,执行任意命令。

2. 核心代码分析

2.1 代码结构

<?php
highlight_file(__FILE__);
error_reporting(0);
function magic($param) {
    $unlucky_list = ['unlucky', 'bad', 'jinx', 'unfortunate'];
    foreach ($unlucky_list as $unlucky) {
        $param = str_replace($unlucky, 'lucky', $param);
    }
    return $param;
}
class Frieren{
    public $spell;
    public $target = 'Nobody';
    public function __construct($spell){
        $this -> spell = $spell;
    }
    public function __wakeup(){
        if ($this -> target !== 'Nobody'){
            echo "成功让".$this -> target."变得幸运!";
        }
    }
}
class Himmel{
    public $sword = "Himmel's sword";
    public $action;
    public function __toString(){
        if ($this -> action === 'try' && $this -> sword !== "Sword of the Hero"){
            echo "'这次的勇者也不是真正的勇者啊...'".PHP_EOL;
            echo "'假勇者又有何妨,我会消灭魔王,让世界恢复和平。'".PHP_EOL;
            $func = $this -> sword;
            $func();
        }else{
            return "真正的勇者?";
        }
    }
}
class Fern{
    public $book;
    public function __invoke(){
        $this -> book -> abliity;
    }
}
class Stark{
    private $ability;
    public function __get($name){
        eval($this -> ability);
    }
}
$spell = $_POST['spell'];
if (isset($spell)){
    $data = serialize(new Frieren($spell));
    $magic_data = magic($data);
    unserialize($magic_data);
}

2.2 关键函数与类

  • magic($param)函数:将字符串$param中的'unlucky', 'bad', 'jinx', 'unfortunate'替换为'lucky'。由于替换后字符串长度可能发生变化,这是字符逃逸的关键。
  • Frieren
    • 属性:$spell(存储传入的字符串),$target(默认值为'Nobody')。
    • 魔法方法__wakeup():在反序列化时自动调用。如果$target不等于'Nobody',则会输出$target的内容。这里$target可以是任意对象,触发其__toString()方法。
  • Himmel
    • 属性:$sword(默认值"Himmel's sword"),$action
    • 魔法方法__toString():当对象被当作字符串使用时触发。如果$action === 'try'$sword !== "Sword of the Hero",则会将$sword作为函数调用($func = $this->sword; $func();)。这要求$sword是一个可调用的对象(即实现了__invoke()方法的对象)。
  • Fern
    • 属性:$book
    • 魔法方法__invoke():当对象被作为函数调用时触发。它会访问$book->abliity(注意此处拼写错误,原代码为abliity,但Stark类中属性为ability,这可能导致问题,但利用链构造时需匹配)。
  • Stark
    • 属性:private $ability(私有属性)。
    • 魔法方法__get($name):当访问不存在的或不可访问的属性时触发。这里会执行eval($this->ability)这是最终执行任意代码的关键点。

2.3 反序列化触发链

unserialize($magic_data) -> 触发Frieren::__wakeup() -> 如果$targetHimmel对象,则将其作为字符串输出 -> 触发Himmel::__toString() -> 如果满足条件,将$sword(需为Fern对象)作为函数调用 -> 触发Fern::__invoke() -> 访问$book->abliity$book需为Stark对象,且abliity属性不存在或不可访问) -> 触发Stark::__get() -> 执行eval($this->ability)

因此,完整的利用链为:Frieren -> Himmel -> Fern -> Stark

3. 字符逃逸原理

序列化后的字符串结构是严格的。例如,spell"test"时,序列化结果为:

O:7:"Frieren":2:{s:5:"spell";s:4:"test";s:6:"target";s:6:"Nobody";}

其中s:5:"spell"表示spell属性是字符串,长度为5,值为"test"(注意:"test"长度为4,但这里s:4表示值长度为4)。

magic()函数会进行字符串替换。例如,将'bad'替换为'lucky',长度从3变为4,增加1个字符。如果我们在spell中精心构造包含大量'bad'的字符串,替换后整个序列化字符串的长度会增加,导致原定的属性值长度"s:4:"test"与实际值长度不匹配。但PHP在反序列化时会根据分号;和括号}来解析字段。如果我们能让增加的长度“吃掉”原字符串的一部分,使得原本属于spell值的一部分被“逃逸”出来,成为后续序列化字符串的一部分,就可以注入新的对象定义。

目标:使spell值的一部分逃逸,并让逃逸的部分正好是s:6:"target";之后的内容,从而将target的值从字符串"Nobody"替换为我们构造的恶意对象。

假设我们想让target的值变为一个Himmel对象,其序列化字符串为:

O:6:"Himmel":2:{s:5:"sword";O:4:"Fern":1:{s:4:"book";O:5:"Stark":1:{s:7:"ability";s:13:"system("ls");";}}s:6:"action";s:3:"try";}

我们需要逃逸的字符串是:

";s:6:"target";O:6:"Himmel":2:{s:5:"sword";O:4:"Fern":1:{s:4:"book";O:5:"Stark":1:{s:7:"ability";s:13:"system("ls");";}}s:6:"action";s:3:"try";}

注意开头是";,用于闭合前一个属性的值,然后开始s:6:"target";定义。

原始序列化字符串中,spell值部分是s:4:"test"。如果我们能在spell中注入足够多的'bad',使得替换后"test"部分被“撑开”,后面的";s:6:"target";s:6:"Nobody";}被整体向后推移。但我们的目标是让"test"后面的部分恰好成为新的target值。这需要精确计算替换增加的字符数。

替换规则'bad'(3字符)-> 'lucky'(4字符),每个替换增加1字符。'jinx'(4字符)-> 'lucky'(4字符),长度不变。'unlucky'(7字符)-> 'lucky'(4字符),减少3字符。'unfortunate'(11字符)-> 'lucky'(4字符),减少7字符。

为简化,通常使用增加字符的替换(如'bad')。设需要逃逸的字符串长度为L。我们需要在spell中放入N'bad',使得替换后增加N个字符,这N个字符正好“吃掉”逃逸字符串之前的";s:6:"target";部分(假设该部分长度为M),并使得逃逸字符串成为新的target值。

实际上,更直接的方法是:让spell的值包含大量'bad',后面紧跟我们想注入的payload。序列化后,spell值的长度是strlen($spell)。经过magic()替换,spell值中所有'bad'变为'lucky',总长度增加('bad'的数量)。此时,序列化字符串中描述spell值长度的部分(例如s:100:)是固定不变的,但实际值变长了。PHP在反序列化时,会根据声明的长度读取值。如果实际值比声明的长,多出的部分会被当作后续的序列化数据解析。因此,我们可以在spell中构造:[大量'bad'][逃逸payload]。替换后,[大量'bad']变长,使得逃逸payload被“挤”出spell值的范围,成为target的值。

计算:设逃逸payload为P,其长度为len(P)。我们需要“吃掉”的字符是";s:6:"target";,其长度为len(";s:6:"target";") = 16。每个'bad'替换增加1字符,所以需要N = len(P) + 16'bad'。这样,替换后增加N个字符,这N个字符会使得原spell值的结尾分号";"向后移动N位,正好让P成为target的值。

但在实际payload中,P是完整的对象序列化字符串,长度较长。我们可以用'bad''jinx'等组合微调。

4. 利用链构造

最终目标是执行任意命令,例如system('cat /fl*');。所以Stark类的$ability属性应设置为"system('cat /fl*');"

构造对象链:

  1. Stark对象:$ability = "system('cat /fl*');"
  2. Fern对象:$book为上面的Stark对象。
  3. Himmel对象:$sword为上面的Fern对象,$action = 'try'
  4. Frieren对象:$target为上面的Himmel对象,$spell为我们构造的逃逸字符串。

注意:Stark$ability是私有属性,序列化时格式为:s:12:"%00Stark%00ability"(类名和属性名前后有NUL字节,需要URL编码)。在payload中需正确表示。

5. 最终Payload

根据文档,最终构造的spell值为:

badbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbad
相似文章
相似文章
 全屏