ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PHP序列化与反序列化:从格式原理到安全实战全解

PHP序列化与反序列化:从格式原理到安全实战全解 搞PHP时间长了你会发现自己总在跟一串带花括号的字符串打交道a:3:{i:0;s:5:hello;i:1;i:42;}。这玩意儿就是serialize()吐出来的格式。很多新手刚接触时觉得它丑、怪、看不懂但用熟了以后会发现这套序列化格式在PHP里几乎是“隐形的基建”——$_SESSION存数据靠它,Redis缓存存对象靠它,跑队列任务分发靠它,Laravel的encrypt也靠它。这篇文章不整虚的直接把PHP serialize从格式原理、对象序列化的坑、实战场景、反序列化安全到常见问题排错串一遍。聊完之后你至少能搞清楚三件事序列化出来的字符串到底该怎么看、什么时候该用它而不是JSON、以及为什么反序列化漏洞能让攻击者直接拿下服务器。1. 序列化这个东西到底在解决什么问题1.1 内存里的数据是“一次性”的PHP变量活在进程内存里请求一结束内存释放变量跟着没了。但很多业务场景需要把变量“带出”当前请求存进数据库、塞进缓存、传给下游服务、写入日志文件。这时候你就得把内存里的数组、对象、资源全部转换成一串能落盘的字节流这个过程就叫序列化。反过来从字节流恢复成变量就是反序列化。你可以把序列化理解成“把活鱼做成冻鱼”鱼在池塘里内存游动自如但没法快递冻成冰坨子字符串之后可以装箱运输到地方再解冻还原。PHP的serialize()和unserialize()就是干这个的两者成对出现序列化时用什么规则反序列化就必须按同一套规则还原。1.2 PHP序列化格式的打字风格PHP序列化格式最显著的特点就是每段数据都带着“类型标记”结构是类型:值或类型:长度:值。这样反序列化时不需要猜类型PHP引擎直接按标记重建变量。来个最基础的例子$data [name 张三, age 28, tags [php, 后端]]; echo serialize($data);输出a:3:{s:4:name;s:9:张三;s:3:age;i:28;s:4:tags;a:2:{i:0;s:3:php;i:1;s:6:后端;}}一行一行的拆解如下。a:3:表示这是一个array关联数组有3个元素。s:4:name;表示字符串长度4字节内容是name。i:28;表示整数28。数组的每个键值对都是“键;值;”紧挨着排值本身可能又是一个数组——这就是嵌套结构靠花括号包起来。这套设计的一个好处是类型信息零丢失。整数就是i浮点就是d布尔就是bNULL是N对象是O字符串是s。你用JSON序列化一个数组再反序列化回来数字键会变成字符串键、整数可能变浮点但PHP的serialize不会它原模原样还原。这在跨请求、跨进程传递数据时非常关键。2. 对象序列化的门道全在类名和属性修饰符里2.1 对象序列化的标准格式序列化对象时格式会长这样class User { public $name zhangsan; protected $status 1; private $token abc123; } $u new User(); echo serialize($u);输出O:4:User:3:{s:4:name;s:8:zhangsan;s:7:status;i:1;s:12:token;s:6:abc123;}看着跟数组差不多但注意看几个细节。O:4:User里的O是Object的标记4是类名字符串长度后面是完整类名含命名空间时是带\的完整类名。protected属性在序列化时键名会被改写为\0*\0status\0是空字节即chr(0)*是通配符。private属性更坑键名改写成\0User\0token也就是空字节 类名 空字节 属性名。这就是为什么很多人把对象序列化结果直接echo或存日志时会发现某些属性名中间露出一截奇怪的间隙其实是空字节在作怪。类名越长私有属性序列化后的键名就越长这是新手最容易看懵的点。如果类继承层级复杂父类的私有属性会写成\0父类名\0属性名跟子类的同名私有属性互不冲突。2.2 __sleep 和 __wakeup序列化钩子PHP提供了一组魔术方法让你在序列化和反序列化前后做手脚。__sleep()在serialize()被调用时触发必须返回一个属性名数组只有列出来的属性才会被序列化。适合用来剔除临时缓存、大字段、资源句柄。__wakeup()在unserialize()恢复对象后立刻触发通常用来重建资源链接、重新加载配置、做初始化校验。举个真实场景。有个类内部维护了一个Redis连接和一个巨大的临时查询结果直接把整个对象序列化会连带把资源对象也一起序列化可PHP序列化资源类型会失败。我一般这样处理class ReportService { private $redis; private $bigTempData; private $config; public function __sleep() { // 只序列化configredis连接和临时数据不保存 return [config]; } public function __wakeup() { // 反序列化后重新建立redis连接 $this-redis new Redis(); $this-redis-connect(127.0.0.1, 6379); } }PHP 7.4之后还有__serialize()和__unserialize()两个新魔术方法优先级更高。建议新项目直接用这对新钩子语义更明确__serialize()返回一个数组这个数组会被序列化__unserialize()接收那个数组自己决定怎么恢复属性。2.3 循环引用和引用计数对象里有self引用数组里赋了引用序列化会不会死循环答案是能处理靠的是R和r两个标记。$a new stdClass(); $a-self $a; echo serialize($a); // O:8:stdClass:1:{s:4:self;R:1;}R:1;表示“引用第1个结构”也就是当前整个对象自身。反序列化时引擎会重建引用关系保证还原后$a-self $a仍然成立。数组里的类似引用则用r标记追踪索引。这一机制挺重要但也容易被忽视。如果你自己写序列化器没处理循环引用基本一跑就爆栈。PHP原生序列化在这个细节上做得很稳这也是我敢直接把ActiveRecord模型扔进缓存的原因。3. 实战场景serialize在哪些地方用得最勤3.1 Session存储PHP原生Session默认把$_SESSION数组序列化后存到文件里。你们常见的PHPSESSID对应文件内容就是一行行变量名|序列化值拼接的。比如user|O:8:stdClass:3:{s:4:name;s:5:admin;s:3:age;i:20;s:8:is_login;b:1;}这套机制的好处是$_SESSION里可以随便存对象、数组、整数类型不会丢。但要注意同一个目录下多个请求并发读写同一个Session文件时如果PHP版本配置的序列化处理器不一致会直接读到乱数据。比如Web服务器用的是php处理器CLI脚本用的是php_serialize等两边文件格式不兼容就会出诡异bug。3.2 缓存层的价值把对象直接塞Redis之前用serialize()现在很多人用igbinary扩展——igbinary也是序列化方案压缩率高、速度更快。但原生serialize()有一个不可替代的优势零依赖、任何PHP环境都能跑。我在项目里一般这么用针对要缓存的复杂结构统一走一层封装优先用igbinary启用扩展时自动回退到serialize。封装函数里有个缓冲池同一个对象在单次请求里重复序列化多次的话直接返回第一次的结果省掉重复开销。3.3 任务队列与消息中间件很多PHP队列库如Laravel Queues的Redis驱动会把Job对象序列化成字符串塞进Redis列表里worker再反序列化恢复处理。这里就能看出来serialize为啥比手写数组传值方便——Job本身是一个对象带着处理方法、参数、依赖序列化之后可以完整恢复。不过使用这种方案有个前提反序列化时类的定义必须存在。如果worker进程没加载对应类文件或者类名变了、属性改了老数据反序列化就可能失败甚至触发autoload失败。这就是升级代码后旧队列消息全报错的原因。我的经验是队列任务在入队前做一次轻量“版本号”校验任务类里加一个public $version 2;worker拉下来先检查版本不匹配就丢弃或转死信。3.4 一些冷门但实用的场景把配置数组序列化到文件做配置缓存避免每次请求都去解析YAML/JSON文件。作为跨语言接口的临时数据交换两个系统都跑PHP时可以直接传serialize串。日志系统里记录完整上下文快照方便出问题时原样反序列化复现现场。4. serialize和JSON到底选哪个4.1 对比维度PHP开发者最常纠结的就是传数据用json_encode还是serialize我直接给结论然后说理由维度serializejson_encode体积较大含类型标记和长度前缀较小纯文本格式速度快原生格式二进制紧凑慢需要解析语法、转义字符类型保留完整保留类型、对象、引用对象丢失方法关联数组可能变对象跨语言仅PHP其他语言难以解析JSON通用几乎所有语言支持安全性反序列化有风险需严格控制输入相对安全但JSON格式也非完全免疫可读性差计算机格式较好肉眼可读单说性能。PHP官方Benchmark和相关测试里serialize通常比json_encode快20%50%但序列化结果体积也大30%左右。网络传输场景下json体积小有优势内网Redis缓存场景serialize快有优势。权衡下来跨系统、跨语言必须用JSONPHP内部传递、缓存、Session、队列优先用serialize。4.2 什么时候别用serialize两个典型场景一是长期存储。数据库里存序列化串万一以后数据要迁移到别的语言解析成本极高。我见过不少老项目数据库字段里躺着serialize串后来想拆出来做搜索统计只能写PHP一次性脚本慢慢解。能避免尽量避免。二是用户能直接操作数据内容的场景。你根本不该信任用户提交的任何序列化字符串也绝不要直接unserialize($_POST[data])。这里就引出了下一个大话题。5. 反序列化安全为什么一行unserialize就能getshell5.1 攻击原理反序列化攻击不是PHP专利Java、Python、Ruby都有同类问题。核心在于unserialize不仅仅是“还原数据”它还会触发对象的魔术方法、还会执行某些代码路径。很多类里天然写着危险方法比如__destruct()里调file_put_contents()、__call()里调数据库查询、__toString()里输出缓存路径。攻击者分析源码后找出一组可以“串联”起来的方法业内叫POP链构造一串精心设计的payload反序列化时代码就按攻击者预期的顺序执行。举个简化示例一个类有__destructclass Cache { public $file; public $content; public function __destruct() { file_put_contents($this-file, $this-content); } }如果代码里有unserialize($_POST[data])攻击者传O:5:Cache:2:{s:4:file;s:15:/var/www/shell.php;s:7:content;s:20:?php ...;}对象析构时直接写了一个webshell文件到web目录。这就是最基础的反序列化写文件攻击。真实场景的POP链当然没这么简单可能跨越好几个类的魔术方法、调用链中还要满足一系列参数条件但最终效果类似——从“输入数据”跳到“执行代码”。5.2 防御策略别在入口处反序列化不可信数据最底线的一条永远不要对用户输入调用unserialize。如果非要用PHP 7提供了第二个参数allowed_classes可以限制反序列化只允许哪些类名出现$obj unserialize($data, [allowed_classes false]); // 全部转成 __PHP_Incomplete_Class $obj unserialize($data, [allowed_classes [User, Order]]); // 只允许指定类把allowed_classes设成false时反序列化出来的全是__PHP_Incomplete_Class对象攻击者没法触发目标类的魔术方法安全边界立刻清晰很多。不过这只防御“利用已知类”如果项目里存在可利用的POP链类依然危险所以还得配合方案二。方案二是加签名。对要持久化或传输的序列化串在写入前计算一个HMAC签名读取时先验签再反序列化$secret your_app_secret; $payload $data . . . hash_hmac(sha256, $data, $secret); // 验证 [$realData, $sig] explode(., $payload, 2); if (!hash_equals(hash_hmac(sha256, $realData, $secret), $sig)) { throw new Exception(数据被篡改); } $obj unserialize($realData, [allowed_classes [User]]);这个思路跟JWT类似核心是让攻击者无法构造合法签名。我自己做项目时默认双管齐下代码入库前写一个安全反序列化助手函数内部做签名校验、allowed_classes白名单、异常捕获业务代码一律走这个助手禁止裸调unserialize()。5.3 发现反序列化漏洞的排查思路如果你的老项目已经有裸调unserialize()的地方怎么评估风险我一般按这个顺序排查搜索源码里所有unserialize(以及safe_unserialize、json_decode等类似入口。看输入源是$_GET/$_POST/$_COOKIE还是$_SESSION还是队列数据、缓存数据。用户可控级别越高风险越大。看这些入口是否做过签名、白名单、类型检查。用一些静态分析工具如RIPS扫一遍常见POP链或者手动审查类里的魔术方法。重点看__destruct、__wakeup、__toString、__call、__invoke这些方法里有没有危险函数调用。及时升级PHP版本到受支持版本老版本尤其是PHP 5.x有多个已知反序列化相关漏洞社区PoC公开得很彻底不升级等于裸奔。补充一点像Pikachu这类靶场平台里专门有反序列化漏洞的题目想亲手验证原理的可以自己在本地靶场搭着练但千万别拿漏洞代码直接往生产环境试。6. 序列化实操中踩过的坑与排错实录6.1 字符串长度耗字节计算序列化里最让人头疼的报错就是unserialize(): Error at offset ...。原因八成是被序列化的字符串按字节数算长度而不是字符数。中文在UTF-8下每字占3字节serialize(张三)会写成s:6:张三;。如果你手动改了内容、截断了字符串、或者从别的编码GBK转换时没处理好长度跟实际字节数对不上unserialize直接失败。赶紧做个快速验证$str 张三; echo strlen($str); // 输出 6不是 2所以排查这个错误时先数一下目标字段里的实际字节长度跟序列化串里声明的是否一致。写工具函数做解析时也要用strlen而别用mb_strlen去取长度。6.2 数组键的类型与顺序坑PHP数组的键有整型和字符串两种序列化时键类型分别用i和s标记。但有个隐藏规则整型键即使以字符串形式写入也会自动转成整型。$arr [1 a, 01 b]; // 1 变成整数键01 保留字符串键 serialize($arr); // a:2:{i:1;s:1:a;s:2:01;s:1:b;}这会导致一个隐蔽bug前端传来JSON对象你array_keys()拿到的键是字符串类型再往数组里塞值之后序列化存储下次取出来键类型可能变了array_key_exists(1, $arr)和isset($arr[1])行为就不一样。遇到这种问题建议在写入前统一用array_keys($arr, ...)检查一遍键格式。6.3 对象不兼容问题反序列化一个不存在的类时PHP不会直接报错而是生成一个__PHP_Incomplete_Class对象。你访问它属性时看似正常但用instanceof判断时全是false方法调用更是直接炸。这类问题常见于代码重构后类名改了、删了、移动了命名空间数据库里还躺着旧数据。我的习惯是所有反序列化入口都加一个结果校验$obj unserialize($raw, [allowed_classes [Order]]); if (!$obj instanceof Order) { // 记录日志、触发告警、放入异常流程 }只信白名单不赌运气。6.4 序列化结果被中途污染有次线上排查一个诡异的缓存错乱问题发现是Redis里存的序列化串末尾被多个PHP进程追加了二进制垃圾其实是两个进程并发写同一个keyLINDEX读到中间态。这种问题偏文艺但排查思路值得说先看数据是不是严格匹配/^[ONasbidrR]:/的模式再用unserialize逐个字段对比任何脏数据都会在解析时暴露。6.5 调试序列化数据的小工具遇到复杂的嵌套对象序列化串肉眼看很容易花眼。我常用两种方式调试用var_export(unserialize($data), true)把还原结果打印成PHP原生数组/对象结构。开发调试阶段写一个递归解析函数把O:、a:、s:等标记切成带缩进的树形文本一眼看明白嵌套层级。7. 踩过几次坑后的个人体会序列化这东西用好了是真的省事用不好也是真的坑。我做了几年PHP后端最大的感受是不要把它当成“万能数据格式”到处撒。PHP内部传递、Session、对象缓存用它顺手又高效跨语言、长期存储、用户输入边界能不碰就不碰。真躲不开不可信的反序列化入口就老老实实把签名校验和allowed_classes白名单做上别再裸奔。最后再分享一个我在实际项目中常留的“保险丝”在序列化助手函数里加一个统一的异常捕获和告警埋点。哪天线上反序列化炸了日志能第一时间给出原始数据片段、来源接口、调用栈排查效率直接拉满。记住一个原则——序列化边界就是你系统信任边界想清楚什么数据能进、什么数据绝不能碰比事后打补丁强太多。
返回列表