IDEA配置Java远程调试,以CVE-2024-4956为例

背景

学习代码审计,看到一些Java的漏洞,想要动手调试,复现漏洞搭建环境可以使用docker快速创建,了解到Java可以远程调试,本文记录学习Java远程调试环境搭建的过程。

远程调试的原理

如下图(图源:doc.oracle.com):

JPDA
首先需要明白上述些许名词的含义:

  • JDPA: Java Platform Debugger Architecture,直译Java平台调试架构,是Java为应用程序提供调试服务的一套框架。层次分明的结构提供了跨平台的特性,包含三层分别是JVM TI、JDWP、JDI
  • JVM TI: Java VM Tool Interface,Java虚拟机工具接口,是由VM(即Java虚拟机)实现的一组本地API,定义了VM必须提供的用于调试的服务。
  • JDWP: Java Debug Wire Protocol,Java调试线路协议,定义了后端与前端之间传输的信息和请求的格式。但JDWP没有定义传输机制
  • JDI: Java Debug Interface,Java调试接口,定义了用户代码级别的信息和请求。

也就是说,JDPA定义了一个框架,该框架包含三大模块,分别是后端的JVM TI、前端的JDI、以及定义了中间信息格式的JDWP。当我们调试某程序时,程序在VM(即Java虚拟机后续不在赘述)中运行,且VM实现了JVM TI,调试器后端即通过JVM TI与VM通信获取运行时的各种响应信息。

JDWP定义了调试器后端与调试器前端之间的通信格式,调试器后端将响应信息按照JDWP的规定包装后发送给调试器前端,还记得前面说的“JDWP没有定义传输机制”吗,这就意味着可以使用多种传输机制,例如可以是我们远程调试时使用的套接字。

现在已经明确了,JVM TI用于在VM运行时(调试时)收集调试关注的信息(响应),将响应按照JDWP打包后可以通过多种方式传输至调试器前端,包含套接字。调试器前端通过实现JDI,规定代码级别的请求,例如在何处断点,并将该信息同样打包成JDWP格式,以控制调试器后端。

像IDEA与Eclipse,都实现了JDI与自己UI界面来控制调试器的后端,进而操控VM,获取运行时的调试信息。

Demo:CVE-2024-4956远程调试

CVE-2024-4956是Nexus Repository 3的一个任意文件读取漏洞。我是用的docker镜像是官方的sonatype/nexus3:3.68.0-java8

首先创建一个IDEA空项目,创建一个远程JVM调试配置,IDEA实现了调试器的客户端,且会自动帮我们生成JVM的启动命令行参数,如下:

创建与配置JVM远程调试
这里调试器模式有两种,附加到远程JVM和;拷贝启动参数,-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005

  • agentlib:jdwp:这是指定使用Java调试线程库的前缀。
  • transport=dt_socket:这表明调试数据将通过套接字(Socket)传输。
  • server=y:表示Java应用程序将作为调试服务器运行,调试器可以远程连接到这个服务器。
  • uspend=n:表示Java虚拟机(JVM)启动时不会暂停,即使调试器还未连接,程序也会继续运行。如果设置为suspend=y,则JVM会在启动时暂停,直到调试器连接后才继续执行。
  • address=5005:这是调试服务器监听的端口号,调试器需要连接到这个端口进行远程调试。这里设置为5005,也可以选择任何未被占用的端口。

第二步,修改VM的启动参数,添加启用远程调试。install4j是一个用于打包Java应用程序的工具,该镜像使用了install4j的环境变量INSTALL4J_ADD_VM_PARAMS,我们可以通过该环境变量修改启动参数。

docker inspect 镜像id

# 原始环境变量
INSTALL4J_ADD_VM_PARAMS=-Xms2703m -Xmx2703m -XX:MaxDirectMemorySize=2703m -Djava.util.prefs.userRoot=/nexus-data/javaprefs
# 修改后的环境变量(即将idea中copy出的参数附加)
INSTALL4J_ADD_VM_PARAMS=-Xms2703m -Xmx2703m -XX:MaxDirectMemorySize=2703m -Djava.util.prefs.userRoot=/nexus-data/javaprefs -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005

docker通过-e选项指定启动的环境变量,于是得到容器的启动命令如下:

docker run -d -p 8081:8081 -p 5005:5005 --name nexus_3.68.0 -e INSTALL4J_ADD_VM_PARAMS="-Xms2703m -Xmx2703m -XX:MaxDirectMemorySize=2703m -Djava.util.prefs.userRoot=/nexus-data/javaprefs -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005" sonatype/nexus3:3.68.0-java8

