从fastjson到Jackson:反序列化漏洞的底层逻辑与迁移实践 我经常会在技术群里看到类似这样的对话有人贴出一条漏洞公告问“fastjson 又报漏洞了要不要升级到 1.2.84”然后底下会有人回复“升了也没用换 Jackson 吧”。这个场景在过去几年里反复出现。直到今天很多项目里依然留着 fastjson 的依赖而每次安全扫描一跑警报清单里它大概率还是会出现。要理解我为什么最终选择禁用 fastjson不能只停留在“它出了很多漏洞”这个表层结论上。真正的问题在于它的设计取向、维护节奏和安全模型已经和现代 Java 服务的工程要求脱节了。这篇文章我会从反序列化漏洞的底层机制讲起解释为什么 fastjson 的补丁总是处于被动局面然后给出一套可落地的 Jackson 迁移路径以及迁移过程中最容易踩的坑。如果你现在还留在 fastjson 上这篇文章就是给你看的。1. 先搞清楚 fastjson 到底“死”在哪——不是写代码的人素质问题而是它把安全责任转移给了调用方很多人对 fastjson 的第一印象是“快”。确实它在早期版本里宣传的卖点就是性能好、API 简单一个JSON.parseObject()就能把字符串转成对象比当时写一堆ObjectMapper配置要省事得多。在 2017 年前后国内很多团队选型时都会因为“用起来方便”而把 fastjson 放进项目。但这个便利背后藏着一个很大的结构性问题fastjson 为了达到“少写代码”的效果默认替调用方做了太多自动推断其中最关键的就是 AutoType 机制。1.1 AutoType一把必须时刻盯着的双刃剑AutoType 的意思是fastjson 在反序列化时允许 JSON 文本里直接指定 Java 类的全限定名然后由反序列化器自动加载并实例化这个类。代码写起来很舒服你不需要像 Jackson 那样显式配置多态类型信息JSON 里天然自带“这个字段的类型是什么”的线索。但问题也出在这里。JSON 是纯文本如果攻击者能控制一段被服务端解析的 JSON他就可以在文本里写入一个恶意类的类名。当 fastjson 反序列化时会根据类名去加载类然后调用对应的 setter、getter、构造方法甚至静态初始化块。假如应用的 ClassPath 上存在一个类它的某个方法里恰好有可以被利用的危险操作攻击者就能借助这个类实现远程代码执行也就是 RCE。AutoType 本质上等于把“要加载哪个类”的决定权交给了数据源。这在安全模型上是非常危险的事情。打个比方这就好比你公司前台有一个访客登记系统。正常来说访客来了要先登记、核验、领临时工牌。fastjson 的 AutoType 机制相当于前台看到一个纸条写着“我是某部门负责人直接放行”就真的放行了。纸条是谁写的内容是否可信它不做严格核验。过去几年里攻击者一直在做的事情就是不断变着法子伪造这种纸条而且每隔一段时间就能找到一种新的写法绕过去。1.2 用 JNDI、RMI 和 JdbcRowSetImpl 理解攻击链路fastjson 早期的著名攻击链很多都跟 JNDI、RMI 和 JdbcRowSetImpl 有关。用比较通俗的方式讲一下是怎么回事。JNDI 是 Java 里用于查找命名和目录服务的接口。在某些利用链里攻击者可以在 JSON 里指定一个远程的 RMI 或 LDAP 服务地址当 fastjson 反序列化时目标类会被加载加载过程中会去远程服务器获取一个恶意 class 文件然后执行其中的代码。JdbcRowSetImpl 是 JDK 自带的一个类它在某些版本里可以通过 setter 设置dataSourceName一旦触发getConnection()它就会按照设置的 JNDI 地址去建立连接。这条链路在很长一段时间内都是 fastjson 攻击的核心路径之一。你不需要背下每个漏洞的编号但需要理解一个模式攻击者找到一个 JDK 或常用库中存在的类这个类在反序列化过程中会触发一个可以被外部控制的危险操作然后利用 fastjson 的 AutoType 机制把类加载进来。每堵住一个类攻击者就换一个类。这就是为什么 fastjson 的安全补丁总在追着漏洞跑——因为攻击面不是 fastjson 本身的代码而是它开放给外部类加载的能力。1.3 升级到 1.2.84 确实有意义但不要把它当成终点fastjson 1.2.84 是 1.x 系列中一个比较重要的安全加固版本。它新增了在异常路径上的类名、依赖和堆栈的深度检查也对java.lang.Exception这条攻击链做了额外处理。在 1.2.80 之后、1.2.83 之前出现过一些新的利用方式1.2.83 和 1.2.84 就是为了堵住这些新出现的问题而发布的。但是如果你把每次“升级到最新版”当成安全策略就会陷入一个非常疲惫的循环下个新版本出现安全团队要求升级。升级后跑回归测试发现某些序列化行为有变化。还没等完全验证完又有新的漏洞公告出来。而且fastjson 1.x 的维护模式已经发生了变化。项目团队把主要精力转向了 fastjson 21.x 进入了一种“只修高危漏洞、不做大功能演进”的状态。这意味着你等来的每一个新版本很可能又是上一次绕过尝试的应急响应。我的判断是如果项目还在用 fastjson 1.x今天最该做的事情不是判断 1.2.84 够不够安全而是启动一个迁移计划把核心序列化依赖从 fastjson 上挪走。原因后面会展开。2. 反序列化漏洞的底层逻辑为什么同一个套路能反反复复出现很多人会有个困惑为什么 fastjson 已经修了这么多轮还能不断出问题是不是写代码的人不认真其实最深层的原因不是某一行代码写错了而是“允许 JSON 指定类名”这个设计方向天然就会持续产生安全对抗空间。2.1 反序列化为什么是安全重灾区Java 对象在内存里是类结构加字段值而 JSON 只是一段纯文本。反序列化要做的事情就是把纯文本“还原”成 Java 对象。这个还原过程有四个隐形的风险点类加载文本里的类名会被 class loader 解析。如果类的来源不可信第一个危险就出现了。方法调用反序列化不只是赋值字段它还可能触发 setter、构造方法、readObject、getter等。这些方法里如果写了敏感逻辑比如建立连接、执行命令、读取文件就会被间接调用。嵌套对象一个对象里套着另一个对象反序列化时是递归处理的。如果嵌套深度和类型数量没有限制很容易构造出超大递归或类型混乱的请求。类型混淆如果同一个字段在不同情况下可以被反序列化成不同类型攻击者就可能用一个“看似安全”的类去触发另一个类的危险逻辑。所谓“反序列化漏洞”多数时候不是 JSON 解析器本身的代码有问题而是它把攻击者输入的数据传递给了应用里那些“有副作用”的方法。fastjson 的 AutoType 把这个风险放大了因为类名是外部的、不可信的而且默认机制里没有足够严格的校验。2.2 黑名单思路为什么总是慢半拍fastjson 历史上采用过黑名单策略也就是维护一个“不允许反序列化的类名列表”。这个思路看起来直觉上没问题把所有已知危险的类都禁掉攻击者不就没办法了吗但工程现实里黑名单有三个先天缺陷滞后性只有等到某个类被人发现可以攻击研究出利用链漏洞报告出来然后才能把它加到黑名单里。在这个完整链条跑完之前攻击者已经在用了。不完整性Java 生态里类库极其庞大JDK 自身、Spring、MyBatis、各种第三方库每一层都有可能出现新的可利用类。你不可能枚举完所有危险类。绕过容易同一个类可能有不同的加载方式、不同的别名、不同的编码形式。攻击者只要改一种写法黑名单就可能形同虚设。后来 fastjson 在较新版本里默认关闭了 AutoType引入了白名单机制这是一个方向正确的变化。但关键问题是fastjson 的生态和文档里仍然有一大批场景依赖 AutoType比如接口返回值是多态类型、泛型擦除后需要保留类型信息等。一旦你在项目里依赖了自动类型解析当新版默认关闭它时你的老代码可能直接抛异常。这就是为什么很多项目升级 fastjson 时总要加一行ParserConfig.getGlobalInstance().setAutoTypeSupport(true)或类似的配置。这一打开安全防线又出现缝隙了。2.3 时间就是最大的成本每个补丁背后都是整个团队的熬夜史我曾经在一个业务系统里经历过 fastjson 漏洞应急。安全团队拿到公告后第一件事是找出所有引入 fastjson 的组件和代码位置然后评估哪些接口暴露在公网哪些即使内网调用也有风险。接下来的问题更麻烦升级到新版后线上有些接口的序列化输出格式变了有的 parseObject 行为也变了还得临时调整代码。那段时间每次发布都很紧张不是因为功能复杂而是因为你不知道这个“安全升级”会不会带来隐藏的兼容性破坏。单次漏洞响应的时间成本往往被严重低估。表面上是“升级一个依赖版本号”实际上要经历依赖冲突排查、回归测试、灰度发布、线上观察、问题修复整个周期短则一天长则一周。如果这样的流程每隔几个月就来一轮团队的技术债会越积越厚。这就是我为什么说fastjson 的死结不在漏洞数量而在它的安全模型让使用方处于持续被动响应的状态。相比之下Jackson 的做法虽然需要调用方多写一点配置但它把安全边界收敛到了一个更清晰的位置。3. 1.2.84 之后为什么“升级到最新版”这套思路彻底失效了在 fastjson 1.2.83 和 1.2.84 发布的时候官方公告里的语气已经和早期不太一样了。你从版本更新内容里能明显看到它越来越多地在做安全加固而不是在增加新功能。搜索热词里也高频出现“fastjson 1.2.84”和“fastjson 迁移为 jackson”说明相当多团队已经把目光从“要不要升级”移到了“怎么迁移”。3.1 版本序列背后的事实1.x 已经进入生命周期末期需要说明的是我并不能替你确认某个版本之后官方还会不会再发布新版本因为版本计划属于项目方动态信息。但从实际工程经验看一个组件一旦从“功能迭代”切换到“漏洞修补”它的寿命就在倒计时了。fastjson 2 是一个重新设计架构的项目它采用了新的序列化器、新的安全模型也换了新的包名com.alibaba.fastjson2。如果你继续守在 fastjson 1.x 上未来要面对的不只是漏洞还有社区活跃度下降、新特性缺失、知识积累减少这些隐性问题。3.2 “升级到 1.2.84然后配置安全模式”这个混合状态很难长期维护有些团队的做法是把 fastjson 升级到最新版同时打开 safeMode或者把 AutoType 关掉再配合 RASP、WAF 等外部防护。这种多层防护的思路本身没有错但问题在于 fastjson 的某些功能依赖 AutoType你一旦把它关掉就得检查所有反序列化代码是否还能正常工作。很多老项目里parseObject(String, Class)的用法短期内确实不需要 AutoType但一旦涉及泛型、多态、嵌套类型事情就复杂了。混合状态意味着你需要维护一套“哪些接口开了 AutoType、哪些没开”的心智模型这在团队人员流动时很容易失传。3.3 延迟迁移的最大风险是“知道该迁移的人正在变少”技术圈有一个规律一个组件的安全问题被讨论得越久真正能把它讲清楚的人就越少。因为早期踩过坑的人可能已经换项目了新来的人只看到一个依赖不知道背后的风险史。等到某个新漏洞爆发时团队里可能找不出一个能独立评估影响范围的人。所以我的建议是尽早做这件事趁现在还有足够多的参考资料和真实踩坑经验把迁移成本控制在一个可控范围内。越往后拖不仅要面对 fastjson 自身的问题还要面对团队内“fastjson 知识断层”的问题。4. 从 fastjson 迁到 Jackson核心思路是收敛边界不是照抄 API选择 Jackson 并不是因为它完美无缺而是因为它的默认安全模型更清晰Jackson 默认不启用多态反序列化。如果 JSON 文本里没有显式的类型信息你就不能指定要加载哪个类。这从根本上避免了“外部数据控制类加载”的问题。当然Jackson 也支持多态。它需要你通过activateDefaultTyping或JsonTypeInfo这类机制显式地告诉序列化器哪些字段、哪些类型需要保留类型信息。但关键区别是这个能力是“按需开启”的默认关闭。这一点和 fastjson 的设计取向完全相反也决定了两个组件的安全基线完全不同。4.1 迁移前必须搞清楚自己的 fastjson 用在了哪里很多项目里fastjson 不只是被直接调用还可能被其他中间件间接依赖。比如某些 RPC 框架、缓存组件、数据同步工具内部可能默认用了 fastjson 做序列化。这种间接依赖是最容易遗漏的因为你没有直接写过import com.alibaba.fastjson但程序运行起来时类还是被加载了。迁移前建议做一次全量扫描在代码仓库里搜索com.alibaba.fastjson定位所有 import。搜索JSON.toJSONString、JSON.parseObject、JSON.parseArray、JSONObject、JSONArray等高频 API。用mvn dependency:tree或 Gradle 的dependencyInsight找出所有传递依赖 fastjson 的组件。检查是否有工具类、配置类、序列化器里注册了 fastjson 的全局配置比如ParserConfig、SerializeConfig、JSONField注解等。这一步做完你才能知道迁移的工作量到底有多大。如果一个项目里 fastjson 只出现在少量工具类中迁移其实很快如果它已经深嵌入自定义序列化逻辑里就需要分阶段处理。4.2 核心 API 对照从 JSON.parseObject 到 ObjectMapper先给一张最常用的 API 对照表方便在迁移时快速找到对应写法功能fastjson 示例Jackson 示例对象转 JSON 字符串JSON.toJSONString(obj)objectMapper.writeValueAsString(obj)JSON 字符串转对象JSON.parseObject(str, User.class)objectMapper.readValue(str, User.class)JSON 字符串转 ListJSON.parseArray(str, User.class)objectMapper.readValue(str, new TypeReferenceListUser() {})JSON 字符串转 MapJSON.parseObject(str, Map.class)objectMapper.readValue(str, new TypeReferenceMapString, Object() {})序列化时忽略 nullJSONField(serialzeFeatures SerializerFeature.WriteMapNullValue)objectMapper.setSerializationInclusion(Include.NON_NULL)或JsonInclude字段别名JSONField(name user_name)JsonProperty(user_name)日期格式JSONField(format yyyy-MM-dd HH:mm:ss)JsonFormat(pattern yyyy-MM-dd HH:mm:ss)4.3 几个必须重新设计的差异点Jackson 并不是 fastjson 的“一键平替”有几处行为差异需要你在迁移时重构代码逻辑。第一泛型信息的处理。fastjson 里写JSON.parseObject(str, new TypeReferenceListUser() {})是老用法Jackson 的写法几乎一样但如果你之前写的是JSON.parseObject(str, List.class)那么拿到的 List 里元素会被解析成 LinkedHashMap而不是 User 对象。Jackson 也是一样的readValue(str, List.class)不会保留元素类型。所以在迁移时凡是涉及泛型集合、泛型对象的代码都必须统一使用TypeReference。第二日期时间格式的默认差异。fastjson 对java.util.Date的默认输出格式通常是时间戳或带毫秒的时间字符串Jackson 默认输出的是 ISO 8601 格式。这个差异会导致接口返回给前端的数据格式变化。迁移时最好是统一在ObjectMapper里配置全局日期格式比如yyyy-MM-dd HH:mm:ss或者使用JavaTimeModule来处理 Java 8 的LocalDateTime。第三字段命名策略。有些项目里 fastjson 默认输出的是驼峰字段userName但当前端要求下划线user_name时通常依赖JSONField注解。Jackson 里对应的做法是JsonProperty但如果接口特别多、字段特别多逐个加注解不现实更建议配置PropertyNamingStrategies.SNAKE_CASE这类全局命名策略。这里要注意全局策略会影响所有字段包括嵌套对象所以要先在小范围接口上验证。第四枚举的序列化方式。fastjson 默认可能输出枚举的 name 或者 ordinalJackson 也有自己的默认行为。如果项目里枚举很多而且枚举值在数据库或外部接口里有固定存储迁移时要保证序列化前后枚举的取值一致避免出现“存的是 1反序列化后变成 2”这类问题。4.4 建议的分阶段迁移路径不要在某个周五晚上一次性把全项目的 fastjson 替换成 Jackson这会成为一场灾难。更稳妥的做法是分四步走先加一个统一的 ObjectMapper 配置类。在这个类里统一设置日期格式、命名策略、null 处理、未知字段处理、自定义序列化器。所有新代码都基于这个配置实例进行操作。挑一个低风险模块做试点。最好是一个内部接口、日志处理、数据同步任务这类不直接暴露给公网、影响范围有限的模块。在这个模块里完成 fastjson 到 Jackson 的替换验证输出差异和兼容性。批量替换直接调用点但不急着删依赖。代码里大量JSON.parseObject和JSON.toJSONString可以先用自定义工具类包一层比如JsonUtils内部实现从 fastjson 换成 Jackson。这样即使外部传参方式不变也能逐步切换底层实现。最后清理间接依赖。用依赖分析工具找出哪些中间件还在传递依赖 fastjson能升级组件的就升级组件不能被替代的就单独评估风险决定是否需要用隔离机制处理。注意Fastjson 和 Jackson 的异常信息格式不太一样。迁移后不要把老的异常解析逻辑直接套用到新序列化器上建议统一校验一遍异常处理代码。5. 迁移落地时的排查链路和边界真正决定成败的是细节很多项目迁移 Jackson 之后功能看起来正常但上线一段时间后会冒出一些奇怪的问题。这些问题往往不是“迁移错了”而是“迁移时不彻底”。5.1 按这个顺序排查输入、配置、序列化器、依赖、性能如果在迁移过程中出现异常不要直接搜“Jackson 报错原因”那样效率很低。我建议按下面顺序排查第一步看输入。先确认 JSON 字符串的格式是否符合预期。比如是不是带 BOM字段命名到底是大驼峰、小驼峰还是下划线字符串里有没有特殊字符很多解析异常其实是输入数据格式不规范导致的。第二步看全局配置。检查 ObjectMapper 是否被多处重复创建。每创建一个 ObjectMapper就相当于有一份独立的配置。如果模块 A 的 ObjectMapper 配置了日期格式模块 B 用的是另一个没有配置的实例输出就会不一致。在大型项目里ObjectMapper 应该是单例统一注入。第三步看自定义序列化器。fastjson 项目里你可能写了ObjectSerializer或ValueFilter这些自定义逻辑在 Jackson 里没有完全对应的概念。排查时要确认所有自定义序列化器都生效了而且没有作用到全局字段上。第四步看间接依赖。运行时如果 ClassNotFound 或者 NoSuchMethodError先查依赖树。很多类是由应用服务器或中间件提供的版本不一致会导致 Jackson 的类加载器加载到不同版本的类。第五步看性能。如果迁移后接口响应时间变长先看是否在每次请求里创建了 ObjectMapper再看是否序列化了重复结构最后考虑是否需要引入JsonNode或流式 API 来减少中间对象创建。5.2 序列化输出不一致的常见坑迁移最容易出问题的不是反序列化而是序列化输出变化。因为反序列化只在服务端内部处理而序列化后的结果会直接返回给前端或写入其他系统。一个字段的命名、一个 null 值的取舍、一个日期的格式都可能引发联调问题。如果你无法接受所有输出一次性变化可以在替换策略上做兼容用 Jackson 生成新数据结构同时保留一个 fastjson 版本的快照用于对比。写一个对比脚本对同一批对象用两种方式序列化然后 diff 输出逐个解决差异。这不解决根本问题但能让你在上线前知道自己改了哪些东西。5.3 要不要考虑 fastjson 2fastjson 2 是官方推出的重写版本包名、API、内部机制都有较大变化。如果你实在不想用 Jackson又需要保留 fastjson 的风格fastjson 2 可能是一个过渡选择。但我的观点是既然已经从 fastjson 1.x 迁移出来就没有必要再迁移到 fastjson 2。因为核心问题——AutoType 和安全性之间的张力——虽然在 fastjson 2 中有改善但设计思路仍然延续了“让 JSON 解析更省事”的方向。相比起来Jackson 的默认安全模型更符合一个长期维护项目对稳定性和可控性的要求。注意不要因为网上有人说“fastjson 2 更安全”就直接在核心链路上替换 fastjson 2。先在小项目上验证它的序列化兼容性和潜在依赖冲突再做决定。5.4 长期维护视角把序列化逻辑收口到统一层迁移完成之后最值得做的一件事是建立项目内部的统一 JSON 工具层。所有业务代码不直接依赖具体的ObjectMapper或某个 JSON 库只依赖JsonUtils工具类。这样做有三个好处以后如果要再替换 JSON 库只需要改一个类。可以在工具类里统一加日志、异常处理、脱敏逻辑而不是散落在各业务代码里。新同事入职后只需要知道“项目里 JSON 统一用这个工具类”不用关心底层实现。这个“统一收口”的思路其实比选哪个 JSON 库更重要。很多项目反复在序列化组件上踩坑根本原因是每个开发都有自己写 JSON 转换的方式同一个项目里既有 fastjson、又有 Jackson、还有 Gson出问题后极难排查。6. 回到最初的问题fastjson 还能不能用如果你问我fastjson 还能不能用我的回答是能用但我不建议在新的核心项目里继续使用。它能用指的是在封闭内网、无外部输入、低安全要求、快速原型这类场景下它的 API 便捷性依然有优势。但它不适合作为公网服务、核心业务链路的默认序列化方案因为它的历史安全模型和补丁节奏决定了你会持续处于被动状态。如果把技术决策看成一笔长期投资重仓 fastjson 的收益是“初期接入快、写代码顺”但成本是“持续的漏洞响应、升级验证、心智负担和安全风险”。而迁移到 Jackson 的成本是“一次性改造”收益是“安全边界清晰、社区生态成熟、长期维护确定性更高”。所以我建议你现在就做三件事在代码仓库里全量搜索com.alibaba.fastjson把使用清单列出来。挑一个内部模块用 Jackson 的小规模试点跑一遍记录所有差异点。把项目里所有 JSON 相关调用收口到一个统一工具层为后续替换做好准备。你不用在一天之内完成全部迁移。但你可以从今天开始让项目朝着“不再依赖 fastjson”的方向挪动。这个过程不会太轻松但几年后回头看你会感谢自己早做了这个决定。