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_include=On,而题目环境明确关闭;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.readonly=Off),既考察基础知识扎实度,又考验环境探测能力,还天然规避了“堆栈溢出”“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(),最终执行命令。
这种分层设计的好处是模块化调试:上传失败?查L1;file_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\FnStream; FnStream的__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支持:访问
?file=phar:///etc/passwd,如果返回File not found而非open_basedir restriction,说明phar://协议可用; - 确认unserialize触发:上传一个
phar,其metadata设为serialize(['test' => '123']),然后访问?file=phar:///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.readonly=On。解决方案:题目给了phpinfo()页面,Ctrl+F搜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'],让$method='system'时调用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; - 触发执行:访问
?file=phar:///var/www/html/uploads/poc.phar/anything,anything可以是任意字符串,只要路径语法正确。
关键细节:$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://...")返回false | phar.readonly=On或phar签名无效 | 检查phpinfo()中phar.readonly;用file -i poc.phar看MIME类型 | 若phar.readonly=On,换is_file()触发;若MIME错误,重用PHPPhar扩展构造 |
unserialize()触发但无回显 | GuzzleHttp类未加载或POP链失效 | get_declared_classes()搜索FnStream;在__call()里加error_log() | 确认composer autoload路径;换monolog/monolog的SyslogHandler链 |
system()执行但无输出 | disable_functions禁用了system | phpinfo()搜disable_functions;尝试shell_exec、passthru | 查disable_functions列表,选未被禁用的函数,如popen |
phar://路径解析报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()上。技巧2:
phar文件名必须含.phar后缀
有学生为绕过上传检测,把poc.phar改成poc.jpg,结果file_exists("phar://uploads/poc.jpg/xxx")失败。因为PHP解析phar://时,会检查文件后缀是否为.phar、.tar等,.jpg直接被拒。解决方案:上传时用poc.phar.jpg双后缀,但phar内部stub仍用标准格式,PHP仍能识别。技巧3:
metadata中避免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')),看是否返回uid=33(www-data)。
每一步都必须有日志证据,不能凭感觉。我在实验室墙上贴了张大纸,每调试通一步就打个勾,八步全勾完,flag自然就出来了。CTF不是玄学,是工程。
6. 后续扩展与思考:从一道题到一类问题的迁移能力
这道题的价值远不止于拿一个flag。它背后是一类通用问题:当传统文件读取路径被封死,如何利用语言特性寻找“执行即读取”的旁路?类似的思路在Java里是JNDI注入,在Python里是pickle反序列化,核心思想一脉相承。我后来带学生分析某电商后台的SSRF漏洞,发现其file_get_contents()调用点同样受open_basedir限制,但phar://协议可用,于是用同样的phar+FnStream链打了内网Redis,拿到了管理员session。
个人体会:做CTF最大的收获不是Writeup,而是建立一套“漏洞模式库”。看到
file_exists()就想到phar,看到unserialize()就想到POP链,看到open_basedir就想到协议绕过。这种肌肉记忆,是在一次次改payload、看日志、查PHP手册中长出来的。别怕犯错,我第一版payload写了17个error_log(),现在看很傻,但正是那些傻问题,让我真正懂了phar的每一个字节。