PHP反序列化
PHP 反序列化学习笔记
作者:@kengwang
Serialize & Unserialize
PHP 内置方法,官方文档:
serialize | unserialize序列化格式速查表
| 数据类型 | 提示符 | 格式示例 |
|---|---|---|
| 字符串 | s |
s:长度:"内容" |
| 已转义字符串 | S |
S:长度:"\xx\xx" |
| 整数 | i |
i:123 |
| 布尔值 | b |
b:1 (true) / b:0 (false) |
| 空值 | N |
N; |
| 数组 | a |
a:大小:{...} |
| 对象 | O |
O:类名长度:"类名":成员数:{...} |
| 引用 | R |
R:序号 |
对象序列化示例
1 |
|
- 序列化结果(格式化)
1 | O:8:"Kengwang":7:{ |
- 非公有字段命名规则
- private:
%00类名%00属性名 - protected:
%00*%00属性名
- private:
魔术方法速览
| 方法 | 触发时机 |
|---|---|
__construct |
实例化 |
__destruct |
对象销毁 |
__sleep |
序列化前 |
__wakeup |
反序列化后 |
__toString |
对象转字符串 |
__get / __set / __isset / __unset |
不可访问属性 |
__invoke |
对象当函数调用 |
__call / __callStatic |
不可访问方法 |
__debugInfo |
var_dump/print_r |
__clone |
克隆完成 |
__set_state |
var_export |
__autoload |
类未定义 |
常见绕过技巧
非公有字段绕过(PHP ≥ 7.1)
- 直接写
public或手动改序列化字符串即可。
绕过 __wakeup(CVE-2016-7124)
- 版本限制:PHP5 < 5.6.25,PHP7 < 7.0.10
- 修改成员数大于实际即可跳过
__wakeup
1 | O:4:"Dino":1:{s:4:"addr";s:3:"209";} → O:4:"Dino":114514:{s:4:"addr";s:3:"209";} |
十六进制字符绕过
- 用
S:4:"\66\6c\61\67"代替s:4:"flag"绕过关键字过滤。
例如
1 |
|
这里检测了是否包含 flag 字符, 我们可以尝试使用 flag 的十六进制 \66\6c\61\67 来绕过, 构造以下:
1 | 'O:4:"Read":1:{s:4:"name";S:4:"\66\6c\61\67";}' |
顺便贴一个 Python 脚本, 可以将字符串转换为 Hex
1 | str = input('Enter a string: ') |
引用(&)技巧
1 | class A { public $a; public $b; } |
正则绕过
有些时候我们会看到^O:\d+ 这种的正则表达式, 要求开头不能为对象反序列化
这种情况我们有以下绕过手段
- 由于\d只判断了是否为数字, 则可以在个数前添加+号来绕过正则表达式
- 将这个对象嵌套在其他类型的反序列化之中, 例如数组
当然, 第一种更佳. 因为若不只匹配开头则仍可以绕过
O:+4:"Dino":...加+号绕过^O:\d+- 或嵌套在数组 / 字符串中。
字符逃逸
例如我们有如下过滤机制:
1 |
|
此代码会将其中的单引号过滤成为转义+单引号, 此时字符串的长度会进行变化, 我们可以利用这一点使 name 中的东西溢出到 path 中.
我们构造恶意字符串时需要先将前面的双引号闭合,同时分号表示此变量结束. 在攻击变量结束之后我们需要用 ;} 结束当前的序列化, 会自动忽略掉这之后的序列化.
我们的每一个单引号会变成两个字符, 于是可以将我们的恶意字符给顶掉, 我们只需要提供 恶意字符串长度 个会被放大变成两倍的字符.
当然如果不是两倍, 我们可以灵活运用 + 来进行倍数配齐
例如我们需要恶意构造 ";s:4:"path";s:4:"flag";}s:4:"fake";s:34:, 长度为 41, 于是我们提供 41 个 '
最终给 name 的赋值为
1 | Kengwang 的学习笔记'''''''''''''''''''''''''''''''''''''''''";s:4:"path";s:4:"flag";}s:4:"fake";s:34: |
我们可以运行一下试试:
1 | [处理前] |
可以看到 path 被替换成了 flag
当然有字符增加就会有字符减少, 对于字符减少, 我们假设有如下情况:
1 |
|
这里把反引号给过滤掉了, 我们先拿到正常的序列化后的串
1 | 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:"
得到结果如下
1 | [处理前] |
Fast Destruct
- 少写
}或减少成员数,提前触发__destruct。
利用不完整类绕过序列化回旋镖
当存在 serialize(unserialize($x)) != $x 这种很神奇的东西时, 我们可以利用不完整类 __PHP_Incomplete_Class 来进行处理
当我们尝试反序列化到一个不存在的类时, PHP 会使用 __PHP_Incomplete_Class_Name 这个追加的字段来进行存储
我们于是可以尝试自己构造一个不完整类
1 |
|
这样就可以绕过了
更近一步, 我们可以通过这个让一个对象被调用后凭空消失, 只需要手动构造无 __PHP_Incomplete_Class_Name 的不完整对象
PHP 会先把他的属性给创建好, 但是在创建好最后一个属性后并未发现 __PHP_Incomplete_Class_Name, 于是会将前面创建的所有的属性回收并引发 __destruct
当然, 要达成这种在反序列化后的变量还存在的时候引发 destruct, 还有下面这一种方法
原生类利用
我们可以通过脚本来获取到这些类:
1 |
|
SoapClient(需开启 soap 扩展)
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), 第一个为文件名, 第二个为打开的模式, 有以下几种模式
1 | ZipArchive::OVERWRITE 总是以一个新的压缩包开始,此模式下如果已经存在则会被覆盖或删除 |
我们可以发现当 flag 为 override (8) 时, 会将目标文件先进行删除, 之后由于并没有进行保存操作, 于是文件就被删除了
在 ByteCTF 2019 - EZCMS 中有出现过
其他
- SQLite3 创建文件
- DirectoryIterator / FilesystemIterator / GlobIterator 遍历目录
- SplFileObject 读取首行
- SimpleXMLElement + XXE
- Reflection系列 反射
- 闭包 (Closure) 代表匿名函数
Phar 反序列化
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
- 受影响函数:
include、file_get_contents、copy、fopen…
本地生成恶意 phar
1 |
|
之后我们就可以将此文件上传到服务器, 再通过文件操作函数调用, 例如 phar://test.phar/test 来让他打开 phar 文件
当然在上面引用的两篇文章中可以看到还有很多意想不到的地方也受到了影响
当然, 如果存在某些校验, 我们也可以通过一些手段绕过.
如果不允许 phar 出现在文件路径开头, 我们可以套上其他的协议: compress.bzip://, compress.bzip2://, compress.zlib://, php://filter/resource=
7. SESSION 反序列化
这里我们主要利用 session.upload_progress 来进行利用.
我们要先知道, 如果没有特别配置的话, session 通常存储在服务器上的某个文件夹中, 并且文件名通常为 sess_{你的SESSION_ID}
由于他存储时时通过反序列化, 所以原本的字符串会被保留. 于是我们可以注入 PHP 代码, 再通过文件包含执行他
利用条件:
- 可以进行任意文件包含 (或允许包含 session 存储文件)
- 知道session文件存放路径,可以尝试默认路径
- 具有读取和写入session文件的权限
这里我们就抄一下 H3 佬的一个 exp:
若服务器存在文件 test.php:
1 |
|
我们可以使用类似条件竞争的方法来进行, 下面是 Python, 我加一点点注释:
1 | 利用脚本 |
如果是反序列化的话, 我们也可以进行反序列化注入
如果我们的文件名可控, 我们在之前放上 | 表示前面的是键名, 后再写入恶意代码. 注意引号要进行转义
便可有exp
8. 例题:2023 SWPU NSS UnS3rialize
8.1 源码
1 |
|
8.2 POP 链
1 | F::__destruct() |
8.3 构造脚本
1 |
|
