资讯动态

PHP反序列化与文件包含漏洞组合利用详解

发布时间:2026/10/2 16:01:44 来源:尧图企业网站定制
1. 项目概述一道经典PHP反序列化文件包含的CTF实战题ZJCTF 2019 的这道题叫“NiZhuanSiWei”直译是“逆转换四维”名字带点玄学色彩但实际考察的是非常扎实的Web安全基本功——PHP反序列化漏洞与本地文件包含LFI的组合利用链。我在带新人做CTF训练时这道题几乎是必讲的入门级“连招教学案例”它不靠冷门特性堆砌难度而是把两个基础漏洞如何环环相扣、层层递进地打出效果拆解得清清楚楚。核心关键词就三个ZJCTF、web、文件包含漏洞、php、serialize——没有花哨的新框架、没有复杂的加密算法就是原生PHP环境里用最朴素的unserialize()和include()把一串看似无害的字符串最终变成服务器上任意代码执行的钥匙。这道题适合三类人重点复现一是刚学完PHP面向对象和魔术方法的新手能亲手验证__wakeup()和__destruct()的真实触发时机二是正在准备CTF比赛的选手需要理解“如何从LFI升级到RCE”的标准路径三是做Web开发的工程师它像一面镜子照出unserialize()在未校验输入时有多危险。我当年第一次解出来时是在一个深夜调试var_dump()输出的序列化字符串盯着O:4:Test:1:{s:4:file;s:10:index.php;}这行字看了十分钟突然意识到如果我能控制这个file字段再让它被include()进去……后面的事就是顺理成章的“读源码→找可控点→构造POP链→打穿服务器”。整个过程不需要任何第三方扩展纯PHP内置函数就能完成正因如此它才成为检验基本功的黄金标尺。2. 题目整体设计与思路拆解为什么是“反序列化LFI”而不是其他组合2.1 题目架构的底层逻辑三层防御与一层突破口这道题的Web服务端结构非常典型是一个精简版的PHP MVC雏形前端路由接收参数后端控制器处理逻辑视图层负责渲染。关键在于它把用户可控输入和服务端敏感操作之间用了一条看似安全、实则脆弱的链条串联起来。我们先看题目公开的源码片段这是解题者通过常规信息收集能拿到的部分// index.php class Test { public $file index.php; public function __construct() { if (isset($_GET[file])) { $this-file $_GET[file]; } } public function __wakeup() { if (preg_match(/flag|php|data|input|zip|bzip2|phar/i, $this-file)) { $this-file index.php; } } public function __destruct() { include($this-file); } } if (isset($_GET[a])) { unserialize($_GET[a]); }这段代码的设计意图很清晰想让Test类的对象在反序列化后自动加载指定文件。但作者加了两道“保险”——__wakeup()里的正则过滤以及include()前的路径限制。问题来了为什么这两道保险最后都失效了答案藏在PHP反序列化机制的细节里。__wakeup()的触发前提是反序列化过程中对象属性数量与定义数量一致而PHP 7.0之后如果反序列化字符串中声明的属性数比实际类定义的多__wakeup()会被跳过。这就是第一层突破口构造一个属性数溢出的序列化字符串绕过__wakeup()的过滤逻辑。第二层更隐蔽include()函数本身支持多种协议封装器wrapper比如php://filter。这个协议不读取文件内容而是对流进行过滤转换其中convert.base64-encode能将任意文件内容转为base64编码。这意味着即使$this-file被限制不能含php字样我们仍可传入php://filter/readconvert.base64-encode/resourceflag.php——它既不含php子串绕过正则又能读取flag文件。这种“协议层面的绕过”正是LFI升级为任意文件读取的关键跳板。2.2 为什么选__destruct()而非__invoke()或__call()在PHP反序列化POP链构建中选择哪个魔术方法作为“终点”即最终触发危险操作的方法至关重要。__destruct()之所以成为本题首选根本原因在于它的触发确定性。只要对象被销毁比如脚本执行结束、变量被unset、内存回收__destruct()就必然执行无需任何额外调用条件。对比之下__invoke()要求对象被当作函数调用$obj()__call()要求调用不存在的方法$obj-nonexistent()这些在CTF题目中往往缺乏触发上下文。而本题的include($this-file)直接放在__destruct()里等于给攻击者发了一张“免检通行证”你只要让这个对象成功反序列化出来剩下的事PHP引擎会自动帮你做完。更值得玩味的是__wakeup()的“双刃剑”属性。很多初学者会误以为它是反序列化的“第一道门”只要绕过它就万事大吉。但本题恰恰利用了它的局限性——当反序列化字符串被恶意篡改如增加属性数__wakeup()不仅不执行还会让对象处于一种“半初始化”状态此时$this-file的值完全由攻击者控制连默认值index.php都不会被赋上。这就像一把锁你以为撬开锁芯就进了门结果发现门根本没上锁只是虚掩着。这种设计不是疏忽而是刻意引导解题者去思考PHP底层机制的边界条件。2.3 文件包含漏洞的“降维打击”从读文件到执行代码很多人把LFILocal File Inclusion简单理解为“只能读配置文件”这是巨大误区。在PHP环境中LFI的真正威力在于它能与其他漏洞组合形成RCERemote Code Execution。本题的路径非常经典LFI → 读取.php源码 → 发现unserialize()的可控点 → 构造反序列化载荷 → 利用__destruct()中的include()→ 再次触发LFI。这个循环的本质是把“文件读取”能力转化成了“代码执行”的权限。具体到本题include()的参数$this-file是完全可控的那么除了读flag.php还能做什么答案是包含PHP伪协议生成的恶意代码。比如data://text/plain,?php system(cat /flag);?或者更隐蔽的php://input配合POST数据。但本题作者封死了data和input协议正则里明确写了所以必须另辟蹊径。这时php://filter的价值就凸显了——它不执行代码只做编码转换因此不在黑名单之列。我们用它读取index.php源码确认unserialize()的调用位置读取class.php看清Test类的完整定义最终读取flag.php拿到flag。整个过程像外科手术一样精准没有暴力猜解全是基于对PHP协议和反序列化机制的深度理解。3. 核心细节解析与实操要点从字符串到flag的每一步推演3.1 序列化字符串的构造原理为什么是O:4:Test:1:{s:4:file;s:10:index.php;}PHP序列化格式有严格语法O:4:Test:1:{s:4:file;s:10:index.php;}这串字符不是随便拼的每个符号都有明确含义O表示这是一个对象Object4:Test表示类名长度为4类名为Test1表示该对象有1个属性{...}内是属性键值对s:4:file是字符串类型长度4值为files:10:index.php同理长度10值为index.php这个格式必须精确否则unserialize()会直接报错返回false。我当年第一次手写时在index.php的长度上栽过跟头数了两遍都是10结果还是失败。后来才发现index.php确实是10个字符但序列化字符串里s:10:的10必须和实际字符串字节数完全一致而中文或特殊字符会导致字节数≠字符数。本题全是ASCII所以没问题但这个细节必须刻进本能——序列化是字节级操作不是字符级。构造绕过__wakeup()的载荷关键在修改属性数。原类定义只有1个属性$file所以正常序列化是:1:。我们把它改成:2:PHP解析时发现属性数对不上就会跳过__wakeup()。最终载荷长这样O:4:Test:2:{s:4:file;s:37:php://filter/readconvert.base64-encode/resourceflag.php;}。注意这里file的值不再是index.php而是精心构造的php://filter协议地址。37是后面整个字符串的字节数必须重新计算——php://filter/readconvert.base64-encode/resourceflag.php共37个字符没错。3.2php://filter协议的实战应用不只是读文件更是“透视眼”php://filter是PHP提供的一个流包装器它的核心能力是对流数据进行过滤转换。在LFI场景下最常用的是convert.base64-encode因为它能把二进制文件如图片、PDF或PHP源码无损转为可打印的ASCII字符串避免乱码。但它的价值远不止于此。比如我们可以用它来探测文件是否存在如果php://filter/readconvert.base64-encode/resourcenonexistent.php返回空说明文件不存在如果返回一串base64则存在。这比盲注高效得多。另一个常被忽略的技巧是string.tolower或string.toupper过滤器。假设目标站对flag做了大小写过滤/flag.php被拦但/FLAG.PHP可能漏网。这时用php://filter/readstring.tolower/resourceFLAG.PHP就能把大写转小写再读取相当于一次“预处理”。本题虽未用到但在真实渗透中屡试不爽。回到本题convert.base64-encode的妙处在于它把flag.php的内容转成base64后include()函数依然能正常加载——因为PHP会先解码base64再执行其中的PHP代码。不过本题flag是明文字符串所以直接base64解码就能看到。3.3 正则绕过的“时间差”陷阱preg_match的贪婪匹配与回溯题目中__wakeup()的过滤正则/flag|php|data|input|zip|bzip2|phar/i看似严密实则暗藏玄机。关键在于preg_match()的匹配机制它默认是贪婪匹配且会进行回溯backtracking。比如当我们传入phpffff时正则引擎会先尝试匹配php成功但如果传入phtp它会先匹配p然后发现h不匹配回溯重试最终不匹配。但本题的绕过不依赖这个而是利用了php://filter的协议特性——php://中的php是协议名filter是子协议中间的://是分隔符。正则引擎按字符串逐字扫描php://filter里确实含php但作者可能没料到php://filter作为一个整体其语义与php文件后缀完全不同。更深层的绕过思路是URL编码。比如把php编码为%70%68%70但本题的$_GET参数会被PHP自动URL解码所以传%70%68%70://filter...到__wakeup()里时已变回php://filter...正则依然会匹配。真正的出路是协议本身——php://filter是PHP内置协议无法被禁用只要allow_url_includeOn本题环境默认开启它就永远有效。这提醒我们安全防护不能只靠字符串过滤必须理解协议层的语义。4. 实操过程与核心环节实现手把手复现从靶机到flag4.1 环境搭建与信息侦察先让靶机“开口说话”要复现这道题第一步不是急着写payload而是搭一个一模一样的环境。我用Docker快速拉起一个PHP 7.2的容器ZJCTF 2019举办时的主流版本docker run -d -p 8080:80 --name zjctf-php -v $(pwd)/web:/var/www/html php:7.2-apache把题目提供的index.php、class.php等文件放到web目录下。启动后访问http://localhost:8080/?aO:4:Test:1:{s:4:file;s:10:index.php;}页面空白——说明unserialize()执行了但include(index.php)导致循环加载Apache报500错误。这是好现象证明反序列化链通了。接下来是信息侦察。先试试基础LFIhttp://localhost:8080/?file../../../../etc/passwd返回404说明?file参数没被直接include印证了题目逻辑——必须走unserialize()路径。再试?aO:4:Test:1:{s:4:file;s:12:/etc/passwd;}依然500因为/etc/passwd不是PHP文件include()解析失败。这时该祭出php://filter了?aO:4:Test:1:{s:4:file;s:48:php://filter/readconvert.base64-encode/resourceindex.php;}。页面返回一串base64解码后正是index.php源码。这步确认了php://filter可用且allow_url_include是开启的。4.2 绕过__wakeup()构造属性数溢出的载荷现在目标明确绕过__wakeup()让$this-file的值不受正则影响。按前面分析把属性数1改成2。但光改数字不够还得补一个“假属性”否则PHP解析会报错。怎么补最简单的是加一个不存在的属性比如xO:4:Test:2:{s:4:file;s:37:php://filter/readconvert.base64-encode/resourceflag.php;s:1:x;s:1:1;}这里s:1:x;s:1:1;是新增的属性x是属性名1是值。总属性数变成2__wakeup()被跳过。但要注意s:37:的37必须是php://filter/...字符串的实际字节数我用Python快速验证 len(php://filter/readconvert.base64-encode/resourceflag.php) 37确认无误。把这个payload URL编码后发请求?aO%3A4%3A%22Test%22%3A2%3A%7Bs%3A4%3A%22file%22%3Bs%3A37%3A%22php%3A%2F%2Ffilter%2Fread%3Dconvert.base64-encode%2Fresource%3Dflag.php%22%3Bs%3A1%3A%22x%22%3Bs%3A1%3A%221%22%3B%7D。页面返回base64字符串解码后就是flag内容。整个过程不到5分钟但每一步都踩在PHP机制的脉搏上。4.3 源码审计与POP链延伸如果题目更难下一步怎么走本题的POP链止于__destruct()但真实CTF中常需更长的链。比如如果Test类没有include()而是有个$handler属性且__destruct()里调用$this-handler-doSomething()我们就得找另一个类其doSomething()方法能触发危险操作。这时就要翻PHP内置类比如SimpleXMLElement的asXML()方法可触发__toString()而Exception类的getMessage()可触发__toString()。这种“类A调用类B类B调用类C”的链式调用就是POPProperty-Oriented Programming。本题虽短但已涵盖POP核心思想找入口unserialize、找出口__destruct、找中间跳板可控属性。我带学员时会让他们手动画出调用图unserialize()→Test::__wakeup()被跳过→Test::__destruct()→include($this-file)→php://filter→flag.php。这张图比任何文字描述都直观。如果题目增加难度比如flag.php被删但/tmp/flag存在我们就可以用phar://协议——先上传一个恶意phar包再用phar:///tmp/malicious.phar触发反序列化形成“反序列化二次利用”。5. 常见问题与排查技巧实录那些年踩过的坑和省下的时间5.1 问题速查表从500错误到flag失之交臂现象可能原因排查命令/技巧解决方案访问payload返回空白页无报错display_errors关闭或error_reporting设为0在index.php开头加ini_set(display_errors, 1); error_reporting(E_ALL);开启错误显示定位unserialize()是否执行返回Notice: unserialize(): Error at offset X of Y bytes序列化字符串语法错误如长度不匹配、括号不闭合用在线PHP序列化工具校验或Pythonpickle.loads()模拟解析逐字符核对s:长度:值用strlen()确认字节数php://filter返回空或404allow_url_include被禁用或resource路径错误?aO:4:Test:1:{s:4:file;s:23:php://filter/resourceindex.php;}去掉read参数检查PHP配置确认allow_url_includeOnbase64解码后是乱码不是flagflag.php不存在或路径错误或文件为空?aO:4:Test:1:{s:4:file;s:12:flag.php;}直接include看是否报错用php://filter读/etc/passwd确认LFI基础功能5.2 独家避坑技巧老手才懂的“秒级定位法”第一个技巧用var_dump()代替echo看中间态。比如在__destruct()里加var_dump($this-file); die();能直接看到$this-file的最终值确认是否被__wakeup()修改过。我见过太多人对着payload反复修改却忘了加一行var_dump结果浪费半小时。第二个技巧URL编码要彻底别信浏览器自动编码。Chrome地址栏对{}、:等符号编码不全必须用Python的urllib.parse.quote()或在线工具全量编码。有一次我传O:4:Test...浏览器只编码了没编码:导致PHP解析失败折腾了20分钟才发现是编码问题。第三个技巧php://filter的resource路径支持相对路径但必须以./开头。比如resource./flag.php而不是flag.php。本题用绝对路径flag.php可行是因为include()默认在当前目录查找但显式写./更稳妥避免include_path干扰。5.3 真实渗透中的延伸思考这道题教会我的三件事第一件事永远不要相信“过滤”能解决问题。__wakeup()的正则看似挡住了php但php://filter的存在让过滤形同虚设。真正的防护是输入白名单如只允许index.php、about.php或禁用危险协议allow_url_includeOff。这道题让我养成习惯看到任何过滤第一反应是“它能被协议绕过吗”第二件事CTF和真实漏洞的区别在于“可控性”。CTF里unserialize()的参数完全由你控制真实系统中可能藏在Cookie、Session或数据库里。但原理相通——只要找到一个unserialize($_xxx)且$_xxx可被污染就是突破口。我后来在审计一个电商系统时发现$_SESSION[cart]被反序列化立刻用本题思路构造了管理员登录绕过。第三件事工具是辅助机制才是核心。Burp Suite能发请求但看不懂__wakeup()为何被跳过Gopherus能自动生成POP链但不明白为什么选SimpleXMLElement。这道题的价值是逼你亲手写一遍序列化字符串数一遍字节数debug一遍var_dump直到PHP的每个机制都刻进肌肉记忆。这才是CTF带给我的最大财富——不是flag而是对PHP底层不可动摇的理解。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