多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

PHP序列化实战与反序列化安全:从原理到防御

PHP序列化实战与反序列化安全:从原理到防御 在PHP开发里最让人头疼的往往不是业务逻辑有多绕而是好不容易在内存里组装好的数组和对象刷新一下页面就没了。想把它存进文件、丢进Redis、塞进队列可数组不能直接写进字符串对象更不能随随便便扔给数据库。于是serialize成了大多数人的第一反应。这个函数可以把PHP变量变成一串自带类型信息的字符串之后用unserialize再变回来整个过程就像给货物打包装箱、运输、再拆箱。很多同学闷头用了很多年却从没认真看过它生成的那串东西到底是什么也没想明白为什么一个看起来平平无奇的unserialize能在无数安全公告里反复出现。这篇文章就是想把serialize从入门到实战、从存储到安全这条线完整捋清楚。不管你是刚接触PHP的新手还是已经写了好几年业务的老手只要你想弄明白序列化的底层细节、想在项目里安全地用它这篇都适合你。我会从序列化格式讲起带你看懂那串a:2:{...}到底在表达什么再结合对象、Redis、Session这些真实场景说实操然后专门用一整章聊反序列化漏洞的原理和防范最后把我这些年踩过的坑和沉淀的封装代码一并掏出来。1. 序列化到底是什么为什么PHP需要它1.1 从“存变量”这个需求说起PHP的一个普通变量生命周期只存在于当前请求。请求一结束$user、$list这些数据就会被释放。想让数据跨请求存活办法无非是写文件、存数据库、发到另一个服务但这些地方都只认字符串或二进制流。于是问题来了怎么把一个包含各种类型整数、字符串、数组、对象、布尔值甚至相互引用的复杂结构的PHP变量完整无损地变成一段可以安全存储和传输的字符串这就是序列化要解决的事。serialize()负责编码unserialize()负责解码。这和json_encode/json_decode做的事有点像但serialize更“PHP原生”能保留更多语言层面的类型信息。说个生活化类比把一堆散装零件放进带分隔层的工具箱箱子上标注好每个区域装的是什么、有多大运到目的地后再按标注取出装回原样。serialize并不是简单地把值拼在一起它把类型、长度、嵌套关系都写进了字符串里反序列化时就能逐字节还原成原来的变量结构。1.2 serialize与json的相爱相杀不少新人在第一次接触serialize时会疑惑为什么不用JSONJSON不是更方便吗这俩确实经常被放在一起比较但它们的定位完全不同。对比项serializejson_encode保留类型信息完整保留PHP类型整型、浮点、布尔、对象类名等只保留JSON能表达的类型PHP对象会被转成stdClass或对象属性数组对象支持能保留类名和属性反序列化可直接还原为对象默认只能处理public属性还原时是stdClass循环引用支持通过R/r标识引用关系原生不支持需要进行额外处理或使用JSON_PARTIAL_OUTPUT_ON_ERROR可读性含类型前缀和长度人眼难读结构清晰人眼可读跨语言几乎只有PHP自己能消化所有主流语言都支持典型适用场景内部存储、Session、Redis缓存、队列任务API输出、前端交互、跨语言系统对接可以这么理解serialize是PHP给自己人开的“快递专线”信息最全、还原最精准但出了PHP的地盘就没人看得懂json是面向全世界的“通用集装箱”虽然丢了部分类型细节但谁都能收。我在实际项目里的习惯是内部系统之间传递数据优先考虑serialize尤其是涉及对象和复杂数组的时候对外接口一律使用JSON。跨语言服务之间更别用serialize那基本等于把一份只有自己人能读的密文送给别人。1.3 PHP序列化格式拆解字母、冒号和花括号的规矩要真正理解序列化不能光会调函数还得能看懂它吐出来的那串字符串。看个最简单的例子$data [ name 张三, age 18 ]; echo serialize($data);输出是a:2:{s:4:name;s:6:张三;s:3:age;i:18;}一行字符拆开看其实很好懂。a:2:表示这是一个数组里面有两个元素。a是array的意思后面的2是元素个数。{和}包裹整个数组的内容。s:4:name表示这是一个字符串s长度为4内容是name。字符串和值之间用;分隔键值对之间也用;。i:18表示这是一个整数i值是18。再举几个常见类型的格式类型表示法示例NULLNN;布尔值bb:1;trueb:0;false整数ii:18;浮点数dd:3.14;字符串ss:3:foo;数组aa:1:{i:0;s:3:foo;}对象OO:8:stdClass:1:{s:3:foo;i:1;}留意到没有每种类型都带一个字母标识后面紧跟数量或长度再用冒号分隔接着是具体内容。字符串类型里特别强调了长度比如张三的UTF-8编码一共6个字节所以写成s:6:张三;这也是很多人初学时会犯迷糊的地方——长度是按字节算不是按字符数算。这个细节后面专门展开。理解格式有什么用你至少能干三件事排查数据异常、手写序列化字符串、安全分析。后两个我后面会讲到。2. 序列化实操从数组到对象从存储到传输2.1 基础用法别把serialize用错基础到没什么好说的但有几个细节经常翻车。$arr [1, 2, 3]; $str serialize($arr); // a:3:{i:0;i:1;i:1;i:2;i:2;i:3;} $restored unserialize($str); // [1, 2, 3]看起来简单但要注意serialize返回的永远是一个字符串如果变量是布尔true序列化结果是b:1;是null结果是N;。所以千万不要用if ($str)去判断序列化是否成功因为0、空字符串都可能被当成“假”。PHP 7.4及以后版本里如果序列化失败会产生TypeError但最常见的资源类型和闭包不是报错而是直接抛出异常或返回false不同版本行为不同。再说反序列化$restored unserialize($str);如果字符串不是合法格式unserialize会返回false同时产生一个E_NOTICE级别提示。有人会问能不能用unserialize强行屏蔽我建议别这样你不应该掩盖错误而应该在查清数据为什么坏了。后面我会专门列一个排查清单。还有一点很多人不知道unserialize的第二个参数$object unserialize($str, [allowed_classes false]);如果序列化里的类是危险来源或者你根本不需要还原对象设置allowed_classesfalse就能让反序列化不实例化任何对象。这个参数在安全上太重要了第三部分细说。2.2 对象序列化__sleep和__wakeup的用法对象序列化比数组复杂一些因为对象有类名、属性和方法。class User { public $name; protected $email; private $password; public function __construct($name, $email, $password) { $this-name $name; $this-email $email; $this-password $password; } } $user new User(张三, zhangsanexample.com, secret); echo serialize($user);输出会包含类名和属性信息大概是O:4:User:3:{s:4:name;s:6:张三;s:8:\0*\0email;s:21:zhangsanexample.com;s:15:\0User\0password;s:6:secret;}注意这两处特殊符号protected属性名会被包成\0*\0也就是空字符加星号加空字符private属性名会被包成\0类名\0。这些\0在源码里看不出来但它们真实存在于序列化字符串中。如果复制到浏览器或文本工具里容易被忽略或截断。这也是为什么序列化数据不能随随便便在网络传输和日志里裸奔因为它可能包含不可见字符处理不当会破坏数据完整性。对象的方法函数不参与序列化。反序列化后对象类会重新被加载如果存在但方法并不会“丢失”因为方法是类的一部分而不是数据。所以如果你改了类的命名空间或类名旧数据基本就废了。官方还提供了两个魔术方法用于控制序列化过程__sleep()在serialize前调用返回一个属性名数组指定哪些属性需要被序列化。常用于排除资源型属性如数据库连接、临时状态或者只保留必要字段。__wakeup()在unserialize后调用常用于重新初始化资源、恢复默认值。示例class User { public $name; public $db; // 数据库连接不能被序列化 public function __sleep() { // 只保留name丢弃db连接 return [name]; } public function __wakeup() { // 恢复时重新建立一个新的“模拟连接” $this-db null; } }这里有一个很实际的场景把用户对象丢进Redis做会话如果对象里挂着PDO连接序列化必然报错。__sleep就是用来提前把不能序列化的东西摘掉的。但要注意__sleep必须返回一个只含属性名的数组不能多一个也不能少一个否则会触发警告甚至在PHP8里直接抛出异常。2.3 存储与传输Redis、Session、队列里的“默认姿势”序列化的最大应用场景不是让你在代码里玩来玩去而是把内存状态搬到外部系统。SessionPHP的会话存储默认就可以用序列化格式。session.serialize_handler常见取值有php、php_serialize等。php格式把每个Session变量单独序列化再拼到一起php_serialize是整体序列化。如果你的项目用了类似Laravel这样的框架可能还自定义了序列化方式。Session数据会被保存到文件或Redis里本质上都离不开“把变量变成字符串再存起来”的套路。RedisRedis的value是字符串或者二进制安全的字符串所以把数组和对象存进去最直接的办法就是serialize。$redis-set(user:1001, serialize($user)); $user unserialize($redis-get(user:1001));但也有很多人用json_encode存。我的选择标准是如果这个数据之后只在PHP里读且包含对象、引用、布尔值等用serialize。如果这个数据将来可能会被Python、Node.js或其他服务读用json_encode。如果你用Laravel的Cache默认值就是serialize或igbinary不用太纠结。队列消息队列里投递的任务经常是一个封装了任务类型、参数、重试次数的对象。比较流行的做法是用JSON或专门的消息编码器如Protocol Buffers但在PHP内部队列如Laravel Queue的默认驱动中任务对象的序列化用的就是serialize。这里有个隐患一旦队列消息被反序列化时调用了不安全的魔术方法就可能变成攻击入口。所以队列里尽量只放轻量的纯数据数组或者用[task SendEmail, params [...]]这种简单结构别把整个带逻辑的对象塞进去。2.4 处理特殊数据中文、二进制、资源类型中文前面提到序列化字符串的长度按字节计算。张三在UTF-8下是6个字节所以s:6:张三;。如果你用mb_strlen之类按字符数处理序列化结果一定会出问题。二进制与不可见字符序列化字符串可能包含\0。在处理的时候你要确保存储介质不会把空字符截断。MySQL的某些旧配置、文件函数默认不会截断空字符但如果你用explode、正则表达式处理\0会带来困扰。推荐的做法不要把序列化字符串直接放进文本型数据库字段要么用base64_encode包一层要么换成TEXT/BLOB并保持编码一致。资源类型和闭包resource文件句柄、curl、数据库连接无法序列化。闭包匿名函数默认也无法序列化。如果你非要把闭包存起来社区里有opis/closure这样的库但实现原理很复杂运行效率也低我的建议是尽量别存重新构造比序列化闭包靠谱得多。浮点数精度序列化不会提升浮点数精度。serialize(0.1 0.2)得到的是d:0.3;或类似值反序列化后依旧是二进制浮点数的不精确表示。别指望序列化来解决精度问题涉及金额时用整数分存储。3. 反序列化安全这可能是你最需要重视的一课3.1 反序列化漏洞为什么会存在很多PHP开发者的安全意识都是从“反序列化漏洞”这个词开始建立的。它到底为什么危险本质原因是unserialize不仅仅是把字符串变成变量它会在还原过程中创建对象并且自动调用某些魔术方法。你传入一个精心构造的序列化字符串它就能帮你实例化一个你本来不想要的类、填上你指定的属性值然后触发__wakeup、__destruct、__toString等方法。如果项目里恰好存在一些危险方法比如能读文件、删文件、执行命令、写日志攻击者就能把这些方法串成一条调用链最终达到恶意目的。这里打个比方。正常情况下你组装货物并贴好标签收货人拆箱后取出货物使用。可如果一个坏人篡改了包裹标签在箱子里放了一个“定时闹钟”收货人一拆箱闹钟就响了甚至自动做了别的事。unserialize就是那个“一拆箱就自动运行”的机制而魔术方法就是闹钟。所以反序列化漏洞并不完全是unserialize的错更准确地说是应用层毫无防备地接受了用户可控的序列化字符串同时又存在可被利用的危险方法二者叠加才产生了危害。**防范的第一步永远是不信任输入。**如果你允许用户直接传一个不明来源的字符串给你反序列化那基本是在给攻击者发入场券。3.2 一个POP链的触发过程“POP链”这个词听着很玄其实可以理解成“属性导向编程链”。攻击者控制反序列化时生成的对象的属性利用这些属性去触发已有类的方法像串糖葫芦一样把方法调用串起来。举个最简单的示意仅为说明原理不代表真实攻击代码class Logger { public $filename; public $data; public function __wakeup() { file_put_contents($this-filename, $this-data); } }如果应用接收了用户提交的序列化字符串而又没有限制类名攻击者可以构造$payload new Logger(); $payload-filename /tmp/evil.php; $payload-data ?php ... ?; echo serialize($payload);当这个字符串被unserialize时Logger对象被创建__wakeup自动执行一个webshell就写进去了。在实际漏洞里链条会更长可能需要跨多个类但核心思想一致类属性的值完全由攻击者控制而危险方法在恢复或销毁对象时自动触发。所以安全防护要做的不仅是过滤还要限制类、校验来源、固化数据结构。3.3 防御手段白名单、签名与代码审计第一道防线不要反序列化不可信数据这应该是铁律。用户提交的cookie、请求参数、HTTP头都不应该直接丢给unserialize。如果必须要用先做身份认证和权限校验再加数据签名。第二道防线使用allowed_classes限制类PHP 7.0开始unserialize支持第二个参数$data unserialize($str, [allowed_classes false]);如果传false遇到任何类都会不实例化对象会变成__PHP_Incomplete_Class或直接返回false取决于版本和具体实现。如果你只希望某个白名单类被允许可以传一个数组$data unserialize($str, [allowed_classes [User, Config]]);这就给了你一个很强大的控制即使攻击者精心构造了序列化字符串因为恶意类不在白名单里无法被实例化链条自然断掉。第三道防线数据签名给序列化字符串加一个HMAC签名反序列化前验证签名只要密钥不泄露攻击者就没法篡改内容。注意要用hash_equals比较签名防止时序攻击。$key your-secret-key; $payload serialize($data); $signature hash_hmac(sha256, $payload, $key); $signed base64_encode($signature . :: . $payload);读取时先验签再解包最后unserialize。这个方法能挡住绝大部分篡改型攻击。第四道防线代码审计与函数梳理定期在项目里搜索这些函数和方法unserialize、__wakeup、__destruct、__toString、__call。看看每个出现点是否能被用户输入触及是否已有白名单和校验。这一步不复杂但能救大命。第五道防线用更安全的替代品其实很多时候你根本没理由把PHP对象原封不动地序列化。内部数据结构如果只是数组用json_encode/json_decode更安全因为JSON反序列化不会自动创建对象、不会触发魔术方法。如果你想保留类型也可以自己写一个小型序列化器只处理你允许的白名单。安全永远是懒人的代价。4. 常见问题排查与性能优化实录4.1 我踩过的几个坑false、引用与编码现象一unserialize返回false排查半天也没发现字符串坏了通常有几种原因数据在传输过程中被截断。数据库字段长度不够、Redis写入时用了非二进制安全的客户端、文件被追加了日志等都容易让末尾少一个字节。字符串编码被改变。比如文件保存时从UTF-8转成了GBK序列化中的长度就会错位因为长度统计的是字节数。PHP版本差异导致格式不兼容。一般标量数组兼容但对象序列化格式在不同版本间可能有细微差异特别是私有属性名的\0处理。字符串被trim或stripcslashes清洗过。序列化数据里可能有\0stripcslashes会把它转成真正的空字符或删除。排查建议把序列化字符串做bin2hex对比正常数据的十六进制或者直接输出到文件里检查结尾是否完整。现象二serialize时出现“Cannot serialize resource”之类的错误正如前面说的resource类型和闭包无法序列化。我的习惯是在业务层做映射把资源对象换成可序列化的标识比如把数据库连接换成数据源名称需要恢复时再创建真正的连接。对象属性里如果存了\PDO实例一定要用__sleep排除掉。现象三Redis中存进去是数组取出来变成string或boolean的错误大概率是你在存储时用了不同序列化方式或者读取时用了不同的反序列化器。比如存的时候是serialize($arr)读的时候却用json_decode自然对不上。建议项目统一封装一个序列化组件不要在业务代码里一会儿serialize一会儿json_encode。4.2 序列化数据版本迁移与兼容线上跑了好几年类结构变了旧的序列化数据读不出来怎么办这是非常现实的问题。假设你有一个User类原来有$name、$age两个属性后来把$age改成了$birthday。旧序列化数据里还是$age而新类已经定义了$birthday反序列化时PHP会尽量把属性塞进去但类属性可能缺失。更麻烦的是私有属性名的\0标记变化一旦类名从App\Models\User改成App\Data\User旧数据里的\0App\Models\User\0password就匹配不上新类了。应对策略有几种数据结构稳定优先。对象属性定义尽量不频繁改名新增字段给默认值。做兼容层。在__wakeup里把废弃属性重新映射到新属性。不要长期存储对象尤其是跨版本的对象。把对象转成纯数组或JSON再落库等要用时再还原成对象。写迁移脚本。定期扫描缓存里所有序列化字符串用一次性的CLI脚本重放到新格式。我个人的经验是序列化适合短生命周期的缓存分钟级、小时级不适合做长期存档。真要存档用版本化JSON或者数据库表都比裸序列化稳。4.3 性能对比serialize vs json vs igbinary总有人问“哪个快”。我简单跑过一个基准结论供参考不同环境会有差异方案数据大小序列化耗时反序列化耗时serialize基准较大基准基准json_encode略小略快略快igbinary最小约一半快快msgpack略小快快看到没serialize在原生方案里并不算特别快。但如果你存的是PHP对象并需要完整恢复它能做的事比JSON多。在需要大量缓存存储时可以考虑改用igbinary扩展。它的序列化格式更紧凑速度也更快而且保留了对PHP类型和对象的支持很多框架在Redis驱动里会优先选择它。安装后可以这样用$data igbinary_serialize($arr); $arr igbinary_unserialize($data);注意igbinary不是PHP自带的需要安装扩展。使用前确保目标环境也安装了否则反序列化会失败。另外一个容易被忽视的性能点大数组序列化时内存峰值会暴涨。因为serialize会临时创建一份完整字符串如果你的数组本身有100MB内存可能冲到200MB以上。解决办法是分片存储或者用生成器/流式处理不要在一条缓存键里塞超大结构。4.4 一份可用的序列化工具类封装为了统一规范我在项目里封了一个SafeSerializer集成了签名验证和类白名单。直接贴核心代码你可以裁剪使用。?php class SafeSerializer { private $key; private $allowedClasses; private $enableCompress; /** * param string $key HMAC签名密钥 * param array|false $allowedClasses 允许反序列化的类白名单false表示不允许任何类 * param bool $enableCompress 是否压缩 */ public function __construct(string $key, $allowedClasses false, bool $enableCompress false) { $this-key $key; $this-allowedClasses $allowedClasses; $this-enableCompress $enableCompress; } public function serialize($data): string { $inner serialize($data); if ($this-enableCompress) { $inner gzcompress($inner, 6); } $hash hash_hmac(sha256, $inner, $this-key); return base64_encode($hash . :: . $inner); } public function unserialize(string $input) { $decoded base64_decode($input, true); if ($decoded false) { return null; } $pos strpos($decoded, ::); if ($pos false) { return null; } $hash substr($decoded, 0, $pos); $inner substr($decoded, $pos 2); $computed hash_hmac(sha256, $inner, $this-key); if (!hash_equals($computed, $hash)) { return null; } if ($this-enableCompress) { $inner gzuncompress($inner); if ($inner false) { return null; } } return unserialize($inner, [allowed_classes $this-allowedClasses]); } }使用示例$serializer new SafeSerializer(my-ma-ji-suo-private-key, [User], true); $userData [id 1, name 张三]; $encoded $serializer-serialize($userData); $restored $serializer-unserialize($encoded);几点说明base64_encode会增大体积约33%但换来的是文本安全和传输稳定。如果存在Redis可以不base64直接用redis客户端的二进制保存但文件和日志里最好base64。压缩适合大对象或纯数据数组小数据压缩反而增加CPU消耗。hash_equals做签名比较避免时序侧信道。allowed_classes这里是数组[User]如果你的业务需要反序列化对象把对应类放进来如果只是数组传false即可。这套封装解决了我自己项目里几个关键痛点数据处理来源不可控、类爆炸、以及缓存数据被误改的风险。你也可以在此基础上加上有效期、类型校验等进一步加固。在我自己的项目里使用serialize的场景其实比想象中少。更多时候我依赖json_encode处理跨层数据依赖Redis里的字符串缓存只有真正需要对象还原和循环引用时才会请出serialize。但每次要用它我一定会问自己一句这份序列化数据是从哪来的如果答案里包含“用户输入”几个字那我宁可改成数组加JSON或者至少套一层签名。序列化本身不背锅背锅的是不设防的使用方式。最后一次上线检查我还干过一件笨事把所有unserialize的入参都加上了allowed_classes顺带把日志里记录的序列化串全部换成十六进制格式避免内部数据泄露到第三方统计平台。这套习惯坚持下来确实少踩了很多坑。
返回列表