第三步把jar包copy出来,附加到IDEA。要确保本地与远程的要调试部分的代码是一样的,这样我们在IDEA本地打断点,调试前端获取断点信息发送到调试后端,调试后端才能正确解析。

一开始我查到的文章,这里写的比较粗略,我一度一位本地是个空项目都能调试了,我在想,那怎么打断点呢?一些文章写要保证本地与远程的源码一样,看过文档我觉得“只要保证打断点部分的代码一样就可以了”,为验证该想法下面我做了实验:

实验:

  1. 在本地写一个web服务,打包成jar包,8090端口提供web服务
  2. 在服务器运行该jar包,为了方便我这里也使用了docker容器里的java环境,5006端口调试
  3. 本地配置IDEA调试客户端环境,连接服务器5006端口的调试端口
  4. 修改本地源码,下断点,访问web服务,观察是否还能正确触发断点
# Dockerfile
FROM vulhub/java:8u221-jdk
COPY ./apptest.jar /tmp/app.jar
EXPOSE 8090
ENTRYPOINT java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5006 -jar /tmp/app.jar
// apptest.jar的源码
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;

public class MainClass {
    public static void main(String[] args) throws Exception {
        HttpServer server = HttpServer.create(new InetSocketAddress(8090), 0);
        server.createContext("/test", new MyHandler());
        server.setExecutor(null); // creates a default executor
        server.start();
    }

    static class MyHandler implements HttpHandler {
        @Override
        public void handle(HttpExchange t) throws IOException {
            String response = "This is the response";
            t.sendResponseHeaders(200, response.length());
            OutputStream os = t.getResponseBody();
            os.write(response.getBytes());
            os.close();
        }
    }
}

# 构建镜像
docker build -t test:v1.0 .
# 启动容器
docker run -d -p 8090:8090 -p 5006:5006 test:v1.0
# 配置IDEA调试客户端

idea成功连接调试后端
随后修改了response的值,甚至是response变量的名称,发现在访问/test路径时,依旧可以触发断点,如下图所示;因此不需要保证本地源码与远程源码的“完全一致”,这点也很好理解,JDWP规定的信息也必然不是像“xx行xx变量有断点”此类的信息,源码被翻译为字节码,只要保证字节码时对应的,即可正确匹配(我觉得)。后续有深入研究再来探讨该问题。

修改变量名称仍可触发断点
手动设置值

CVE-2024-4956漏洞分析

如上配置好调试环境,把jar包copy出来,在IDEA中导入,项目结构=》模块=》依赖=》小加号“jar或目录”如下图:

导入jar包
我这里因为是看了别人的分析,知道漏洞点位于哪里,所以直接从docker容器里复制的特定jar包出来的。看了其他师傅的分析,get了一个小技巧:

# 将目录下的所有 jar 都复制到同一目录下, 方便 IDEA 添加依赖
mkdir ../all-lib
find . -name "*.jar" -exec cp {} ../all-lib/ \;

从官方给出的临时解决方案开始分析:

官方给出的临时解决方案
告诉我们要删除jetty.xml中的<Set name="resourceBase"><Property name="karaf.base"/>/public</Set>行,之后通过访问robots.txt来观察,若是404代表临时解决方案生效。

nexus对静态资源文件的获取有如下三种方法,优先级从1到3;目的都是获取路径,再检查请求的文件是否存在于这些路径中:

  1. getFileIfOnFileSystem,该方法从系统定义的环境变量或系统属性中获取路径,再从这些路径中get文件。默认为空。
  2. this.resourcePaths本身就是一个哈希表,是系统维护的一批路径,通过调试可以发现有2012条。
  3. this.servletContext,调用了Jetty的WebAppContext获取资源文件。
    获取资源文件
    2012条
    方法1默认为空,常规的静态资源文件通过方法2获取,在2中无法命中的交给3即jetty处理。问题即出在3处,即jetty的处理中。

访问不存在的a.txt

	// servletContext.getResource(path)
	public Resource getResource(String path) throws MalformedURLException {
        if (path != null && path.startsWith("/")) {
            if (this._baseResource == null) {
                return null;
            } else {
                try {
                    Resource resource = this._baseResource.addPath(path);
                    return this.checkAlias(path, resource) ? resource : null;
                } catch (Exception var3) {
                    Exception e = var3;
                    LOG.ignore(e);
                    return null;
                }
            }
        } else {
            throw new MalformedURLException(path);
        }
    }
    // this._baseResource.addPath(path);
        public Resource addPath(String subPath) throws IOException {
        if (URIUtil.canonicalPath(subPath) == null) {
            throw new MalformedURLException(subPath);
        } else {
            return "/".equals(subPath) ? this : new PathResource(this, subPath);
        }
    }
    

