漏洞分析 | Apache Tomcat远程代码执行漏洞(CVE-2025-24813)

漏洞概述

Apache Tomcat是美国阿帕奇(Apache)基金会的一款轻量级Web应用服务器。用于实现对Servlet和JavaServer Page(JSP)的支持。近期,网宿安全演武实验室监测到Apache Tomcat在特定条件下,存在远程代码执行漏洞。(网宿评分:高危、CVSS 3.1评分:8.1)

当同时满足如下利用条件时:开启PUT(默认关闭)、开启tomcat 持久化session配置、依赖库中存在反序列化利用链,攻击者即可构造恶意请求获取服务器权限。目前该漏洞POC状态已在互联网公开,建议客户尽快做好自查及防护。

受影响版本

11.0.0-M1 <= Apache Tomcat <= 11.0.2

10.1.0-M1 <= Apache Tomcat <= 10.1.34

9.0.0.M1 <= Apache Tomcat <= 9.0.98

漏洞分析

查看官方commit

https://github.com/apache/tomcat/commit/0a668e0c27f2b7ca0cc7c6eea32253b9b5ecb29c?diff=split)不难发现,DefaultServlet在处理PUT请求时,对临时文件有了新的操作,主要修改了java/org/apache/catalina/servlets/DefaultServlet.java#executePartialPut(),说明问题很可能出现在这里。

    protected File executePartialPut(HttpServletRequest req, Range range,
                                     String path)
        throws IOException {
        // Append data specified in ranges to existing content for this
        // resource - create a temp. file on the local filesystem to
        // perform this operation
        File tempDir = (File) getServletContext().getAttribute
            (ServletContext.TEMPDIR);
        // Convert all '/' characters to '.' in resourcePath
        String convertedResourcePath = path.replace('/', '.');
        File contentFile = new File(tempDir, convertedResourcePath);
        if (contentFile.createNewFile()) {
            // Clean up contentFile when Tomcat is terminated
            contentFile.deleteOnExit();
        }
        RandomAccessFile randAccessContentFile =
            new RandomAccessFile(contentFile, "rw");
        WebResource oldResource = resources.getResource(path);
        // Copy data in oldRevisionContent to contentFile
        if (oldResource.isFile()) {
            BufferedInputStream bufOldRevStream =
                new BufferedInputStream(oldResource.getInputStream(),
                        BUFFER_SIZE);
            int numBytesRead;
            byte[] copyBuffer = new byte[BUFFER_SIZE];
            while ((numBytesRead = bufOldRevStream.read(copyBuffer)) != -1) {
                randAccessContentFile.write(copyBuffer, 0, numBytesRead);
            }
            bufOldRevStream.close();
        }
        randAccessContentFile.setLength(range.length);
        // Append data in request input stream to contentFile
        randAccessContentFile.seek(range.start);
        int numBytesRead;
        byte[] transferBuffer = new byte[BUFFER_SIZE];
        BufferedInputStream requestBufInStream =
            new BufferedInputStream(req.getInputStream(), BUFFER_SIZE);
        while ((numBytesRead = requestBufInStream.read(transferBuffer)) != -1) {
            randAccessContentFile.write(transferBuffer, 0, numBytesRead);
        }
        randAccessContentFile.close();
        requestBufInStream.close();
        return contentFile;
    }

在executePartialPut()中,我们主要关注临时文件的创建过程,即:

1、设置存放地址:通过 ServletContext 的 TEMP_DIR(javax.servlet.context.tempdir)属性获取临时文件夹,work\Catalina\localhost\ROOT

2、构造临时文件:将path中所有的“/”替换为“.”,进而构造临时文件,并在已设置的存放地址下创建。

显然,这里并没有对文件内容做安全验证,但如何调用呢?回到

java/org/apache/catalina/servlets/DefaultServlet.java#doPut()

    protected void doPut(HttpServletRequest req, HttpServletResponse resp)
        throws ServletException, IOException {
        if (readOnly) {
            resp.sendError(HttpServletResponse.SC_FORBIDDEN);
            return;
        }
        String path = getRelativePath(req);
        WebResource resource = resources.getResource(path);
        Range range = parseContentRange(req, resp);
        InputStream resourceInputStream = null;
        try {
            // Append data specified in ranges to existing content for this
            // resource - create a temp. file on the local filesystem to
            // perform this operation
            // Assume just one range is specified for now
            if (range != null) {
                File contentFile = executePartialPut(req, range, path);
                resourceInputStream = new FileInputStream(contentFile);
            } else {
                resourceInputStream = req.getInputStream();
            }
            if (resources.write(path, resourceInputStream, true)) {
                if (resource.exists()) {
                    resp.setStatus(HttpServletResponse.SC_NO_CONTENT);
                } else {
                    resp.setStatus(HttpServletResponse.SC_CREATED);
                }
            } else {
                resp.sendError(HttpServletResponse.SC_CONFLICT);
            }
        } finally {
            if (resourceInputStream != null) {
                try {
                    resourceInputStream.close();
                } catch (IOException ioe) {
                    // Ignore
                }
            }
        }
    }

