March 13, 2025

CVE-2025-24813 Tomcat Session 反序列化组合拳

前言

看到一篇文章就来复现一下,补充一些细节利用点。
https://forum.butian.net/article/674

回顾历史

这三个漏洞是 Tomcat 历史一些相对比较严重的 RCE 漏洞,也算前世今生了。

环境配置

IDEA 部署 Tomcat 在新版有一些改变,主要是 AddFrameWork 按钮没了。


你得全局搜才搜的出来,创建一下基本框架,然后新建 lib 目录加入项目依赖

随后修改一下 Tomcat 里的 conf/Context.xml 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?xml version="1.0" encoding="UTF-8"?>  
<!--
Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file distributed with this work for additional information regarding copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.-->
<!-- The contents of this file will be loaded for each web application -->
<Context>

<!-- Default set of monitored resources. If one of these changes, the -->
<!-- web application will be reloaded. --> <WatchedResource>WEB-INF/web.xml</WatchedResource>
<WatchedResource>WEB-INF/tomcat-web.xml</WatchedResource>
<WatchedResource>${catalina.base}/conf/web.xml</WatchedResource>

<!-- Uncomment this to disable session persistence across Tomcat restarts -->
<!-- <Manager pathname="" /> --> <Manager className="org.apache.catalina.session.PersistentManager">
<Store className="org.apache.catalina.session.FileStore"/>
</Manager> </Context>

主要是加入如下内容

1
2
3
4
<Manager className="org.apache.catalina.session.PersistentManager">  
<Store className="org.apache.catalina.session.FileStore"/>
</Manager>

这一步是开启 Tomcat 的 Session 储存功能。

随后配置 Web.xml,加入默认的 DefaultServlet

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?xml version="1.0" encoding="UTF-8"?>  
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<servlet> <servlet-name>defalut</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>readonly</param-name>
<param-value>false</param-value>
</init-param> <load-on-startup>1</load-on-startup>
</servlet> <servlet-mapping> <servlet-name>defalut</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping></web-app>

DefaultServlet


自带的一个 Servelet 会处理一些默认类型的请求,如 PUT、POST、GET。

CVE-2017-12615、CVE-2024-50379均涉及该方法,也就是 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) {
}
}

}

}
}
}

这里直接获取 Web 根目录了,然后将文件上传,这也是最开始的 PUT 文件上传漏洞点。而今天的漏洞点出自上述的 else 分支。

Tomcat 在处理不完整的 PUT 请求有单独的逻辑。这个和请求头 Content-Range 有关

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
protected Range parseContentRange(HttpServletRequest request, HttpServletResponse response) throws IOException {  
String contentRangeHeader = request.getHeader("Content-Range");
if (contentRangeHeader == null) {
return IGNORE;
} else if (!this.allowPartialPut) {
response.sendError(400);
return null;
} else {
ContentRange contentRange = ContentRange.parse(new StringReader(contentRangeHeader));
if (contentRange == null) {
response.sendError(400);
return null;
} else if (!contentRange.getUnits().equals("bytes")) {
response.sendError(400);
return null;
} else {
Range range = new Range();
range.start = contentRange.getStart();
range.end = contentRange.getEnd();
range.length = contentRange.getLength();
if (!range.validate()) {
response.sendError(400);
return null;
} else {
return range;
}
}
}
}

https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Content-Range

其实你可以理解为和分块差不多一个意思。

1
2
3
4
5
6
7
PUT /CVE_2025_24813_war_exploded/a/session HTTP/1.1
Host: 127.0.0.1:8080
Content-Length: 1000
Content-Range: bytes 0-1000/1200

testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest

简单利用该方法上传一个session 文件,内容随意。我们主要是看看细节

start、end、length和请求头对应,随后会进入 executePartialPut 内。

在这里会将斜杠替换为.,因此我们可以上传一个 xxx.session 文件。具体处理逻辑在底部

核心逻辑在这里,以 range.length作为文件的长度,range.start作为起始点开始处理流,这也是为什么 length 必须要大于 body 的总长度,不然无法将内容完全上传。

之后会返回 409 错误,但此时文件已经上传


我们需要注意一点

linux 系统下的缓存文件夹是/tmp,这里我是 macos,因此会在一些偏僻的位置,想要复现的同学可以自行输出一下。

至此完成了文件上传分析的部分。

FileStore#load

这部分是反序列化触发点,CVE-2020-9484出自这里,所以不难看出这其实是一个组合漏洞,当时CVE-2020-9484是因为存在目录穿越问题所以可以穿越加载一个自定义的序列化文件。

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
public Session load(String id) throws ClassNotFoundException, IOException {  
File file = this.file(id);
if (file != null && file.exists()) {
Context context = this.getManager().getContext();
Log contextLog = context.getLogger();
if (contextLog.isTraceEnabled()) {
contextLog.trace(sm.getString(this.getStoreName() + ".loading", new Object[]{id, file.getAbsolutePath()}));
}

ClassLoader oldThreadContextCL = context.bind(Globals.IS_SECURITY_ENABLED, (ClassLoader)null);

ObjectInputStream ois;
try {
FileInputStream fis = new FileInputStream(file.getAbsolutePath());

StandardSession var9;
try {
ois = this.getObjectInputStream(fis);

try {
StandardSession session = (StandardSession)this.manager.createEmptySession();
session.readObjectData(ois);
session.setManager(this.manager);
var9 = session;
} catch (Throwable var19) {
if (ois != null) {
try {
ois.close();
} catch (Throwable var18) {
var19.addSuppressed(var18);
}
}

throw var19;
}

if (ois != null) {
ois.close();
}
} catch (Throwable var20) {
try {
fis.close();
} catch (Throwable var17) {
var20.addSuppressed(var17);
}

throw var20;
}

fis.close();
return var9;
} catch (FileNotFoundException var21) {
if (contextLog.isDebugEnabled()) {
contextLog.debug(sm.getString("fileStore.noFile", new Object[]{id, file.getAbsolutePath()}));
}

ois = null;
} finally {
context.unbind(Globals.IS_SECURITY_ENABLED, oldThreadContextCL);
}

return ois;
} else {
return null;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private File file(String id) throws IOException {  
File storageDir = this.directory();
if (storageDir == null) {
return null;
} else {
String filename = id + ".session";
File file = new File(storageDir, filename);
File canonicalFile = file.getCanonicalFile();
if (!canonicalFile.toPath().startsWith(storageDir.getCanonicalFile().toPath())) {
log.warn(sm.getString("fileStore.invalid", new Object[]{file.getPath(), id}));
return null;
} else {
return canonicalFile;
}
}
}

修复方式就是getCanonicalFile处理穿越问题。因此无法目录穿越了,只能加载缓存目录下的.session后缀文件并且将其反序列化。反序列化的触发点是没删除的。

漏洞复现

写了一个 POC

成功复现。

About this Post

This post is written by Boogipop, licensed under CC BY-NC 4.0.

#Java#CVE