CVE-2024-4956 Nexus Repository 3 任意文件读取调试分析

Posted by sule01u on June 10, 2024

CVE-2024-4956 Nexus Repository 3 任意文件读取调试分析

漏洞概述

2024年5月,Nexus Repository官方Sonatype发布了新补丁,修复了一处路径穿越漏洞CVE-2024-4956。经分析,该漏洞可以通过特定的路径请求来未授权访问系统文件,进而可能导致信息泄露。该漏洞无前置条件且利用简单。

  • 漏洞成因

    Nexus Repository仅依赖Jetty自带的方法进行请求路径的安全检查,而未进行深入的验证,导致攻击者可以利用路径穿越攻击访问文件系统上的任意位置。

  • 漏洞影响

    成功利用这一漏洞的攻击者可以读取Nexus Repository服务器上的任意文件,这可能包括配置文件、数据库备份以及其他敏感数据。此外,特定情况下如果攻击者能够进一步利用服务器上的其他配置或漏洞,可能会完全控制受影响的服务器。

环境搭建

调试分析

根据官方的临时修复方案,删除(basedir)/etc/jetty/jetty.xml <Set name="resourceBase"><Property name="karaf.base"/>/public</Set>这一行 整个xml

<New id="NexusHandler" class="org.sonatype.nexus.bootstrap.jetty.InstrumentedHandler">
    <Arg>
      <New id="NexusWebAppContext" class="org.eclipse.jetty.webapp.WebAppContext">
        <Set name="descriptor"><Property name="jetty.etc"/>/nexus-web.xml</Set>
        <Set name="resourceBase"><Property name="karaf.base"/>/public</Set>
        <Set name="contextPath"><Property name="nexus-context-path"/></Set>
        <Set name="throwUnavailableOnStartupException">true</Set>
        <Set name="configurationClasses">
          <Array type="java.lang.String">
            <Item>org.eclipse.jetty.webapp.WebXmlConfiguration</Item>
          </Array>
        </Set>
      </New>
    </Arg>
  </New>

断点直接下在WebResourceServiceImpl补丁删除的那块代码上⾯,这⾥以/robots.txt路由为例⼦

image-20240610201616555

跟进getResource

image-20240603004212969

再次跟进,来到了ContextHandlergetResource⽅法,这里的_baseResource就是jetty.xml⾥配置的public⽬录

image-20240603004433068

继续跟进addPath方法,这里会调用URIUtil.canonicalPath

image-20240603004527077

创建了⼀个PathResource对象准备返回

image-20240611001602702

实际的uri拼接在于URIUtil.addPath函数中,注意这⾥的encodePath参数对path⼜进⾏了⼀波编码

image-20240611001827629

【重点】canonicalPath 处理逻辑

这里我们直接拿poc来直接调试分析一下

GET /%2F%2F%2F%2F%2F%2F%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fetc%2Fpasswd

image-20240611003646131

然后我们直接走到URIUtil.canonicalPath方法来分析

image-20240611004001503

canonicalPath方法代码如下所示,增加了详细的注释

public static String canonicalPath(String path) {
    // 检查路径是否为空或空字符串
    if (path != null && !path.isEmpty()) {
        boolean slash = true; // 标志当前字符是否是'/'
        int end = path.length(); // 获取路径的长度

        int i;
        label68:
        // 遍历路径中的每个字符
        for(i = 0; i < end; ++i) {
            char c = path.charAt(i); // 获取路径中的当前字符
            switch (c) {
                case '.':
                    // 如果当前字符是'.'且前一个字符是'/'
                    if (slash) {
                        break label68; // 跳出循环
                    }
                    slash = false; // 当前字符不是'/'
                    break;
                case '/':
                    slash = true; // 当前字符是'/'
                    break;
                default:
                    slash = false; // 当前字符不是'/'
            }
        }

        // 如果遍历到路径的末尾
        if (i == end) {
            return path; // 返回原路径
        } else {
            StringBuilder canonical = new StringBuilder(path.length()); // 创建一个新的字符串构建器
            canonical.append(path, 0, i); // 将原路径的前i个字符复制到新的字符串构建器中
            int dots = 1; // 初始化dots为1,用来计数'.'字符
            ++i; // 继续遍历路径

            for(; i < end; ++i) {
                char c = path.charAt(i); // 获取路径中的当前字符
                switch (c) {
                    case '.':
                        // 如果当前字符是'.'
                        if (dots > 0) {
                            ++dots; // 增加dots计数
                        } else if (slash) {
                            dots = 1; // 如果前一个字符是'/',dots置为1
                        } else {
                            canonical.append('.'); // 否则将'.'添加到新的字符串构建器中
                        }
                        slash = false; // 当前字符不是'/'
                        continue;
                    case '/':
                        // 如果当前字符是'/'
                        if (doDotsSlash(canonical, dots)) {
                            return null; // 如果doDotsSlash返回true,返回null
                        }
                        slash = true; // 当前字符是'/'
                        dots = 0; // dots置为0
                        continue;
                }

                // 将前面的'.'字符添加到新的字符串构建器中
                while(dots-- > 0) {
                    canonical.append('.');
                }

                canonical.append(c); // 添加当前字符到新的字符串构建器中
                dots = 0; // dots置为0
                slash = false; // 当前字符不是'/'
            }

            // 检查剩余的'.'字符
            if (doDots(canonical, dots)) {
                return null; // 如果doDots返回true,返回null
            } else {
                return canonical.toString(); // 返回处理后的路径字符串
            }
        }
    } else {
        return path; // 如果路径为空或空字符串,直接返回
    }
}

