老炮踩坑录 · V06 · 老炮视野系列
基于「企业融合评估平台」真实源码,复盘那些选的时候都说没问题、上线一年后才爆雷的技术选型决定
关键词:Fastjson 反序列化 · 重复依赖 · 外部进程 · 命令注入 · 版本安全
👋 欢迎阅读
🏠个人主页:知守观
📘我的专栏:老炮踩坑录
💻当前内容:技术选型
文章目录
- 前言
- 铁律一:带 CVE 的版本不能用——版本号是安全声明
- 铁律二:同一种能力只留一个入口——重复选型就是给未来埋冲突
- 铁律三:外部进程别扛核心链路——黑盒出问题你只能干瞪眼
- 自查清单
- 老炮点评
前言
上一篇十条军规发完,有个朋友私信我:你说的这些都是写代码的规矩,那选型呢?框架、中间件、工具,引进来的时候大伙都觉得没问题,问题往往一年后才冒出来。
这个话题我有发言权。
离职以后我把这个项目的pom.xml拉出来数了一遍依赖,越数越后背发凉。光 JSON 序列化就引了三套,分页框架引了两套,PDF 生成同时用着 iText 和一个外部命令行工具。
今天把我在这个项目里踩到的选型坑整理成三条铁律,每条都带有真实代码说明。
铁律一:带 CVE 的版本不能用——版本号是安全声明
打开pom.xml,第一行就写着 Spring Boot2.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:178JSONObjectjsonObject=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=newPageInfo<>(list);有人那么写:
// ApplyInfoMapper.java —— MyBatis-Plus 写法IPage<ApplyInfo>page=newPage<>(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-lang2.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的核心逻辑:
publicstaticbooleanconvert(StringsrcPath,StringdestPath){StringBuildercmd=newStringBuilder();cmd.append(toPdfTool);cmd.append(" --header-line --disable-javascript ");cmd.append(srcPath);// 用户相关的文件路径直接拼进命令cmd.append(" ");cmd.append(destPath);// 输出路径也直接拼Processproc=Runtime.getRuntime().exec(cmd.toString());// ...proc.waitFor();// 没有超时,卡死就一直等}这段代码有四个坑,每一个都能在线上要你的命。
坑一:命令拼接。srcPath和destPath直接用字符串拼进命令行。如果这两个值里有空格、分号、反引号,Shell 会把它们当成分隔符或命令。Linux 下destPath要是带个; rm -rf /,后果自己想。正确的写法是用ProcessBuilder传字符串数组,每个参数是独立元素,不经过 Shell 解析:
ProcessBuilderpb=newProcessBuilder(toPdfTool,"--header-line","--disable-javascript",srcPath,destPath);Processproc=pb.start();坑二:waitFor()没有超时。wkhtmltopdf 渲染一个复杂 HTML 可能要几秒,如果它内部死锁或者某个资源加载卡住,waitFor()会一直阻塞,占着一个 Tomcat 线程。请求一多时,线程池被占满,整个服务就假死了。应该用waitFor(timeout, unit),超时后destroyForcibly():
if(!proc.waitFor(30,TimeUnit.SECONDS)){proc.destroyForcibly();thrownewTimeoutException("PDF 生成超时");}坑三:环境耦合。工具路径写死在配置里,Windows 一套、Linux 一套:
wkhtmltopdf:pdftool:windows:F:\Program Files\wkhtmltopdf\bin\wkhtmltopdf.exelinux:/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老炮踩坑录」,不错过每一篇真实案例,少踩坑。