Tomcat CVE-2024-50379 / CVE-2024-56337 条件竞争漏洞分析
4ra1n 发表于 浙江 技术文章 2505浏览 · 2024-12-25 01:41

Tomcat CVE-2024-50379 / CVE-2024-56337 条件竞争漏洞分析

0x01 漏洞描述

两个漏洞内容是一样的,只是 CVE-2024-50379 没有修复完全,CVE-2024-56337 做了二次修复

漏洞描述:

Time-of-check Time-of-use (TOCTOU) Race Condition vulnerability during 
JSP compilation in Apache Tomcat permits an RCE on case insensitive file 
systems when the default servlet is enabled for write (non-default 
configuration).

几个关键点:

  • insensitive file systems (大小写不敏感系统:windows 系统)
  • the default servlet (用于处理静态文件的 DefaultServlet 类)
  • enabled for write (允许写:参考 CVE-2017 的那个 PUT RCE 需要特殊配置)
  • Race Condition (条件竞争)

0x02 调试环境

参考曾经 PUT RCE 需要打开 conf/web.xmlreadonlyfalse

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

启动 Tomcat 时使用 bin/catalina.batjpda 启动

catalina.bat jpda start

lib/*.jar 文件使用 IDEA 右键 Add As Library 然后我们就可以动态调试了

注意 Tomcat 默认使用的是 8000 调试端口

0x03 初步分析

参考 0x01 中的几个关键点,猜测基本的漏洞原理:

  • 线程 A 写 JSP(大写,注意 JspServlet 不处理这种文件只处理小写)
  • 线程 B 读 jsp(小写)
  • 由于 windows 大小写问题导致目标解析了 JSP 文件

接下来我们从 DefaultServlet 的入口 doGet 开始

protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
    this.serveResource(request, response, true, this.fileEncoding);
}

跟入 serveResource 可以主要逻辑如下:

  • getResource 得到文件如果不存在返回 404
  • getResource 得到文件如果存在返回具体内容
protected void serveResource(HttpServletRequest request, HttpServletResponse response, boolean content, String inputEncoding) throws IOException, ServletException {
    boolean serveContent = content;
    String path = this.getRelativePath(request, true);
    // ...
    if (path.length() == 0) {
        this.doDirectoryRedirect(request, response);
    } else {
        WebResource resource = this.resources.getResource(path);
        boolean isError = DispatcherType.ERROR == request.getDispatcherType();
        String requestUri;
        if (!resource.exists()) {
            // 返回 404
        } else {
            // 返回文件内容
        }
    }
}

跟入 this.resources.getResource(path) 到达 StandardRoot#getRource

protected WebResource getResource(String path, boolean validate, boolean useClassLoaderResources) {
    if (validate) {
        path = this.validate(path);
    }
    return this.isCachingAllowed() ? this.cache.getResource(path, useClassLoaderResources) : this.getResourceInternal(path, useClassLoaderResources);
}

以上 DefaultServlet 负责静态资源处理,而 JspServlet 负责 jsp 处理和编译

其中 JspServlet 只处理 jsp/jspx 参考下图 org.apache.catalina.mapper.Mapper#internalMapWrapper

访问 a.JSPa.Jsp 等变形内容,都是由 DefaultServlet 处理

跟入 JspServlet#serviceJspFile 方法,进入 getResource 方法

private void serviceJspFile(HttpServletRequest request, HttpServletResponse response, String jspUri, boolean precompile) throws ServletException, IOException {
    JspServletWrapper wrapper = this.rctxt.getWrapper(jspUri);
    if (wrapper == null) {
        synchronized(this) {
            wrapper = this.rctxt.getWrapper(jspUri);
            if (wrapper == null) {
                // 跟入
                if (null == this.context.getResource(jspUri)) {

调试后发现同样到达了 StandardRoot#getRource 方法,二者在寻找本地文件的代码相同

回到 StandardRoot 类,由于该类是两个 Servlet 共同寻找文件的方法,我们从该类开始深入分析,即可找到竞争的原因

return this.isCachingAllowed() ? this.cache.getResource(path, useClassLoaderResources) : this.getResourceInternal(path, useClassLoaderResources);

在调用 this.cache.getResource 前判断是否开启了 cache

注意该功能文档位于:https://tomcat.apache.org/tomcat-9.0-doc/config/resources.html

主要内容是:

  • 如果此标志的值为true,则将使用静态资源的缓存。

  • 如果未指定,则标志的默认值为true。

跟入 cache 类(这里无需过多关心,竞争的原理和这里的缓存无关)

protected WebResource getResource(String path, boolean useClassLoaderResources) {
    // 这里匹配 path 不处理 .class 和 .jar 文件
    if (this.noCache(path)) {
        return this.root.getResourceInternal(path, useClassLoaderResources);
    } else {
        // 默认 strategy 是空
        WebResourceRoot.CacheStrategy strategy = this.getCacheStrategy();
        if (strategy != null && strategy.noCache(path)) {
            return this.root.getResourceInternal(path, useClassLoaderResources);
        } else {
            this.lookupCount.increment();
            // 关键方法
            CachedResource cacheEntry = (CachedResource)this.resourceCache.get(path);
            if (cacheEntry != null && !cacheEntry.validateResource(useClassLoaderResources)) {
                this.removeCacheEntry(path);
                cacheEntry = null;
            }

注意第一次访问 favicon.ico 得到的是空

默认内容是以下

由于结果是空,进入下一行关键方法 cacheEntry.validateResource

this.root.getResourceInternal(this.webAppPath, useClassLoaderResources);

进入 StandardRootgetResourceInternal

result = webResourceSet.getResource(path);

进入 DirResourceSetgetResource

public WebResource getResource(String path) {
    this.checkPath(path);
    String webAppMount = this.getWebAppMount();
    WebResourceRoot root = this.getRoot();
    if (path.startsWith(webAppMount)) {
        // webAppMount 默认是空
        File f = this.file(path.substring(webAppMount.length()), false);
        // ...
        // 返回文件
    } else {
        return new EmptyResource(root, path);
    }
}

0x04 AbstractFileResourceSet file

进入核心方法 this.file 中 (AbstractFileResourceSet)

protected final File file(String name, boolean mustExist) {
    // 一些前置处理
    String canPath = null;
    try {
        // JRE 方法
        canPath = file.getCanonicalPath();
    } catch (IOException var6) {
    }
    // 一些后置处理 返回文件对象
}

进入 JRE 方法 WinNTFileSystem#getCanonicalPath

该方法内容简单总结:将给定的文件路径标准化和规范化

  • 处理 Windows 驱动器
  • 方法检查是否启用缓存。如果缓存未启用,则调用 canonicalize0 方法标准化
  • 如果缓存可用,则首先检查缓存中是否已经存在该路径的标准化结果,如果存在直接返回
  • 另有一个前缀缓存机制,优化父目录相关的问题

可以看到在 JRE 中非常注重提升性能,避免重复代码和计算问题

关键部分我已经注释在代码中

@Override
public String canonicalize(String path) throws IOException {
    // windows 盘符问题处理代码忽略
    if (!useCanonCaches) {
        return canonicalize0(path);
    } else {
        // 从缓存中获取
        String res = cache.get(path);
        if (res == null) {
            String dir = null;
            String resDir = null;
            // 拿不到缓存 就从前缀缓存中获取下
            if (useCanonPrefixCache) {
                dir = parentOrNull(path);
                if (dir != null) {
                    resDir = prefixCache.get(dir);
                    if (resDir != null) {
                        String filename = path.substring(1 + dir.length());
                        res = canonicalizeWithPrefix(resDir, filename);
                        cache.put(dir + File.separatorChar + filename, res);
                    }
                }
            }
            if (res == null) {
                // 缓存和前缀缓存都没拿到就调用 canonicalize0 方法
                res = canonicalize0(path);
                // 设置缓存
                cache.put(path, res);
                if (useCanonPrefixCache && dir != null) {
                    resDir = parentOrNull(res);
                    if (resDir != null) {
                        File f = new File(res);
                        if (f.exists() && !f.isDirectory()) {
                            // 设置前缀缓存
                            prefixCache.put(dir, resDir);
                        }
                    }
                }
            }
        }
        return res;
    }
}

观察到 cacheprefixCache 内容分别如下:一个是具体文件,一个是目录,都是 JRE 为性能而做的优化

注意到一个 native 方法 canonicalize0 底层做了什么

我找到了 WinNTFileSystem#canonicalize0C 代码

JNIEXPORT jstring JNICALL
Java_java_io_WinNTFileSystem_canonicalize0(JNIEnv *env, jobject this,
                                           jstring pathname)
{
    // ...
    WCHAR *cp = (WCHAR*)malloc(len * sizeof(WCHAR));
    if (cp != NULL) {
        // 核心方法
        if (wcanonicalize(path, cp, len) >= 0) {
            rv = (*env)->NewString(env, cp, (jsize)wcslen(cp));
        }
        free(cp);
    } else {
        JNU_ThrowOutOfMemoryError(env, "native memory allocation failed");
    }
    // ...
    return rv;
}

核心方法是:wcanonicalize

找到 jdkjdk/src/windows/native/java/io/canonicalize_md.c 方法

int
wcanonicalize(WCHAR *orig_path, WCHAR *result, int size)
{
    // ...
    dst = result; 
    // ...
    h = FindFirstFileW(path, &fd);
    // ...
    if (h != INVALID_HANDLE_VALUE) {
        // 如果找到文件 拼接存在的文件名
        /* Lookup succeeded; append true name to result and continue */
        FindClose(h);
        if (!(dst = wcp(dst, dend, L'\\', fd.cFileName,
                        fd.cFileName + wcslen(fd.cFileName)))){
            goto err;
        }
        src = p;
        continue;
    } else {
        if (!lastErrorReportable()) {
            // 找不到文件 拼接原始文件名

           if (!(dst = wcp(dst, dend, L'\0', src, src + wcslen(src)))){
               goto err;
           }
            break;
        } else {
            goto err;
        }
    }
}

