背景
学习代码审计,看到一些Java的漏洞,想要动手调试,复现漏洞搭建环境可以使用docker快速创建,了解到Java可以远程调试,本文记录学习Java远程调试环境搭建的过程。
远程调试的原理
如下图(图源:doc.oracle.com):
首先需要明白上述些许名词的含义:
- 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和;拷贝启动参数,-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,我们可以通过该环境变量修改启动参数。
# 原始环境变量
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本地打断点,调试前端获取断点信息发送到调试后端,调试后端才能正确解析。
一开始我查到的文章,这里写的比较粗略,我一度一位本地是个空项目都能调试了,我在想,那怎么打断点呢?一些文章写要保证本地与远程的源码一样,看过文档我觉得“只要保证打断点部分的代码一样就可以了”,为验证该想法下面我做了实验:
实验:
- 在本地写一个web服务,打包成jar包,8090端口提供web服务
- 在服务器运行该jar包,为了方便我这里也使用了docker容器里的java环境,5006端口调试
- 本地配置IDEA调试客户端环境,连接服务器5006端口的调试端口
- 修改本地源码,下断点,访问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调试客户端
随后修改了response的值,甚至是response变量的名称,发现在访问/test路径时,依旧可以触发断点,如下图所示;因此不需要保证本地源码与远程源码的“完全一致”,这点也很好理解,JDWP规定的信息也必然不是像“xx行xx变量有断点”此类的信息,源码被翻译为字节码,只要保证字节码时对应的,即可正确匹配(我觉得)。后续有深入研究再来探讨该问题。
CVE-2024-4956漏洞分析
如上配置好调试环境,把jar包copy出来,在IDEA中导入,项目结构=》模块=》依赖=》小加号“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;目的都是获取路径,再检查请求的文件是否存在于这些路径中:
getFileIfOnFileSystem
,该方法从系统定义的环境变量或系统属性中获取路径,再从这些路径中get文件。默认为空。this.resourcePaths
本身就是一个哈希表,是系统维护的一批路径,通过调试可以发现有2012条。this.servletContext
,调用了Jetty的WebAppContext获取资源文件。
方法1默认为空,常规的静态资源文件通过方法2获取,在2中无法命中的交给3即jetty处理。问题即出在3处,即jetty的处理中。
// 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;
}
- 先检查传入的路径path,既不是null也非空;
- 之后进入第一个label68循环,在该循环中对路径的每一个字符进行遍历,出现
/
在.
之前时跳出label68的循环。 - 下面判断循环是“正常结束”还是“提前跳出”,“正常结束”即
i==end
; “提前跳出”即遇到“出现/
在.
之前”的情况。“正常结束”则返回path。 - 若是“提前跳出”,则维护一个dots变量标识点的数量并新建一个字符串,并将不包含该
.
在内的往前所有字符,保存至新字符串中,称之为标准字符串;接下来从下一个位置开始继续遍历字符串。 - 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.若为正常字符则将前面的点号都追加上,将此正常字符也追加。 - 下面来看doDotsSlash函数的逻辑,接受两个参数,标准字符串和点的数量dots,若点的数量为0,直接追加一个
/
,返回false;若点的数量为1,返回false;若点的数量为2,则将标准字符串长度减一(删掉最后一个字符),再寻找标准字符串中的最后一个/
,将之后的都删掉。返回false;若点的数量大于2,则向标准字符串追加/
,最后追加/
,返回false。 - 只有一种情doDotsSlash会返回true,则标准化字符串返回null,即已经有两个
/
,且标准化字符串的长度小于2,即全是.
。
上面以“流水账”的形式走了一遍代码的流程,可以看出,标准化函数canonicalPath,已经在避免路径穿越的情况发生;第一次遍历path中的每个字符串,当遇到/
之后有.
时,跳过该.
将前面的保存为新的标准化字符串,认为是没有问题的;对后续的字符串特殊处理,进入第二个遍历,遇到.
前点的前面没有斜线也没有点时,认为时“良性”直接追加点号;否则将数量dots加1;
当再次遇到斜线后,前面无点、有一个点、有2个以上点时,都将点追加至标准字符串即可。只有当斜线前面点的数量恰好为2时,删除最后一个斜线后的所有字符,这两个连续的点也将被跳过。
写到这里的时候我在想这么严格的过滤,这怎么绕过?
但是addPath中,只是一个通过canonicalPath函数做了个判断,结果是否为null,并没有使用其返回值;之后将传入的路径与基础路径进行拼接,造成了路径穿越。
如果使用一些很明显的路径穿越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