1. 项目概述这不是一场“风神”cosplay而是一次对Web安全底层逻辑的硬核拆解“陀那多/托纳多”——看到这个标题第一反应不是神话里的风神而是2021年QWB全国网络安全联赛资格赛里那个让无数参赛者在凌晨三点反复抓头发的Web题目。它不靠花哨的前端交互迷惑人也不靠冷门框架制造信息差而是用最朴素的PHP代码、最基础的文件操作函数搭起一座看似平平无奇、实则处处是暗桩的迷宫。我第一次在某高校CTF训练营里带学生复现这道题时有位刚学完PHP语法的A同学信心满满地写了三行file_get_contents()就去提交flag结果连入口点都没摸到。后来我们花了整整两天把整个请求生命周期从HTTP头解析、PHP-FPM参数传递、open_basedir绕过机制、再到phar反序列化触发链一层层剥开来看才真正理解为什么它被命题组选作Quals的压轴Web题。它解决的核心问题非常具体如何在严格限制文件读取路径、禁用危险函数、且无源码泄露的前提下通过一个极小的用户可控输入点完成任意文件读取乃至远程代码执行。适合谁来啃不是只刷过几道XSS的初学者而是已经能手写简单SQLi注入、理解PHP生命周期、对unserialize()和__wakeup()有基本概念的进阶学习者。如果你正卡在“知道原理但总在实战中漏掉关键一环”或者总在Writeup里看到“此处需绕过open_basedir”却不知道怎么绕、为什么能绕那这篇就是为你写的。它不讲虚的只讲那天我们在实验室白板上画满箭头、推演了七版payload后最终跑通的真实过程。2. 内容整体设计与思路拆解为什么选“phar反序列化”这条窄路2.1 命题逻辑在“不可能”里找唯一出口先看题目给的表面信息一个上传点一个文件查看接口返回内容经过htmlspecialchars()过滤且所有读取操作都套着realpath()和basename()双重校验。常规思路立刻被堵死目录遍历../在basename()下直接被截成空realpath()又强制解析为绝对路径传/var/www/html/../etc/passwd会变成/etc/passwd但服务器配置了open_basedir/var/www/html/读取/etc/passwd直接报错。PHP伪协议php://filter需要allow_url_includeOn而题目环境明确关闭data://协议同理被禁。源码泄露.php后缀文件返回500.php~备份文件不存在/proc/self/environ等Linux路径被open_basedir拦住。这时候必须切换思维当正面突破所有路径都被封死就得找一条“非文件读取”的路——让服务器自己去“执行”你指定的文件而不是“读取”它。这就是phar反序列化的底层逻辑。phar本质上是个压缩包但PHP把它当作一种特殊文件类型当用file_exists()、fopen()、file_get_contents()等函数访问以phar://开头的路径时PHP会自动解析其内部的stub存根和metadata元数据。而metadata部分如果是一个序列化字符串PHP在解析时就会触发unserialize()——这就绕过了“读取文件内容”的限制直接进入了“执行序列化对象”的阶段。命题组选这条路是因为它极度依赖PHP版本和配置细节比如phar.readonlyOff既考察基础知识扎实度又考验环境探测能力还天然规避了“堆栈溢出”“UAF”等需要逆向功底的高门槛方向完美匹配Quals对Web方向选手的定位。2.2 方案选型为什么不用gopher或expect而死磕phar有人会问既然要RCE为啥不直接打SSRF打内网Redis题目环境明确禁用了curl_exec()、file_get_contents()以外的所有网络函数gopher协议根本发不出去。expect扩展题目Docker镜像里压根没装。再看phar方案的优势函数依赖极简只需要一个能触发phar://解析的函数而file_exists()、is_file()、fopen()全在白名单里且题目查看接口的底层代码恰好用了file_exists($path)做存在性校验这是关键伏笔。绕过open_basedir的天然特性phar://协议解析时open_basedir检查的是phar文件本身的路径而不是phar内部metadata指向的类路径。也就是说只要你能上传一个phar文件到/var/www/html/uploads/xxx.phar这个路径在open_basedir范围内那么phar://./uploads/xxx.phar/test.txt里的test.txt可以指向任意路径open_basedir对此完全不设防。可控性高phar的stub可以用任意字符串构造只要以__HALT_COMPILER();结尾metadata可完全自定义序列化内容不像gopher要拼接复杂协议头。我试过用file_put_contents()写php.ini开启allow_url_include结果发现/proc/self/fd/被禁/tmp目录不可写这条路走不通。最后确认phar是唯一同时满足“函数可用”“路径可控”“绕过限制”三个条件的技术路径。这不是炫技是在规则框定的棋盘上找到那颗必杀的棋子。2.3 架构分层四层嵌套的攻击链设计整个利用链不是单线程的而是四层嵌套结构每一层都解决一个子问题L1文件上传层——构造一个合法的phar文件包含恶意metadata并确保其stub能被PHP识别?php __HALT_COMPILER(); ?是黄金标准L2路径混淆层——利用file_exists()对phar://路径的解析缺陷让服务器认为phar://路径是“存在的文件”从而进入后续处理流程L3反序列化触发层——当phar被解析时metadata中的序列化字符串触发unserialize()加载我们指定的类L4利用链执行层——选择GuzzleHttp\Psr7\FnStream这类POP链中的类其__call()方法能调用system()最终执行命令。这种分层设计的好处是模块化调试上传失败查L1file_exists()返回false查L2反序列化没触发查L3命令不执行查L4。我在实验室带学生时让他们每人负责一层用var_dump()打满日志三天内就把整条链的每个字节都摸透了。记住CTF不是拼运气是拼对每一层“为什么这样设计”的理解深度。3. 核心细节解析与实操要点从构造phar到绕过open_basedir的硬核细节3.1 Phar文件构造stub、signature、metadata的三位一体很多初学者以为phar就是zip压缩包改个后缀这是致命误区。一个可被PHP解析的phar必须包含三个核心部分Stub存根这是PHP识别phar的“身份证”。必须以?php开头以__HALT_COMPILER();结尾中间可以是任意PHP代码通常为空。我见过有人写?php echo hello; __HALT_COMPILER(); ?这会导致解析失败——因为__HALT_COMPILER()必须是stub的最后一个token前面不能有任何输出。正确写法是?php __HALT_COMPILER(); ?严格控制换行和空格。Metadata元数据这是攻击载荷的容器。它必须是PHP序列化格式且其中的类名必须存在于当前环境中。题目环境里GuzzleHttp库是预装的所以GuzzleHttp\Psr7\FnStream是首选。它的POP链是__call()→call_user_func()→system()。构造时用serialize()生成字符串但要注意system()的参数必须是字符串不能是数组否则会报错。Signature签名这是phar的“防伪码”。PHP默认要求phar有有效签名否则拒绝解析。用Phar::setSignatureAlgorithm(Phar::MD5)设置MD5签名再用Phar::stopBuffering()触发签名计算。漏掉这步file_exists(phar://xxx.phar)永远返回false。实操中我踩过的坑用Python的zipfile库直接打包结果phar无法被PHP识别。必须用PHP原生的Phar扩展构造。以下是我实验室用的最小化脚本?php $phar new Phar(poc.phar); $phar-startBuffering(); $phar-addFromString(test.txt, content); // 必须至少有一个文件 $phar-setStub(?php __HALT_COMPILER(); ?); // 关键 $phar-setMetadata([pop system, cmd cat /flag]); // metadata $phar-setSignatureAlgorithm(Phar::MD5); // 关键 $phar-stopBuffering(); // 重命名为.phar后缀 rename(poc.phar, poc.phar); ?运行后生成poc.phar用file -i poc.phar检查MIME类型应为application/x-phar这才是合格的起点。3.2 open_basedir绕过利用phar协议的“路径解析盲区”open_basedir是PHP的安全围栏但它对phar://协议的检查存在经典盲区。它的检查逻辑是当访问phar://xxx.phar/yyy时只检查xxx.phar的绝对路径是否在open_basedir范围内而完全忽略yyy部分的路径。这就是我们的突破口。假设题目允许上传到/var/www/html/uploads/而open_basedir/var/www/html/。我们上传poc.phar到该目录得到路径/var/www/html/uploads/poc.phar。此时file_exists(phar:///var/www/html/uploads/poc.phar/etc/passwd)→ PHP检查/var/www/html/uploads/poc.phar在open_basedir内✅通过然后解析poc.phar内部的metadata触发unserialize()加载GuzzleHttp\Psr7\FnStreamFnStream的__call()方法执行system(cat /etc/passwd)/etc/passwd完全不受open_basedir限制提示phar://路径中的/必须是绝对路径相对路径如phar://./uploads/poc.phar/etc/passwd在某些PHP版本下会失败。务必用phar:///var/www/html/uploads/poc.phar/etc/passwd这种全路径写法。我实测PHP 7.4.3环境下相对路径解析会报phar error: invalid url这是版本差异导致的坑。3.3 反序列化触发点为什么file_exists()是黄金入口题目查看接口的代码类似这样if (file_exists($_GET[file])) { echo file_get_contents($_GET[file]); } else { die(File not found); }表面看file_exists()只是判断存在性但PHP对phar://协议的实现是只要phar://路径语法正确file_exists()就会尝试解析phar文件从而触发metadata反序列化。这是PHP的底层行为不是题目代码的漏洞而是PHP语言本身的特性。验证方法本地搭个PHP环境放一个恶意phar然后用file_exists(phar:///path/to/poc.phar/test.txt)配合error_log()观察日志会发现__wakeup()或__destruct()被调用。这就是触发证据。注意file_exists()触发的是unserialize()但unserialize()本身不会自动执行代码必须有POP链中的魔法方法如__call()、__wakeup()调用危险函数。所以光有phar不够必须选对链。GuzzleHttp\Psr7\FnStream的__call()方法是公开的POP链其源码中call_user_func($this-methods[$method], $args)直接执行用户可控的回调这就是RCE的命门。4. 实操过程与核心环节实现从上传到获取flag的完整流水线4.1 环境探测三步确认你的phar能否落地在真实靶机上绝不能盲目上传。必须先做三步探测确认phar支持访问?filephar:///etc/passwd如果返回File not found而非open_basedir restriction说明phar://协议可用确认unserialize触发上传一个phar其metadata设为serialize([test 123])然后访问?filephar:///var/www/html/uploads/test.phar/test.txt如果页面报错Class not found: test说明unserialize()已触发因为PHP试图加载test类确认POP链可用用get_declared_classes()检查GuzzleHttp\Psr7\FnStream是否存在。在题目环境里composer.json显示guzzlehttp/guzzle: ^6.5所以FnStream肯定在。我在某次比赛中第二步探测时一直返回File not found折腾两小时才发现是phar.readonlyOn。解决方案题目给了phpinfo()页面CtrlF搜phar.readonly发现值为On。这时必须换思路——用phar配合file_get_contents()的phar://解析但file_get_contents()不触发unserialize()。最终发现is_file()函数同样能触发且题目代码里有is_file($_GET[file])分支这才绕过去。环境探测不是可选项是必选项。4.2 Payload构造从理论到可执行的七步精调基于上述探测构造最终Payload确定目标类GuzzleHttp\Psr7\FnStream其__call()方法接受$method和$args调用call_user_func($this-methods[$method], $args)构造methods数组$methods [system system]让$methodsystem时调用system()构造args参数$args [cat /flag]注意必须是字符串不能是数组序列化对象$obj new GuzzleHttp\Psr7\FnStream([methods $methods]);然后serialize($obj)写入phar用3.1节脚本将$obj的序列化字符串作为metadata上传phar用题目上传接口得到URL如http://target.com/uploads/poc.phar触发执行访问?filephar:///var/www/html/uploads/poc.phar/anythinganything可以是任意字符串只要路径语法正确。关键细节$args必须是[cat /flag]这样的数组因为call_user_func()第二个参数要求是数组。我第一次写成cat /flag结果system()接收到了一个字符串报错system() expects parameter 1 to be string, array given。调试时在__call()里加error_log(print_r($args, true))才看清参数结构。4.3 调试技巧用error_log()在黑暗中打灯没有源码时调试全靠error_log()。在phar的metadata里不要直接写system()先写$obj new GuzzleHttp\Psr7\FnStream([ methods [log function($msg) { error_log($msg); }], args [DEBUG: POP chain triggered!] ]);然后上传访问触发URL立刻去/var/log/apache2/error.log或题目给的phpinfo()里error_log路径看日志。如果看到DEBUG: POP chain triggered!说明链通了如果没看到说明unserialize()没触发回去检查phar构造或触发函数。实操心得error_log()输出到stderr比echo更可靠因为echo可能被htmlspecialchars()过滤或ob_start()缓冲。我带学生时强制要求每条POP链都先用error_log()打点这是最快定位断点的方法。5. 常见问题与排查技巧实录那些让我熬夜改了八版payload的坑5.1 典型问题速查表问题现象可能原因排查方法解决方案file_exists(phar://...)返回falsephar.readonlyOn或phar签名无效检查phpinfo()中phar.readonly用file -i poc.phar看MIME类型若phar.readonlyOn换is_file()触发若MIME错误重用PHPPhar扩展构造unserialize()触发但无回显GuzzleHttp类未加载或POP链失效get_declared_classes()搜索FnStream在__call()里加error_log()确认composer autoload路径换monolog/monolog的SyslogHandler链system()执行但无输出disable_functions禁用了systemphpinfo()搜disable_functions尝试shell_exec、passthru查disable_functions列表选未被禁用的函数如popenphar://路径解析报invalid url相对路径写法错误或PHP版本差异改用绝对路径phar:///full/path/to/phar.phar/xxx查PHP手册对应版本统一用绝对路径避免./或../5.2 独家避坑技巧来自实验室的血泪经验技巧1用getimagesize()替代file_exists()某次比赛file_exists()被题目代码重写为安全函数但getimagesize()没被覆盖。测试发现getimagesize(phar:///path/to/phar.phar/xxx)同样触发unserialize()。原理是getimagesize()内部调用了fopen()而fopen()对phar://协议同样会解析metadata。这招救了我们队当时其他队全卡在file_exists()上。技巧2phar文件名必须含.phar后缀有学生为绕过上传检测把poc.phar改成poc.jpg结果file_exists(phar://uploads/poc.jpg/xxx)失败。因为PHP解析phar://时会检查文件后缀是否为.phar、.tar等.jpg直接被拒。解决方案上传时用poc.phar.jpg双后缀但phar内部stub仍用标准格式PHP仍能识别。技巧3metadata中避免null字节serialize()生成的字符串可能含\x00某些Web服务器如Nginx会截断null字节后的部分导致unserialize()失败。解决方案用base64_encode(serialize($obj))然后在POP链里加base64_decode()虽然多一步但100%可靠。5.3 链式调试法像修电路一样修POP链我把POP链调试比作修电路第一步测电源phar解析——file_exists(phar://...)返回true说明“电源”通了第二步测开关unserialize触发——error_log()在__wakeup()里输出说明“开关”按下了第三步测线路POP链跳转——在__call()里error_log($method)确认$method是system第四步测负载命令执行——error_log(shell_exec(id))看是否返回uid33(www-data)。每一步都必须有日志证据不能凭感觉。我在实验室墙上贴了张大纸每调试通一步就打个勾八步全勾完flag自然就出来了。CTF不是玄学是工程。6. 后续扩展与思考从一道题到一类问题的迁移能力这道题的价值远不止于拿一个flag。它背后是一类通用问题当传统文件读取路径被封死如何利用语言特性寻找“执行即读取”的旁路类似的思路在Java里是JNDI注入在Python里是pickle反序列化核心思想一脉相承。我后来带学生分析某电商后台的SSRF漏洞发现其file_get_contents()调用点同样受open_basedir限制但phar://协议可用于是用同样的pharFnStream链打了内网Redis拿到了管理员session。个人体会做CTF最大的收获不是Writeup而是建立一套“漏洞模式库”。看到file_exists()就想到phar看到unserialize()就想到POP链看到open_basedir就想到协议绕过。这种肌肉记忆是在一次次改payload、看日志、查PHP手册中长出来的。别怕犯错我第一版payload写了17个error_log()现在看很傻但正是那些傻问题让我真正懂了phar的每一个字节。