jackson 最近密集出了一波安全修复。我是先从发行版侧看到的:SUSE 在 9 月 29 日发了 advisory(SUSE-SU-2026:4394-1),Quarkus 9 月 30 日发了 LTS 维护版 3.33.4,Debian 的 trixie 里 jackson-databind 挂着 12 项安全议题,IBM 在 10 月 2 日到 3 日连发了两份安全公告。对应的 jackson 侧修复版本是 2.18.10、2.21.6、2.22.2,新的 3.x 线是 3.1.6 和 3.2.2。
这一波里 CVE 编号不少,大部分名字都能猜到套路------async parser 数字长度校验绕过、CBOR/Smile 解析器不检查 maxNameLength,都是"某条 StreamReadConstraints 约束在某个解析路径上没被调用"。但有一条我盯着看了很久:CVE-2026-68497,jackson-databind 把 JSON 字符串绑定到 javax.xml.datatype.Duration 或 XMLGregorianCalendar 字段时的拒绝服务,CVSS 7.5。
吸引我的是它的漏洞逻辑跟其他几条不一样:maxNumberLength 这条守卫明明存在,默认值 1000,而且一直在正常工作------但攻击形态的数字根本不走这条守卫,因为它们穿着字符串的衣服。
IBM 的公告里描述得很直白:一个包含"字母 P、后面跟几百万位数字、再跟字母 Y"的 Duration 值,单个几 MB 的请求就能换来几十秒到几分钟的单线程 CPU 消耗,几个并发请求就能占满服务的 worker 线程。XML Schema 的词法规则允许 Duration 的数字成分任意长,jackson 会把这段字符串原样递给 JDK 的 DatatypeFactory.newDuration(),最终落到 BigInteger(String) 构造器上------而这个构造器的耗时随位数是平方级增长的。
平方级从哪来不难推:JDK 的大数内部是个 int 数组,从十进制字符串构造时得一段一段往里拼,每拼一段都要对已经累积的整个大数做一遍乘加------段数随位数线性涨,每段的成本也随大数长度线性涨,两个线性一乘就是平方。这也是为什么这类 DoS 总围着"超长数字字符串"打转:数字是少数几种"解析成本超过传输成本"的数据形态。
还有一点决定了它的量级:公告原文特意写明这些反序列化器"默认注册,无需 opt-in"。翻译一下------你不需要开 enableDefaultTyping 这类高危开关,不需要任何特殊配置,一个最普通的 ObjectMapper,只要 DTO 里挂了 Duration 或 XMLGregorianCalendar 字段,这条路径就在那儿。它跟那些"配置了多态类型才会中"的洞完全不是一个暴露等级。数字来自公告,公告是别人写的。平方级到底长什么样、修复版补在哪儿、补上之后正常用户受不受影响,这些只能自己跑一遍才知道。我的沙盒是 OpenJDK 11.0.32 单核,jackson-databind 用 2.17.1------这个版本不是随手挑的,IBM 公告里写明他们的受影响产品里装的就是这个版本。修复对照用 2.22.2,jar 都从 Maven Central 现拉的。中间还踩了个无伤大雅的小坑:databind 有 2.22.2,annotations 却只有 2.22------annotations 的版本号从 2.20 起去掉了第三位,我第一把照着 databind 的版本号拼 URL,下回来一个 404 的 HTML,还以为沙盒网络抽风了。
平方级到底有多平方
实验代码很短,一个带 Duration 字段的 POJO,一个把 "P"、N 个字符 '9'、"Y" 拼起来的方法,核心循环就是反复调用 mapper.readValue(json, Order.class),用 ThreadMXBean 抓线程 CPU 时间。正常的 "P1Y2M" 先跑一遍确认路径没坏,然后位数从 10 万开始加倍:
| 数字位数 | payload 大小 | CPU 耗时 | 相对上轮 |
|---|---|---|---|
| 100,000 | 约 100KB | 0.153s | --- |
| 200,000 | 约 200KB | 0.537s | ×3.51 |
| 400,000 | 约 400KB | 2.140s | ×3.98 |
| 800,000 | 约 800KB | 8.568s | ×4.00 |
位数每翻一倍,耗时就往 ×4 的方向走,后两轮稳稳停在 3.98 和 4.00------平方增长没跑了。首轮 3.51 偏低,是单核沙盒上 JIT 预热占的便宜,不影响结论。jackson 侧的 readValue 正常返回,没有报错。800KB 的请求体在 Web 服务的世界里是个再普通不过的尺寸,但单线程 8.5 秒意味着什么不用我多说:Tomcat 默认 200 个 worker 线程,两百个这样的请求排队进来,服务就给废掉了。公告说"几十秒到几分钟"没有夸张,我这张表外推到几百万位,单请求就是分钟级。
内存这条线我也看了:80 万位解析出来的 BigInteger,bitLength 是 2657543,折合 300 多 KB,跟 800KB 的 payload 一个量级,根本打不动堆。这是个纯 CPU 型的洞------不打内存、不抛错、请求还正常返回,监控上比 OOM 难抓多了。单核容器里更直观:一个请求把整个核占住 8.5 秒。
更大位数我就没往下跑了:8.5 秒对应 80 万位,按平方外推,800 万位得烧 14 分钟------单核沙盒,没必要为证明趋势把机器挂上一刻钟,两轮稳定的 ×4 已经把结论钉死了。
守卫为什么管不住它
这个漏洞最有意思的地方在对照实验里。我把同样的 5000 位数字换成 JSON number 形态({"count":9999...})跑了一遍:
javascript
StreamConstraintsException: Number value length (5000) exceeds
the maximum allowed (1000, from `StreamReadConstraints.getMaxNumberLength()`)
0.018 秒,秒拦。同一个 jackson,同一个约束开关,number 形态五千位都进不去,字符串形态八十万位畅通无阻。
原因得拆两层看。maxNumberLength 是 jackson-core 在 parser 层执行的,检查对象是 JSON 的 number token------词法上不带引号的那种。而 Duration 的攻击形态是带引号的字符串 token,parser 眼里它就是个普通字符串,归 maxStringLength 管(默认 20MB),轮不到 maxNumberLength 出手。等这个字符串穿过 parser 到了 databind 层,被转交给 XML datatype 反序列化器的时候,已经没有任何长度检查在岗了------jackson 自己的数字反序列化器在转换前会调 validateIntegerLength,但 XML datatype 这条路径的代码偏偏没做这个预检查。两头都指望对方,结果谁都没管。
耗时来源我单独验证了一遍:绕开 jackson,直接 new BigInteger(80万个'9'),8.59 秒------跟走 jackson 全链路几乎一致,说明账都烧在 JDK 的构造器里,jackson 只是那个把门没关上的角色。
修复版补在了哪里
换 2.22.2 跑同一套代码,同样的 80 万位 payload:
ini
[stage1] digits=800000 cpu=0.004s outcome=JsonMappingException
8.568 秒变成 0.004 秒,两千倍的差距,请求在进反序列化器之前就被打回。报错信息也换了主语。这里我拿 5000 位的 payload 单独探了一遍,2.22.2 的报错是 Date/time value length (5002) exceeds the maximum allowed (1000)------修复的思路就是把日期/时间字符串值的长度也纳入 maxNumberLength 的管辖。
阈值这边有个容易忽略的坑。我拿 999 到 5000 位做了探测,发现 999 位也会被拦:报错里的长度是 1001,P 和 Y 两个字符也算在总长里。也就是说修复版能放行的最长 Duration 字符串是 998 个数字位。正常业务里 ISO-8601 的 duration 写法("P1Y2M3DT4H5M6.789S")撑死几十个字符,这个上限对正常用户没有任何感知,我特意确认过 "P1Y2M" 在修复版解析正常。
谁该去查一下版本
影响面这个东西,对 jackson 来说基本等于"所有连着网的 Java 服务":Spring Boot 的 web starter 默认带 jackson-databind,请求体反序列化走的就是它。暴露面具体到这个 CVE,是那些 DTO 里挂着 Duration 或 XMLGregorianCalendar 字段、且直接消费外部请求的服务。这俩类平时出镜率不高,来路都是 XML 时代:XMLGregorianCalendar 是 JAXB 把 XSD 的 date/dateTime 映射到 Java 的产物,老 SOAP 接口、一些支付和电信协议的对接包里常见;Duration 对应 XSD 的 duration 类型,就是 "P1Y2M" 这种 ISO-8601 时长写法。业务代码不天天写,但对接老系统的时候经常被动带上------很多人甚至不知道自己的依赖树里藏着这些字段。严重程度从厂商侧的追踪也能看出来:IBM 10 月 2 日和 3 日那两份公告里,Workload Automation 和 Sterling B2B Integrator 都在受影响产品列表上------集成中间件,最常吃 XML/JSON 报文的那类东西,也恰是这两类字段最常藏身的地方。
这次同一波修的还不止这一个。jackson-core 的非阻塞 parser 在分块喂入时也会绕过 maxNumberLength------累计数字的缓冲区只受 maxStringLength(默认 20MB)管,等于把 1000 位的数字上限放大了两万倍,Tenable 对 CVE-2026-68494 的分析把这个账算得很清楚,受影响的正是 WebFlux、Quarkus、Vert.x 这类把请求字节流式喂给 parser 的响应式框架。CBOR 和 Smile 两个二进制格式更直接:maxNameLength 压根没实现(CVE-2026-68495/68496)。同一套 StreamReadConstraints,四处没接上------这套约束 2.15 才有、2.16 才铺到二进制格式,这两年一直在补覆盖面,这次的 Duration 洞补的是 databind 转换层那一段。
对照受影响区间查版本就行:2.0.0 到 2.18.9、2.19.0 到 2.21.5、2.22.0 到 2.22.1 都在范围内。命令是 mvn dependency:tree | grep jackson-databind,盯一眼输出里 jar 后面的版本号,落在上面三个区间任何一个就升。修复版本:2.18.10、2.21.6、2.22.2(com.fasterxml 坐标),3.x 线是 3.1.6 和 3.2.2(tools.jackson 坐标)。用响应式框架的话,jackson-core 那两个 CVE(68494/68495/68496)修在同一批版本里,顺手一起升了。
这一波看下来,我印象最深的是约束的分层问题:StreamReadConstraints 这套机制是好的,但它守在 parser 层,而数据在 databind 层还要再转换一次------每穿过一层,守卫就得在那层重新上岗一次。这次的洞就是两层互相指望的产物。往自己身上套一下也成立:凡是自定义了 deserializer 或者在 @JsonCreator 里做转换的地方,长度和规模的检查都得自己带一份,parser 层的 StreamReadConstraints 不会替你穿透到转换逻辑里------它俩中间隔着一次"字符串变成对象"的机会,机会本身就是边界。说到底,在 JDK 自己的 JSON API(JEP 540,Simple JSON API,刚 target 到 JDK 28 的 incubator 阶段)能扛大旗之前,jackson 还要当很多年的默认选择,这类"转换层补课"大概率还有下一波。
完整代码贴在文末,复制就能跑。
java
// DurationDoS.java --- 运行: java -cp "jackson-databind.jar:jackson-core.jar:jackson-annotations.jar" DurationDoS.java
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.xml.datatype.Duration;
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;
import java.math.BigInteger;
public class DurationDoS {
static class Order { public Duration sla; }
static long cpuNanos() {
return ManagementFactory.getThreadMXBean().getCurrentThreadCpuTime();
}
// 公告描述的形态: "P" + N位数字 + "Y"
static String payload(int digits) {
StringBuilder sb = new StringBuilder(digits + 16);
sb.append("{"sla":"P");
for (int i = 0; i < digits; i++) sb.append('9');
sb.append("Y"}");
return sb.toString();
}
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
mapper.readValue("{"sla":"P1Y2M"}", Order.class); // sanity
double prev = -1;
for (int n : new int[]{100_000, 200_000, 400_000, 800_000}) {
long c0 = cpuNanos();
String outcome;
try { mapper.readValue(payload(n), Order.class); outcome = "deserialized"; }
catch (Exception e) { outcome = e.getClass().getSimpleName(); }
double cpu = (cpuNanos() - c0) / 1e9;
System.out.printf("digits=%d cpu=%.3fs outcome=%s ratio=%.2f%n",
n, cpu, outcome, prev > 0 ? cpu / prev : 0);
prev = cpu;
}
// 对照: number 形态 5000 位 -> StreamConstraintsException 秒拦
// 修复版 2.22.2: 同 80 万位 payload -> cpu=0.004s JsonMappingException
}
}
对照实验另起一个小类,或者直接追加到 main 末尾:
csharp
// 对照1: 同样5000位,换成JSON number形态 -> parser层秒拦
String numJson = "{"count":" + "9".repeat(5000) + "}";
try {
new ObjectMapper().readValue(numJson, java.util.Map.class);
} catch (Exception e) {
System.out.println("number形态: " + e.getClass().getSimpleName() + " / "
+ e.getMessage().split("\n")[0]);
}
// 对照2: 绕开jackson直测JDK构造器 -> 耗时与全链路几乎一致,账都烧在BigInteger上
long c0 = System.nanoTime();
new BigInteger("9".repeat(800_000));
System.out.printf("raw BigInteger(80万位): %.3fs%n", (System.nanoTime() - c0) / 1e9);
修复版的验证不用改代码,把 classpath 里的 jackson-databind/core 换成 2.22.2 重跑主循环就行------报错会从正常返回变成 JsonMappingException,阈值探测(999~5000位)同法。