
第一次见到[极客大挑战 2019]PHP 这道题是在BUUCTF的Web分类榜单上。题目没有给任何附件只有一个靶机地址点进去之后页面正中间是一只猫习惯性按F12看了一眼控制台干净得让人发慌。但恰恰是这种“看起来什么都没有”的题藏着Web方向最经典的一条完整链路备份文件泄露 - 源码审计 - PHP反序列化 - 绕过魔术方法 - 拿到flag。这道题非常适合刚接触CTF Web的朋友尤其是想搞懂PHP反序列化到底在打什么的人。它不涉及复杂的POP链构造也没有花里胡哨的绕过就是把反序列化最核心的“魔术方法触发逻辑”摆在台面上让你看。只要能完整复现这道题的流程后续再看CVE级反序列化漏洞、框架RCE链都会轻松很多。下面我按实际做题的顺序把每一步从“为什么这样做”到“执行后看到什么”都讲清楚。这不是简单的答案粘贴而是踩过坑之后才梳理出来的完整思路。1. 题目初见一张猫图片和一个不寻常的提示1.1 靶场打开后第一眼能看到什么访问题目给的地址页面很简单背景是一只猫下面写着一些站点说明整体观感就是一个普通到不能再普通的PHP页面。这个时候不要急着到处乱点先做两件事看页面源码看响应包。按下F12查看HTML源码会发现页面结构里有一个链接指向index.php这本身很寻常。但如果你继续往下看或者直接访问index.php页面会带着一个?select参数跳转或者说渲染出“欢迎来到我的猫咖”之类的字样。这个select参数出现得很突兀一个正常展示猫咪图片的页面为什么要用select这种名字这就是第一个信号参数可控。另一个信号来自页脚或源码注释。原题里藏着一句类似“备份文件”或者“你不试试备份吗”的提示具体措辞不同平台略有差异但意图很清楚网站根目录下存在备份文件。这类提示在CTF里出现就等于直接告诉你去翻index.php.bak。1.2 顺着提示往前走页面里那条不太显眼的线索很多新手卡在第一步是因为只盯着主页面看忽略了?select这个参数的存在意义。它没有出现在表单里也不是导航链接只有看源码才能发现。真实Web渗透里这叫“隐藏参数”攻击者通常会用参数爆破工具去发现但CTF题目通常更仁慈直接把参数名写在URL跳转里。所以第一步的结论是这道题的入口是一个可控的GET参数select后面的工作全部围绕它展开。你不需要一开始就理解它为什么会造成漏洞只需要记住——凡是在业务逻辑里看不出必要性的参数往往就是出题人留给你的门。2. 备份文件泄露源码是怎么被翻出来的2.1 常见的备份文件命名习惯拿到入口线索后要做的第一件事就是尝试读取源码。index.php本身会被服务器解析成PHP执行结果直接访问只能看到宠物图片和文字。但开发者在编辑代码时经常会在同目录留下编辑器自动生成的备份文件这些文件由于后缀不是.php或者文件名特殊服务器不会当作PHP执行而是直接以纯文本方式暴露出来。这道题里最典型的备份文件是index.php.bak。除此之外CTF题目里常出现的备份文件后缀还有这些文件特征来源说明index.php.bak手动备份 / 编辑器备份最经典许多题目直接用它index.php~vim备份文件vim编辑时留下的临时副本index.php.swpvim交换文件vim异常退出时会残留index.php.swovim交换文件另一种交换文件命名.index.php.swpvim隐藏交换文件前面带点更隐蔽index.php.txt手动改名有些开发者会改成txt备份www.zip/web.zip/backup.zip整站打包备份压缩包泄露能拖下整个站点这些命名没有固定规律实战里需要逐个尝试或者用目录扫描工具批量探测。CTF环境下时间有限直接构造URL访问index.php.bak通常是最快的验证方式。2.2 下载 index.php.bak 并还原源码访问http://靶机地址/index.php.bak浏览器会直接下载一个文件或者把内容明文显示在页面上。用任意文本编辑器打开能看到完整的PHP源码。BUUCTF这个靶场上最常见的源码版本大致长这样?php error_reporting(0); include flag.php; class Flag{ public $name; public $var; public function __construct(){ $this-name cat; $this-var index.php; } public function __destruct(){ if($this-name cat){ echo file_get_contents($this-var); } } public function __wakeup(){ $this-name dog; } } if(isset($_GET[select])){ $a $_GET[select]; $b unserialize($a); } ?不同平台的源码可能有一两个字面上的差异但核心逻辑基本一致定义了Flag类包含name和var两个公有属性入口处接收GET参数select直接交给unserialize()进行反序列化。拿到这段源码后这道题的性质就清楚了——unserialize()的入参完全可控对象销毁时会自动触发__destruct()而__destruct()里存在file_get_contents()这个危险函数。整套利用链都已经给你搭好了只差最后一脚。2.3 为什么备份文件泄露在CTF里这么常见从真实业务角度说备份文件泄露是典型的开发者安全意识不足。很多人在服务器上编辑代码前会先复制一份.bak或者用vim编辑时没注意异常退出于是交换文件被留在Web根目录。Nginx和Apache默认不会解析.bak后缀所以这些文件会被当作静态资源原样返回源码就裸奔了。CTF出题人特别爱用这个点因为它成本低、普及度高而且能自然引出“拿到源码后做什么”。Web渗透测试里拿到源码等于拿到了攻击面的地图后续所有的参数分析、逻辑漏洞挖掘、反序列化利用都建立在对源码的理解之上。所以这道题表面考的是PHP反序列化实则是把“信息收集 - 源码审计 - 漏洞利用”这条实战链路完整演了一遍。3. 逐行审计这段源码的杀机藏在哪3.1 三个魔法方法之间的关系看不懂反序列化题大概率是没弄明白魔术方法的触发时机。PHP里有几个带双下划线的特殊方法它们不需要你手动调用而是在某个特定事件发生时自动执行。这道题一口气出现了三个__construct()在new创建对象时自动调用。__wakeup()在unserialize()反序列化时自动调用。__destruct()在对象被销毁时自动调用。题目代码里__construct()会把属性设为name cat、var index.php但这只是默认值我们通过反序列化完全可以覆盖成别的值。关键矛盾在__wakeup()和__destruct()之间__destruct()里要$this-name cat才会执行file_get_contents($this-var)而__wakeup()会在反序列化刚结束时强制把$this-name改成dog。换句话说正常走反序列化流程的话name一进来就被换成dog后面的强比较直接失败什么都读不到。这就像你去自动售货机买水投币之后机器先给你的饮料自动换成了矿泉水而你手里拿着的是买雪碧的钱。想要拿到你想要的东西就得想办法让售货机“跳过换货那一步”。3.2 输入点确认$_GET[select] 可控源码最后几行是整个漏洞链的触发器if(isset($_GET[select])){ $a $_GET[select]; $b unserialize($a); }没有任何过滤、没有长度限制、没有黑名单select参数原封不动地进入了unserialize()。这就意味着我们可以完全按照自己的意愿构造一个序列化字符串让它反序列化出一个Flag对象属性值就是我们指定的值。如果这三个对象有一个是private或protected序列化字符串里会带上不可见字符构造起来很头疼。但题目里name和var都是public序列化字符串的格式非常干净可以直接手写。3.3 本地复现环境怎么搭看代码始终有点抽象建议本地起个环境亲手跑一遍。如果你机器上装了PHP直接在题目源码目录执行php -S 0.0.0.0:8080然后访问http://127.0.0.1:8080/?select...就能调试。没有PHP环境的用Docker拉一个PHP5.6的镜像因为后面要用的__wakeup绕过漏洞在老版本PHP上才生效docker run -d -p 8080:80 php:5.6-apache再把源码挂载进去改动代码后不用重启容器刷新页面就生效。本地调试时可以临时把error_reporting(0)改成error_reporting(E_ALL)一旦反序列化失败页面会直接给你高亮警告信息比在生产靶机上瞎猜效率高得多。4. 构造 Payload利用 __wakeup 绕过反序列化拦截4.1 手写序列化字符串格式逐字节拆解反序列化的核心就是提供一段合法的序列化数据。先看正常序列化一个Flag对象会生成什么O:4:Flag:2:{s:4:name;s:3:cat;s:3:var;s:8:flag.php;}拆开看每个部分片段含义O:4:Flag这是一个对象(Object)类名长度为4类名是Flag2对象有2个属性s:4:name第一个属性名是长度为4的字符串names:3:cat第一个属性值是长度为3的字符串cats:3:var第二个属性名是长度为3的字符串vars:8:flag.php第二个属性值是长度为8的字符串flag.php属性名和属性值的长度都必须准确多一个字符少一个字符unserialize()直接报错返回false。这也是手工构造payload时最容易翻车的地方。目标很明确让反序列化得到的对象$name保持为字符串cat$var指向flag.php。这样对象销毁时__destruct()里的判断通过就会把flag.php的内容原样打印出来。4.2 CVE-2016-7124把属性个数改大就能跳过 __wakeup现在唯一的障碍是__wakeup()会把name改成dog。如何跳过它这里用到的知识是CVE-2016-7124一个经典的反序列化漏洞。漏洞描述很简单当序列化字符串中表示对象属性个数的值大于对象真实的属性个数时__wakeup()将不会被调用。也就是说Flag类只有2个属性但我把O:4:Flag:2:{...}里的2改成3甚至改成100PHP在反序列化时就会因为“属性数量对不上”而跳过__wakeup()的执行。这是PHP5.6.25之前、PHP7.0.10之前存在的逻辑缺陷官方后来已修复所以这个绕过方法在今天的标准PHP环境里已经失效了。但BUUCTF这道题为了考点保留用的就是老版本PHP环境。最终payloadO:4:Flag:3:{s:4:name;s:3:cat;s:3:var;s:8:flag.php;}得到序列化字符串后因为要放在GET参数里必须做URL编码。表达式里的双引号、冒号、花括号、分号在URL里都是特殊字符直接用会被解析出问题。编码后是O%3A4%3A%22Flag%22%3A3%3A%7Bs%3A4%3A%22name%22%3Bs%3A3%3A%22cat%22%3Bs%3A3%3A%22var%22%3Bs%3A8%3A%22flag.php%22%3B%7D4.3 发送 Payload 并拿到 flag把payload拼到URL后面完整请求如下http://靶机地址/?selectO%3A4%3A%22Flag%22%3A3%3A%7Bs%3A4%3A%22name%22%3Bs%3A3%3A%22cat%22%3Bs%3A3%3A%22var%22%3Bs%3A8%3A%22flag.php%22%3B%7D直接访问页面会输出flag.php文件的内容。如果flag在一个PHP变量里比如源码里写了$flag flag{...}那么file_get_contents一样能读到因为它是按文件原始内容读取不会执行PHP代码读出来的就是源码明文。页面里出现类似flag{...}的字符串这道题就做完了。如果服务器把PHP代码执行的结果返回给了浏览器而flag藏在PHP注释里页面显示空白时可以换个思路把var设成伪协议O:4:Flag:3:{s:4:name;s:3:cat;s:3:var;s:57:php://filter/readconvert.base64-encode/resourceflag.php;}这样file_get_contents读取的就不再是PHP执行后的结果而是原始文件内容的base64编码把输出的base64字符串解密后就能看到完整的flag.php源码。这是文件读取类题目通用的兜底技巧不局限于这道题。4.4 如果绕过失效怎么办版本差异与伪协议兜底有朋友在自己的VPS上搭环境复现这道题发现怎么改属性个数都不生效页面一点反应都没有。原因几乎可以肯定是PHP版本过高__wakeup绕过已经被官方修复。解决办法有两个方向。一是把环境降到PHP5.6.25以下或PHP7.0.10以下再跑一遍就能看到效果。二是在高版本环境里只能放弃绕过__wakeup改找其他角度比如寻找代码里是否存在第二个反序列化入口、是否有其他的魔术方法可以利用。不过对于这道原本设定好的题目直接换环境是最省事的。在线靶场如果出现绕过不生效还有一种可能是题目被换成了修复版源码。不过BUUCTF这类主流平台为了教学目的通常保留原始漏洞环境。5. 同类“配方”复盘以后遇到反序列化题怎么下手5.1 反序列化题目的通用分析流程做完这道题再看其他PHP反序列化题会发现套路高度一致。我习惯按这个顺序来找入口哪些参数会被传入unserialize()是GET、POST、Cookie还是请求头。找类源码里有哪几个类重点关注魔术方法。找危险函数file_get_contents、eval、system、exec、include、unlink都属于高危险一旦出现在魔术方法里基本就是利用点。看属性哪些属性能够影响危险函数的参数。比如这道题里var直接就是file_get_contents的参数。找路径从可控入口到危险函数之间需要经过哪些方法能不能通过修改属性值直接触发。构造payload注意属性个数、类名、属性名长度都要对URL编码别漏。大多数简单的反序列化题都逃不出这个流程。难点通常在于入口不直接是unserialize()而是需要先触发__toString()或者__call()才能走到危险方法这就是所谓的POP链。但原理仍然是“控制对象属性利用魔术方法自动触发危险操作”。5.2 强弱比较、文件读取、敏感函数这道题顺便教的三件事这道题虽然主角是反序列化但从源码里还能学到三个独立的小知识点对后续刷题很有用。第一个是强比较和弱比较的区别。__destruct()里的判断用的是也就是强比较要求值和类型都一致所以name必须确实是字符串cat才能通过。如果出题人写的是那可利用性就完全变了——PHP的弱比较存在大量类型混淆问题比如abc 0结果为true1abc 1也为true攻击者可能用数字0就能绕过字符串判断。今后审计代码时看到和要立刻引起警觉它们往往决定了一道题有没有隐藏的弱类型绕过。第二个是file_get_contents的特性。它按原始字节读取文件内容不会因为目标是PHP文件就执行里面的代码所以它能用来读PHP源码。这是Web题目里读取源码最常用的函数之一配合php伪协议还能实现base64编码输出避免特殊字符影响页面渲染。第三个是include flag.php这种文件包含风格的引入方式。很多题目会把flag放在一个单独的PHP文件里但这个文件不会直接输出任何内容而是定义了一个变量或者什么都不做。常规页面看过去什么都没有只有通过源码审计才能确定它的存在。5.3 我踩过的坑和调试技巧最后分享几个实际做题过程中踩过的坑每一个都浪费过不少时间。第一个坑是序列化字符串里属性个数忘记改。只改name和var的值属性个数还写着2那么__wakeup()不会被跳过对象反序列化后name被改成dog__destruct()判断失败页面完全没反应。这时候的症状和“绕过不生效”一模一样所以第一个要排查的就是数字是否已经改成大于实际属性个数的值。第二个坑是URL编码不完整。有些人直接在浏览器地址栏粘贴原始payload浏览器能自动编码一部分但分号、花括号、双引号在某些环境下会被吞掉导致反序列化失败。稳妥做法是完整编码或者用Burp Suite、curl这类工具发送而不是依赖浏览器curl http://靶机地址/?selectO%3A4%3A%22Flag%22%3A3%3A%7Bs%3A4%3A%22name%22%3Bs%3A3%3A%22cat%22%3Bs%3A3%3A%22var%22%3Bs%3A8%3A%22flag.php%22%3B%7D第三个坑是本地测试时目标文件名不对。自己搭环境时随便建了一个flag.txt文件来测试结果payload里写的是flag.php页面当然什么都没有。做题时如果源码里引入了flag.php就优先猜flag.php如果猜不到可以用php://filter伪协议去读index.php先把当前目录的文件结构摸清楚。第四个坑是忽略PHP报错。题目源码里第一行就是error_reporting(0)这是为了不让报错信息干扰做题。但本地调试时保持开启报错非常有用反序列化失败、文件不存在、属性长度不对都会在页面输出提示。在线靶场没有报错信息时只能靠仔细核对payload的每一个字符来排查。这种“看起来简单但每一步都有讲究”的题恰恰是巩固基础最好的材料。反序列化作为PHP Web里的高频考点从这道题开始建立起“对象属性可控 - 魔术方法触发 - 危险函数执行”的思维模型后面再遇到复杂的高通配POP链至少不会两眼一抹黑。