介绍

Tomcat 默认会开启 session 的持久化功能,它是通过当 Tomcat 正常退出的时候,会将 session 的值进行序列化,存到 work 目录下的 SESSIONS.ser 文件中(正常来说完整路径是 <Tomcat的安装目录>/work/Catalina/localhost/<项目名>/SESSSIONS.ser),然后下次启动时会对这个文件中的内容进行反序列化(启动成功后就会删除 SESSIONS.ser 文件),从而恢复 session 的值。

如果我们可以控制 SESSIONS.ser 文件的内容(往往是通过任意文件上传),就可能造成反序列化漏洞。

源码分析

这里先来分析一下 SESSIONS.ser 文件的内容。先来试着写入值到 session 中,然后关闭 Tomcat

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.just.servlet;

import com.just.pojo.User;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;

@WebServlet("/demo")
public class DemoServlet extends HttpServlet {
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
HttpSession session = req.getSession();
Object user = session.getAttribute("user");
System.out.println("user = " + user);
if (user == null) {
session.setAttribute("user", new User("test", 1));
}
}
}

然后可以得到上面的 session 对应的 SESSIONS.ser 文件。

根据开头的序列化魔数 ACED0005 ,就可以很明显的知道里面存的是 jdk 原生序列化的内容。至于其内容存的信息,我们可以分析其源码。

我们可以找到持久化 session 的源码在 org.apache.catalina.session.StandardSession 类中的 doReadObject()doWriteObject() 方法中。

根据这里的源码,我们就可以知道其序列化存的信息是什么了。但是这里需要注意的是,如果熟悉序列化的结构的人,会发现这里貌似源码和 SESSIONS.ser 文件的内容对不上。因为 SESSIONS.ser 文件中的第一块序列化的数据很明显是 java.lang.Integer 类型的,而源码中第一次 readObject() 返回的结果显示是 Long 类型的。很明显在 doReadObject() 之前还有一次反序列化。然后不难找到,在这里传入 streamdoReadObject() 方法的时候其实已经操作过 stream 了。

这下就对的上了。

根据分析源码,不难得到这里 SESSIONS.ser 的结构是:

开头一个数字标识有几个 session ,然后后面的就是各个 session 的具体信息。

有一点需要注意的是的是:

我们可以发现这里都是调用的 readObject() 然后对结果进行强制类型转化,而非调用 readInt()readDouble() 方法。这样我们构造恶意的 SESSIONS.ser 文件就很容易了,就不需要在意其结构,就直接写入一个恶意的对象。因为对 readObject() 结果进行强制类型转化并不会影响反序列化漏洞,而调用的不是 readObject() 而是其它 readXxx() 方法,就需要考虑写入 SESSIONS.ser 文件的结构才能实现反序列化漏洞。
不过再怎么样也都可以造成反序列化漏洞,这里只是说明一下为什么直接序列化一个恶意的对象到 SESSIONS.ser 文件中就可以造成反序列化漏洞了。

然后我们分析一下 是怎么调用的 doReadObject()doWriteObject() 方法。

我们可以调试 Tomcat 启动的过程。发现其核心关于持久化 session 的逻辑在 org.apache.catalina.session.StandardManager 中。其通过 doLoad() 方法在 Tomcat 停止的时候加载 SESSIONS.ser 文件然后删除 SESSIONS.ser 文件,通过 doUnload() 方法在 Tomcat 停止的时候存储 session 的内容到 SESSION.ser 文件中。

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
protected void doLoad() throws ClassNotFoundException, IOException {
if (log.isDebugEnabled()) {
log.debug("Start: Loading persisted sessions");
}

// Initialize our internal data structures
sessions.clear();

// Open an input stream to the specified pathname, if any
File file = file();
if (file == null) {
return;
}
if (log.isDebugEnabled()) {
log.debug(sm.getString("standardManager.loading", pathname));
}
Loader loader = null;
ClassLoader classLoader = null;
Log logger = null;
try (FileInputStream fis = new FileInputStream(file.getAbsolutePath());
BufferedInputStream bis = new BufferedInputStream(fis)) {
Context c = getContext();
loader = c.getLoader();
logger = c.getLogger();
if (loader != null) {
classLoader = loader.getClassLoader();
}
if (classLoader == null) {
classLoader = getClass().getClassLoader();
}

// Load the previously unloaded active sessions
synchronized (sessions) {
try (ObjectInputStream ois = new CustomObjectInputStream(bis, classLoader, logger,
getSessionAttributeValueClassNamePattern(),
getWarnOnSessionAttributeFilterFailure())) {
Integer count = (Integer) ois.readObject();
int n = count.intValue();
if (log.isDebugEnabled())
log.debug("Loading " + n + " persisted sessions");
for (int i = 0; i < n; i++) {
StandardSession session = getNewSession();
session.readObjectData(ois);
session.setManager(this);
sessions.put(session.getIdInternal(), session);
session.activate();
if (!session.isValidInternal()) {
// If session is already invalid,
// expire session to prevent memory leak.
session.setValid(true);
session.expire();
}
sessionCounter++;
}
} finally {
// Delete the persistent storage file
if (file.exists()) {
file.delete();
}
}
}
} catch (FileNotFoundException e) {
if (log.isDebugEnabled()) {
log.debug("No persisted data file found");
}
return;
}

if (log.isDebugEnabled()) {
log.debug("Finish: Loading persisted sessions");
}
}
protected void doUnload() throws IOException {

if (log.isDebugEnabled())
log.debug(sm.getString("standardManager.unloading.debug"));

if (sessions.isEmpty()) {
log.debug(sm.getString("standardManager.unloading.nosessions"));
return; // nothing to do
}

// Open an output stream to the specified pathname, if any
File file = file();
if (file == null) {
return;
}
if (log.isDebugEnabled()) {
log.debug(sm.getString("standardManager.unloading", pathname));
}

// Keep a note of sessions that are expired
ArrayList<StandardSession> list = new ArrayList<>();

try (FileOutputStream fos = new FileOutputStream(file.getAbsolutePath());
BufferedOutputStream bos = new BufferedOutputStream(fos);
ObjectOutputStream oos = new ObjectOutputStream(bos)) {

synchronized (sessions) {
if (log.isDebugEnabled()) {
log.debug("Unloading " + sessions.size() + " sessions");
}
// Write the number of active sessions, followed by the details
oos.writeObject(Integer.valueOf(sessions.size()));
for (Session s : sessions.values()) {
StandardSession session = (StandardSession) s;
list.add(session);
session.passivate();
session.writeObjectData(oos);
}
}
}

// Expire all the sessions we just wrote
if (log.isDebugEnabled()) {
log.debug("Expiring " + list.size() + " persisted sessions");
}
for (StandardSession session : list) {
try {
session.expire(false);
} catch (Throwable t) {
ExceptionUtils.handleThrowable(t);
} finally {
session.recycle();
}
}

if (log.isDebugEnabled()) {
log.debug("Unloading complete");
}
}

