Tomcat 远程命令执行漏洞(CVE-2024-50379)

Tomcat 远程命令执行漏洞(CVE-2024-50379)

前言

中午突然看见Tomcat出漏洞了,还是RCE就想赶紧分析一把,虽然看着很眼熟(CVE-2017-12615),但是实际测试下来,还是有一个不同的,那就是我们需要绕过路径检查,下面是我分析的过程。

分析

影响版本

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环境很好弄,直接去官网下就行了,我用的是9.0.65,然后是需要修改的配置,下面将tomcat/conf/web.xml的配置修改一下

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即可

前置知识

这个漏洞其实就是CVE-2017-12615的一个路径检查绕过,本质上还是利用了DefaultServlet的doPUT方法进行文件上传

我们看一下该方法的内容

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
protected void doPut(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
if (this.readOnly) {
this.sendNotAllowed(req, resp);
} else {
String path = this.getRelativePath(req);
WebResource resource = this.resources.getResource(path);
Range range = this.parseContentRange(req, resp);
if (range != null) {
InputStream resourceInputStream = null;

try {
if (range == IGNORE) {
resourceInputStream = req.getInputStream();
} else {
File contentFile = this.executePartialPut(req, range, path);
resourceInputStream = new FileInputStream(contentFile);
}

if (this.resources.write(path, (InputStream)resourceInputStream, true)) {
if (resource.exists()) {
resp.setStatus(204);
} else {
resp.setStatus(201);
}
} else {
resp.sendError(409);
}
} finally {
if (resourceInputStream != null) {
try {
((InputStream)resourceInputStream).close();
} catch (IOException var13) {
}
}

}

}
}
}

可以看到只有readonly是false的时候才会进入下面的执行逻辑,而this.readOnly是在init方法里被赋值的,而且是直接通过读取配置信息的

1
2
3
4
5
6
7
public void init() throws ServletException {
//...其他参数赋值...
if (this.getServletConfig().getInitParameter("readonly") != null) {
this.readOnly = Boolean.parseBoolean(this.getServletConfig().getInitParameter("readonly"));
}
//...其他参数赋值...
}

