欢迎光临
我们一直在努力

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

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

    • 前言
    • 一、先回顾前三篇:本篇要解决什么问题
    • 二、源码阅读:PHP 序列化字符串的结构边界
    • 三、基础语法:长度按字节计算
      • 1. 英文字符串
      • 2. UTF-8 字符串
    • 四、为什么推荐直接使用 `serialize()`
    • 五、字符串边界探针:观察解析器实际读取范围
    • 六、字符串边界问题的审计判断标准
      • 1. 输入是否进入序列化结构
      • 2. 长度是否在最后一步计算
      • 3. 是否存在二次替换
      • 4. 是否检查了解析结果
    • 七、`__wakeup()` 的触发条件
    • 八、`__serialize()` 与 `__unserialize()` 的版本差异
    • 九、对象声明属性数与实际属性数
    • 十、从源码到运行结果的审计流程
    • 十一、常见误区
      • 误区 1:序列化字符串是加密内容
      • 误区 2:长度按字符数计算
      • 误区 3:只要使用 `strlen()` 就一定安全
      • 误区 4:看到 `__wakeup()` 就直接下结论
      • 误区 5:属性数量异常在所有 PHP 版本都一样
      • 误区 6:`allowed_classes` 可以解决所有反序列化问题
    • 十二、开发侧防御方案
      • 1. 外部数据优先使用 JSON
      • 2. 必须反序列化时限制类
      • 3. 使用 HMAC 校验完整性
      • 4. 不要手工拼接序列化字符串
      • 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 篇中,我们看到字符串格式类似:

    s:5:"admin"

    第 12 篇中,我们知道对象被 unserialize() 还原后,可能自动进入:

    __wakeup()
    __unserialize()
    __destruct()

    第 13 篇中,我们进一步把多个对象通过属性连接起来,观察方法之间的数据流。

    本篇把这几部分连起来,但不直接讨论第三方系统利用,而是研究解析器的输入边界:

    外部数据
    ↓
    手工拼接序列化字符串
    ↓
    字符串长度决定读取范围
    ↓
    unserialize() 按结构解析
    ↓
    对象恢复和魔术方法触发

    因此,审计时不能只看到:

    unserialize($data);

    还要继续向上追踪 $data 是如何生成的,特别是它是否经过了字符串拼接、替换、编码和长度计算。


    二、源码阅读:PHP 序列化字符串的结构边界

    PHP 序列化结果不是普通的文本格式,而是由类型标记、长度字段和数据内容共同组成的结构化字符串。

    例如:

    s:5:"admin"

    可以拆成:

    片段含义
    s String,字符串类型
    5 字符串占用的字节数
    "admin" 字符串内容

    反序列化时,PHP 会根据 5 读取后面的 5 个字节,而不是只依赖下一个双引号判断字符串结束位置。

    这意味着,下面两部分必须一致:

    声明长度:5
    实际字节数:5

    如果业务代码手工构造类似内容:

    $serialized = 'a:2:{'
    . 's:4:"note";s:' . strlen($input) . ':"' . $input . '";'
    . 's:4:"role";s:5:"guest";'
    . '}';

    那么 $input 就进入了序列化语法内部。此时必须重点检查:

  • 长度是否由 PHP 正确计算;
  • 输入是否经过了编码转换;
  • 输入中的引号和分号是否会影响后续结构;
  • 拼接完成后是否真正执行过 unserialize() 验证。

  • 三、基础语法:长度按字节计算

    1. 英文字符串

    s:5:"admin"

    admin 占用 5 个字节,所以长度为 5。


    2. UTF-8 字符串

    中文在 UTF-8 编码下,一个字符通常占用多个字节。PHP 序列化记录的是字节长度,而不是人眼看到的字符数量。

    配套代码 01_length_bytes.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;

    本机运行结果:

    [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

    可以看到:

    中文:2 个字符
    UTF-8:6 个字节
    序列化片段:s:6:"中文"

    这也是手工分析序列化数据时非常容易忽略的地方。


    四、为什么推荐直接使用 serialize()

    如果直接对 PHP 变量调用 serialize(),长度由 PHP 自动计算:

    $record = [
    'note' => $input,
    'role' => 'guest',
    ];

    $serialized = serialize($record);

    这种方式不会要求开发者手动填写:

    s:<长度>:"<内容>"

    相反,下面这种拼接方式维护成本较高:

    $serialized = 'a:2:{'
    . 's:4:"note";s:' . strlen($input) . ':"' . $input . '";'
    . 's:4:"role";s:5:"guest";'
    . '}';

    它至少存在三个审计风险:

    • 输入长度必须和实际编码保持一致;
    • 输入内容可能改变解析边界;
    • 后续开发者修改固定字段时,容易破坏整体格式。

    因此,业务代码不应该把序列化字符串当成普通模板拼接。需要序列化时,应让 PHP 负责完整编码,并在解析前进行完整性校验。


    五、字符串边界探针:观察解析器实际读取范围

    下面的示例故意使用手工拼接,只用于观察边界,不代表推荐写法。

    <?php
    function buildManualRecord($note)
    {
    return 'a:2:{'
    . 's:4:"note";s:' . strlen($note) . ':"' . $note . '";'
    . 's:4:"role";s:5:"guest";'
    . '}';
    }

    普通输入 hello 会生成:

    a:2:{s:4:"note";s:5:"hello";s:4:"role";s:5:"guest";}

    此时 note 的长度为 5,后面的 role 字段可以正常解析。

    配套代码 03_boundary_probe.php 会分别观察普通输入和包含序列化片段的边界探针。

    本机运行结果:

    [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. 输入是否进入序列化结构

    $input = $_POST['note'];
    $raw = '…s:' . strlen($input) . ':"' . $input . '"…';

    如果外部输入直接进入结构化字符串,需要继续检查编码和长度。


    2. 长度是否在最后一步计算

    推荐:

    $raw = serialize(['note' => $input]);

    风险较高:

    $raw = 's:' . strlen($input) . ':"' . $input . '"';

    即使使用了 strlen(),也要确认中间没有发生 URL 解码、字符集转换或其他内容替换。


    3. 是否存在二次替换

    例如:

    $raw = serialize($data);
    $raw = str_replace('guest', $input, $raw);

    这种逻辑可能让原本已经正确的长度字段失效。序列化完成后,不建议再对结构化字符串进行业务替换。


    4. 是否检查了解析结果

    $value = unserialize($raw);
    if ($value === false) {
    exit('invalid serialized data');
    }

    需要注意:false 本身也可以是合法的序列化值,所以更稳妥的写法是配合格式来源、类型检查和业务字段检查。


    七、__wakeup() 的触发条件

    第 12 篇已经介绍过,__wakeup() 通常会在对象被 unserialize() 还原后自动调用。

    配套代码 02_wakeup_order.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;

    本机运行结果:

    [1] before unserialize: O:11:"WakeupTrace":1:{s:5:"state";s:8:"original";}
    [trace] __wakeup() called
    [2] after unserialize: state=restored

    执行顺序:

    读取序列化结构
    ↓
    恢复对象属性
    ↓
    调用 __wakeup()
    ↓
    继续后续业务逻辑

    审计时要继续追踪 __wakeup() 修改了哪些属性,以及这些属性是否会被后续方法读取。


    八、__serialize() 与 __unserialize() 的版本差异

    PHP 7.4.0 引入了新的对象序列化接口:

    __serialize()
    __unserialize()

    当类实现了这套新机制时,PHP 7.4 及以上版本会优先使用它们处理对象序列化和还原;旧版本主要使用 public/protected/private 属性和 __wakeup() 等旧机制。

    配套代码 06_version_hooks.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 中,实际输出为:

    [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、错误处理设置、类定义和序列化数据结构影响。


    九、对象声明属性数与实际属性数

    对象序列化格式中,类名后面的数字表示属性数量:

    O:10:"CountTrace":1:{…}

    这里的 1 是属性数量字段。配套代码 04_property_count_observation.php 使用两个观察样本:

    声明数量为 1,实际提供 1 个属性
    声明数量为 2,实际仍提供 1 个属性

    本机 PHP 7.3.4 输出:

    === declared-1 ===
    [trace] __wakeup() called
    result state: wakeup-called
    === declared-2 ===
    result: false

    当前环境下,数量为 1 的样本成功还原并触发 __wakeup();数量为 2 的样本直接解析失败。

    这个结果不能被扩展成跨版本结论。审计报告中应该写清楚:

    PHP 版本:7.3.4
    SAPI:CLI
    输入结构:对象声明属性数与实际属性数不一致
    实际结果:unserialize() 返回 false

    如果在其他 PHP 版本中观察到不同结果,应把它记录为版本特性差异,并在等价环境中复现,而不是直接套用网上的历史结论。


    十、从源码到运行结果的审计流程

    第一步:定位反序列化入口

    搜索:

    unserialize($data)

    并向前追踪 $data 的来源:

    • $_GET、$_POST、$_COOKIE;
    • 请求头或上传内容;
    • 数据库、缓存和 Session;
    • Base64、URL 编码或压缩数据解码结果。

    第二步:确认序列化数据的生成方式

    区分:

    $raw = serialize($value);

    和:

    $raw = '…s:' . strlen($input) . ':"' . $input . '"…';

    后者需要重点检查边界、编码和字段数量。

    第三步:追踪魔术方法

    检查目标类中是否存在:

    __wakeup()
    __unserialize()
    __destruct()
    __toString()
    __get()
    __call()

    重点记录:

    谁触发方法?
    方法读取哪个属性?
    属性是否可被输入影响?
    方法是否改变后续流程?

    第四步:确认版本和运行方式

    至少记录:

    PHP 具体版本
    SAPI 类型
    操作系统
    相关扩展
    错误处理设置

    第五步:建立最小复现样本

    不要一开始就分析完整项目。先保留目标类和相关方法,使用固定字符串、状态变量和日志输出还原问题。


    十一、常见误区

    误区 1:序列化字符串是加密内容

    错误。序列化只是编码和结构化,不提供保密性,也不自动提供完整性校验。

    误区 2:长度按字符数计算

    错误。PHP 序列化字符串记录的是字节长度。UTF-8 中文需要特别注意。

    误区 3:只要使用 strlen() 就一定安全

    不一定。如果后续发生 URL 解码、字符集转换或字符串替换,之前计算的长度仍可能失效。

    误区 4:看到 __wakeup() 就直接下结论

    错误。要确认对象是否成功还原、方法是否触发、当前 PHP 版本如何处理,以及方法是否影响后续业务。

    误区 5:属性数量异常在所有 PHP 版本都一样

    错误。对象解析行为属于版本相关实现细节,必须在目标版本或等价环境中复测。

    误区 6:allowed_classes 可以解决所有反序列化问题

    错误。它主要限制允许还原的类,不能替代签名校验、类型检查和业务校验。


    十二、开发侧防御方案

    1. 外部数据优先使用 JSON

    如果业务只是传递数组或状态,优先使用 JSON:

    $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. 必须反序列化时限制类

    $value = unserialize($input, [
    'allowed_classes' => ['SafeRecord'],
    'max_depth' => 32,
    ]);

    限制类只能缩小对象恢复范围,不能替代完整性验证。


    3. 使用 HMAC 校验完整性

    $expected = hash_hmac('sha256', $input, $secret);

    if (!hash_equals($expected, $providedSign)) {
    throw new RuntimeException('invalid signature');
    }

    签名密钥必须保存在服务端,不能由客户端提交。


    4. 不要手工拼接序列化字符串

    错误示例:

    $raw = 's:' . strlen($input) . ':"' . $input . '"';

    推荐:

    $raw = serialize(['value' => $input]);


    5. 魔术方法保持最小副作用

    __wakeup()、__unserialize() 和 __destruct() 应尽量只做状态恢复和资源清理,不要在其中直接处理外部输入、动态路径、动态回调或高风险操作。


    十三、本篇总结

    字符串边界问题可以概括为:

    输入进入手工序列化结构
    ↓
    长度或编码计算出现偏差
    ↓
    解析器读取范围发生变化
    ↓
    后续字段可能被误读或解析失败

    本篇重点:

  • PHP 序列化字符串的长度按字节计算;
  • 手工拼接序列化文本容易造成长度和边界错误;
  • unserialize() 成功还原对象后,可能触发 __wakeup() 或 __unserialize();
  • 对象声明属性数与实际属性数不一致时,结果必须结合具体 PHP 版本观察;
  • PHP 7.4 之后需要额外关注 __serialize() 和 __unserialize();
  • 审计结论应包含版本、SAPI、输入结构和实测输出;
  • 开发侧优先使用 JSON,配合类限制、签名校验和业务字段检查。
  • 下一篇《PHP 审计 15|反序列化(五):Phar 反序列化与原生类利用》将继续讨论 PHP 原生类、特殊数据格式和反序列化入口的审计方法。


    免责声明

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

    上一篇

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

    赞(0)
    未经允许不得转载:171主机测评 » Burst Lab | PHP 审计 14|反序列化(四):字符串逃逸与绕过技巧
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址