并且可以发现其存储的路径是通过下面的代码获取的:

这里的 pathname 默认值就是 SESSIONS.ser

实验测试

我们写一个 cc9 的反序列化 expSESSIONS.ser 文件中,然后启动 Tomcat ,发现成功命令执行。

进一步分析

根据上面的分析,我们可以发现这种攻击方式其实很鸡肋,因为不仅需要程序项目没有用到 session (因为如果项目自身用到了 session ,那么在关闭 Tomcat 的时候写入 session 的内容到 SESSIONS.ser 文件中,就会覆盖我们上传的 SESSIONS.ser 文件,但是这点并不是很鸡肋,因为现在大部分的项目都是前后端分离,使用的是 token 鉴权而非 session ,所以现在很多项目都没有用 session ),最重要是需要重新启动 Tomcat 才能生效,而实战中我们几乎不可能让目标重启 Tomcat

有没有办法让目标不重启就可以加载 SESSIONS.ser 文件呢?

答案是可以的!

Whenever Apache Tomcat is shut down normally and restarted, or when an application reload is triggered, the standard Manager implementation will attempt to serialize all currently active sessions to a disk file located via the pathname attribute. All such saved sessions will then be deserialized and activated (assuming they have not expired in the mean time) when the application reload is completed.

因此根据官方文档可以看出来,除了服务停止或者重启,还可以让部署的程序触发 reload 来做到。

这里的思路是参考 2022 rwctf Desperate Cat 的出题人 wp 的,其中 wp 利用到了这个技巧。

Tomcat 部署的程序进行 reload 有两种方式:

第一种reload的方式

第一种 reload 的方式需要满足两个条件:

  1. Context reloadable 配置为 true(默认是 false );
  2. /WEB-INF/classes/ 或者 /WEB-INF/lib/ 目录下的文件发生变化。

由于 Context reloadable 默认是 false ,要动态修改它可以通过执行:

1
${pageContext.servletContext.classLoader.resources.context.reloadable=true}

至于怎么修改,就具体情况具体分析了。

然后上传文件到 /WEB-INF/classes/ 目录或者 /WEB-INF/lib/ 目录下就可以触发 Tomcat reload 程序了。

但是这里需要注意的是:如果上传的 jar 包或者 class 文件的格式错误,会导致程序异常崩溃,从而整个网页都无法正常访问了。

第二种reload的方式

第一种方式由于需要修改 reloadable 的值,但是大部分情况应该都是改不了的,所以相对来说不是很好用。这里可以用第二种方式。

WatchedResource - The auto deployer will monitor the specified static resource of the web application for updates, and will reload the web application if it is updated. The content of this element must be a string.

Tomcat 9 环境下,默认的 WatchedResource 包括:

  • WEB-INF/web.xml
  • WEB-INF/tomcat-web.xml
  • ${CATALINA_HOME}/conf/web.xml

Tomcat 会有后台线程去监控这些文件资源,在 Tomcat 开启 autoDeploy 的情况下(此值默认为 true,即默认开启 autoDeploy ),一旦发现这些文件资源的 lastModified 时间被修改,也会触发 reload

由于应用本身没有 WEB-INF/tomcat-web.xml 配置文件, 因此通过利用程序本身的写文件漏洞,来创建一个 WEB-INF/tomcat-web.xml/ 目录,也可以让应用强行触发 reload ,加载并反序列化先前写入的恶意 SESSIONS.ser 文件。

总结

Tomcat 部署的程序具有任意文件上传漏洞的时候,我们可以先上传一个恶意的 SESSIONS.ser 文件到 Tomcatwork 目录,然后再上传一个 WEB-INF/tomcat-web.xml 文件(内容随便)来触发 Tomcatreload ,进而反序列化 SESSIONS.ser 文件产生反序列化漏洞。

此外,如果靶机可以加载我们指定任意的类,我们还可以上传恶意的 jar 包到 WEB-INF/lib/ 目录下,然后再来指定靶机加载我们恶意的 jar 包中的 class 文件,执行其中 static 代码块中的代码,造成 RCE 。这种方式好在不需要靶机有反序列化漏洞的组件,但是需要我们能够加载我们通过文件上传漏洞上传的 jar 包中的 class 文件。