其中 FindFirstFileW 函数说明如下

https://learn.microsoft.com/zh-cn/windows/win32/api/fileapi/nf-fileapi-findfirstfilew

忽略了大部分代码,只保留关键代码:

  • 假设输入 orig_path 是 xxx/a.jsp
  • FindFirstFileW 实际找到的文件是 PUT 成功上传的 a.JSP 文件(已存在文件)
  • 最终返回的结果是找到的本地的 a.JSP
  • 如果找不到文件,原样返回

总结:

  • canonicalize0 方法的输入是 a.jsp 时,如果本地有 a.JSP 文件,会返回 a.JSP 而不是 a.jsp
  • canonicalize0 方法输入是 a.jsp 时,如果本地没有 a.JSP 文件,原样返回 a.jsp

为了验证结果,我本地 PUT 写了个 a.JSP 文件,手动访问 a.jsp 成功断点并截图,证明了我的猜测

下一步 a.jsp 小写 jsp 被写进去了缓存中 (a.jsp -> a.JSP)

注意高版本 Java 的该属性默认是 false 不存在缓存机制,你无法进入这个 else 分支

回到 AbstractFileResourceSet 的 file 方法后置处理部分

try {
    // JRE 方法
    canPath = file.getCanonicalPath();
} catch (IOException var6) {
}

