1. 为什么要在容器镜像里动 Tomcat 的版本号
做容器化交付的同行大概都遇到过这种场景:镜像推到客户环境或者第三方扫描平台,一份 SBOM 报告甩过来,第一行就写着tomcat 9.0.54,后面跟着一长串 CVE 编号。有些是误报,有些是组件传递依赖带进来的,但业务方不管这些,看到版本号就要你升级。可问题是,这个 Tomcat 根本不是我们主动装的,是某个基础镜像自带的,升级路径完全不在自己手里。
这时候"隐藏版本号"就成了一个很现实的需求。注意我这里说的隐藏,不是让你去欺骗安全审计,而是消除镜像里那些不必要暴露的版本指纹,让扫描结果回归到真实的风险面,而不是被一个 banner 字符串牵着鼻子走。Tomcat 的版本号会出现在好几个地方:RELEASE-NOTES、LICENSE、NOTICE、catalina.jar里的ServerInfo.properties,还有启动日志里打印的那一行Server version: Apache Tomcat/9.0.xx。这些信息对运行时毫无用处,但对扫描器来说就是明晃晃的靶子。
我先把结论摆出来:真正需要处理的是ServerInfo.properties和启动 banner,而不是简单粗暴地删文件。删RELEASE-NOTES只能骗过一部分基于文件名的检测,稍微像样点的扫描器直接解压 jar 读属性文件,照样能拿到版本。所以这篇内容我会从原理讲到实操,把每一层都拆开说清楚,包括哪些能改、哪些不能碰、改完之后怎么验证。
适合谁来读?如果你负责镜像构建、做私有化交付、或者被安全扫描报告折磨过,这篇应该能省你不少时间。如果你只是想了解 Tomcat 的内部结构,也能从里面看到不少平时不会注意的细节。
2. Tomcat 版本号到底藏在哪几个角落
动手之前得先摸清楚敌情。Tomcat 的版本信息分布比大多数人想象的分散,我按"扫描器最爱查"的顺序列一下,你可以对照自己的镜像逐个核对。
2.1 ServerInfo.properties:版本号的老巢
这是最核心的一处。路径在catalina.jar内部:
org/apache/catalina/util/ServerInfo.properties文件内容长这样:
server.info=Apache Tomcat/9.0.54 server.number=9.0.54.0 server.built=Sep 3 2021 12:00:00 UTCTomcat 启动时,Catalina类会读取这个文件,把server.info打印到日志里,同时通过Server的getInfo()暴露给 JMX 和管理端口。扫描器解压 jar 读这个文件是最直接的手段,因为路径固定、格式固定,几乎零成本。
2.2 启动日志里的 banner
每次catalina.sh run或者容器启动,标准输出都会有一行:
Server version name: Apache Tomcat/9.0.54 Server built: Sep 3 2021 12:00:00 UTC这行来自ServerInfo.properties,所以改了属性文件,日志自然就变了。但要注意,有些镜像的 entrypoint 脚本会自己 echo 版本信息,这种得单独处理。
2.3 发行包附带的文本文件
$CATALINA_HOME目录下通常有:
RELEASE-NOTESLICENSENOTICERUNNING.txt
这些文件里都带着版本号。基于文件内容匹配的扫描器会 grep 这些文件。删掉或者改写都行,但要注意LICENSE和NOTICE涉及合规,不建议直接删除,改写里面的版本描述更稳妥。
2.4 jar 包文件名与 MANIFEST
catalina.jar、tomcat-util.jar这些文件名本身不带版本号,但META-INF/MANIFEST.MF里可能有Implementation-Version。这个字段扫描器也会读,需要一并处理。
2.5 管理端与错误页
如果开了 manager 应用,登录页和 404 错误页默认会显示 Tomcat 版本。这个属于应用层,容器层面改属性文件管不到,需要单独配置server.xml里的ErrorReportValve,把showServerInfo设为false。
我把这几处整理成一张表,方便你对照排查:
| 位置 | 路径 | 扫描器关注度 | 处理难度 |
|---|---|---|---|
| ServerInfo.properties | catalina.jar 内 | 极高 | 中 |
| 启动日志 banner | stdout | 高 | 低(随属性文件变) |
| RELEASE-NOTES 等 | CATALINA_HOME | 中 | 低 |
| MANIFEST.MF | 各 jar 内 | 中 | 中 |
| 错误页/管理页 | 应用层 | 低 | 低 |
提示:动手前先用
unzip -l catalina.jar | grep -i serverinfo确认路径,不同大版本的 Tomcat 路径基本一致,但小版本之间偶有差异,先看清楚再改。
3. 改 ServerInfo.properties 的正确姿势
这一节是重头戏。改属性文件听起来简单,但直接在 Dockerfile 里sed替换 jar 内的文件是行不通的——jar 是压缩包,不能就地编辑。得走"解压、改、重打包"的流程,而且重打包有个大坑:jar 的签名会失效。如果原 jar 带签名,重打包后 Tomcat 启动可能报SecurityException。所以要先确认有没有签名。
3.1 先判断 jar 是否被签名
进到镜像里或者解压出来的catalina.jar所在目录,执行:
unzip -l catalina.jar | grep -i "META-INF/.*\.SF\|META-INF/.*\.RSA\|META-INF/.*\.DSA"如果输出为空,说明没签名,可以放心重打包。如果有输出,得先把签名文件删掉再重打包,否则校验会失败。删签名文件本身不影响运行,只是去掉了完整性校验,对内部交付场景可以接受。
3.2 解压与修改
我习惯在构建阶段用一个临时目录操作,避免污染最终镜像。核心命令:
mkdir -p /tmp/tomcat-patch cd /tmp/tomcat-patch unzip -q /opt/tomcat/lib/catalina.jar -d catalina-extract然后编辑catalina-extract/org/apache/catalina/util/ServerInfo.properties。这里有个关键决策:改成什么。三种常见做法,各有取舍:
- 改成通用值:比如
server.info=Apache Tomcat,去掉版本号。最干净,但有些扫描器看到没有版本号反而会标记为"未知版本",触发人工复核。 - 改成伪装版本:比如写一个很老的版本号。不推荐,这属于误导审计,而且老版本号本身可能命中更多 CVE,适得其反。
- 改成中性字符串:比如
server.info=Application Server。我比较倾向这种,既消除了版本指纹,又不会显得异常。
server.number和server.built两个字段建议一并处理,server.number可以留空或者写0.0.0.0,server.built写一个中性日期。改完内容大概是这样:
server.info=Application Server server.number=0.0.0.0 server.built=Jan 1 2020 00:00:00 UTC3.3 重打包与替换
重打包时要注意保持 jar 的结构,尤其是META-INF/MANIFEST.MF必须在最前面。用jar命令比zip更稳妥:
cd catalina-extract # 如果之前发现有签名文件,先删掉 rm -f META-INF/*.SF META-INF/*.RSA META-INF/*.DSA jar cfm /tmp/tomcat-patch/catalina.jar META-INF/MANIFEST.MF .然后把新 jar 覆盖回原位置:
cp /tmp/tomcat-patch/catalina.jar /opt/tomcat/lib/catalina.jar这里有个实操心得:重打包后的 jar 权限和属主可能变,如果原 jar 是root:root 644,新 jar 也要保持一致,否则某些严格模式下的容器启动会报权限错误。用chmod 644和chown对齐一下。
3.4 在 Dockerfile 里固化这套流程
上面是手工操作,真正交付要写进 Dockerfile。我一般用一个多阶段构建,把 patch 逻辑放在构建阶段,最终镜像只保留结果:
FROM tomcat:9.0-jdk11 AS patcher USER root RUN set -eux; \ mkdir -p /tmp/patch; \ cd /tmp/patch; \ unzip -q /usr/local/tomcat/lib/catalina.jar -d extract; \ printf 'server.info=Application Server\nserver.number=0.0.0.0\nserver.built=Jan 1 2020 00:00:00 UTC\n' \ > extract/org/apache/catalina/util/ServerInfo.properties; \ rm -f extract/META-INF/*.SF extract/META-INF/*.RSA extract/META-INF/*.DSA; \ cd extract; \ jar cfm /tmp/patch/catalina.jar META-INF/MANIFEST.MF .; \ cp /tmp/patch/catalina.jar /usr/local/tomcat/lib/catalina.jar; \ chmod 644 /usr/local/tomcat/lib/catalina.jar FROM tomcat:9.0-jdk11 COPY --from=patcher /usr/local/tomcat/lib/catalina.jar /usr/local/tomcat/lib/catalina.jar用多阶段的好处是最终镜像里不会残留/tmp/patch和extract目录,减少攻击面,也避免扫描器从临时文件里翻出原始版本信息。这一点很多人会忽略——你在构建阶段解压出来的原始ServerInfo.properties如果留在镜像层里,扫描器照样能读到。
注意:
COPY --from只复制文件,不复制权限以外的元数据,所以chmod要在 patcher 阶段做完,或者复制后再补一次。
4. 文本文件与 MANIFEST 的连带处理
ServerInfo.properties改完只是解决了大头,剩下的边角料不处理,扫描报告还是会冒出版本号。这一节把连带项一次说清。
4.1 RELEASE-NOTES 与 NOTICE 的处理策略
RELEASE-NOTES里全是版本变更记录,删掉最省事,但有些合规检查会看这个文件是否存在。我的做法是清空内容但保留文件:
: > /usr/local/tomcat/RELEASE-NOTESNOTICE和LICENSE涉及开源合规,不能删也不能乱改主体内容,但可以把里面出现的版本号字符串替换掉。用sed处理:
sed -i 's/Apache Tomcat\/9\.0\.[0-9]*/Apache Tomcat/g' /usr/local/tomcat/NOTICERUNNING.txt同理,里面提到版本的地方替换成通用描述。这些操作放在 Dockerfile 的RUN里一行搞定,注意sed的正则要转义斜杠。
4.2 MANIFEST.MF 里的 Implementation-Version
catalina.jar重打包时,MANIFEST.MF是原样保留的,里面可能有:
Implementation-Version: 9.0.54这个字段扫描器会读。处理方式是在重打包前用sed改掉:
sed -i 's/^Implementation-Version:.*/Implementation-Version: 0.0.0/' extract/META-INF/MANIFEST.MF注意MANIFEST.MF对格式敏感,每行不能超过 72 字节,改的时候别引入超长行。Implementation-Version这行本身很短,一般不会超,但如果你替换成很长的字符串就要小心。
4.3 其他 jar 的版本字段
tomcat-util.jar、tomcat-api.jar等也可能带Implementation-Version。全量处理的话,写个循环:
for j in /usr/local/tomcat/lib/*.jar; do if unzip -p "$j" META-INF/MANIFEST.MF 2>/dev/null | grep -q "Implementation-Version"; then echo "need patch: $j" fi done先探测再决定要不要逐个 patch。实测下来,大部分版本号集中在catalina.jar,其他 jar 即使有也不一定被扫描器重点关照,按需处理即可,不必过度工程化。
4.4 启动脚本里的 echo
有些自定义基础镜像的 entrypoint 会打印版本,比如:
echo "Starting Tomcat ${TOMCAT_VERSION}"这种得看具体镜像的脚本,用grep -rn "TOMCAT_VERSION\|9\.0\." /usr/local/tomcat/bin/排查一遍。有就改,没有就跳过。这一步容易被遗漏,因为大家默认版本信息都在 jar 里,实际上脚本层也可能埋雷。
5. 应用层版本泄露的收尾
容器层面处理完,还有应用层的口子。Tomcat 默认的错误页会显示版本,manager 应用登录页也会。这些不改,前面做的功夫等于白费一半。
5.1 ErrorReportValve 的 showServerInfo
在conf/server.xml的Host节点里,默认有一个ErrorReportValve:
<Valve className="org.apache.catalina.valves.ErrorReportValve" showReport="false" showServerInfo="false" />很多镜像默认showServerInfo是true,改成false后错误页就不再显示版本。这个改动对业务无影响,纯粹是信息收敛。改完记得重启验证,访问一个不存在的路径看返回的 HTML 里还有没有Apache Tomcat字样。
5.2 manager 与 host-manager 应用
如果镜像里带了webapps/manager和webapps/host-manager,而业务又用不到,直接删掉是最省心的:
rm -rf /usr/local/tomcat/webapps/manager /usr/local/tomcat/webapps/host-manager删掉不仅消除版本泄露,还减少攻击面。如果业务确实需要 manager,那就得改它的WEB-INF/classes/...里的页面模板,工作量大且容易在升级时丢失,不推荐。我的经验是:能删就删,不能删就接受这点残留,因为 manager 本身有认证保护,风险可控。
5.3 验证清单
改完之后别急着交付,按这个清单过一遍:
| 检查项 | 命令 | 预期结果 |
|---|---|---|
| 属性文件 | unzip -p catalina.jar org/apache/catalina/util/ServerInfo.properties | 无真实版本号 |
| 启动日志 | docker run --rm 镜像 2>&1 | grep -i "server version" | 显示中性字符串 |
| 错误页 | curl -s http://localhost:8080/nonexist | grep -i tomcat | 无输出 |
| 文本文件 | grep -rn "9\.0\." /usr/local/tomcat/*.txt /usr/local/tomcat/NOTICE | 无匹配 |
启动日志那条尤其重要,因为它是运行时行为的直接体现,比静态文件检查更有说服力。
6. 踩过的坑与几条硬经验
这套流程我前后在几个项目里跑过,踩的坑不算少,挑几个有代表性的说说,能帮你少走弯路。
6.1 重打包后 Tomcat 起不来:签名校验的锅
第一次操作时没检查签名,重打包后启动直接报Invalid signature file digest for Manifest main attributes。原因就是原 jar 带签名,删了.SF文件但MANIFEST.MF里还留着签名相关的摘要条目。解决办法是重打包前把MANIFEST.MF里Name:开头的签名段落也清掉,或者干脆用一个全新的最小MANIFEST.MF:
Manifest-Version: 1.0 Created-By: patched实测用最小 manifest 完全不影响 Tomcat 运行,因为catalina.jar的类加载不依赖 manifest 里的特殊字段。这个做法比逐个删签名段落更干净。
6.2 多阶段构建的层缓存陷阱
用多阶段构建时,如果 patcher 阶段的RUN指令没有把unzip和jar命令放在同一层,中间层会残留解压出来的原始文件。虽然最终镜像COPY --from只拿结果,但如果有人基于 patcher 阶段做调试镜像,原始版本就暴露了。所以把解压、修改、重打包、清理写在同一个RUN里,用&&串联,最后rm -rf /tmp/patch。这是镜像构建的基本功,但在 patch 场景下格外重要。
6.3 升级基础镜像后 patch 失效
基础镜像从tomcat:9.0.54升到tomcat:9.0.65,catalina.jar的路径没变,但ServerInfo.properties的字段顺序可能变,sed匹配不上就静默失败。我的做法是在 Dockerfile 里加断言:
grep -q "server.info=Application Server" extract/org/apache/catalina/util/ServerInfo.properties \ || { echo "patch failed"; exit 1; }构建时如果 patch 没生效直接失败,而不是产出一个带版本号的镜像。这个断言救过我好几次,强烈建议加上。
6.4 别把"隐藏"当成"修复"
最后说个观念上的坑。隐藏版本号只是让扫描报告干净,并不消除真实漏洞。如果那个 Tomcat 版本确实有可利用的 CVE,藏得再深也挡不住真正的攻击。所以这套操作的正确姿势是:先评估真实风险,该升级的升级,升级不了的做补偿控制(比如网络隔离、WAF 规则),隐藏版本号只是减少信息暴露的辅助手段。把它当成安全建设的全部,那就是自欺欺人。
我在实际交付里通常会把 patch 脚本和风险评估报告一起给到业务方,明确写清楚"版本号已收敛,但底层组件版本未变,风险等级维持原判"。这样既满足了扫描合规,又不误导决策。踩过几次坑之后我越来越觉得,技术手段的边界要说清楚,比手段本身更重要。