老炮踩坑录 · V06 · 老炮视野系列
基于「企业融合评估平台」真实源码,复盘那些选的时候都说没问题、上线一年后才爆雷的技术选型决定
关键词:Fastjson 反序列化 · 重复依赖 · 外部进程 · 命令注入 · 版本安全
👋 欢迎阅读
🏠个人主页:知守观
📘我的专栏: 老炮踩坑录
💻当前内容:技术选型
前言
上一篇十条军规发完,有个朋友私信我:你说的这些都是写代码的规矩,那选型呢?框架、中间件、工具,引进来的时候大伙都觉得没问题,问题往往一年后才冒出来。
这个话题我有发言权。
离职以后我把这个项目的 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 里还有几个老朋友:
| 依赖 | 版本 | 发布年份 | 问题 |
|---|---|---|---|
| fastjson | 1.2.37 | 2017 | autoType RCE,官方修到 1.2.83 |
| poi / poi-ooxml | 3.12 | 2014 | 多个 XXE、拒绝服务漏洞 |
| commons-fileupload | 1.3 | 2013 | CVE-2016-1000031 反序列化 RCE |
| guava | 20.0 | 2016 | 多个已修复漏洞 |
| spring-boot | 2.1.0 | 2018 | 已 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 库:
- Jackson——Spring Boot 默认带的,HTTP 消息转换用它
- Fastjson 1.2.37——业务代码里 925 处调用的主力
- 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 打开,对照这张表过一遍:
| 检查项 | 怎么查 | 危险信号 |
|---|---|---|
| 依赖有没有公开 CVE | NVD 搜组件名+版本号,或看官方 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老炮踩坑录」,不错过每一篇真实案例,少踩坑。