if (canPath != null && canPath.startsWith(this.canonicalBase)) {
    String absPath = this.normalize(file.getAbsolutePath());
    // ... 一些处理
    if (!canPath.equals(absPath)) {
        if (!canPath.equalsIgnoreCase(absPath)) {
            this.logIgnoredSymlink(this.getRoot().getContext().getName(), absPath, canPath);
        }
        return null;
    } else {
        // 只有 canPath 和 absPath 相同时才会返回文件
        return file;
    }
    // ...
    return null;
}

0x05 abs path 与 can path

getCanonicalPath 的结果是 can path

后续和 abs path 做比较,决定了返回是 null 还是具体的 File 对象

其中 abs path 是用户输入的路径拼接处理后的本地绝对路径(不一定必须存在)

其中 can path 是 JRE 类 WinNTFileSystem JNI/cache 处理后得到的路径

大体上分两种情况分析:

(1)PUT a.JSP 情况

abs path 显然是 /a.JSP

情况1.1 can path 无缓存,本地不存在 /a.JSP 文件,原样返回,得到 /a.JSP

情况1.2 can path 无缓存,本地存在 /a.JSP 文件,原样返回,得到 /a.JSP

情况1.3 can path 有缓存 a.JSP -> a.JSP 直接读缓存,原样返回,得到 /a.JSP

情况1.4 can path 有缓存 a.JSP -> a.JSP 直接读缓存,原样返回,得到 /a.JSP

以上任意一种情况,都一定会导致缓存中写入一条新的 a.JSP -> a.JSP

当我们访问 GET a.JSP 时,可以找到上传的 a.JSP 文件(此时由 DefaultServlet 处理静态资源)

(2)GET a.jsp 情况

abs path 显然是 /a.jsp

此时应该有三种缓存情况:

2.1. 缓存 a.jsp -> a.jsp
2.2. 缓存 a.jsp -> a.JSP
2.3. 没有缓存

情况2.1的原因:GET a.jsp 的时候没有上传 a.JSP 文件,原样返回了