只有当range不为空,才走到调用executePartialPut()的分支。这里也很好解决,添加合法Content-Range请求头即可。关于Content-Range语法,可以参考:

https://runebook.dev/cn/docs/http/headers/content-range

不过这里需要注意的是,range.length代表的是整个文件的长度,而请求体只占其中一部分,所以得合理地设置Content-Range,具体逻辑详见

java/org/apache/catalina/servlets/DefaultServlet.java# parseContentRange()

    protected Range parseContentRange(HttpServletRequest request,
                                      HttpServletResponse response)
        throws IOException {
        // Retrieving the content-range header (if any is specified
        String rangeHeader = request.getHeader("Content-Range");
        if (rangeHeader == null)
            return null;
        // bytes is the only range unit supported
        if (!rangeHeader.startsWith("bytes")) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST);
            return null;
        }
        rangeHeader = rangeHeader.substring(6).trim();
        int dashPos = rangeHeader.indexOf('-');
        int slashPos = rangeHeader.indexOf('/');
        if (dashPos == -1) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST);
            return null;
        }
        if (slashPos == -1) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST);
            return null;
        }
        Range range = new Range();
        try {
            range.start = Long.parseLong(rangeHeader.substring(0, dashPos));
            range.end =
                Long.parseLong(rangeHeader.substring(dashPos + 1, slashPos));
            range.length = Long.parseLong
                (rangeHeader.substring(slashPos + 1, rangeHeader.length()));
        } catch (NumberFormatException e) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST);
            return null;
        }
        if (!range.validate()) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST);
            return null;
        }
        return range;
    }

测试一下,成功上传。

那么PUT成功以后,如何触发恶意文件内容呢?回顾漏洞概述中的触发条件之一,” 开启tomcat 持久化session配置”,触发点对应的代码逻辑如下:

    public Session load(String id)
        throws ClassNotFoundException, IOException {
        // Open an input stream to the specified pathname, if any
        File file = file(id);
        if (file == null) {
            return (null);
        }
        if (! file.exists()) {
            return (null);
        }
        if (manager.getContext().getLogger().isDebugEnabled()) {
            manager.getContext().getLogger().debug(sm.getString(getStoreName()+".loading",
                             id, file.getAbsolutePath()));
        }
        ObjectInputStream ois = null;
        Loader loader = null;
        ClassLoader classLoader = null;
        ClassLoader oldThreadContextCL = Thread.currentThread().getContextClassLoader();
        try (FileInputStream fis = new FileInputStream(file.getAbsolutePath());
                BufferedInputStream bis = new BufferedInputStream(fis)) {
            Context context = manager.getContext();
            if (context != null)
                loader = context.getLoader();
            if (loader != null)
                classLoader = loader.getClassLoader();
            if (classLoader != null) {
                Thread.currentThread().setContextClassLoader(classLoader);
                ois = new CustomObjectInputStream(bis, classLoader);
            } else {
                ois = new ObjectInputStream(bis);
            }
            StandardSession session =
                    (StandardSession) manager.createEmptySession();
            session.readObjectData(ois);
            session.setManager(manager);
            return (session);
        } catch (FileNotFoundException e) {
            if (manager.getContext().getLogger().isDebugEnabled())
                manager.getContext().getLogger().debug("No persisted data file found");
            return (null);
        } finally {
            if (ois != null) {
                // Close the input stream
                try {
                    ois.close();
                } catch (IOException f) {
                    // Ignore
                }
            }
            Thread.currentThread().setContextClassLoader(oldThreadContextCL);
        }
    }

而session文件的默认存储位置恰好位于executePartialPut()设置的临时目录,所以攻击者携带JSESSIONID=file的Cookie访问tomcat以后,程序就会去临时目录反序列化file.session用于匹配鉴权参数。

漏洞复现

web.xml

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

context.xml

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

webapps\ROOT\WEB-INF\lib\commons-collections-3.2.1.jar

修复方案

目前官方已有可更新版本,建议受影响用户升级至最新版本:

Apache Tomcat >=11.0.3
Apache Tomcat >=10.1.35
Apache Tomcat >=9.0.99

产品支持

网宿全站防护-WAF模块已支持对该漏洞利用攻击的防护,并持续挖掘分析其他变种攻击方式和各类组件漏洞,第一时间上线防护规则,缩短防护“空窗期”。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值