浅谈下Fastjson的autotype绕过

Posted by kingkk on 2020-06-08

前言

继去年1.2.47 Fastjson被绕过之后,最近的1.2.68又出现了绕过。

正好前段时间翻了一遍Fastjson的源码,对整体逻辑有了一些了解,就尝试分析下autotype的校验过程,以及这两次绕过的思路。若有错误,还望指出。

autotype的校验

为什么校验一直被绕过?

1.2.24之后,fastjson对反序列化的类型进行了校验,主要就体现在ParserConfig.checkAutoType函数中

里面会对反序列化的类型进行黑白名单和校验,然后获取对应的Java类。

至于为什么没开启SupportAutoType属性依然会存在反序列化的危险呢?

可以看到在解析过程中,只要key值为@type时,就会进入checkAutoType函数尝试获取类。

而且校验SupportAutoType属性的工作却是在checkAutoType函数中完成的(跟进之后也可以看到是在函数最末端调校验的值,并且在这之前有多处return)

那为什么要有这种设计呢?主要原因在于fastjson想让一些基础类(还有一些白名单中的异常类)可以不受SupportAutoType限制就可以反序列化。

例如之前别人提出的验证是否使用fastjson的java.net.Inet6Addressjava.net.URL也都是这个原理。

可以看到,即使不开启SupportAutoType依然是可以获取到具体的java类的。

所以,这就是为什么校验一直被绕过,感觉主要原因就在于为了实现这个feature,而导致的一些逻辑问题。

校验过程

checkAutoType主要有三个参数

  • String typeName 被序列化的类名
  • Class<?> expectClass 期望类
  • int features 配置的feature值

先简单说下expectClass这个期望类,它的主要目的是为了让一些实现了expectClass这个接口的类可以被反序列化。

然后来看下校验的过程,一开始就是一些非null和长度限制的判断

之后判断exceptClass的类型,如果非null并且不是如下类型,则设置expectClassFlagtrue

简单说的话就是不允许如下类型的exceptClass

  • Object.class
  • Serializable.class
  • Cloneable.class
  • Closeable.class
  • EventListener.class
  • Iterable.class
  • Collection.class

之后比较长的一个部分就是比较类的哈希值,是否在内部白名单和内部黑名单中

如果在不在内部白名单并且 开启了SupportAutoType 或者 存在期望类时:如果在白名单中则直接加载,在黑名单中则异常退出。(讲起来有点绕,直接看代码可能好点)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
String className = typeName.replace('$', '.');
Class<?> clazz;

final long BASIC = 0xcbf29ce484222325L;
final long PRIME = 0x100000001b3L;

final long h1 = (BASIC ^ className.charAt(0)) * PRIME;
if (h1 == 0xaf64164c86024f1aL) { // [
throw new JSONException("autoType is not support. " + typeName);
}

if ((h1 ^ className.charAt(className.length() - 1)) * PRIME == 0x9198507b5af98f0L) {
throw new JSONException("autoType is not support. " + typeName);
}

final long h3 = (((((BASIC ^ className.charAt(0))
* PRIME)
^ className.charAt(1))
* PRIME)
^ className.charAt(2))
* PRIME;

boolean internalWhite = Arrays.binarySearch(INTERNAL_WHITELIST_HASHCODES,
TypeUtils.fnv1a_64(className)
) >= 0;

if (internalDenyHashCodes != null) {
long hash = h3;
for (int i = 3; i < className.length(); ++i) {
hash ^= className.charAt(i);
hash *= PRIME;
if (Arrays.binarySearch(internalDenyHashCodes, hash) >= 0) {
throw new JSONException("autoType is not support. " + typeName);
}
}
}

if ((!internalWhite) && (autoTypeSupport || expectClassFlag)) {
long hash = h3;
for (int i = 3; i < className.length(); ++i) {
hash ^= className.charAt(i);
hash *= PRIME;
if (Arrays.binarySearch(acceptHashCodes, hash) >= 0) {
clazz = TypeUtils.loadClass(typeName, defaultClassLoader, true);
if (clazz != null) {
return clazz;
}
}
if (Arrays.binarySearch(denyHashCodes, hash) >= 0 && TypeUtils.getClassFromMapping(typeName) == null) {
throw new JSONException("autoType is not support. " + typeName);
}
}
}

之后就是尝试从各种地方去获取class类

首先尝试从TypeUtilsmappings中获取对应类

里面原本就有一些类,而且后续会被当作已获取类的缓存使用

然后是尝试从deserializers.findClass中获取class类

这里面的类主要是在ParserConfig.initDeserializers()中被赋值的。

也就相当于这些特殊类也可以被无条件的反序列化

然后就是尝试从typeMapping中获取对应类,这其中默认的值为空,需要开发人员自行赋值。

之后就是类在白名单中时(但几乎不大可能),尝试自动去加载类。

最后,如果通过以上方式可以加载到类,则校验期望类,没有问题的话就直接返回对应的class。

所以其实到这里,依然还没有出现SupportAutoType的校验,但已经可以返回类了(但正常情况下返回的一般都是程序中预先设置好的一些类,还不存在动态加载)。

然后就是在没有开启SupportAutoType时,通过黑白名单去校验类,黑名单抛出异常,白名单加载类并返回。

之后的部分就是通过ASM的操作,去读取类是否有JSONType的注解(有注解的类一般都是开发自行写的JavaBean)

