技术选型的三条铁律:Fastjson CVE、重复依赖、外部进程,每条都有真实代码

1 阅读10分钟

老炮踩坑录 · V06 · 老炮视野系列

基于「企业融合评估平台」真实源码,复盘那些选的时候都说没问题、上线一年后才爆雷的技术选型决定

关键词:Fastjson 反序列化 · 重复依赖 · 外部进程 · 命令注入 · 版本安全

👋 欢迎阅读

v06.jpg 🏠个人主页:知守观
📘我的专栏: 老炮踩坑录
💻当前内容:技术选型

前言

上一篇十条军规发完,有个朋友私信我:你说的这些都是写代码的规矩,那选型呢?框架、中间件、工具,引进来的时候大伙都觉得没问题,问题往往一年后才冒出来。

这个话题我有发言权。

离职以后我把这个项目的 pom.xml 拉出来数了一遍依赖,越数越后背发凉。光 JSON 序列化就引了三套,分页框架引了两套,PDF 生成同时用着 iText 和一个外部命令行工具。

今天把我在这个项目里踩到的选型坑整理成三条铁律,每条都带有真实代码说明。

铁律一:带 CVE 的版本不能用——版本号是安全声明

打开 pom.xml,第一行就写着 Spring Boot 2.1.0.RELEASE。这个版本 2018 年底发布,2020 年就停止维护了。我写本篇的时候,Spring Boot 已经出到 4.1.1了。

2.1.0 本身倒没直接出过能一招利用的漏洞,但它拉进来的依赖有。

最典型的是 Fastjson 1.2.37。

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.37</version>
</dependency>

Fastjson 1.2.37 发布于 2017 年,autoType 反序列化漏洞在当时被爆了一轮又一轮。官方修到 1.2.83 才算把这条缝彻底堵上,中间隔了快五年。

这个项目里 Fastjson 用得有多狠?我搜了一下:

grep -rn "fastjson\|JSONObject\|JSONArray" src/main/java | wc -l
→ 925 处,分布在 83 个文件里

大量调用长这样:

// LoginServiceImpl.java:178
JSONObject jsonObject = JSON.parseObject(resultMap.get("result").toString());

parseObject 只传了一个字符串参数,没指定目标类型。这就是 autoType 的入口——Fastjson 会根据 JSON 里的 @type 字段自动实例化任意类。

我不确定这个接口对外暴露时有没有被实际打过——离职后自然没权限看线上日志。但代码层面这个洞是实打实在的,扫漏洞的工具一跑一个准。

同一份 pom.xml 里还有几个老朋友:

依赖版本发布年份问题
fastjson1.2.372017autoType RCE,官方修到 1.2.83
poi / poi-ooxml3.122014多个 XXE、拒绝服务漏洞
commons-fileupload1.32013CVE-2016-1000031 反序列化 RCE
guava20.02016多个已修复漏洞
spring-boot2.1.02018已 EOL,不再发安全补丁

这些不是"可能有问题",是 CVE 库里查得到编号的公开漏洞。攻击者不需要零日,拿现成的 EXP 就能打。

我后来的规矩很简单:引依赖前先查这个版本有没有公开 CVE。 查的渠道就两个——NVD 官网搜组件名加版本号,或者看依赖的官方 changelog 里 Security 章节。五分钟的事,省掉的是上线后被打穿的代价。

如果项目里已经有了,升级。Fastjson 升到 1.2.83 以上,或者直接换 Jackson(Spring Boot 默认就带,不用额外引)。POI 升到 4.x 或 5.x。commons-fileupload 1.3 这种,要么升要么用 Spring 的 MultipartFile 别直接碰底层。

版本号写在 pom.xml 里不是装饰,它是你对安全态势的一个声明。

这个项目的 Fastjson 问题,我在《FastJSON 1.2.37 的 autoType》里专门写过,这里就不展开了。

铁律二:同一种能力只留一个入口——重复选型就是给未来埋冲突

这个项目的依赖里有个很有意思的现象:同一件事,引了不止一个库。

先说分页。pom.xml 里同时有 MyBatis-Plus 和 PageHelper:

<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.3.0</version>
</dependency>
<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
    <version>1.2.5</version>
</dependency>

两套都在干活。我搜了一下:

  • 用 PageHelper.startPage / PageInfo 的:15 个文件
  • 用 MyBatis-Plus 的 IPage / selectPage 的:20 个 Mapper

同一个列表查询,有人这么写:

