PHP反序列化绕过实战:__wakeup与正则校验的攻防博弈

发布时间:2026/10/21 7:47:49
PHP反序列化绕过实战:__wakeup与正则校验的攻防博弈 打CTF绕不开PHP反序列化这关而反序列化又绕不过__wakeup和正则校验这两个坎。经常有人问__wakeup到底怎么绕过正则明明写了O:开头就拒为什么把序列化串改成O:4:...就能提交成功这类题目考来考去本质上只问一件事——你有没有真正吃透PHP序列化字符串的格式以及PHP解析器和正则引擎之间的视角差异。这篇文章用一道模拟题复盘完整思考过程从序列化结构讲到两大绕过细节再补上我踩过的坑。适合已经接触过反序列化、但总在绕步环节卡壳的选手也适合想系统梳理PHP反序列化考点的朋友。1. 先把序列化这件事吃透1.1 序列化字符串的基础结构所有绕过技巧都藏在序列化字符串本身所以第一件事是把格式结构看明白。拿一个最简单的类来说?php class User { public $name admin; public $isAdmin false; } echo serialize(new User()); ?输出结果O:4:User:2:{s:4:name;s:5:admin;s:7:isAdmin;b:0;}拆开看每一段都是有含义的不是随机字符O表示这是一个对象Object的序列化结果。4:User表示类名是User长度为4个字符。2表示这个对象序列化后包含2个属性。{}内部是属性列表每个属性由属性名和属性值构成。s:4:name表示属性名是长度为4的字符串names:5:admin表示属性值是长度为5的字符串admin。b:0表示布尔值falseb:1对应true。其中2这个数字特别关键。反序列化时PHP的解析器会先读取这个数字把它当作属性数量的预期值再根据后续内容逐个恢复属性值。这个数字一旦和真实属性数量不一致就会引发PHP解析器的特殊分支后面的__wakeup绕过就是从这里做文章。1.2 魔术方法的触发时机PHP类里有一批魔术方法它们在特定时机被自动调用。序列化场景最常涉及这几个魔术方法触发时机常见用途__wakeup()反序列化时最先执行校验属性、动态设置状态__destruct()对象销毁时收尾操作常被用作利用链出口__toString()对象被当作字符串使用时拼接字符串、写日志等__call()调用不存在的方法时劫持方法调用__get()/__set()访问/设置不可访问属性时属性代理、绕过访问限制__wakeup()的特殊之处在于它几乎是反序列化的第一步。很多题目把业务逻辑放在这里做限制比如反序列化后必须满足某个条件否则强制重置关键属性。攻击者如果能跳过这一步就等于绕过了整道题最核心的闸门。理解触发时机还有一个实际意义构造利用链时要沿着魔术方法的调用关系打通整条链路。比如一个类没有直接的危险函数但它的__destruct()会调用另一个对象的__toString()而__toString()又触发了file_get_contents()这就形成了一条完整的POP链。可以把__wakeup()类比成包裹签收确认__destruct()类比成拆完包装后的垃圾分类环节很多隐藏动作就发生在这些不起眼的“附带流程”里。1.3 为什么一个unserialize就能出事很多人第一次接触反序列化漏洞时都会问unserialize()不是PHP内置函数吗怎么就成了漏洞入口关键在于unserialize()的输入完全可控。它根据传入的字符串自动决定创建哪个类、给哪些属性赋什么值。攻击者可以伪造一个属于任意类的对象并把这个对象的属性设置成任何内容。比如目标程序里有一个Admin类类中有个$filePath属性正常用户不能控制它但攻击者直接构造一个序列化串把这个属性改成/etc/passwd或者某个可写路径反序列化后程序一旦用到这个属性危险就发生了。更别说配合__destruct()、__wakeup()这些魔术方法攻击者可以借用程序已有的类方法实现文件读取、代码执行、SQL注入等效果。PHP反序列化漏洞在很多题目里不是单一存在它往往和程序自身的业务逻辑绑定。比如某个类专门负责读取文件、调用命令、访问数据库这些类天然就是反序列化利用的最佳“武器库”。攻击者不需要写自己的恶意代码只要把程序里已有的类拼装成一条调用链就能实现预期效果。2.__wakeup绕过从CVE到实战2.1 CVE-2016-7124的漏洞本质__wakeup绕过背后是一个经典的CVECVE-2016-7124。它影响的PHP版本是PHP 5.6.25之前和PHP 7.0.10之前很多CTF模拟环境恰好复现了老版本的行为所以这个技巧在解题里非常常见。这个漏洞的核心是当序列化字符串中声明的属性数量大于对象实际拥有的属性数量时__wakeup()魔术方法不会被调用。也就是说攻击者只要把O:4:User:2:{...}中的数字2改成一个更大的数比如3、9、999PHP在反序列化时就会跳过__wakeup()直接进入下一步。为什么PHP会有这种奇怪的行为本质上是解析器内部实现的一个旁路分支。反序列化时解析器先读取属性数量N然后按N的预期去恢复属性列表。如果恢复过程中发现实际属性对的数量少于声明数量或者出现了与预期不符的边界情况解析器走的是异常处理路径而这个路径中恰好漏掉了对__wakeup()的调用。这个行为在后续版本中被修复但代码逻辑上的“意外”被很多研究者提炼成了通用的绕过手法。用生活化的话说这就像一个点餐系统选了5道菜结账时厨房说只有3道菜系统直接跳到支付完成流程却没执行“确认签收”这个动作。外部看到的表象就是你明明提交了一个对象程序却跳过了它的“确认签收”逻辑。2.2 属性数量法操作细节手工调整属性数量看似简单但有几个细节必须处理到位。先看原始序列化串再动手O:4:User:2:{s:4:name;s:5:admin;s:7:isAdmin;b:1;}这个对象有name和isAdmin两个属性。现在把属性数量从2改为3O:4:User:3:{s:4:name;s:5:admin;s:7:isAdmin;b:1;}注意这里只修改2为3括号内部的键值对保持不变。这样反序列化时PHP按照声明的3个属性去解析但实际只有2对键值就会走向跳__wakeup的分支。括号内部仍然合法闭合所以对象还是能成功创建出来只是漏掉了__wakeup()这一步。动手修改时建议遵守三条原则属性数量必须大于实际属性对数量不能小于或等于。类名、属性名、属性值的长度都必须和字符串内容严格对应尤其不能随意改动引号内的内容否则整个序列化串会变成非法格式。提交payload前要想清楚目标对象是否会被二次处理比如URL编码、Base64解码、JSON解析。如果题目从GET参数拿到数据后先做了URL解码那提交时就要把{、}、:、这些特殊字符做一次编码否则传输层可能把它截断或转义。很多新手卡在“数字改了但wakeup还是执行”的场景最常见原因就是属性数量虽然改大了但字符串在传输过程中被截断导致unserialize()直接报错题目走的是die()分支而不是继续执行。2.3 版本影响与误判场景既然这个绕过依赖PHP老版本行为新版本通常就失效了。PHP 7.0.10之后、PHP 5.6.25之后提交相同payload__wakeup()依然会被正常调用属性数量不一致只会产生警告或直接导致反序列化失败。实际解题中判断题目环境是否可用这个绕过可以先从题目给出的源码、容器描述、PHP版本提示等信息确认。如果题目没有给出版本信息则要考虑用其他绕过方案作为备用。还有一个常见的误判场景私有属性和保护属性。PHP序列化时私有属性名会变成\00类名\00属性名的格式保护属性名会变成\00*\00属性名的格式。这里的\00是一个真实存在的空字节不是两个字符。计算属性名长度时空字节也要计入。比如一个私有属性$flag类名User实际序列化后的键名是\00User\00flag长度是141410字节不能简单按flag的4字节算。手工构造payload时一旦算错长度整个字符串就无法被正常解析。我自己的习惯是把每次要提交的payload先在本地PHP脚本里unserialize()一遍然后var_dump()看结果。如果本地环境版本比较新wakeup绕不过去可以把目标逻辑单独抽出来模拟版本行为或者直接构造一个不带__wakeup()的等价类来验证解析器对被修改序列化串的态度。3. 正则校验的绕过姿势3.1 加号填充位最实用的正则绕过很多题目会给输入加一层正则校验最常见的写法是if (preg_match(/^O:\d/, $data)) { die(hack); }这个正则会拦截所有以O:加数字开头的序列化串。比如正常的O:4:User:2:{...}一眼就被识别出来。绕过的经典姿势是把它改成O:4:User:2:{s:4:name;s:5:admin;s:7:isAdmin;b:1;}区别只在O:和数字4之间多了一个号。正则引擎的作用是匹配字符流/^O:\d/要求O:后面紧跟数字但这里跟的是所以匹配失败正则校验被绕过。PHP的反序列化解析器在读取对象类型标识时会自动跳过、接受这个号把O:4按O:4来处理。最终反序列化结果和正规payload完全一致。这是“正则引擎视角”和“PHP解析器视角”差异的最典型体现。正则引擎是纯文本层的匹配器它不知道PHP反序列化语法里号可以被忽略PHP解析器是按语法规则读取的它天然就支持这种容错。类似思路还会出现在其他语言的反序列化题目中本质都是利用解析器额外容错来制造差异。加号填充的好用之处在于它只改了一个字符就能同时保住对象类型、属性数量和属性列表结构不影响任何后续逻辑。在O:后面加是通用姿势在C:这种自定义序列化标识后面加也有效果但CTF里最常见的还是O:场景。3.2 数组包裹与校验点的博弈如果正则只校验开头还有一种绕法是套数组。把对象序列化串塞进一个数组里整个payload就变成了a:1:{i:0;O:4:User:3:{s:4:name;s:5:admin;s:7:isAdmin;b:1;}}此时正则/^O:\d/去匹配整个字符串开头是a:1:自然过不了、也拦不住。PHP解析器反序列化时会得到一个数组数组第一个元素是目标对象。这个方式好用但有一个很大的限制反序列化结果从“对象”变成了“数组”。有些题目直接拿unserialize($data)的返回值去访问属性比如$obj-isAdmin如果$obj是数组这段代码会直接报错或返回false。所以数组包裹只适合题目代码明确接收数组元素、或者后续逻辑会自动从数组中取出对象的场景。数组包裹代表的是一条“改结构”的思路加号填充代表的是“改字符”的思路。两种思路没有高低之分关键是看目标代码怎么取用反序列化结果。有的题目正则写成/(^|{|,)O:\d/会把位于数组内部的对象标识也拦截掉这时候数组包裹就失效了必须回到加号填充或者组合使用。3.3 标识符大小写与引用技巧正则校验还有一种常见误区就是只写O:不写o:。PHP反序列化解析器对标识符大小写的容忍度在不同版本中有差异部分环境下小写o开头的序列化串也能被正常解析。假设题目正则写成/^O:\d/那么payload可以用o:4:User:2:{...}试试。大小写加加号双管齐下通常能覆盖更多正则过滤场景。但这条要注意大小写容错不是所有PHP版本都通用本地实测为准。我一般的做法是把目标源码的PHP版本和序列化解析行为先摸清楚再决定用大写还是小写。白白试错一轮反而浪费比赛时间。还有一个思路是利用引用类型标识符R或r。PHP序列化支持对象引用和属性引用R表示引用r表示引用对象。在构造多个属性时可以让一个属性的值引用另一个属性从而让同一个对象或同一个字符串在序列化串中只出现一次。有些正则如果只拦截O:字符串而对象内大量属性值里出现同样的对象标识输出长度和特性会被改变有机会绕过字符数量类校验。这个思路更偏技巧性适合作为备选方案。3.4 关键词过滤的延伸S:转义正则不只会拦对象标识还会拦敏感关键词。比如题目写了preg_match(/admin|flag/i, $data)这时候序列化串里的admin和flag也会被拦下来。针对字符串值的关键词过滤可以用PHP的S:类型代替s:类型。S:和s:的区别在于S:允许在字符串内容中使用\xx形式的十六进制转义。比如字符串admin可以写成S:5:\61\64\6d\69\6e;61、64、6d、69、6e分别是a、d、m、i、n的十六进制ASCII码。正则引擎按普通文本去匹配看到的是一串反斜杠和十六进制数字匹配不到adminPHP解析器则能正确解读转义还原出原始字符串。这个技巧不仅能用在属性值上也能用在属性名上。属性名如果包含敏感词同样可以转成S:类型。注意S:转义要求合法转义字符必须是有效的十六进制编码否则unserialize会报错。长度仍然按照解码后的字节数计算不能按转义后的文本长度算。S:转义是关键词过滤的通用绕法但只解决字符串内容问题。如果正则同时拦对象标识和敏感词那就要组合加号填充、大小写、十六进制转义一起上。我做过一道题就是三合一o:4绕过对象标识属性名用S:转义绕过关键词同时改属性数量绕过__wakeup最终才把payload送到目标位置。4. 完整解题流程演示4.1 模拟题目源码与考点定位把前面的知识点拼在一起走一遍完整题目。下面是某模拟环境里的简化题?php highlight_file(__FILE__); class User { public $name ; public $isAdmin false; public function __wakeup() { if ($this-name ! admin) { $this-isAdmin false; } } } if (isset($_POST[data])) { $data $_POST[data]; if (preg_match(/^O:\d/, $data)) { die(hack); } $obj unserialize($data); if ($obj-isAdmin) { echo file_get_contents(/flag); } else { echo not admin; } } ?这个题目把两个考点叠在一起了。第一层是__wakeup()拦截反序列化时如果name不是admin会把isAdmin重置为false。正常提交一个对象isAdmin就会被这个魔术方法重置怎么都过不了最后的判断。第二层是正则拦截preg_match(/^O:\d/, $data)拒绝O:后直接跟数字的payload想直接提交正常序列化串也不行。解题目标很明确构造一个User对象让name等于admin、isAdmin等于true同时跳过__wakeup()并绕过正则。4.2 利用链构造与辅助工具这道题不需要很复杂的POP链真正的重点是绕过高空中的两道闸门。在更复杂的题目里反序列化往往只是入口后续还要依靠目标程序已有的类方法形成调用链。比如某个日志类有__destruct()会写文件某个缓存类有__toString()会读文件层层拼接才能到达flag。复杂场景我一般用现成工具辅助构造。phpggc是目前最常用的PHP反序列化利用链生成工具它集成了大量主流框架和CMS的POP链。常用操作是php phpggc -l php phpggc -i Laravel/RCE1 php phpggc Laravel/RCE1 exec id第一条命令列出所有已知利用链第二条查看某个链的详细信息第三条生成一个可执行的序列化payload。工具能省不少时间但不要盲目套用。ctf题里的目标类是自定义的工具通常帮不上忙手工分析源码仍是核心能力。回到模拟题由于目标类只有两个属性利用链很直白手工构造序列化串就行。4.3 手工构造最终payload第一步先按正常逻辑序列化目标对象。假设在本地写一个同样结构的User类执行?php $u new User(); $u-name admin; $u-isAdmin true; echo serialize($u); ?得到O:4:User:2:{s:4:name;s:5:admin;s:7:isAdmin;b:1;}第二步绕过__wakeup()。属性数量2改成3得到O:4:User:3:{s:4:name;s:5:admin;s:7:isAdmin;b:1;}第三步绕过正则校验。在O:后面加号得到最终payloadO:4:User:3:{s:4:name;s:5:admin;s:7:isAdmin;b:1;}提交前需要把特殊字符做URL编码因为题目使用的是POST参数。{、}、:、、这些字符在POST表单里直接传递容易出问题尤其是号会被解析成空格。所以提交的是URL编码后的版本O%3A%2B4%3A%22User%22%3A3%3A%7Bs%3A4%3A%22name%22%3Bs%3A5%3A%22admin%22%3Bs%3A7%3A%22isAdmin%22%3Bb%3A1%3B%7D这里有一个很关键的坑被URL解码成空格后O:4就变成了O: 4PHP解析器不认识空格反序列化直接失败。所以提交前必须确保以编码后的形式到达服务端。我一般习惯用抓包工具直接改原始POST数据而不是在表单里手动填写这样能避免浏览器和表单层做额外解码。用抓包工具把data参数改成编码后的payload后放行请求。服务端获取到的$data已经是被URL解码过的O:4:User:3:{...}正则匹配/^O:\d/失败出于正则校验的路直接走完接着unserialize()识别O:4按O:4解析出User对象对象属性数量声明为3、实际只有2对键值触发老版本PHP的行为跳过__wakeup()最后判断$obj-isAdmin为true服务端读取flag文件并输出。4.4 提交前自检与验证提交之前有个小步骤值得做在本地模拟题目逻辑验证payload是否真的有效。如果本地PHP版本太新__wakeup绕过会失效但其他部分仍可验证。我的常用验证脚本是这样?php class User { public $name ; public $isAdmin false; public function __wakeup() { echo wakeup called!\n; if ($this-name ! admin) { $this-isAdmin false; } } } $data $_POST[data]; echo before unserialize\n; $obj unserialize($data); var_dump($obj); echo $obj-isAdmin ? success\n : fail\n; ?如果本地环境版本较新会看到wakeup called!说明属性数量绕过没有生效。这不代表payload本身错误只是版本差异。此时可以观察解析器对O:4的处理确认正则绕过部分是否有效再换一个复现老版本行为的环境测试完整的__wakeup绕过。自检清单基本固定成这几项属性数量是否大于真实属性对数量。字符串长度和实际内容是否一致尤其是包含空字节的私有属性名。O:后面的是否保留完整提交过程中有没有被URL解码成空格或流失。花括号是否闭合整个串是否是一个合法的unserialize输入。目标代码拿到反序列化结果后是直接当对象用还是先从数组中取元素再决定用不用数组包裹绕过。5. 常见问题排查与速查5.1 wakeup没绕过版本与数字逐项排查最常遇到的问题是payload提交后__wakeup()还是执行了关键属性被重置。优先排查三件事PHP版本太新。PHP 7.0.10及以上、PHP 5.6.25及以上默认修复了这个CVE行为属性数量不一致时__wakeup()依然会被调用。属性数量没有改对。只是把2改成了别的小于或等于实际属性数量的数字不会跳绕过分支。序列化串在传输中被截断。比如花括号、引号被转义或解码破坏unserialize()直接返回false目标代码走了异常分支。还有一个隐蔽问题如果目标类有继承关系父类属性在序列化串中也会体现为键值对。计算实际属性数量时要把当前类本地的属性和继承属性一起数进去只盯着子类自己定义的属性数很容易改错。5.2 提交后提示非法序列化长度和转义背锅非法序列化提示几乎都是格式问题。最常见的是字符串长度标志和实际内容不一致尤其是包含中文、空字节、转义符的属性名。PHP序列化中的长度按字节计算多一个空字节就多一个数位。手工改动payload时必须逐字节核对长度标志。另一个常见原因是键盘引号问题。序列化字符串必须使用英文双引号和分号复制粘贴时一旦混入中文引号整个串就废了。如果题目在unserialize()之前还做了stripslashes()或addslashes()处理payload里的反斜杠也会发生变化。这在S:十六进制转义场景中尤其致命\61可能变成61导致解析失败。遇到这种场景先确认处理函数是什么再调整payload中的转义写法。5.3 对象类型不对数组包裹和类名陷阱数组包裹绕过适合特定场景但如果目标代码直接访问$obj-isAdmin而$obj是数组返回结果自然不正常。排查时先看反序列化结果的类型再决定是否改用加号填充、大小写等方式。类名大小写也是一个陷阱。PHP类名通常大小写不敏感但序列化字符串中的类名字符串会直接决定实例化哪个类。如果目标类名首字母是大写Userpayload里写成小写user可能实例化失败。命名空间类在序列化串里会显示完整命名空间路径长度计算要把反斜杠也算进去比如App\Classes\Admin这类类名长度不只包含字母和反斜杠的数量还要确保每个字符都对齐。5.4 绕过速查表把常用绕过思路整理成一张表刷题时对着查效率很高绕过目标常用方法适用条件注意点__wakeup()属性数量参数调大PHP老版本环境版本高于修复线时不生效需确认环境^O:\d正则O:4加号填充正则只匹配紧贴数字提交时注意号URL编码^O:开头正则外层套数组目标代码支持取数组元素反序列化结果是数组不是对象O:大小写过滤小写o:部分PHP版本支持不同版本行为有差异实测确认关键词过滤S:十六进制转义目标支持S:字符串类型转义字符必须合法长度按解码后字节数计算5.5 实战经验小结绕过技巧说到底是在玩两个层面的信息差正则引擎把输入当作纯文本字符流PHP解析器把输入当作有语法规则的结构化信息。同一个输入在两层视角下可能得出完全相反的结论这就是O:4、数组包裹、S:转义能够成立的根本原因。我个人的体会是这类题目最值得先做的不是背payload而是把序列化串手工打印出来对着每个字节数一遍。多数几遍之后对“属性数量”“字符串长度”“文件结构”这些概念会形成肌肉记忆。之后再遇到__wakeup、正则、关键词过滤这些变体就能快速定位到是哪个部位被卡住了。踩过几次坑之后你会发现题目改来改去核心考点永远是那三样对象结构、魔术方法、解析器与正则之间的缝隙。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询