Burst Lab | PHP 审计 14|反序列化(四):字符串逃逸与绕过技巧
-
- 前言
- 一、先回顾前三篇:本篇要解决什么问题
- [二、源码阅读:PHP 序列化字符串的结构边界](#二、源码阅读:PHP 序列化字符串的结构边界)
- 三、基础语法:长度按字节计算
-
- [1. 英文字符串](#1. 英文字符串)
- [2. UTF-8 字符串](#2. UTF-8 字符串)
- [四、为什么推荐直接使用 `serialize()`](#四、为什么推荐直接使用
serialize()) - 五、字符串边界探针:观察解析器实际读取范围
- 六、字符串边界问题的审计判断标准
-
- [1. 输入是否进入序列化结构](#1. 输入是否进入序列化结构)
- [2. 长度是否在最后一步计算](#2. 长度是否在最后一步计算)
- [3. 是否存在二次替换](#3. 是否存在二次替换)
- [4. 是否检查了解析结果](#4. 是否检查了解析结果)
- [七、`__wakeup()` 的触发条件](#七、
__wakeup()的触发条件) - [八、`__serialize()` 与 `__unserialize()` 的版本差异](#八、
__serialize()与__unserialize()的版本差异) - 九、对象声明属性数与实际属性数
- 十、从源码到运行结果的审计流程
- 十一、常见误区
-
- [误区 1:序列化字符串是加密内容](#误区 1:序列化字符串是加密内容)
- [误区 2:长度按字符数计算](#误区 2:长度按字符数计算)
- [误区 3:只要使用 `strlen()` 就一定安全](#误区 3:只要使用
strlen()就一定安全) - [误区 4:看到 `__wakeup()` 就直接下结论](#误区 4:看到
__wakeup()就直接下结论) - [误区 5:属性数量异常在所有 PHP 版本都一样](#误区 5:属性数量异常在所有 PHP 版本都一样)
- [误区 6:`allowed_classes` 可以解决所有反序列化问题](#误区 6:
allowed_classes可以解决所有反序列化问题)
- 十二、开发侧防御方案
-
- [1. 外部数据优先使用 JSON](#1. 外部数据优先使用 JSON)
- [2. 必须反序列化时限制类](#2. 必须反序列化时限制类)
- [3. 使用 HMAC 校验完整性](#3. 使用 HMAC 校验完整性)
- [4. 不要手工拼接序列化字符串](#4. 不要手工拼接序列化字符串)
- [5. 魔术方法保持最小副作用](#5. 魔术方法保持最小副作用)
- 十三、本篇总结
- 免责声明
前言
在前面三篇文章中,我们已经完成了三个阶段的学习:
- 第 11 篇:理解 PHP 序列化字符串的基本格式;
- 第 12 篇:分析
__wakeup()、__destruct()等魔术方法的触发时机; - 第 13 篇:从对象属性关系出发,建立安全的 POP Chain 调用图。
本篇继续研究一个经常出现在代码审计中的问题:
当业务代码手工拼接序列化字符串时,字符串长度和结构边界会发生什么变化?
本文重点讨论:
- PHP 序列化字符串长度为什么按字节计算;
- 手工拼接序列化数据为什么容易出现边界错误;
- 字符串边界变化如何影响后续字段解析;
__wakeup()的触发条件与 PHP 版本特性差异;- 如何从源码阅读、版本复现和开发防御三个角度分析问题。
⚠️ 免责声明:本文仅用于网络安全学习、CTF 竞赛、靶场练习以及获得书面授权的代码审计。本文示例只使用固定字符串、状态标记和解析结果进行本地验证,不提供针对真实系统的可直接利用内容。
本机运行环境 :本文使用实际版本为 PHP 7.3.4 NTS x64,Zend Engine 3.3.4。文中"本机实测结果"均来自该环境。
一、先回顾前三篇:本篇要解决什么问题
第 11 篇中,我们看到字符串格式类似:
text
s:5:"admin"
第 12 篇中,我们知道对象被 unserialize() 还原后,可能自动进入:
text
__wakeup()
__unserialize()
__destruct()
第 13 篇中,我们进一步把多个对象通过属性连接起来,观察方法之间的数据流。
本篇把这几部分连起来,但不直接讨论第三方系统利用,而是研究解析器的输入边界:
text
外部数据
↓
手工拼接序列化字符串
↓
字符串长度决定读取范围
↓
unserialize() 按结构解析
↓
对象恢复和魔术方法触发
因此,审计时不能只看到:
php
unserialize($data);
还要继续向上追踪 $data 是如何生成的,特别是它是否经过了字符串拼接、替换、编码和长度计算。
二、源码阅读:PHP 序列化字符串的结构边界
PHP 序列化结果不是普通的文本格式,而是由类型标记、长度字段和数据内容共同组成的结构化字符串。
例如:
text
s:5:"admin"
可以拆成:
| 片段 | 含义 |
|---|---|
s |
String,字符串类型 |
5 |
字符串占用的字节数 |
"admin" |
字符串内容 |
反序列化时,PHP 会根据 5 读取后面的 5 个字节,而不是只依赖下一个双引号判断字符串结束位置。
这意味着,下面两部分必须一致:
text
声明长度:5
实际字节数:5
如果业务代码手工构造类似内容:
php
$serialized = 'a:2:{'
. 's:4:"note";s:' . strlen($input) . ':"' . $input . '";'
. 's:4:"role";s:5:"guest";'
. '}';
那么 $input 就进入了序列化语法内部。此时必须重点检查:
- 长度是否由 PHP 正确计算;
- 输入是否经过了编码转换;
- 输入中的引号和分号是否会影响后续结构;
- 拼接完成后是否真正执行过
unserialize()验证。
三、基础语法:长度按字节计算
1. 英文字符串
text
s:5:"admin"
admin 占用 5 个字节,所以长度为 5。
2. UTF-8 字符串
中文在 UTF-8 编码下,一个字符通常占用多个字节。PHP 序列化记录的是字节长度,而不是人眼看到的字符数量。
配套代码 01_length_bytes.php:
php
<?php
class Demo
{
public $name = 'admin';
public $age = 18;
}
$obj = new Demo();
$ascii = serialize($obj);
echo "[ASCII] ", $ascii, PHP_EOL;
echo "[ASCII length] ", strlen($ascii), PHP_EOL;
$obj->name = '中文';
$utf8 = serialize($obj);
echo "[UTF-8] ", $utf8, PHP_EOL;
echo "[name bytes] ", strlen($obj->name), PHP_EOL;
preg_match_all('/./us', $obj->name, $matches);
echo "[name chars] ", count($matches[0]), PHP_EOL;
本机运行结果:
text
[ASCII] O:4:"Demo":2:{s:4:"name";s:5:"admin";s:3:"age";i:18;}
[ASCII length] 53
[UTF-8] O:4:"Demo":2:{s:4:"name";s:6:"中文";s:3:"age";i:18;}
[name bytes] 6
[name chars] 2
可以看到:
text
中文:2 个字符
UTF-8:6 个字节
序列化片段:s:6:"中文"
这也是手工分析序列化数据时非常容易忽略的地方。
四、为什么推荐直接使用 serialize()
如果直接对 PHP 变量调用 serialize(),长度由 PHP 自动计算:
php
$record = [
'note' => $input,
'role' => 'guest',
];
$serialized = serialize($record);
这种方式不会要求开发者手动填写:
text
s:<长度>:"<内容>"
相反,下面这种拼接方式维护成本较高:
php
$serialized = 'a:2:{'
. 's:4:"note";s:' . strlen($input) . ':"' . $input . '";'
. 's:4:"role";s:5:"guest";'
. '}';
它至少存在三个审计风险:
- 输入长度必须和实际编码保持一致;
- 输入内容可能改变解析边界;
- 后续开发者修改固定字段时,容易破坏整体格式。
因此,业务代码不应该把序列化字符串当成普通模板拼接。需要序列化时,应让 PHP 负责完整编码,并在解析前进行完整性校验。
五、字符串边界探针:观察解析器实际读取范围
下面的示例故意使用手工拼接,只用于观察边界,不代表推荐写法。
php
<?php
function buildManualRecord($note)
{
return 'a:2:{'
. 's:4:"note";s:' . strlen($note) . ':"' . $note . '";'
. 's:4:"role";s:5:"guest";'
. '}';
}
普通输入 hello 会生成:
text
a:2:{s:4:"note";s:5:"hello";s:4:"role";s:5:"guest";}
此时 note 的长度为 5,后面的 role 字段可以正常解析。
配套代码 03_boundary_probe.php 会分别观察普通输入和包含序列化片段的边界探针。
本机运行结果:
text
[safe] a:2:{s:4:"note";s:5:"hello";s:4:"role";s:5:"guest";}
[safe result]
array(2) {
["note"]=>
string(5) "hello"
["role"]=>
string(5) "guest"
}
[boundary-probe] a:2:{s:4:"note";s:30:"hello";s:4:"role";s:5:"admin";";s:4:"role";s:5:"guest";}
[boundary-probe result]
array(2) {
["note"]=>
string(30) "hello";s:4:"role";s:5:"admin";"
["role"]=>
string(5) "guest"
}
这个结果说明,前面的内容被当成了 note 字符串的一部分,后面的字段仍然按照解析器读完指定长度后的位置继续处理。
这里要强调两点:
- 这不是普通字符串替换,而是结构化数据边界的变化;
- 如果长度、引号和分号组合不完整,
unserialize()也可能返回false或产生警告。
代码审计时,应把这类问题记录为"手工序列化格式构造风险",而不是只检查某一个关键词。
六、字符串边界问题的审计判断标准
遇到类似代码,可以按下面的顺序分析:
1. 输入是否进入序列化结构
php
$input = $_POST['note'];
$raw = '...s:' . strlen($input) . ':"' . $input . '"...';
如果外部输入直接进入结构化字符串,需要继续检查编码和长度。
2. 长度是否在最后一步计算
推荐:
php
$raw = serialize(['note' => $input]);
风险较高:
php
$raw = 's:' . strlen($input) . ':"' . $input . '"';
即使使用了 strlen(),也要确认中间没有发生 URL 解码、字符集转换或其他内容替换。
3. 是否存在二次替换
例如:
php
$raw = serialize($data);
$raw = str_replace('guest', $input, $raw);
这种逻辑可能让原本已经正确的长度字段失效。序列化完成后,不建议再对结构化字符串进行业务替换。
4. 是否检查了解析结果
php
$value = unserialize($raw);
if ($value === false) {
exit('invalid serialized data');
}
需要注意:false 本身也可以是合法的序列化值,所以更稳妥的写法是配合格式来源、类型检查和业务字段检查。
七、__wakeup() 的触发条件
第 12 篇已经介绍过,__wakeup() 通常会在对象被 unserialize() 还原后自动调用。
配套代码 02_wakeup_order.php:
php
<?php
class WakeupTrace
{
public $state = 'original';
public function __wakeup()
{
echo "[trace] __wakeup() called", PHP_EOL;
$this->state = 'restored';
}
}
$object = new WakeupTrace();
$serialized = serialize($object);
echo "[1] before unserialize: ", $serialized, PHP_EOL;
$restored = unserialize($serialized);
echo "[2] after unserialize: state=", $restored->state, PHP_EOL;
本机运行结果:
text
[1] before unserialize: O:11:"WakeupTrace":1:{s:5:"state";s:8:"original";}
[trace] __wakeup() called
[2] after unserialize: state=restored
执行顺序:
text
读取序列化结构
↓
恢复对象属性
↓
调用 __wakeup()
↓
继续后续业务逻辑
审计时要继续追踪 __wakeup() 修改了哪些属性,以及这些属性是否会被后续方法读取。
八、__serialize() 与 __unserialize() 的版本差异
PHP 7.4.0 引入了新的对象序列化接口:
php
__serialize()
__unserialize()
当类实现了这套新机制时,PHP 7.4 及以上版本会优先使用它们处理对象序列化和还原;旧版本主要使用 public/protected/private 属性和 __wakeup() 等旧机制。
配套代码 06_version_hooks.php 同时定义了三种方法:
php
<?php
class VersionHooks
{
public $state = 'original';
public function __serialize()
{
echo "[trace] __serialize() called", PHP_EOL;
return ['state' => $this->state];
}
public function __unserialize($data)
{
echo "[trace] __unserialize() called", PHP_EOL;
$this->state = $data['state'];
}
public function __wakeup()
{
echo "[trace] __wakeup() called", PHP_EOL;
$this->state = 'wakeup-called';
}
}
在本机 PHP 7.3.4 中,实际输出为:
text
[serialized] O:12:"VersionHooks":1:{s:5:"state";s:8:"original";}
[trace] __wakeup() called
[state] wakeup-called
本机结果说明:PHP 7.3.4 没有把 __serialize() 和 __unserialize() 当作新的序列化协议入口,而是继续使用旧的属性恢复和 __wakeup() 流程。
版本分析应记录为:
| 行为点 | PHP 7.3.4 本机实测 | PHP 7.4 及以上复测重点 |
|---|---|---|
serialize() 处理新式方法 |
未调用 __serialize() |
检查是否进入 __serialize() |
unserialize() 处理新式方法 |
调用 __wakeup() |
检查是否优先进入 __unserialize() |
| 属性数量异常数据 | 本机样例返回 false |
需要在目标版本单独复测 |
这里不能简单写成"某个大版本全部可用或全部不可用"。实际结果还会受到 PHP 小版本、SAPI、错误处理设置、类定义和序列化数据结构影响。
九、对象声明属性数与实际属性数
对象序列化格式中,类名后面的数字表示属性数量:
text
O:10:"CountTrace":1:{...}
这里的 1 是属性数量字段。配套代码 04_property_count_observation.php 使用两个观察样本:
text
声明数量为 1,实际提供 1 个属性
声明数量为 2,实际仍提供 1 个属性
本机 PHP 7.3.4 输出:
text
=== declared-1 ===
[trace] __wakeup() called
result state: wakeup-called
=== declared-2 ===
result: false
当前环境下,数量为 1 的样本成功还原并触发 __wakeup();数量为 2 的样本直接解析失败。
这个结果不能被扩展成跨版本结论。审计报告中应该写清楚:
text
PHP 版本:7.3.4
SAPI:CLI
输入结构:对象声明属性数与实际属性数不一致
实际结果:unserialize() 返回 false
如果在其他 PHP 版本中观察到不同结果,应把它记录为版本特性差异,并在等价环境中复现,而不是直接套用网上的历史结论。
十、从源码到运行结果的审计流程
第一步:定位反序列化入口
搜索:
php
unserialize($data)
并向前追踪 $data 的来源:
$_GET、$_POST、$_COOKIE;- 请求头或上传内容;
- 数据库、缓存和 Session;
- Base64、URL 编码或压缩数据解码结果。
第二步:确认序列化数据的生成方式
区分:
php
$raw = serialize($value);
和:
php
$raw = '...s:' . strlen($input) . ':"' . $input . '"...';
后者需要重点检查边界、编码和字段数量。
第三步:追踪魔术方法
检查目标类中是否存在:
php
__wakeup()
__unserialize()
__destruct()
__toString()
__get()
__call()
重点记录:
text
谁触发方法?
方法读取哪个属性?
属性是否可被输入影响?
方法是否改变后续流程?
第四步:确认版本和运行方式
至少记录:
text
PHP 具体版本
SAPI 类型
操作系统
相关扩展
错误处理设置
第五步:建立最小复现样本
不要一开始就分析完整项目。先保留目标类和相关方法,使用固定字符串、状态变量和日志输出还原问题。
十一、常见误区
误区 1:序列化字符串是加密内容
错误。序列化只是编码和结构化,不提供保密性,也不自动提供完整性校验。
误区 2:长度按字符数计算
错误。PHP 序列化字符串记录的是字节长度。UTF-8 中文需要特别注意。
误区 3:只要使用 strlen() 就一定安全
不一定。如果后续发生 URL 解码、字符集转换或字符串替换,之前计算的长度仍可能失效。
误区 4:看到 __wakeup() 就直接下结论
错误。要确认对象是否成功还原、方法是否触发、当前 PHP 版本如何处理,以及方法是否影响后续业务。
误区 5:属性数量异常在所有 PHP 版本都一样
错误。对象解析行为属于版本相关实现细节,必须在目标版本或等价环境中复测。
误区 6:allowed_classes 可以解决所有反序列化问题
错误。它主要限制允许还原的类,不能替代签名校验、类型检查和业务校验。
十二、开发侧防御方案
1. 外部数据优先使用 JSON
如果业务只是传递数组或状态,优先使用 JSON:
php
$data = json_decode($input, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($data)
|| !isset($data['name'])
|| !is_string($data['name'])) {
throw new InvalidArgumentException('invalid data');
}
JSON 不会根据输入内容自动恢复任意 PHP 对象,但字段类型和业务含义仍然需要校验。
2. 必须反序列化时限制类
php
$value = unserialize($input, [
'allowed_classes' => ['SafeRecord'],
'max_depth' => 32,
]);
限制类只能缩小对象恢复范围,不能替代完整性验证。
3. 使用 HMAC 校验完整性
php
$expected = hash_hmac('sha256', $input, $secret);
if (!hash_equals($expected, $providedSign)) {
throw new RuntimeException('invalid signature');
}
签名密钥必须保存在服务端,不能由客户端提交。
4. 不要手工拼接序列化字符串
错误示例:
php
$raw = 's:' . strlen($input) . ':"' . $input . '"';
推荐:
php
$raw = serialize(['value' => $input]);
5. 魔术方法保持最小副作用
__wakeup()、__unserialize() 和 __destruct() 应尽量只做状态恢复和资源清理,不要在其中直接处理外部输入、动态路径、动态回调或高风险操作。
十三、本篇总结
字符串边界问题可以概括为:
text
输入进入手工序列化结构
↓
长度或编码计算出现偏差
↓
解析器读取范围发生变化
↓
后续字段可能被误读或解析失败
本篇重点:
- PHP 序列化字符串的长度按字节计算;
- 手工拼接序列化文本容易造成长度和边界错误;
unserialize()成功还原对象后,可能触发__wakeup()或__unserialize();- 对象声明属性数与实际属性数不一致时,结果必须结合具体 PHP 版本观察;
- PHP 7.4 之后需要额外关注
__serialize()和__unserialize(); - 审计结论应包含版本、SAPI、输入结构和实测输出;
- 开发侧优先使用 JSON,配合类限制、签名校验和业务字段检查。
下一篇《PHP 审计 15|反序列化(五):Phar 反序列化与原生类利用》将继续讨论 PHP 原生类、特殊数据格式和反序列化入口的审计方法。
免责声明
本文所有内容仅限授权靶场、本地私有实验环境学习研究,任何未经授权对第三方系统开展测试属于违法行为。请在合法授权范围内进行安全研发与验证。