从2017年第一次公开绕过细节开始,Fastjson就一直在安全圈保持着极高的“出镜率”。一个JSON解析库能连续多年成为漏洞研究的热点,在整个Java生态里都很难找到第二个。我见过不少团队被这个库折腾得够呛:有的因为线上系统用了旧版本,被扫描到风险后只能连夜升级;有的则是在代码审计时发现自己写的接口直接用了JSON.parseObject(input),完全控制了解析入口。其实搞懂Fastjson的漏洞原理,不需要把每个利用链都背得滚瓜烂熟,关键是理解它为什么会被绕过、不同版本之间卡的点在哪、以及你在评估一个目标时“出不出网”为什么是首要问题。这篇文章我就按自己的理解,把1.2.24、1.2.47、1.2.80这三段经典的版本漏洞、利用思路、不出网判断方法以及修复路径完整梳理一遍。
适合看这篇文章的人有三类:一是做Java安全测试的同行,需要快速理清漏洞脉络;二是负责中间件或Java系统的运维开发,想知道修复方案到底选哪个;三是刚接触反序列化漏洞,想把“为什么一条JSON能变成命令执行”这件事从根上搞懂的同学。
1. 先搞明白:一个JSON解析库为什么会变成RCE入口
1.1 反序列化的本质:把数据变回对象
先说最基本的概念。JSON本身只是一段有格式的文本,它描述的是数据。而反序列化做的事情,就是把这段文本按照某种规则还原成内存里的Java对象。比如前端传过来{"name":"张三","age":18},服务端解析之后得到一个User对象,name字段是"张三",age字段是18。这个流程在正常情况下没有任何问题。
但Java对象的还原,并不只是“把字符串塞进字段”这么简单。当一个对象的属性被赋值时,Java会调用对应的setter方法;当某些字段被访问时,会调用getter方法。这些方法里写的是业务逻辑,但也可能藏着危险动作。如果攻击者能够控制“反序列化成哪个类”以及“给哪些属性赋值”,就相当于拿到了一个可以任意调用类方法的入口。
Fastjson和普通的JSON库有一个关键区别:它支持在JSON文本里通过@type字段指定要反序列化的具体类名。也就是说,解析器拿到一段JSON之后,会看里面有没有@type,如果有,就按照这个类名去加载类,然后反序列化。这个特性本意是为了解决多态问题——比如一个接口有多个实现类,序列化时记录下具体是哪个实现类,反序列化时才能还原正确。但它同时也打开了一扇门:如果这个类名和属性可以被攻击者控制,攻击者就能让服务器去加载任意类。
1.2 AutoType:漏洞的关键入口
@type这个特性在Fastjson里被叫做AutoType,意思是“自动类型”。它在1.2.x系列里默认是开启的(不同小版本策略略有差异)。解析时Fastjson会调用checkAutoType方法,传入从JSON里提取出来的类名,然后决定是否允许加载这个类。
核心问题就出在这条加载链路上。一个类被加载之后,Fastjson会查找它的setter方法,并按照JSON里的字段一一赋值。如果这个类本身是一个“危险类”——比如它的某个setter或getter会触发JNDI查询、会加载字节码、会读写文件——那么攻击者就能在反序列化的过程中触发远程命令执行。
打个简单的比方:AutoType相当于火车站的乘客安检口。正常情况下它检查每个人的票,但总有一些人能伪造票或者从别的入口溜进去。Fastjson从1.2.24到今天,大部分漏洞的本质都是在和这个“安检”机制博弈。
1.3 从一条JSON到命令执行,中间发生了什么
用最直白的方式描述整条链路:
- 服务端代码调用了
JSON.parseObject(input),input是攻击者可控的HTTP请求体。 - 解析器看到输入里有
@type字段,提取出类名。 checkAutoType对这个类名做校验,校验逻辑涉及黑名单、缓存、期望类等机制。- 类加载通过后,解析器实例化对象,并逐个调用可用的setter或getter。
- 某个setter或getter内部触发了危险操作,比如
JdbcRowSetImpl的setAutoCommit会触发connect(),进而执行JNDI lookup。 - JNDI lookup返回一个远程加载的恶意类引用,服务器执行了恶意代码。
所以反序列化漏洞的利用,通常要满足两个条件:一是解析入口可控,二是存在一条可用的“危险类”利用链。版本演进的本质,就是在不断封堵这些危险类和绕过手段。
2. 三个版本段的利用思路:黑名单、缓存绕过与新探索
2.1 1.2.24及以下:没有黑名单的“裸奔”时代
在1.2.24及更早版本中,AutoType机制刚出来不久,几乎没有什么完整的防护。只要目标代码用了JSON.parseObject或者JSON.parse,并且能控制JSON内容,攻击者就可以直接在@type字段里写上任意类名。
当时最出名的利用链之一是通过com.sun.rowset.JdbcRowSetImpl这个JDK自带类来完成的。这个类有一个dataSourceName属性,设置后,当setAutoCommit(true)被调用时会触发connect(),然后对该数据源发起JNDI lookup。攻击者可以搭一个恶意的JNDI服务(比如通过RMI或者LDAP协议),在返回的对象里嵌入一段恶意字节码。服务器端一旦执行lookup,就会把恶意类加载进JVM,完成命令执行。
还有另一条链是com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl。这条链不需要JNDI服务,因为它直接在本地通过defineClass加载字节码。攻击者可以把恶意的字节码base64编码后塞进JSON的特定字段,反序列化时触发newTransformer(),字节码就被加载执行。这条链的利用条件更苛刻,对Fastjson版本和JDK版本都有要求,但它“不出网也能打”的特点,让它成为了一个经典研究案例。
这个时代给人的感觉就是“完全没有防御”。开发者如果直接用了1.2.24及以下的版本,攻击者只需要知道目标的JSON接口地址,再准备一个可用的利用链,就能尝试RCE。
2.2 1.2.25到1.2.47:黑名单与缓存绕过
1.2.25开始,官方加入了黑名单机制和checkAutoType校验。黑名单里收集了一批已知的危险类,比如JdbcRowSetImpl、TemplatesImpl这些都被加了进去。同时,checkAutoType会对类名做一些简单的关键字匹配,拦截掉看起来可疑的加载请求。这套机制刚上线时,确实挡住了一批脚本扫描。
但安全问题从来不是“加个名单”就能一劳永逸的。1.2.47这个版本被曝出的绕过思路,在当时的圈子引起了很大震动:先利用java.lang.Class将某个类“缓存”起来,再第二次加载时绕过黑名单校验。攻击者可以通过两步构造,第一次先让Fastjson加载一个无害的Class对象并把目标类放进缓存,第二次解析时checkAutoType发现类是缓存命中的,就直接放行,不再做黑名单检查。
这个绕过思路的价值在于:它打的不是“名单漏了某个类”的简单错误,而是打在了“缓存机制”这个设计缺陷上。只要目标版本在1.2.25到1.2.47之间,且AutoType没有被完全关闭,攻击者就可以用同样方法重新启用黑名单中的类,套用旧的利用链完成攻击。这就是为什么很多修复建议里会特别强调:不要只修到1.2.47,要再往上升,就是因为1.2.47虽然修正了部分绕过,但后续还是被不断挖掘出新的方式。
实操中我看到过的情况是:很多自研框架为了兼容性,把fastjson版本锁在1.2.4x,认为“只要不是最老的版本就安全”,结果扫描器一发一个准。
2.3 1.2.80:旧链被封后的新探索
1.2.80是2022年中被曝出存在新绕过方式的版本。在这个时间点,官方已经做了大量加固工作:黑名单扩充了好几轮、加入了safeMode安全模式、对Class的缓存行为也做了限制。按常理说,利用的难度应该大幅提升。
但安全研究员们发现,在特定的期望类场景下,仍然存在绕过checkAutoType的可能性。1.2.80的绕过利用,思路集中在“期望类”上。简单说,如果反序列化代码本身指定了某个父类或接口,而攻击者传入的@type是它的合法子类,那么checkAutoType会直接以“期望类”身份放行,跳过黑名单。这时候如果能在期望类的上下文里找到某个危险子类,利用就能成立。公开讨论中比较多的入口包括Throwable、AutoCloseable这些基础类型——它们的子类数量非常多,而且有些子类是第三方库提供的,黑名单根本覆盖不过来。
这个版本的利用难度比之前高了不少,往往需要结合目标环境里实际存在的第三方库才能找到可用链条。但它说明了一个很残酷的现实:只要AutoType还开着,黑名单的思路就会被不断地用“新类”去挑战。你不可能通过“不停地往名单里加类”来获得永久的安全。
2.4 版本对比速查
| 版本范围 | 核心防护机制 | 关键绕过思路 | 利用难度 |
|---|---|---|---|
| 1.2.24及以下 | 基本无防护 | 直接指定黑名单外的危险类,JNDI/字节码利用 | 低 |
| 1.2.25 - 1.2.47 | 引入黑名单和checkAutoType | 借助java.lang.Class缓存绕过黑名单校验 | 中 |
| 1.2.48 - 1.2.79 | 黑名单持续扩充,限制Class缓存 | 依赖期望类上下文或新发现的可用类 | 中高 |
| 1.2.80 | 大量历史链路已封堵 | 利用期望类放行机制,寻找危险子类 | 高 |
这张表可以解释一个最常见的困惑:为什么漏洞公告总是按版本号划界,因为在不同的时间节点上,防护机制和绕过思路确实有质的差异。排查系统时,先确认fastjson的具体版本,比什么都重要。
3. 不出网判断:评估危害时绕不开的一步
3.1 “出网/不出网”到底指什么,为什么先判断它
“出网”这个词在利用评估里指目标服务器能否主动访问外网。为什么它如此关键?因为绝大多数反序列化利用链的最经典打法,都需要让服务器主动连外:要么是JNDI到攻击者控制的LDAP/RMI服务,要么是服务器请求一个远程URL上的恶意类或恶意配置。目标服务器如果根本无法访问外网,那么这类利用方式就直接失效了。
换句话说,判断出不出网,是决定“攻击者能不能用标准打法成功利用”的首要问题。也是防御方评估实际风险的关键步骤。一个内网深处的系统,即便存在fastjson漏洞,攻击者想利用它来弹出命令,难度会大几个量级。理解这个判断思路,能帮你在做风险评估时给出一个更客观的结论。
3.2 三种常用判断手法
DNS解析外呼判断。攻击者可以准备一个自己可控的域名,在Payload里插入一段让目标解析该域名的逻辑。比如通过JNDI注入让服务器去lookup一个包含恶意域名的地址,或者利用某些类的属性让目标发起DNS解析。如果随后在域名解析日志里看到了来自目标服务器的IP,就说明“能出网”。DNS是判断出网最常用的手法,因为很多网络环境即使禁了HTTP,也会放行DNS,而且整个过程不像HTTP请求那样容易引起警觉。
HTTP请求判断。让目标发起一个到攻击者可控HTTP服务器的请求。通过观察服务器收到的连接日志,确认目标是否能访问指定的TCP端口。这个方法比DNS更直接,能同时确认协议层和端口层是否通畅。但它更容易被网络策略拦截:如果目标环境有出网白名单,或者对HTTP请求做了代理管控,这个方法就会失败。
延时判断。有些环境既不能DNS外呼也不放行HTTP,但仍然存在一种“弱出网”的可能性——比如TCP层能出去但没有应用层协议。这时可以通过恶意类里的sleep逻辑做时间延时判断:如果反序列化后服务器响应明显变慢,说明代码被成功执行了,只是结果传不回来。这种方式牺牲了“证据强度”,但有时候是唯一可行的确认手段。
3.3 判断完之后的利用方向
如果目标不出网,不代表漏洞完全没用,只是利用路径变了。常见的思路有:
- 使用本地字节码类加载链,不需要远程连接,利用
TemplatesImpl这类在服务器内直接拼接字节码的机制。 - 通过错误信息回显来“带外”,比如让恶意代码把执行结果写入HTTP响应的某个字段中。
- 寻找目标内网可达的其他服务,利用服务器作为跳板发起内网探测。
对防御方来说,判断“不出网”的直接价值是:你初步评估的风险等级可以降一档。但要注意,不出网只是暂时的,攻击者可以在内网其他机器上做跳板,所以修复建议不能因为“不出网”就无限期拖延。
4. 修复方法与防御落地:不要只改一行配置
4.1 升级版本是第一优先级
如果你确认系统在用fastjson,且版本低于1.2.83,那修复方案里最核心的一条就是升级。1.2.83是目前官方发布的最后稳定版本,修复了1.2.80的已知绕过问题。升级时需要注意:fastjson的小版本间API兼容性整体比较好,但某些旧的序列化方式在新版中行为可能略有差异,建议在测试环境先跑一遍bug回归测试。
升级前先确认项目是通过哪种方式引入的fastjson。Maven项目直接在pom.xml里改版本号,Gradle则在build.gradle里调整。如果项目里同时存在多个依赖间接引入了fastjson,还需要检查依赖树,确保最终生效的版本是新的。这一步经常被忽略——明明pom里写了新版本,但一个间接依赖又带入了旧版本,导致修复无效。
4.2 AutoType安全模式:把门彻底锁上
单纯升级到1.2.83也不能保证100%安全,因为只要代码里用了AutoType,新的利用链理论上是无法被穷尽的。更彻底的做法是直接关闭AutoType。
1.2.68及以上版本提供了safeMode安全模式。开启方式有三种:
// 方式一:代码中全局开启 ParserConfig.getGlobalInstance().setSafeMode(true);// 方式二:JVM启动参数 -Dfastjson.parser.safeMode=true// 方式三:全局配置文件 fastjson.parser.safeMode=truesafeMode开启后,@type字段会被完全忽略,所有反序列化都按照JSON对应的类型来处理,不再支持多态。这就从根本上关闭了AutoType的攻击面。如果你的业务里确实需要用到多态序列化,官方也提供了一种替代方案:使用Feature.SupportAutoType白名单方式,只允许指定类被加载,而不是一刀切全部放行。
这里要提醒一点:safeMode对你业务功能的影响需要提前测试。如果项目里大量依赖多态,比如把各种子类对象序列化后存入Redis,再读出来还原,那直接开safeMode可能导致反序列化结果变成父类类型,功能错乱。
4.3 代码侧排查:找到所有解析入口
升级和开safeMode是从运行时层面防御。但还有一个事必须做:代码审计。你需要找到项目里所有调用fastjson解析的地方,包括:
JSON.parseObject(input)JSON.parse(input)JSONObject.parseObject(input)JSONArray.parseArray(input)等
对每个入口,确认传入的JSON内容是否可能被外部用户控制。如果解析的是自己内部配置、不涉及用户输入,风险相对较低;如果直接解析了HTTP请求体、消息队列的消息、第三方接口返回值,就需要重点关注。把入口整理成一个清单,逐个确认修复方案是否覆盖到位。
在实际代码审计中,我见到的典型问题是:开发为了性能或方便,直接在Controller里写JSON.parseObject(request.getBody()),完全不做类型校验。这种代码结构给漏洞利用提供了很好的条件。
4.4 修复自测清单
修复不能“改完就当结束了”,需要验证。这里给一份可以直接拿来用的自测清单:
| 项目 | 自测内容 | 预期结果 |
|---|---|---|
| 版本确认 | 检查依赖树,确认实际生效版本 | 必须是1.2.83或更高 |
| 安全模式 | 访问一个携带@type字段的请求 | 被拒绝或忽略,不触发目标类加载 |
| 功能回归 | 测试项目中所有依赖反序列化的业务 | 核心功能不报错,数据还原正常 |
| 依赖梳理 | 扫描所有间接依赖是否携带旧版fastjson | 无遗留低版本 |
| 日志检查 | 查看升级前后日志中是否有checkAutoType异常记录 | 无异常说明AutoType调用不再暴露 |
这份清单在每次修复后跑一遍,基本能覆盖绝大部分踩坑点。
5. 常见问题与实操排查记录
5.1 怎么快速确认项目里有没有用到Fastjson
最简单的方法是直接搜代码依赖。Maven项目执行:
mvn dependency:tree | grep fastjsonGradle项目执行:
gradle dependencies --configuration runtimeClasspath | grep fastjson如果项目是直接解压的war包或jar包,也可以搜索包里是否包含com.alibaba.fastjson目录。另外,运行中的Java进程可以通过查看进程加载的jar包来确认:
jps -l jinfo <pid> | grep fastjson相比代码搜索,这种方式更直观——即使代码里没发现显式import,但如果某个第三方框架内部用了fastjson,一样会加载到JVM里。
5.2 怎么确认当前版本是否受影响
确认版本号之后,对照官方的安全公告和已知漏洞版本范围比较。这里有个容易被忽略的点:fastjson内部版本号不一定和依赖声明的一致。有些框架做了版本封装,源码里的版本号可能是内置的。最可靠的方式是直接在ClassPath下找到对应的jar包,看jar包名里的版本号。
如果需要进一步确认运行时的实际行为,可以构造一个包含@type字段的测试请求,指向一个无害的类(比如java.lang.String),观察服务端是否返回了autotype相关错误信息。如果返回了,说明AutoType解析在运行;如果没有,说明要么被safeMode关闭了,要么请求根本没到达解析逻辑。
5.3 修复后如何验证有效性
升级到1.2.83并开启safeMode后,可以用一个标准验证流程确认修复效果。先构造一个使用已知危险类名的@type请求发送到接口,预期结果是:服务端响应报错或者完全不理解这个JSON的结构,而不是尝试加载并执行。同时观察应用日志,不应该出现对危险类的加载记录。
还有一个比较容易忽略的验证点:生产环境里可能要重启应用才会真正加载新的jar包和配置。如果你只是替换了jar包而没有重启,旧版本依然在运行,自测却看的是新代码,就会得出误判。
我在实际排查中遇到过一种情况:运维替换了jar包,但应用是通过JDK自带的类加载机制按整个lib目录加载的,替换时目录里同时存在了新旧两个版本jar包,最终被加载的是旧版本。所以验证时不要只看pom,要确认进程内实际加载的jar包路径和版本。
6. 最后再分享两点个人的经验
第一点,处理Fastjson这类历史漏洞,心态上不要追求“一劳永逸”。它是攻击面和修复方持续对抗的典型样本。今天把1.2.83修了,明天如果业务里引入了一个新的第三方库,库内部如果透出了Autotype的调用面,风险可能会再次出现。所以日常开发里要养成一个习惯:凡是涉及反序列化的代码,入口必须做类型白名单,不依赖全局默认配置。
第二点,我在评审代码时发现,不少修复只关注了“直接引用的fastjson”,忽略了“间接引用”的场景。比如某个中间件客户端内部封装了fastjson,或者某个消息序列化组件间接依赖了它。这种间接依赖在升级主版本后仍可能传递旧版本,造成漏修。花十分钟梳理一次完整依赖树,比事后在线上排查到半夜要划算得多。
Fastjson的攻防历史,是整个Java反序列化安全问题的一个缩影。理解它,不只是为了修好一个库,也是为了在面对其他序列化组件时,能更快地判断风险、设计防御方案。