// 处理路径中的连续'.'字符和'/'字符
private static boolean doDotsSlash(StringBuilder canonical, int dots) {
    if (dots == 2) {
        // 如果dots为2,表示路径中有"..",需要返回上一级目录
        int length = canonical.length();
        if (length == 0) {
            return true; // 如果字符串构建器为空,返回true表示路径无效
        }

        int slash = canonical.lastIndexOf("/"); // 获取最后一个'/'的位置
        if (slash < 0) {
            canonical.setLength(0); // 如果没有'/',清空字符串构建器
        } else {
            canonical.setLength(slash); // 否则将字符串构建器的长度设置为最后一个'/'的位置
        }
    } else if (dots == 1) {
        // 如果dots为1,表示路径中有".",忽略
    } else if (dots > 2) {
        // 如果dots大于2,将'.'添加到字符串构建器中
        while(dots-- > 0) {
            canonical.append('.');
        }
    }
    return false; // 返回false表示路径有效
}

// 处理路径中剩余的'.'字符
private static boolean doDots(StringBuilder canonical, int dots) {
    if (dots == 2) {
        // 如果dots为2,表示路径中有"..",需要返回上一级目录
        int length = canonical.length();
        if (length == 0) {
            return true; // 如果字符串构建器为空,返回true表示路径无效
        }

        int slash = canonical.lastIndexOf("/"); // 获取最后一个'/'的位置
        if (slash < 0) {
            canonical.setLength(0); // 如果没有'/',清空字符串构建器
        } else {
            canonical.setLength(slash); // 否则将字符串构建器的长度设置为最后一个'/'的位置
        }
    } else if (dots == 1) {
        // 如果dots为1,表示路径中有".",忽略
    } else if (dots > 2) {
        // 如果dots大于2,将'.'添加到字符串构建器中
        while(dots-- > 0) {
            canonical.append('.');
        }
    }
    return false; // 返回false表示路径有效
}

canonicalPath大致处理逻辑:

处理斜杠:

  • 遍历前8个字符(全是斜杠),slash始终为 true

遇到第一个点号

  • 第9个字符是点号(.),slashtrue,跳出循环。此时 i = 8

初始化 StringBuilder

  • canonical.append(path, 0, i) 将前8个斜杠添加到 canonical,此时 canonical = "////////",i=9,开始遍历剩余的路径字符。

遇到点号和斜杠

  • 第9到26个字符处理过程中,路径包含多个 ../../ 模式。每次遇到 .. 后的斜杠会调用 doDotsSlash 函数。

调用 doDotsSlash 处理 ..

  • 每次遇到 .. 和随后的斜杠,会返回上一级目录。第一次遇到..的时候,dots = 2,调用 doDotsSlash
    • canonical.length() = 8
    • canonical.lastIndexOf("/") = 7(最后一个斜杠的位置)
    • canonical.setLength(7),结果:canonical = "///////"
  • 以此类推,处理 ../../../../../../../,多次调用 doDotsSlash 函数返回上级目录,逐步移除斜杠。最终返回的路径为 /etc/passwd

总结规律: ⼀个../可以消去⼀个/

柳暗花明

再回头来看,只要canonicalPath不为null, subpath是直接交给PathResource对象的,然后addPath拼接url

image-20240611012844357

image-20240611013348426

其实还有一些细节也没讲到,大家可以自己多调试一些payload,了解最终的poc是怎么形成的

img