情况2.2的原因:GET a.jsp 的时候已经存在 PUT 上传的 a.JSP 文件,返回以存在文件优先(参考上文 JNI 部分)

情况2.3的原因:高版本 JDK 默认不开缓存 / 手动关了缓存功能 / 第一次访问

情况2.1分析:can path 直接读缓存 /a.jsp,进入 JspServlet 后续处理阶段,能不能 RCE 看 a.JSP 文件在不在

情况2.2分析:can path 直接读缓存 /a.JSP,无法通过 equals 验证,返回空,最终返回 404

情况2.3分析:can path 返回看本地有没有 a.JSP(又分为以下两种情况)

情况2.3.1分析:本地有 a.JSP 返回 a.JSP 导致 equals 校验失败,给缓存添加了 a.jsp -> a.JSP

情况2.3.2分析:本地无 a.JSP 返回 a.jsp 可以过校验,给缓存添加了 a.jsp -> a.jsp

如果是 情况 2.3.2 第一次访问,缓存添加了 a.jsp -> a.jsp 内容

然后 PUT a.JSP 成功上传一个问题

再走 情况2.1 进入 JspServlet 后续处理阶段,因为 a.JSP 文件存在,导致了 RCE

0x06 二次校验

在我后续调试中,发现即使 a.jsp 的 equals 校验通过,后续还有一层校验

当顺利通过这一层之后,后续存在 Tomcat 内置缓存的处理

org.apache.catalina.webresources#getResource

对得到的 resource 再次校验

cacheEntry.validateResource(useClassLoaderResources);

跟入到达:this.root.getResourceInternal(this.webAppPath, useClassLoaderResources);

层层深入再次到达:AbstractFileResourceSet file 方法

我们要保证二次校验这里还是 a.jsp -> a.jsp

0x07 n次校验

还没有结束,在 JspServlet 后续处理的过程中,会再次抵达 AbstractFileResourceSet file 方法

JspCompilationContext#getLastModified

URL jspUrl = this.getResource(resource);

JspCompilationContext#getResource

还没有结束

JspCompilationContext#getLastModified 中调用了

uc.getLastModified()

到达 org.apache.catalina.webresources.CachedResource#getResource

再次抵达 AbstractFileResourceSet file 方法

最后达到真正的 JspServlet 读取文件的地方

JspUtil#getInputStream

再次抵达 AbstractFileResourceSet file 方法

0x08 竞争的是什么

竞争的难点是什么:我们需要保证,以上的 n 次 file 方法,取到的 cache 都必须是 a.jsp -> a.jsp

只要有一次是:a.jsp -> a.JSP 就会导致无法通过 equals 校验,返回 null 无法后续走完全程

回顾一下,什么情况下会出现:a.jsp -> a.JSP

PUT a.JSP 只要完成了,本地存在了 a.JSP 文件

就会导致下一次的 AbstractFileResourceSet file 方法中 can path 得到 a.JSP 和 abs path 不一致

以上,可以看到,保存了一次 a.jsp -> a.jsp

我们必须要 PUT a.JSP 文件落地之前

完美地通过以上 n 次的 AbstractFileResourceSet file 方法(注意这个 n 不止4次,实际需要更多次)

以上 n 次的 equals 校验,必须都拿到 a.jsp - > a.jsp 才会成功执行 jsp 文件导致 RCE

通过以上 n 次之后,还需要再 InputStream 读取的时候,文件正好落地,成功读到文件内容

0x09 过期策略

如果你仔细调试,你会发现为什么有时候 cache 里的 a.jsp -> a.jsp 不见了

因为 cache 有过期策略

synchronized String get(String key) {
    // 超过 300 个缓存就清空
    if (++queryCount >= queryOverflow) {
        cleanup();
    }
    // 根据 key 取
    Entry entry = entryFor(key);
    if (entry != null) {
        return entry.val();
    }
    return null;
}
private Entry entryFor(String key) {
    Entry entry = map.get(key);
    if (entry != null) {
        long delta = System.currentTimeMillis() - entry.timestamp();
        // 默认超时 30 秒
        if (delta < 0 || delta >= millisUntilExpiration) {
            // a.jsp -> a.jsp 只会保持 30 秒
            map.remove(key);
            entry = null;
        }
    }
    return entry;
}

所以我们的调试要在 30 秒内速速操作,超过就找不到了

0x08 修复

CVE-2024-50379 的修复如下

https://github.com/apache/tomcat/commit/43b507ebac9d268b1ea3d908e296cc6e46795c00

https://github.com/apache/tomcat/commit/631500b0c9b2a2a2abb707e3de2e10a5936e5d41

