ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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 版本都一样误区 6allowed_classes 可以解决所有反序列化问题十二、开发侧防御方案1. 外部数据优先使用 JSON2. 必须反序列化时限制类3. 使用 HMAC 校验完整性4. 不要手工拼接序列化字符串5. 魔术方法保持最小副作用十三、本篇总结免责声明上一篇前言在前面三篇文章中我们已经完成了三个阶段的学习第 11 篇理解 PHP 序列化字符串的基本格式第 12 篇分析__wakeup()、__destruct()等魔术方法的触发时机第 13 篇从对象属性关系出发建立安全的 POP Chain 调用图。本篇继续研究一个经常出现在代码审计中的问题当业务代码手工拼接序列化字符串时字符串长度和结构边界会发生什么变化本文重点讨论PHP 序列化字符串长度为什么按字节计算手工拼接序列化数据为什么容易出现边界错误字符串边界变化如何影响后续字段解析__wakeup()的触发条件与 PHP 版本特性差异如何从源码阅读、版本复现和开发防御三个角度分析问题。⚠️免责声明本文仅用于网络安全学习、CTF 竞赛、靶场练习以及获得书面授权的代码审计。本文示例只使用固定字符串、状态标记和解析结果进行本地验证不提供针对真实系统的可直接利用内容。本机运行环境本文使用实际版本为PHP 7.3.4 NTS x64Zend Engine 3.3.4。文中“本机实测结果”均来自该环境。一、先回顾前三篇本篇要解决什么问题第 11 篇中我们看到字符串格式类似s:5:admin第 12 篇中我们知道对象被unserialize()还原后可能自动进入__wakeup() __unserialize() __destruct()第 13 篇中我们进一步把多个对象通过属性连接起来观察方法之间的数据流。本篇把这几部分连起来但不直接讨论第三方系统利用而是研究解析器的输入边界外部数据 ↓ 手工拼接序列化字符串 ↓ 字符串长度决定读取范围 ↓ unserialize() 按结构解析 ↓ 对象恢复和魔术方法触发因此审计时不能只看到unserialize($data);还要继续向上追踪$data是如何生成的特别是它是否经过了字符串拼接、替换、编码和长度计算。二、源码阅读PHP 序列化字符串的结构边界PHP 序列化结果不是普通的文本格式而是由类型标记、长度字段和数据内容共同组成的结构化字符串。例如s:5:admin可以拆成片段含义sString字符串类型5字符串占用的字节数admin字符串内容反序列化时PHP 会根据5读取后面的 5 个字节而不是只依赖下一个双引号判断字符串结束位置。这意味着下面两部分必须一致声明长度5 实际字节数5如果业务代码手工构造类似内容$serializeda:2:{.s:4:note;s:.strlen($input).:.$input.;.s:4:role;s:5:guest;.};那么$input就进入了序列化语法内部。此时必须重点检查长度是否由 PHP 正确计算输入是否经过了编码转换输入中的引号和分号是否会影响后续结构拼接完成后是否真正执行过unserialize()验证。三、基础语法长度按字节计算1. 英文字符串s:5:adminadmin占用 5 个字节所以长度为 5。2. UTF-8 字符串中文在 UTF-8 编码下一个字符通常占用多个字节。PHP 序列化记录的是字节长度而不是人眼看到的字符数量。配套代码01_length_bytes.php?phpclassDemo{public$nameadmin;public$age18;}$objnewDemo();$asciiserialize($obj);echo[ASCII] ,$ascii,PHP_EOL;echo[ASCII length] ,strlen($ascii),PHP_EOL;$obj-name中文;$utf8serialize($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-86 个字节 序列化片段s:6:中文这也是手工分析序列化数据时非常容易忽略的地方。四、为什么推荐直接使用serialize()如果直接对 PHP 变量调用serialize()长度由 PHP 自动计算$record[note$input,roleguest,];$serializedserialize($record);这种方式不会要求开发者手动填写s:长度:内容相反下面这种拼接方式维护成本较高$serializeda:2:{.s:4:note;s:.strlen($input).:.$input.;.s:4:role;s:5:guest;.};它至少存在三个审计风险输入长度必须和实际编码保持一致输入内容可能改变解析边界后续开发者修改固定字段时容易破坏整体格式。因此业务代码不应该把序列化字符串当成普通模板拼接。需要序列化时应让 PHP 负责完整编码并在解析前进行完整性校验。五、字符串边界探针观察解析器实际读取范围下面的示例故意使用手工拼接只用于观察边界不代表推荐写法。?phpfunctionbuildManualRecord($note){returna: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. 长度是否在最后一步计算推荐$rawserialize([note$input]);风险较高$raws:.strlen($input).:.$input.;即使使用了strlen()也要确认中间没有发生 URL 解码、字符集转换或其他内容替换。3. 是否存在二次替换例如$rawserialize($data);$rawstr_replace(guest,$input,$raw);这种逻辑可能让原本已经正确的长度字段失效。序列化完成后不建议再对结构化字符串进行业务替换。4. 是否检查了解析结果$valueunserialize($raw);if($valuefalse){exit(invalid serialized data);}需要注意false本身也可以是合法的序列化值所以更稳妥的写法是配合格式来源、类型检查和业务字段检查。七、__wakeup()的触发条件第 12 篇已经介绍过__wakeup()通常会在对象被unserialize()还原后自动调用。配套代码02_wakeup_order.php?phpclassWakeupTrace{public$stateoriginal;publicfunction__wakeup(){echo[trace] __wakeup() called,PHP_EOL;$this-staterestored;}}$objectnewWakeupTrace();$serializedserialize($object);echo[1] before unserialize: ,$serialized,PHP_EOL;$restoredunserialize($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: staterestored执行顺序读取序列化结构 ↓ 恢复对象属性 ↓ 调用 __wakeup() ↓ 继续后续业务逻辑审计时要继续追踪__wakeup()修改了哪些属性以及这些属性是否会被后续方法读取。八、__serialize()与__unserialize()的版本差异PHP 7.4.0 引入了新的对象序列化接口__serialize()__unserialize()当类实现了这套新机制时PHP 7.4 及以上版本会优先使用它们处理对象序列化和还原旧版本主要使用 public/protected/private 属性和__wakeup()等旧机制。配套代码06_version_hooks.php同时定义了三种方法?phpclassVersionHooks{public$stateoriginal;publicfunction__serialize(){echo[trace] __serialize() called,PHP_EOL;return[state$this-state];}publicfunction__unserialize($data){echo[trace] __unserialize() called,PHP_EOL;$this-state$data[state];}publicfunction__wakeup(){echo[trace] __wakeup() called,PHP_EOL;$this-statewakeup-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 SAPICLI 输入结构对象声明属性数与实际属性数不一致 实际结果unserialize() 返回 false如果在其他 PHP 版本中观察到不同结果应把它记录为版本特性差异并在等价环境中复现而不是直接套用网上的历史结论。十、从源码到运行结果的审计流程第一步定位反序列化入口搜索unserialize($data)并向前追踪$data的来源$_GET、$_POST、$_COOKIE请求头或上传内容数据库、缓存和 SessionBase64、URL 编码或压缩数据解码结果。第二步确认序列化数据的生成方式区分$rawserialize($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 版本都一样错误。对象解析行为属于版本相关实现细节必须在目标版本或等价环境中复测。误区 6allowed_classes可以解决所有反序列化问题错误。它主要限制允许还原的类不能替代签名校验、类型检查和业务校验。十二、开发侧防御方案1. 外部数据优先使用 JSON如果业务只是传递数组或状态优先使用 JSON$datajson_decode($input,true,512,JSON_THROW_ON_ERROR);if(!is_array($data)||!isset($data[name])||!is_string($data[name])){thrownewInvalidArgumentException(invalid data);}JSON 不会根据输入内容自动恢复任意 PHP 对象但字段类型和业务含义仍然需要校验。2. 必须反序列化时限制类$valueunserialize($input,[allowed_classes[SafeRecord],max_depth32,]);限制类只能缩小对象恢复范围不能替代完整性验证。3. 使用 HMAC 校验完整性$expectedhash_hmac(sha256,$input,$secret);if(!hash_equals($expected,$providedSign)){thrownewRuntimeException(invalid signature);}签名密钥必须保存在服务端不能由客户端提交。4. 不要手工拼接序列化字符串错误示例$raws:.strlen($input).:.$input.;推荐$rawserialize([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 分析
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进