Burst Lab | PHP 审计 14|反序列化(四):字符串逃逸与绕过技巧

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 调用图。

本篇继续研究一个经常出现在代码审计中的问题:

当业务代码手工拼接序列化字符串时,字符串长度和结构边界会发生什么变化?

本文重点讨论:

  1. PHP 序列化字符串长度为什么按字节计算;
  2. 手工拼接序列化数据为什么容易出现边界错误;
  3. 字符串边界变化如何影响后续字段解析;
  4. __wakeup() 的触发条件与 PHP 版本特性差异;
  5. 如何从源码阅读、版本复现和开发防御三个角度分析问题。

⚠️ 免责声明:本文仅用于网络安全学习、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 就进入了序列化语法内部。此时必须重点检查:

  1. 长度是否由 PHP 正确计算;
  2. 输入是否经过了编码转换;
  3. 输入中的引号和分号是否会影响后续结构;
  4. 拼接完成后是否真正执行过 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 字符串的一部分,后面的字段仍然按照解析器读完指定长度后的位置继续处理。

这里要强调两点:

  1. 这不是普通字符串替换,而是结构化数据边界的变化;
  2. 如果长度、引号和分号组合不完整,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 复制代码
输入进入手工序列化结构
        ↓
长度或编码计算出现偏差
        ↓
解析器读取范围发生变化
        ↓
后续字段可能被误读或解析失败

本篇重点:

  1. PHP 序列化字符串的长度按字节计算;
  2. 手工拼接序列化文本容易造成长度和边界错误;
  3. unserialize() 成功还原对象后,可能触发 __wakeup()__unserialize()
  4. 对象声明属性数与实际属性数不一致时,结果必须结合具体 PHP 版本观察;
  5. PHP 7.4 之后需要额外关注 __serialize()__unserialize()
  6. 审计结论应包含版本、SAPI、输入结构和实测输出;
  7. 开发侧优先使用 JSON,配合类限制、签名校验和业务字段检查。

下一篇《PHP 审计 15|反序列化(五):Phar 反序列化与原生类利用》将继续讨论 PHP 原生类、特殊数据格式和反序列化入口的审计方法。


免责声明

本文所有内容仅限授权靶场、本地私有实验环境学习研究,任何未经授权对第三方系统开展测试属于违法行为。请在合法授权范围内进行安全研发与验证。

上一篇

Burst Lab | PHP 审计 13|反序列化(三):POP 链构造与 Gadget Chain 分析

相关推荐
名字还没想好☜1 小时前
Go 用 slices/maps 标准库泛型函数:告别手写 Contains、Sort、去重(Go 1.21)
开发语言·后端·算法·golang·go
淼澄研学1 小时前
Python实战:基于LangChain与Chroma构建企业级RAG知识库问答系统
开发语言·python·langchain
行业微动态1 小时前
安全下载Token钱包官网不断升级
安全
4311媒体网1 小时前
帝国CMS网站自定义搭建技巧
服务器·开发语言·前端
布莱克6051 小时前
C++拷贝构造与拷贝赋值运算符的区别详解
开发语言·c++
Dachui_11222 小时前
企业内网大模型公网访问方案:Ollama + OpenWebUI + ZeroNews 实战部署记录
运维·服务器·安全·远程工作·内网穿透
移动云开发者联盟2 小时前
移动云智慧电力,用AI筑牢电网安全防线
安全
lhldsg2 小时前
全民健身解决方案:从共享球场到智能运营的技术实践
java·大数据·开发语言·需求分析
芯盾时代2 小时前
《金融业网络安全管理办法(征求意见稿)》全条款深度拆解(一)
网络·安全·web安全