如上是getResource与addPath的源码,首先会判断传入的path值是否为空,是否以/开头,之后与_baseResource“拼接”,即addPath方法,_baseResource的path属性为:“/opt/sonatype/nexus/public”,即将会从public路径下寻找匹配的文件。

addPath中为防止路径穿越的问题,做了处理,即canonicalPath函数,对传入的subPath进行“标准化”,具体逻辑如下:

// URIUtil.canonicalPath(subPath)
    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;
        }
    }
    // (doDotsSlash(canonical, dots))
	private static boolean doDotsSlash(StringBuilder canonical, int dots) {
        switch (dots) {
            case 0:
                canonical.append('/');
                break;
            case 1:
                return false;
            case 2:
                if (canonical.length() < 2) {
                    return true;
                }

                canonical.setLength(canonical.length() - 1);
                canonical.setLength(canonical.lastIndexOf("/") + 1);
                return false;
            default:
                while(true) {
                    if (dots-- <= 0) {
                        canonical.append('/');
                        break;
                    }

                    canonical.append('.');
                }
        }

        return false;
    }
  1. 先检查传入的路径path,既不是null也非空;
  2. 之后进入第一个label68循环,在该循环中对路径的每一个字符进行遍历,出现/.之前时跳出label68的循环。
  3. 下面判断循环是“正常结束”还是“提前跳出”,“正常结束”即i==end; “提前跳出”即遇到“出现/.之前”的情况。“正常结束”则返回path。
  4. 若是“提前跳出”,则维护一个dots变量标识点的数量并新建一个字符串,并将不包含该.在内的往前所有字符,保存至新字符串中,称之为标准字符串;接下来从下一个位置开始继续遍历字符串。
  5. dots一开始将被初始化为1,新的遍历将跳过该.,直接读取下一个字符,此时进入一个switch判断该字符:1.若为.:先判断dos,若dots大于0则dots自增并将slash变量置为false。若dots不大于0判断slash是否为true,若为true,dots置为1;2.若为/,判断doDotsSlash函数的值,若为true返回null,否则将slash置为true且清零dots。3.若为正常字符则将前面的点号都追加上,将此正常字符也追加。
  6. 下面来看doDotsSlash函数的逻辑,接受两个参数,标准字符串和点的数量dots,若点的数量为0,直接追加一个/,返回false;若点的数量为1,返回false;若点的数量为2,则将标准字符串长度减一(删掉最后一个字符),再寻找标准字符串中的最后一个/,将之后的都删掉。返回false;若点的数量大于2,则向标准字符串追加/,最后追加/,返回false。
  7. 只有一种情doDotsSlash会返回true,则标准化字符串返回null,即已经有两个/,且标准化字符串的长度小于2,即全是.

上面以“流水账”的形式走了一遍代码的流程,可以看出,标准化函数canonicalPath,已经在避免路径穿越的情况发生;第一次遍历path中的每个字符串,当遇到/之后有.时,跳过该.将前面的保存为新的标准化字符串,认为是没有问题的;对后续的字符串特殊处理,进入第二个遍历,遇到.前点的前面没有斜线也没有点时,认为时“良性”直接追加点号;否则将数量dots加1;

当再次遇到斜线后,前面无点、有一个点、有2个以上点时,都将点追加至标准字符串即可。只有当斜线前面点的数量恰好为2时,删除最后一个斜线后的所有字符,这两个连续的点也将被跳过。

写到这里的时候我在想这么严格的过滤,这怎么绕过?

但是addPath中,只是一个通过canonicalPath函数做了个判断,结果是否为null,并没有使用其返回值;之后将传入的路径与基础路径进行拼接,造成了路径穿越。
addPath函数
拼接
如果使用一些很明显的路径穿越payload是会被判null,从而抛出错误的。这就是前面doDotsSlash函数中,dots数量为2且标准化字符串长度小于2的情况。(测了下挺鸡肋的,两个.开头会抛出400错误,这是因为之前已经有了开头必须为/的判断了,因此这里的path前两个字符必定为"//")

第一次调,还有很多稀里糊涂的地方,有疑问评论区交流、多多批评

Reference

https://docs.oracle.com/javase/8/docs/technotes/guides/jpda/architecture.html
https://blog.csdn.net/ywlmsm1224811/article/details/98611454
https://exp10it.io/2024/05/通过-java-fuzzing-挖掘-nexus-repository-3-目录穿越漏洞-cve-2024-4956/
https://xz.aliyun.com/t/14623
https://support.sonatype.com/hc/en-us/articles/29412417068819-Mitigations-for-CVE-2024-4956-Nexus-Repository-3-Vulnerability

  • 8
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值