之后如果 开启了SupportAutoType 或者 有JSONType的注解 或者 存在期望类,则会直接去加载对应类

成功加载类之后,如果有注解,则加入mapping缓存并直接返回

如果是继承/实现了ClassLoaderDataSourceRowSet这些类的话直接异常。

如果存在期望类,则需要加载的类是期望类的子类或实现,并直接返回,否则异常。

如果类指定了JSONCreator注解,并且开启了SupportAutoType 则抛出异常。

最后,校验了是否开启SupportAutoType,然后将类添加至mapping缓存,并返回对应类。

到此就是checkAutoType的校验与加载类的过程。

小结

可以看到虽然函数名是checkAutoType,但是其实这是一个校验与加载类的过程。

而且真正的SupportAutoType校验其实是被放到最后的,在这之前也存在许多加载类并返回类的地方,目的也就是一开始说的为了实现基础类的任意反序列化的feature。

这也就意味着需要通过逻辑来保证在这之前返回的类都是安全的,但也正是因为这个原因导致了autotype被逻辑绕过。

可以看到主要有如下种情况可以直接返回class

  • acceptHashCodes 白名单
  • INTERNAL_WHITELIST_HASHCODES 内部白名单
  • TypeUtils.mappings mappings缓存
  • deserializers.findClass 指定类
  • typeMapping.get 默认为空
  • JsonType 注解
  • exceptClass 存在期望类

1.2.47的绕过

主要分析思路,这回的绕过主要靠的是mappings缓存的绕过

根据之前分析的流程可以知道,当mappings缓存中存在指定类时,可以直接返回并且不受SupportAutoType的校验。

TypeUtils.loadClass中,如果参数中cache值为true时,则会在加载到类之后,将类加入mappings缓存

寻找所有调用了该函数,并且cache设置为true的只有它的重载函数,然后继续寻找调用了该重载的地方

可以看到除了TypeUtils中,还有MiscCodec中调用了该方法

这里的逻辑是当class是一个java.lang.Class类时,会去加载指定类(从而也就无意之间加入了mappings缓存)

java.lang.Class同时也是个默认特殊类,可以直接反序列化。

因此就可以首先通过反序列化java.lang.Class指定恶意类,然后恶意类被加入mappings缓存后,第二次就可以直接从缓存中获取到恶意类,并进行反序列化。

Throwable和1.2.68的绕过

这两个的绕过主要都是基于exceptClass期望类的feature特性。

之前分析的时候提到,期望类的功能主要是实现 继承了期望类的class能被反序列化出来(并且不受autotype影响)

但是默认情况下exceptClass这个参数是空的,也就不存在期望类的特性。所以主要关注在程序内部别的地方的调用。

全局搜索一下可以看到主要有ThrowableDeserializerJavaBeanDeserializer两个类中有调用到。

先来说ThrowableDeserializer,它主要是对 Throwable异常类进行反序列化的。

ThrowableDeserializer中可以根据第二个@type的值来获取具体类,并且传入指定期望类进行加载。

因此对一个异常类进行反序列化时,则可以依赖exceptClass期望类的特性去反序列化一个继承异常类的class。

但没有gadget时这也只能算作一个feature,本意也就是为了反序列化出异常类,并且异常类的限制其实比较苛刻。

其实一开始看浅蓝师傅发了这个之后,自己也关注到了JavaBeanDeserializer中的期望类调用,然后开始尝试看何种情况会调用JavaBeanDeserializer

ParserConfig.getDeserializer中可以看到,其实JavaBeanDeserializer的优先级其实是最低的(通常情况下都是一些第三方类才会调用到这里)

当时就草草看了下一些默认的基础类发现貌似没有可以走到这部分逻辑的就没整了(然后就被打脸了)。

1.2.68的绕过主要靠的就是AutoCloseable类,恰好fastjson没有为它指定特定的deserializer,因此会走到最后的else条件,创建对应的JavaBeanDeserializer。并且它是默认在mappings缓存中的,可以无条件反序列化。

JavaBeanDeserializer中也和之前一样,会根据第二个@type的值去获取对应的class

这里的exceptClass期望类也就是当前类AutoCloseable

而且相较于Throwable来说,AutoCloseable的范围则会大得多,常用的流操作、文件、socket之类的都继承了AutoCloseable接口。

之后的工作则是需要找一个gadget,但相较于1.2.47的绕过来说,exceptClass期望类的返回位置相对比较靠后。

因此会存在黑名单的校验与ClassLoaderDataSourceRowSet的校验。

也就意味着之前的gadget是都不能用了,要找一条新的基于AutoCloseable的gadget。

至于后面的利用FieldDeserializer去拓展gadget就不在这里展开说了。

最后

以我个人的分析来看,主要原因还是在于Fastjson为了维护最开始那些基础类的无限制反序列化的特性。

导致即使开发人员关闭了SupportAutoType属性,但并不能阻止所有反序列化的情况。

Fastjson内部也是通过逻辑来保证校验前的返回类不会出现恶意类的情况,但是当整个项目变大之后,相互之间的调用会使得逻辑变得复杂,从而也就出现了逻辑绕过。

一次次的绕过和修复,对研究人员的代码功底要求也比较高,这种相互之间的博弈也相当精彩,值得好好学习一番。

References

https://b1ue.cn/archives/382.html

https://b1ue.cn/archives/348.html