读操作和写操作加了锁

官方修复时的想法应该是:(不考虑缓存情况)

当 PUT 操作没有完成写入的时候,GET a.jsp getResource 操作阻塞

官方的锁考虑了大小写,PUT a.JSP 的 write 操作和 GET a.jsp 的 read 操作是同一个锁,读和写冲突

private String getLockKey(String path) {
    // Normalize path to ensure that the same key is used for the same path.
    String normalisedPath = RequestUtil.normalize(path);
    if (caseSensitive) {
        return normalisedPath;
    }
    // a.JSP 和 a.jsp 都是 a.jsp 的 lock key
    return normalisedPath.toLowerCase(Locale.ENGLISH);
}

CVE-2024-56337 二次修复

官方给出建议:必须设置该属性为 false

sun.io.useCanonCaches

回顾上文 canonicalize 方法当该属性是 false 时直接 JNI 获取路径,不进行缓存

if (!useCanonCaches) {
    return canonicalize0(path);
} else {
    // ...

缓存这里存在的安全问题

  • 第一次 GET a.jsp 时缓存中添加了 a.jsp -> a.jsp
  • 第 n 次 GET a.jsp 可能有两种缓存:a.jsp -> a.JSP / a.jsp->a.jsp

如果某一个时刻

  • PUT a.JSP 文件已经写入了
  • GET a.jsp 的时候缓存中内容是 a.jsp -> a.jsp 成功绕过 equals 验证
  • JspServlet 读到 a.JSP 文件有内容成功执行

如果不使用缓存,将会出现以下问题:

  • GET a.jsp 时如果 a.JSP 文件存在,can path 一定是 JSP 导致无法通过 equals 校验
  • GET a.jsp 时如果 a.JSP 文件不存在,can path 一定是 jsp 可以进入 JspServlet 后续

没有缓存,于是当 PUT a.JSP 文件落地后,后续的 GET a.jsp 将全部得到 a.JSP 无法通过 equals 校验

正是因为缓存,导致了一种可能:PUT a.JSP 后可能 GET a.jsp 得到的 can path 还是 a.jsp

以上内容,考虑情况比较多且比较复杂,难免存在一些错误之处,欢迎师傅们讨论和指出问题

0x05 检测

作为扫描器,不可以使用线程对冲的方式,可能会导致巨量脏数据

经过一些测试和综合考虑,我找到一种几乎无误报漏报,无损无脏数据的扫描方式

我已经给 xray 商业版(洞鉴)添加了该扫描方案,具体逻辑如下:

(1)判断 PUT 操作有效

如果 PUT 任意一个随机文件得到 204 返回,证明开启了 PUT 操作

补充:为了证实 PUT 操作确实有效和脏数据考虑,下一步可以 GET 访问确认成功,最后 DELETE 删除该临时文件

(2)判断操作系统

感谢漏洞百出群友的帮助,找到了一种判断目标是否是 WINDOWS 系统的方法

以 Linux 系统为例

PUT 上传一个 :.txt 文件成功,响应 201

访问 :.txt 成功,内容正确

但是在 Windows 系统会遇到 409 的报错

根据该差异,可以确定目标系统的类型

(3)版本信息

通过以上两点,可以证明开启 PUT 且是 WINDOWS

只需要再通过一个 404 路径,使用正则取到 Tomcat 报错版本信息

根据官方公告,精确匹配 tomcat 版本

// Affected versions:
//- Apache Tomcat 11.0.0-M1 through 11.0.1
//- Apache Tomcat 10.1.0-M1 through 10.1.33
//- Apache Tomcat 9.0.0.M1 through 9.0.97
if (ver >= 9_000_000 && ver <= 9_000_097) ||
    (ver >= 10_001_000 && ver <= 10_001_033) ||
    (ver >= 11_000_000 && ver <= 11_000_001) ||
    // 目前来看 9/10/11 的 M 版本都有漏洞
    (strings.Contains(verStr, "M") &&
        (strings.HasPrefix(verStr, "9.") ||
            strings.HasPrefix(verStr, "10.") ||
         strings.HasPrefix(verStr, "11."))) {
        // 报告漏洞
    }
}

通过这三步,即可完成一个几乎无误报漏报无损无脏数据的漏洞检测方案

(注意:几乎完美,但不能覆盖所有情况,如果用户手动关闭缓存,远程无法检测到)

0x06 致谢

感谢 漏洞百出 群友一起交流以及给出 windows 系统检测思路

1 条评论
某人
表情
可输入 255