下面的执行逻辑很简单,我就将简化的执行逻辑描述一下,通过获取请求的相对路径获取对应的资源对象,然后判断请求的内容大小,只要不为空即可到下面的this.resources.write(path, (InputStream)resourceInputStream, true)进行文件写入,然后会判断资源文件是否存在,如果存在则返回204,不存在则返回201,写入失败返回409,最后关闭输入流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
String path = this.getRelativePath(req);	//获取请求的相对路径
WebResource resource = this.resources.getResource(path); //根据路径获取资源对象
Range range = this.parseContentRange(req, resp); //解析请求中的内容范围
if (range != null) {
InputStream resourceInputStream = null;

try {
if (range == IGNORE) {
resourceInputStream = req.getInputStream();
} else {
File contentFile = this.executePartialPut(req, range, path);
resourceInputStream = new FileInputStream(contentFile);
}

if (this.resources.write(path, (InputStream)resourceInputStream, true)) { //写入文件
if (resource.exists()) { //判断文件已经是否存在
resp.setStatus(204);
} else {
resp.setStatus(201);
}
} else {
resp.sendError(409);
}

漏洞分析

直接测试一下和CVE-2017-12615的区别

可以看到根本无法触发DefaultServlet的doPUT方法,但是可以看到只需要切换一个大小写的后缀就可以实现漏洞利用了

image-20241218180529470

其实我想过是不是路径的问题,那么哪里做了检测呢,实际上我们都忽略了一个要点,那就是我们要调用的是DefaultServlet啊,调用的是Servlet,本质上一个请求能够触发的只有一个Servlet,用jsp后缀调用的肯定是JspServlet啊,我们可以看看,jsp请求随便打个断点是否能够看到识别到的wrapper是JspServlet

image-20241218164332207

可以看到识别到的的确是JspServlet,而且能够正常进入JspServlet

image-20241218164445572

我们通过查看web.xml得知两个servlet的匹配逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
<!-- 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>

那么,我们就要看看是为什么指匹配上了,实际上通过堆栈回溯,发现其实逻辑在org.apache.catalina.mapper.MapperinternalMapWrapper方法,这里就将会解析path分配对应的servlet,代码很长就一点一点解读

首先获取了路径的起始偏移量和结束位置,并计算了上下文路径的长度。如果上下文路径的长度等于路径的长度(这里实际上在上一个堆栈即进入本函数前对上下文做了匹配,用于分辨不同的上下文,然后将截断完的path传入),则设置 noServletPathtrue,表示没有Servlet路径

1
2
3
4
5
6
7
int pathOffset = path.getOffset();
int pathEnd = path.getEnd();
boolean noServletPath = false;
int length = contextVersion.path.length();
if (length == pathEnd - pathOffset) {
noServletPath = true;
}

计算Servlet路径的起始位置,并设置路径的偏移量,使得后续操作可以正确处理Servlet路径。

1
2
int servletPath = pathOffset + length;
path.setOffset(servletPath);

紧接着进入精确匹配包装器即internalMapExactWrapper方法(this.internalMapExactWrapper(exactWrappers, path, mappingData);),这个方法通过exactFindwrappers数组与path进行了精确匹配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private final void internalMapExactWrapper(MappedWrapper[] wrappers, CharChunk path, MappingData mappingData) {
MappedWrapper wrapper = (MappedWrapper)exactFind(wrappers, (CharChunk)path);
if (wrapper != null) {
mappingData.requestPath.setString(wrapper.name);
mappingData.wrapper = (Wrapper)wrapper.object;
if (path.equals("/")) {
mappingData.pathInfo.setString("/");
mappingData.wrapperPath.setString("");
mappingData.contextPath.setString("");
mappingData.matchType = MappingMatch.CONTEXT_ROOT;
} else {
mappingData.wrapperPath.setString(wrapper.name);
mappingData.matchType = MappingMatch.EXACT;
}
}

}

获取通配符匹配的包装器数组,并调用 internalMapWildcardWrapper 方法进行通配符匹配映射。如果找到匹配的包装器且是JSP通配符,检查路径是否以 / 结尾,并根据情况设置 checkJspWelcomeFiles

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
boolean checkJspWelcomeFiles = false;
MappedWrapper[] wildcardWrappers = contextVersion.wildcardWrappers;
if (mappingData.wrapper == null) {
this.internalMapWildcardWrapper(wildcardWrappers, contextVersion.nesting, path, mappingData);
if (mappingData.wrapper != null && mappingData.jspWildCard) {
char[] buf = path.getBuffer();
if (buf[pathEnd - 1] == '/') {
mappingData.wrapper = null;
checkJspWelcomeFiles = true;
} else {
mappingData.wrapperPath.setChars(buf, path.getStart(), path.getLength());
mappingData.pathInfo.recycle();
}
}
}

然后是设置重定向,但是因为前面noServletPath设置为true这里显然是不会触发的

1
2
3
4
5
6
if (mappingData.wrapper == null && noServletPath && ((Context)contextVersion.object).getMapperContextRootRedirectEnabled()) {
path.append('/');
pathEnd = path.getEnd();
mappingData.redirectPath.setChars(path.getBuffer(), pathOffset, pathEnd - pathOffset);
path.setEnd(pathEnd - 1);
}

再然后是扩展名匹配,很明显这里就直接传入了jsp和jspx的JspServlet的wrapper数组,所以这里就会直接匹配到JspServlet而不会进入DefaultServlet了

1
2
3
4
MappedWrapper[] extensionWrappers = contextVersion.extensionWrappers;
if (mappingData.wrapper == null && !checkJspWelcomeFiles) {
this.internalMapExtensionWrapper(extensionWrappers, path, mappingData, true);
}

image-20241218173434298

如果没有找到匹配的包装器,检查是否需要匹配欢迎文件。遍历欢迎文件资源,尝试进行精确匹配和通配符匹配。如果找到匹配的文件,进行扩展名匹配映射,并设置相应的包装器和路径信息

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
boolean checkWelcomeFiles;
char[] buf;
int i;
if (mappingData.wrapper == null) {
checkWelcomeFiles = checkJspWelcomeFiles;
if (!checkWelcomeFiles) {
buf = path.getBuffer();
checkWelcomeFiles = buf[pathEnd - 1] == '/';
}

if (checkWelcomeFiles) {
for(i = 0; i < contextVersion.welcomeResources.length && mappingData.wrapper == null; ++i) {
path.setOffset(pathOffset);
path.setEnd(pathEnd);
path.append(contextVersion.welcomeResources[i], 0, contextVersion.welcomeResources[i].length());
path.setOffset(servletPath);
this.internalMapExactWrapper(exactWrappers, path, mappingData);
if (mappingData.wrapper == null) {
this.internalMapWildcardWrapper(wildcardWrappers, contextVersion.nesting, path, mappingData); //精确匹配包装器
}

if (mappingData.wrapper == null && contextVersion.resources != null) {
String pathStr = path.toString();
WebResource file = contextVersion.resources.getResource(pathStr);
if (file != null && file.isFile()) {
this.internalMapExtensionWrapper(extensionWrappers, path, mappingData, true);
if (mappingData.wrapper == null && contextVersion.defaultWrapper != null) {
...省略...
}
}
}
}

path.setOffset(servletPath);
path.setEnd(pathEnd);
}
}

当前面的都匹配失败则会检测是否存在默认的wrapper,然后直接赋予默认的wrapper,并配置路径信息,这里可以通过调试看到就是在这里获取到了DefaultServlet

1
2
3
4
5
6
7
if (mappingData.wrapper == null && !checkJspWelcomeFiles) {
if (contextVersion.defaultWrapper != null) {
mappingData.wrapper = (Wrapper)contextVersion.defaultWrapper.object;
mappingData.requestPath.setChars(path.getBuffer(), path.getStart(), path.getLength());
mappingData.wrapperPath.setChars(path.getBuffer(), path.getStart(), path.getLength());
mappingData.matchType = MappingMatch.DEFAULT;
}

image-20241218174524934

关于条件竞争

关于条件竞争的问题,是jspservlet在解析过程中有额外的获取wrapper的过程,这里有一次利用StanardRoot来获取资源文件路径的过程,正常文件是可以获取到url的,但是如果传上去Jsp,读取jsp的时候正常读取会获取不到这个

测了一下,条件竞争的地方就在这里,具体原因不太明确但是就是这个会让jsp能够得到值,大概就是刚执行完这个函数就会让JspServlet能够获取到本该获取不到的资源,这里就是我们需要竞争的地方,而执行完的一瞬间,下一行就会清空cache,然后就拿不到值了,这里还是蛮有意思的,具体的我就不展开讨论了

Ft1gZehe92pkqhWVNm4ZV6aD0Rx3

坑点

在使用默认的tomcat即只改动web.xml的时候,利用本漏洞会将jsp传到ROOT目录下,这个原因应该是分配上下文的时候从四个不同的上下文中获取到了第一个,而第一个上下文的docBase刚好是ROOT

image-20241218175804824

image-20241218180039436


Tomcat 远程命令执行漏洞(CVE-2024-50379)
https://pho3n1x-web.github.io/2024/12/18/Tomcat-RCE-CVE-2024-50379/
Author
Pho3n1x
Posted on
December 18, 2024
Licensed under