最近翻到一道很有意思的新生赛题[SWPUCTF 2021 新生赛]ez_unserialize名字里那个ez很诚实但仔细做下来你会发现这题把PHP反序列化漏洞里最核心的几条知识点全都串了一遍。我一直觉得反序列化漏洞是Web安全里非常适合新手入门的方向它不像堆溢出、条件竞争那样需要很深的底层功底关键在于理解PHP对象生命周期和魔术方法的调用机制而这道题正好是这条学习路径上一个不错的节点。今天这篇就从头到尾复盘一下这道题的完整思考和解法包括PHP序列化机制的底层逻辑、代码审计时怎么高效率定位可利用点、payload手工构造的全过程以及新生赛里常见的干扰项处理。不管你是刚接触CTF的新人还是想把反序列化基础打牢一点这篇都值得花几分钟看完。1. 反序列化漏洞的底层原理从serialize到unserialize先说清楚PHP的序列化机制。序列化serialize就是把一个对象转换成一串带有类型标记和属性名、属性值的字符串方便存储或传输反序列化unserialize则是把这串字符重新还原成内存里的PHP对象。单看机制本身这功能很正常问题出在反序列化过程中PHP会自动触发对象里的某些魔术方法。PHP里有一组以双下划线开头的方法统称魔术方法它们不需要你手动调用而是在特定事件发生时自动执行。和反序列化直接相关的魔术方法主要有这么几个方法名触发时机典型用途__construct()对象被实例化时初始化属性__destruct()对象被销毁时脚本结束或unset释放资源__wakeup()反序列化恢复对象时重新初始化连接等__toString()对象被当作字符串使用时返回字符串表示__get($name)访问不可访问或不存在的属性时动态属性读取真正的危险点在于如果反序列化的数据来自用户可控输入而某个类的魔法方法里有敏感操作比如读写文件、执行命令、拼SQL那么攻击者只要精心构造序列化字符串就能在反序列化的一瞬间或脚本收尾阶段让这些危险操作被自动触发。用个生活化的类比序列化就像你把行李箱打包每个物品外面贴上标签写明这是什么、里面装着什么反序列化就是拆箱而拆箱过程中箱子自己会弹开几个暗格——如果这个箱子被设计成暗格里藏了个自动发夹子机关那你打开箱子时就会被夹到。攻击者做的事就是往箱子里塞一张精心裁剪过的纸条让拆箱过程把隐藏的机关触发出来。在CTF里反序列化题目最常见的形态就是代码把用户输入直接丢给unserialize()处理而代码中某个类的某个魔术方法里存在可利用的敏感函数。ez_unserialize就是这种典型结构。2. 源码审计的思路三分钟定位可利用点拿到题目首先看到的是源码这种新生赛反序列化题基本都会把代码直接贴出来。审计代码时我习惯按固定顺序来效率比较高。先找unserialize()的调用位置和入参来源确认是否有过滤。再列出所有定义了魔术方法的类尤其是__destruct、__wakeup、__toString、__get这些反序列化过程会触发的。最后看这些魔术方法内部的函数调用优先关注文件操作、命令执行、SQL拼接三类危险函数。这道题的核心源码大致可以简化为这样一个结构?php class A { public $info; public function __destruct() { echo $this-info; } } $data $_GET[data]; if (isset($data)) { unserialize($data); }注意到几个关键信息第一unserialize()的入参直接来自$_GET[data]完全没有任何过滤这是典型的可控输入点。第二A类定义了__destruct()析构函数它做的事情是echo $this-info。第三对象被反序列化出来后如果没有被显式销毁会在脚本执行结束时自动调用析构函数。那问题来了echo一个对象属性的值看起来很人畜无害怎么利用关键就在这里。反序列化时攻击者能完全控制对象的所有属性值。如果我们把$info这个属性的值设置成某个对象一个实现了__toString()方法的对象那么echo $this-info在尝试输出这个对象时PHP就会自动调用这个对象的__toString()方法。这一步就构成了反序列化漏洞利用里非常经典的POP链雏形。所谓POP链就是面向属性编程Property-Oriented Programming核心思想是通过控制对象的属性值让代码原有的函数调用关系为我们所用让一个无害的操作触发一串危险的执行链。不需要注入新代码只需要精心搭配已有的类和属性。明白了这一点你就会理解为什么反序列化题目的审计重点不是找到直接命令执行点而是梳理类与类之间的调用链条——安全研究员常说的gadget就是这么串联起来的。3. 从触发点反推一个有意思的魔术方法设计第一步审计发现了出口__destruct里的echo至少能把一个对象变成字符串。接下来要做的就是找到代码里哪个类实现了__toString方法并且这个方法内部有敏感操作。这类题里最常见的出法有两种一种是目标类有一个方法会读取文件内容比如file_get_contents而__toString直接返回这个读取结果另一种是目标类的__toString方法里直接调用了危险函数比如eval、system、call_user_func等。本次题目属于前者。假设源码里还有这样一个类?php class B { public $filename; public function __toString() { return file_get_contents($this-filename); } }逻辑链条立即清晰了反序列化入口$_GET[data]传给unserialize()。反序列化还原一个A类对象其$info属性被设置成一个B类对象。脚本结束A类对象析构执行echo $this-info。echo一个B类对象触发B::__toString()。B::__toString()执行file_get_contents($this-filename)。文件内容被当作字符串输出到页面。所以payload要达成的目标是构造一个A对象它的$info属性指向一个B对象且这个B对象的$filename属性值是目标敏感文件路径。现在整个利用链完全清晰剩下的就是手工生成对应的序列化字符串。这也是手工构造的一个好处比用网上工具更稳同时能加深对格式的记忆题目里稍微改改结构你也能反应过来。4. 序列化字符串手工构造一步一步推导出payload先说PHP序列化格式的基本规则新手容易在这里绕晕。一个对象序列化后是这样一段字符O:1:A:1:{s:4:info;O:1:B:1:{s:8:filename;s:5:flag.php;}}拆解这段字符每个部分含义如下片段含义O:1:A反序列化一个对象类名长度为1类名为A:1:该对象有1个属性{ ... }属性列表s:4:info属性名字符串类型长度4内容是infoO:1:B:...info属性的值是一个B类对象s:8:filenameB对象的属性名filenames:5:flag.phpfilename属性的值字符串flag.php字符串用s:长度:内容表示对象用O:长度:类名:属性数:{...}表示读起来很像一串带元数据的协议。只要记住这几个基本类型几乎所有简单反序列化payload都可以手写。有个容易踩坑的细节属性名的长度必须准确。比如info是4个字符你写s:3:info就会导致反序列化失败或者解析偏移拿到意想不到的结果。CTF里只要格式有一点点错unserialize都会返回false然后题目的输出就什么都没了。真出问题时第一步永远是检查长度。现在根据题目目标构造完整的payload。假设目标文件是根目录下的flag.php?php class A { public $info; } class B { public $filename; } $a new A(); $a-info new B(); $a-info-filename flag.php; echo serialize($a);输出结果O:1:A:1:{s:4:info;O:1:B:1:{s:8:filename;s:8:flag.php;}}看到差别没有flag.php长度是8不是5上面举例时我故意写的是s:5:flag.php那种payload直接反序列化失败。这种低级错误我当年也犯过排查了很久才反应过来是字符串长度和实际内容不匹配。现在把payload放到GET参数里提交?dataO:1:A:1:{s:4:info;O:1:B:1:{s:8:filename;s:8:flag.php;}}这里还隐藏着一个新生赛专门用来考验选手的坑序列化字符串里的引号、花括号、双引号在URL里属于特殊字符如果你直接复制进浏览器地址栏或者用curl直接传会被URL解析破坏掉服务端收到的data参数值早就不完整了unserialize自然失败。解决办法是先用URL编码。常见需要编码的字符有双引号编码为%22花括号{编码为%7B}编码为%7D分号;不需要编码但保险起见也可以编。这个payload编码后长这样?dataO%3A1%3A%22A%22%3A1%3A%7Bs%3A4%3A%22info%22%3BO%3A1%3A%22B%22%3A1%3A%7Bs%3A8%3A%22filename%22%3Bs%3A8%3A%22flag.php%22%3B%7D%7D个人习惯不管参数多简单提交payload永远走URL编码养成肌肉记忆对后面做更复杂的题目帮助很大。用curl验证很方便curl http://目标地址/题目路径/?dataO%3A1%3A%22A%22%3A1%3A%7Bs%3A4%3A%22info%22%3BO%3A1%3A%22B%22%3A1%3A%7Bs%3A8%3A%22filename%22%3Bs%3A8%3A%22flag.php%22%3B%7D%7D如果一切顺利响应里就是flag.php的内容。5. 常见干扰项与绕过手法为什么你的payload打不出来做这道题过程中最容易遇到的坑也代表了一类反序列化新手题的干扰套路。第一个干扰项是URL编码问题前面已经讲了。很多人在这一步踩坑以为payload错了其实是传输层被URL解析修改了。第二个干扰项是某个类里存在__wakeup()方法。前面说过__wakeup在反序列化时自动触发比__destruct更早。如果目标类里的__wakeup做了某些破坏操作比如把$filename置空或者直接exit你的POP链就断在起跑线上了。经典绕过手段是CVE-2016-7124当反序列化字符串里对象的属性数量大于真实属性数量时PHP会跳过__wakeup()的执行。具体来说把对象属性个数的那个数字改大比如原本是1改成2或更大。这个漏洞在新版PHP里已经被修复但很多CTF题目为了考察知识点会用旧版本环境搭建所以仍然是一个需要掌握的技巧。第三个干扰项是序列化字符串里属性名长度不一致这个前面已经讲过了但值得再说一次手工构造payload非常容易出现长度计算错误尤其是属性名是中文或特殊字符的时候。第四个干扰项是反序列化后对象在脚本结束前被显式销毁__destruct提前触发导致输出顺序变化。有些题目里会写unset($obj)或者把对象丢进数组再释放导致你预期的输出位置变了。这种干扰在新生赛里出现概率不高但遇到过几次解决方案是在本地用PHP写个Demo把题目源码简化后跑一遍观察输出。为了方便排查我列一张自查清单每当payload打不通就按这个顺序检查检查项操作value是否被过滤查看是否有waf或正则替换特殊字符URL编码是否完整将payload全部做百分号编码序列化格式是否合法用php的serialize()生成标准payload做对比魔术方法触发顺序本地搭建环境逐步echo调试目标文件路径是否正确尝试读取index.php、/etc/passwd等路径6. 一类通用规律的提炼反序列化题的标准解题路径把ez_unserialize做完可以顺手总结出一套适合PHP反序列化新手题的通用解题路径。这套路径我后来用在了不少其他题目上命中率很高这里分享给大家。第一步找入口。全局搜索unserialize、$_GET、$_POST、$_REQUEST这些关键词。注意有些题目的入口藏在比较深的位置比如先经过一个json_decode或者先经过自定义的解析函数再回到unserialize。第二步找危险函数。把代码里所有命令执行类system、exec、shell_exec、eval、文件读写类file_get_contents、file_put_contents、fopen、include、数据库操作类函数全部标出来这个就是攻击终点。第三步找魔法方法。看这些危险函数被哪个方法包裹尤其是__destruct、__wakeup、__toString、__get、__call、__isset、__set。反序列化的魔法方法触发时机不同利用链入口就不同__destruct对象销毁时触发最常出现因为脚本结束必然销毁。__wakeup反序列化时最先触发也常被用来做过滤或初始化。__toString对象作为字符串使用时的自动转换适合做链中衔接。__get/__call访问不存在属性或方法时触发配合extract、动态调用时很好用。第四步从终点倒推入点把POP链串起来。拿危险函数所在的类作为链尾检查有哪些魔法方法会调用它然后控制它所在类的属性值继续往前推直到找到一个能接收用户输入的反序列化入口类。第五步用PHP本地生成payload。这是很重要的一个质量保障本地可以用真实的类定义跑保证序列化字符串里的每个长度都准确无误。你当然可以直接在浏览器里手写但一旦类名或属性名有字母大小写差别本地一跑就知道。我在实际做题时习惯把题目给出的源码复制到本地写上生成payload的PHP脚本在命令行里跑出来再交给curl提交。这个流程比手写安全很多也方便批量尝试不同路径。用本地脚本还有一个隐藏优势你可以直接在类里加调试代码比如每个魔术方法里加一个echo标记这样就能精确知道反序列化过程到底触发了哪些方法、执行顺序是什么。遇到复杂的链时这招几乎是半开卷做题。7. 入门之后的延伸思考ez_unserialize这道题本身不算难但它非常适合作为理解反序列化漏洞的起点。做完之后有两条知识线值得继续深入。第一条线是POP链的复杂度升级。新生赛通常只有一个跳板类从A到B但真实场景的题目可能有五六个类互相引用甚至有数组、对象嵌套这时候手工构造就不现实了。我看到很多成熟的选手会写Gadget链自动生成脚本扫描类之间的关系搜索一条从入口到危险函数的路径。早期自己手工搭链的意义在于理解每一步的触发原因而不是追求能搭多复杂的链。第二条线是真正开发场景下的反序列化防护。多数框架已经把反序列化入口收得很紧但历史上有大量出自知名框架的反序列化漏洞。研究这类漏洞的乐趣在于它暴露的是框架设计者在灵活和安全之间的取舍失误理解这种取舍对未来做代码审计或写安全代码都有帮助。还有一点值得留个心眼凡是会拿到外来数据做反序列化的地方都应该使用白名单机制限制允许的类名或者改用JSON等格式从根本上降低风险。CTF里我们研究怎么打穿实际工程里要想的是怎么防住。安全研究的两头都很重要。回到这道题本身我最终拿到flag的时候回头看整个利用链只用了两个类、一个析构函数、一个字符串转换方法、一条文件读取操作。新生赛ez的含义确实名不虚传但恰恰是这种简单题目把反序列化里最核心的思路完整呈现了出来。弄清这条链在代码里是怎么走通的后面遇到任何反序列化题你都会有一种原来是变了个壳的通透感。