打CTF这几年,最让我印象深刻的反而不是那种需要各种骚操作组合的大题,而是那些“看起来平平无奇,实际一动手全是坑”的小题。有一次参加一个叫破阵阁的线上CTF,Web方向排了一道题,名字就很会骗人:诡异的命令执行。点开一看源码,十几行,入口就是一个eval,标准得不能再标准。第一反应当然是经典的?cmd=system("ls");,结果直接被拦。我试了编码、拼接、各种奇怪的符号,全部被一道正则挡回来。仔细读过滤规则才发现,输入里能用的字符只剩下字母、数字、括号、逗号、句点和分号,没有引号,没有管道符,甚至没有空格。看到这里我反而来精神了:这根本不是命令注入绕过题,这是一道典型的PHP无参数RCE题。这篇文章把完整的踩坑过程、最终payload和背后的原理都写清楚,希望能让卡在类似题目上的朋友少走几个小时的弯路。
1. 入口代码与字符白名单:为什么system("ls")必死无疑
1.1 题目源码与过滤规则逐行拆解
这道题给的核心代码很短,我凭记忆还原一下,关键逻辑都在:
<?php highlight_file(__FILE__); if (isset($_GET['cmd'])) { $cmd = $_GET['cmd']; if (preg_match('/[^a-zA-Z0-9(),.;]/', $cmd)) { die("Hacker!"); } if (preg_match('/system|exec|shell|pass|popen|proc|assert|eval|include|require/is', $cmd)) { die("No!"); } eval($cmd); } ?>拿到代码第一件事不是兴奋,是看懂过滤器到底在干什么。第一个正则/[^a-zA-Z0-9(),.;]/是个取反匹配,意思是:只要cmd参数里出现字母数字、圆括号、逗号、句点、分号以外的任何字符,直接终止。所以双引号、单引号、美元符号、空格、斜杠、星号、&、|、换行符这些我们平时绕WAF常用的字符,一个都用不了。
第二个正则/system|exec|shell|pass|popen|proc|assert|eval|include|require/is是关键词黑名单,把常见的命令执行类函数、代码执行类函数、文件包含类函数全都点名了。注意这里有个细节,黑名单里的eval只是封住了“在输入里再调用eval()”这条路,并不影响入口处的eval($cmd)本身。也就是说,这道题本质上是:给我一个eval执行点,但只允许我用白名单字符去构造代码。
1.2 双重拦截带来的误区:先别急着绕关键字
多数人到这里的第一反应是绕黑名单,比如大小写混用、加反斜杠、用变量拼接函数名。但仔细想一下,黑名单根本不是主要矛盾。就算system这几个字母没被正则拦死,我手里也没有引号,写不出system("ls")这种合法调用。system(ls)在PHP 7以上会直接把ls当成未定义常量处理,抛出致命错误。
这个地方就是整道题第一个“诡异”的点:题目名字叫命令执行,实际上能用的却是一个“只能由函数调用组成的PHP表达式环境”。eval会执行print_r(1);这种代码,也会执行show_source(...);这种代码,但它不允许我直接写一个字符串字面量。攻击面从“构造命令字符串”彻底变成了“寻找函数与函数的组合路径”。
所谓无参数RCE,核心就一句话:**不写任何字符串,完全靠无参函数的返回值和数组操作函数,把敏感信息从环境里“掏”出来,再交给另一个函数消费掉。**想通这一点,题目才真正开始。
2. 重新长出眼睛:用无参函数搭出信息收集链
2.1 无参函数家族的弹药盘点
既然不能写字符串,那就得找“不需要参数就能返回字符串或数组”的函数。我把解这道题用到的无参函数家族整理成了表格,后面实战中用得非常高频:
| 函数 | 返回值 | 典型用途 |
|---|---|---|
getcwd() | 当前工作目录的绝对路径字符串 | 定位目录 |
phpinfo() | 直接输出PHP配置 | 泄露路径、扩展、禁用函数 |
localeconv() | 本地化信息关联数组 | 借current()取小数点“.”,充当路径分段符 |
get_defined_vars() | 当前符号表里所有变量的数组 | 拿到GET/POST里传入的字符串 |
getallheaders() | 所有HTTP请求头组成的数组 | 把自定义请求头当作字符串仓库 |
scandir($dir) | 目录下所有文件名数组 | 列目录,拿到文件名 |
chdir($dir) | 切换当前目录 | 通过改变工作目录来保留状态 |
current()/next()/end()/prev() | 数组当前/下一个/末尾/前一个元素 | 从数组中取字符串 |
array_reverse() | 反转后的数组 | 调整数组顺序,方便取目标元素 |
show_source()/readfile()/highlight_file() | 输出文件内容 | 最终读取flag |
这些函数的名字都在白名单里,参数大部分来自其他函数调用,所以完全不需要引号。我当时在草稿纸上把这串函数写下来,瞬间就觉得有戏。
2.2 从getcwd到scandir:第一条有效payload
先拿最基础的两发payload试水。第一发:
?cmd=print_r(getcwd());页面返回了一个Linux绝对路径,比如/var/www/html,说明getcwd()能正常拿到当前目录,而且print_r能正确输出字符串。这一步的意义是确认函数调用链没被瓦掉。
第二发是列目录:
?cmd=print_r(scandir(getcwd()));这次返回了数组结构,大致长这样:
Array ( [0] => . [1] => .. [2] => flag.php [3] => index.php )到这里,“眼睛”就装回来了。当前目录下确实存在flag.php,整个题的关键变成了:怎么把这个字符串从数组里取出来,再喂给一个能输出文件内容的函数。
2.3 为什么是print_r而不是echo
这里有个小细节值得说:输出数组我优先选了print_r而不是echo。echo是语言结构,不是函数,直接echo一个数组在PHP里会触发“Array to string conversion”的警告,输出只是毫无意义的Array。print_r专门用来打印数组结构,层次分明;var_dump也行,但输出格式太啰嗦,还会顺带打印字符串长度和类型。在payload里越短越不容易触发长度或字符限制,所以print_r是性价比最高的选择。
另外我也顺手记下一个经典招数:current(localeconv())无参数就能返回小数点的字符串“.”,在没有引号的情况下,这就是一个现成的“路径符号”。后面在很多场景里可以用它代替目录里的.,甚至和chdir配合构造相对路径。不过这道题临时还没用到,因为scandir(getcwd())已经给出了完整的文件名。
3. 临门一脚:从文件列表里把flag“拿”出来
3.1 为什么不能直接show_source('flag.php')
理论上现在离flag只有一步:show_source可以读取文件并高亮输出,问题是没有引号,写不出'flag.php'这个字符串。有人会想,那用show_source(flag.php)行不行?不行,flag.php会被解析成常量,PHP7以上直接致命错误。未定义常量这道坎,绕不过去。
所以只能换个思路:scandir(getcwd())返回的数组里正好有flag.php这个字符串,我只需要让某个函数把它从数组里取出来。这就要用到数组指针操作函数。
3.2 数组反转payload的诞生
最开始我写的是:
?cmd=show_source(next(scandir(getcwd())));意思是让next()从数组里取出下一个元素,作为文件名传给show_source。结果页面没有flag,只有一段目录相关的警告。原因很简单:scandir(getcwd())返回的数组前两个元素是.和..,next()从当前指针位置往后移动返回的是第二个元素,也就是..。show_source("..")自然读不了任何文件。
当时我第一反应是写next(next(...)),立刻意识到不行。函数每次执行scandir(getcwd())都会生成一个全新数组,数组指针也重新归位,根本不存在“跨调用连续移动”这回事。而且next()的返回值是字符串,不是一个数组,没法继续往下套。
正确解法是借用array_reverse把数组倒过来。原始数组如果是:
[0] => . [1] => .. [2] => flag.php [3] => index.php反转后变成:
[0] => index.php [1] => flag.php [2] => .. [3] => .此时next()取第二个元素,恰好就是flag.php。最终payload:
?cmd=show_source(next(array_reverse(scandir(getcwd()))));从内向外拆开看,这条链很清楚:
getcwd()取当前目录绝对路径scandir()把目录变成文件名数组array_reverse()反转数组,把目标文件挪到第二位next()移动数组指针并取出这个文件名的字符串show_source()读取文件并语法高亮输出
页面直接打出了<?php flag{...},flag到手。
这里有一个特别容易翻车的数组指针细节:新数组的内部指针初始位于第一个元素上,next()会先把指针向后移动一位,再返回新位置的值,所以它返回的其实是反转后数组的第二个元素,不是第一个。我第一次用current去取,结果拿到的永远是index.php,折腾了几分钟才反应过来。写这种链式payload的时候,脑子里一定要清楚指针移动的时机。
3.3 目录切换与更通用的读法
如果目标文件不在当前目录,这套组合拳还能升级。思路是利用chdir改变当前工作目录,因为getcwd()每次都会读取最新的工作目录,状态可以被后续语句保留。比如想先切到上级目录再读文件,可以这样:
?cmd=chdir(next(scandir(getcwd())));show_source(next(array_reverse(scandir(getcwd()))));前半段next(scandir(getcwd()))取到目录列表第二个元素,正常情况下是..,chdir("..")成功切到上级;后半段重新列目录,再走一遍array_reverse加next的套路,读上级目录里的目标文件。
如果目录层级复杂,还可以先用一条print_r(scandir(getcwd()));观察当前目录结构,再根据文件名排位选择current、next、end、prev来精确定位。核心方法论就一句话:**先侦察,后精确打击。**不要指望一发payload直接蒙中,目录列表输出一次又不亏。
4. 诡异感从哪来:eval、白名单与函数原语的博弈
4.1 过滤掉的明明是“危险函数”,为什么还能读文件
讲到这,很多人会问:为什么黑名单已经把system、exec、include都禁了,文件还是被读走了?因为PHP的函数设计是组合式的,危险并不集中在某一个函数身上。show_source正常用法是给开发者展示源码,scandir正常用法是列目录,array_reverse和next更是一点攻击性都没有。可一旦它们被串联在eval之后,就构成了一条完整的“列目录-取文件名-读文件”链路。
黑名单只能挡“看起来危险”的那几个名字,却挡不住函数与函数之间互相配合产生的杀伤力。真正的漏洞根源是eval本身,而不是哪个具体函数。
4.2 如果出题人再收紧两手,还有哪些后门
看穿这一点之后,我开始想一个问题:如果出题人把show_source、readfile、highlight_file也加进黑名单,是不是就无解了?其实还有很多后门。
第一是phpinfo()。它自带输出能力,能直接泄露当前路径、已加载模块、disable_functions列表,为后续攻击提供地图。
第二是get_defined_vars()。请求里带的所有变量都会进符号表。比如我在GET后面多加一个参数?x=/flag,再执行print_r(get_defined_vars());,就能看到x变量的值。配合current这类数组函数,可以把/flag这个字符串从符号表里掏出来,继续传给文件读取函数。这等于绕开了“没有引号”的限制。
第三是getallheaders()。HTTP请求头也是数组,自定义一个X-File: /flag,同样可以把这个头当成字符串仓库,再交给其他函数消费。
所以就算文件读取函数被禁得干干净净,只要还能拿信息、还能取字符串,后面还可以考虑日志投毒加include、pearcmd、glibc相关的那几条比较深的路线,但那就超出这道题的范畴了。我在文章里只把思路点到,真有需要可以顺着这条线继续深挖。
4.3 从题目角度重新理解“命令执行”四个字
复盘完整场,这道题真正诡异的地方在于:它明明叫“命令执行”,实际考的却是“纯函数调用图”。过滤规则并没有区分“命令”和“数据”,它只是粗暴地把输入丢给eval,再试图用正则保住几个危险函数。可在PHP里,任何能返回字符串的无参函数和任何能消费字符串的函数,都可能被编排成一条可利用的链。
用黑名单正则保护eval,本质上就是不可靠的。白名单正则稍微好一点,但只要它放行了“函数调用”这种结构,攻击者依然能用合法的字符组合出任意函数链。这也是为什么现在很多安全规范都强调:尽量不要在业务代码里用eval,非用不可时也要限制可执行代码的形态,而不是单纯拉黑几个函数名。
我个人觉得,这道题与其说考命令注入绕过,不如说考“对PHP函数组合的想象力”。当你把每个函数当成积木,而不是总想着怎么补全一句system命令的时候,题目的诡异感就会自动消失。
5. 遇到“看着像命令执行”的题目,通用排查路线与避坑清单
5.1 四步解题路线
以后再碰到这类题目,我建议按下面四步走,能少浪费很多时间。
第一步,先读过滤规则,不要急着打payload。确认是黑名单还是白名单,明确哪些字符、哪些关键字是活的,这直接决定整个攻击面的形状。
第二步,判断回显条件。有直接回显最省事;无回显就想延时判断,比如sleep、ping外带,或者把命令执行结果写到web目录下的静态文件里再访问。很多人卡在无回显上,其实不是过滤多强,而是输出通道没构建好。
第三步,在存活函数里找“侦察类”和“消费类”函数。侦察类负责拿信息,比如phpinfo、getcwd、scandir、getenv、get_defined_vars、getallheaders;消费类负责把信息变现,比如show_source、readfile、highlight_file。如果system、exec这些命令执行函数还活着,那自然是最高优先级。
第四步,组装最短链路:取字符串、传参、输出。像调试程序一样逐步推进,先列目录看结构,再决定用current还是next,不要指望一发入魂。
5.2 常见过滤规则与绕过思路对照表
我把一些常见过滤规则和对应的绕过思路整理成了一张表,方便应急查阅:
| 场景 | 典型过滤 | 常用绕过思路 |
|---|---|---|
| 过滤空格 | 空格被删 | $IFS、${IFS}、Tab符%09、重定向符</ |
| 过滤命令分隔符 | ;、&&、` | `被删 |
| 过滤关键字 | cat、flag被删 | c""at、c\at、通配符c*、head/tail替代 |
| 过滤引号 | 无法构造字符串 | 从数组函数、请求头、环境变量中取字符串 |
| 无回显 | 命令结果不输出 | 写入web目录再访问、延时判断、DNS外带 |
| 仅允许字母括号 | 所有符号被限制 | PHP无参数RCE函数链:scandir+array_reverse+show_source |
5.3 几个容易翻车的细节
最后列几个我自己踩过、也看别人反复踩的坑。
数组指针不跨调用保留。不要写next(next(scandir(...)))这种依赖指针连续移动的payload,你每次调用scandir拿到的都是一个全新数组,指针状态是重新初始化的。要保留状态,就用chdir改变工作目录,或者用变量符号表存字符串。
分号很重要。eval里执行多条语句必须用分号隔开,这类题目过滤规则通常会留分号,因为出题人也知道PHP代码不能没有分号。万一哪天真遇到分号也被过滤的题,可以在payload末尾加一个?>试一下,PHP里最后一条语句可以省略末尾分号。
PHP7以上的未定义常量是致命错误。system(ls)这种写法现在直接报错,不会再被当成字符串处理,别在这上面浪费时间。
show_source读目录会报错。如果你发现payload输出的是目录相关的警告而不是文件内容,八成是数组指针取错了,取回来的是.或者..,可以先打一发print_r(scandir(getcwd()));确认目录每个下标对应的名字,再调整取元素的函数。
最后聊点个人体会。我在最初尝试的时候就一直卡在“怎么补一个引号”“怎么绕正则”上,后来发现方向整个就是错的。题目不是要你去补齐命令,而是要你在函数之间建立一条通路。从那以后我养成了个习惯,遇到这类只允许字母括号的代码执行场景,第一件事就是过一遍无参函数的弹药库:getcwd、scandir、localeconv、current、next、end、array_reverse、show_source、get_defined_vars。这几个名字基本已经成了肌肉记忆。
这些经验放到防守侧也完全成立:只要应用里还在用eval,入参还能影响函数调用,那么禁掉几个命令执行函数是远远不够的。文件读取函数、目录遍历函数、环境信息函数,互相组合起来一样可以致命。把这条链路理解透了,再看到“诡异的命令执行”这类题,你就会觉得它其实一点也不诡异——只是需要我们跳出“补全命令”的惯性思维,站到函数组合的高度去看问题。