// ApplyInfoServiceImpl.java —— PageHelper 写法
PageHelper.startPage(pageNum, pageSize);
List<ApplyInfo> list = applyInfoMapper.selectList(queryWrapper);
PageInfo<ApplyInfo> pageInfo = new PageInfo<>(list);

有人那么写:

// ApplyInfoMapper.java —— MyBatis-Plus 写法
IPage<ApplyInfo> page = new Page<>(pageNum, pageSize);
IPage<ApplyInfo> result = applyInfoMapper.selectPage(page, queryWrapper);

两套分页的参数、返回类型、count 查询行为都不一样。新人接需求,先看老代码,看到两种写法,不知道该跟哪个。跟着跟着,一个接口里就混着用了——PageHelper.startPage 设了分页参数,下一行调的 MyBatis-Plus 的 selectPage,分页参数互相覆盖,算出来的页数怎么都不对。

再说 JSON。这个项目里同时存在三套 JSON 库:

  1. Jackson——Spring Boot 默认带的,HTTP 消息转换用它
  2. Fastjson 1.2.37——业务代码里 925 处调用的主力
  3. json-lib 2.2.3(net.sf.json-lib)——还有人在用 JSONObject.fromObject

三套库处理 null、日期、字段名的策略都不一样。同一个对象,用 Fastjson 序列化出来 date 是时间戳,用 Jackson 配了 date-format 就是字符串。前端拿到数据,一会儿是数字一会儿是字符串,报了 bug 排查半天,查下来是两个接口用了不同的序列化库。

PDF 生成也一样。pom.xml 里引了 iText 5,实际代码里跑的是 wkhtmltopdf 外部进程(下一条细说)。iText 的依赖从头到尾没被 import 过,纯属占着位置。

还有 commons-lang 2.6 和 commons-lang3 同时在 pom.xml 里——commons-lang 早就停更了,API 和 lang3 还不兼容,引着它就是给 ClassNotFoundException 留种子。

重复选型的代价不是多占几十兆磁盘,是认知负担和隐性冲突。

我现在的做法:引新依赖之前,先搜项目里有没有同类库。已经有 Jackson 了就别再加 Fastjson,已经有 MyBatis-Plus 了就别再加 PageHelper。真要换,就统一换,把旧的删干净,别让两套并存。

如果是接手老项目,第一件事就是把依赖清单拉出来,把同类的合并。删掉一个不用的依赖,比加十个新依赖都值钱——少一个库,就少一份漏洞面,少一个出 bug 的变量。

重复选型的问题,和《代码腐化的五个信号》里的 “重复方法”是同一个病根。

铁律三:外部进程别扛核心链路——黑盒出问题你只能干瞪眼

这个项目的 PDF 导出,用的是 wkhtmltopdf。

wkhtmltopdf 本身没问题,把 HTML 渲染成 PDF 的老牌工具。问题在于它是个外部命令行程序,Java 代码通过 Runtime.exec 去调它。

来看 HtmlToPdf.java 的核心逻辑:

public static boolean convert(String srcPath, String destPath) {
    StringBuilder cmd = new StringBuilder();
    cmd.append(toPdfTool);
    cmd.append(" --header-line --disable-javascript ");
    cmd.append(srcPath);      // 用户相关的文件路径直接拼进命令
    cmd.append(" ");
    cmd.append(destPath);     // 输出路径也直接拼
    Process proc = Runtime.getRuntime().exec(cmd.toString());
    // ...
    proc.waitFor();           // 没有超时,卡死就一直等
}

这段代码有四个坑,每一个都能在线上要你的命。

坑一:命令拼接。 srcPath 和 destPath 直接用字符串拼进命令行。如果这两个值里有空格、分号、反引号,Shell 会把它们当成分隔符或命令。Linux 下 destPath 要是带个 ; rm -rf /,后果自己想。正确的写法是用 ProcessBuilder 传字符串数组,每个参数是独立元素,不经过 Shell 解析:

ProcessBuilder pb = new ProcessBuilder(
    toPdfTool, "--header-line", "--disable-javascript",
    srcPath, destPath
);
Process proc = pb.start();

坑二:waitFor() 没有超时。 wkhtmltopdf 渲染一个复杂 HTML 可能要几秒,如果它内部死锁或者某个资源加载卡住,waitFor() 会一直阻塞,占着一个 Tomcat 线程。请求一多时,线程池被占满,整个服务就假死了。应该用 waitFor(timeout, unit),超时后 destroyForcibly():

if (!proc.waitFor(30, TimeUnit.SECONDS)) {
    proc.destroyForcibly();
    throw new TimeoutException("PDF 生成超时");
}

坑三:环境耦合。 工具路径写死在配置里,Windows 一套、Linux 一套:

wkhtmltopdf:
  pdftool:
    windows: F:\Program Files\wkhtmltopdf\bin\wkhtmltopdf.exe
    linux: /usr/local/bin/wkhtmltopdf

换台服务器,路径变了,没人更新配置,PDF 导出直接报"系统找不到指定的文件"。运维凌晨被叫醒,查半小时结果发现是路径不对。

坑四:出问题没法在 Java 层调试。 wkhtmltopdf 跑在独立进程里,它内部渲染失败、字体缺失、网络超时,Java 这边只能拿到一个退出码和 stderr 文本。日志里看到 Exit code: 1,不知道到底是 HTML 有问题还是字体没装还是磁盘满了。排查的时候得登录服务器手动跑命令复现,脱离了 Java 的调试体系。

我不是说外部进程不能用。批处理、定时任务、边缘功能,调一下外部工具没问题。但核心链路上的能力,别押在外部进程上。这个项目的 PDF 导出是企业申报的核心环节——用户填完资料导出评估报告,这一步挂了,整个业务流程就断了。

如果让我重做这个选型,我会优先用 JVM 内的方案:OpenPDF(iText 的开源分支,LGPL 协议)或者 Apache PDFBox。渲染效果可能不如 wkhtmltopdf 还原 HTML,但部署可控、调试方便、没有命令注入风险。实在需要 HTML 转 PDF,也可以考虑飞书的文档导出或者浏览器无头模式,但那是另一回事了。

外部进程是黑盒。黑盒可以用,别让它扛核心链路。

这个 PDF 工具的安全问题,我在《HTML 转 PDF 三种方案对比》里详细拆过,感兴趣的可以翻一下看看。

自查清单

把 pom.xml 打开,对照这张表过一遍:

检查项怎么查危险信号
依赖有没有公开 CVENVD 搜组件名+版本号,或看官方 changelog版本发布超过两年没升级 = 大概率有未修复漏洞
同一能力是否引了多套搜 pom.xml 里同类依赖(JSON、分页、PDF、Excel)两套以上并存 = 未来会出隐性冲突
有没有引了没用的依赖搜代码里有没有 import 对应包零引用 = 白占漏洞面,该删
核心链路有没有外部进程搜 Runtime.exec / ProcessBuilder外部进程处理核心业务 = 出问题难排查
外部进程调用有没有超时和参数化看 waitFor 有没有传 timeout,命令是否用数组传exec(String) + waitFor() 无参 = 命令注入+卡死风险

老炮点评

三条铁律再压缩,其实就一句话:技术选型不是选最酷的,是选最不容易出事的。

选 Fastjson 因为它快、API 顺手,结果留了个 RCE 的洞。选 PageHelper 因为写法简单,结果和 MyBatis-Plus 打起架来。选 wkhtmltopdf 因为 HTML 还原度高,结果线上出问题只能登录服务器敲命令。

这些选择在做决定的那一刻都有道理。技术选型的难就难在——你要为那个道理付多久的代价。

一个库引进来,不是引进来就完了。它要跟着你三年五年,要升级、要补漏洞、要教会每个新人怎么用。选型时多花十分钟查 CVE、多花十分钟搜项目里有没有同类库、多花十分钟想清楚核心链路能不能扛外部依赖,上线后省的是十个通宵。

我自己现在引依赖有个笨办法:写在一张便签上,问自己三个问题——这个版本有没有洞?项目里有没有同类的?它挂了我的核心链路会不会断? 三个问题都答得让自己放心,再往 pom.xml 里写。


延伸阅读 上一篇 《做了18年开发,我总结的十条军规》 讲的是写代码层面的规矩,这篇讲选型层面的。两篇对着看,一个谈"写法",一个谈 "选型" 。


下期预告:《从单体到微服务:这个项目该不该拆?》 前面讲的都是“怎么改”,下期换个视角:这个项目到底该不该拆成微服务?先说结论——不该;再说什么时候该。

如果本文对你有帮助,欢迎:

👍 点赞 | ⭐ 收藏 | 👤 关注 | 💬 留言

我是老炮,18 年 Java 老兵,仍在一线。关注「Java老炮踩坑录」,不错过每一篇真实案例,少踩坑。