Tomcat 隐藏版本这件事,很多人以为就是在错误页面里把那个版本号抹掉,结果一轮自查下来,curl -I出来的响应头还挂着Apache-Coyote/1.1,404 页面底部照样躺着完整版本,管理端甚至把版本号写在了标题栏。我前后在几套不同的部署形态里做过这件事:有传统解压式部署的,有包管理器装的,有 Docker 镜像里跑的,还有 Spring Boot 内嵌 Tomcat 打成可执行 jar 的。这几种形态的改法完全不一样,踩的坑也不一样。
下面这套内容,讲的是怎么把 Tomcat 对外暴露的版本信息真正清干净——不是改个数字糊弄过去,而是从响应头、错误页面、默认应用、反向代理层四个位置一起收口,同时把改完之后怎么验证、怎么保证下次发布不被打回原形一并说清楚。适合运维、后端开发,以及正在做安全加固交付的同学参考。前面几个章节偏"位置在哪",中间几章偏"怎么改",最后一章是验证和我的实操体会,赶时间的可以直接跳到第 5 章看清单。
1. Tomcat 的版本号究竟从哪几个口子漏出去
很多人做加固的时候只盯着一个地方,改完自测一遍觉得没问题,上线之后被第三方扫出问题,回头一查发现漏了好几个出口。先把所有出口盘清楚,后面才知道该在哪些层动刀。
1.1 Server 响应头:Connector 层统一吐出的 Coyote 标识
只要请求打到了 Tomcat 的 HTTP Connector,响应里默认会带一行Server: Apache-Coyote/1.1。注意这里显示的不是Apache Tomcat/9.0.x,而是Apache-Coyote/1.1——Coyote 是 Tomcat 的 HTTP 连接器组件名,1.1 指的是它实现的协议版本,不是 Tomcat 的版本号。
正因为长得不像版本号,很多人在自查的时候直接把这行忽略了,觉得"Coyote 不算敏感信息吧"。实际上在指纹识别里,Apache-Coyote这四个字母本身就是极其明确的信号:它几乎可以断定服务端是 Tomcat,而且1.1这个后缀从 Tomcat 5 一直沿用到 10,说明不了具体版本,但足以把你从"未知 Web 服务"归到"Tomcat 站点"这一栏里。攻防对抗中,第一步永远是缩小范围,能确认是 Tomcat,后面所有的探测都会围绕 Tomcat 的特性展开。
这一行是在 Connector 处理响应时写进去的,位置比你想象的深,用 filter、用拦截器都拦不住它——因为它写在 Tomcat 内部把响应刷给 socket 的环节。想干掉它只有两条路:改 Connector 的属性,或者改更底层的类。
1.2 错误页脚注:ErrorReportValve 渲染出来的完整版本
访问一个不存在的路径,Tomcat 默认返回的那个 404 页面,底部有一行小字,格式大概是Apache Tomcat/9.0.56。这一行才是真正把精确版本暴露出去的地方,比响应头危险得多。
这个页面是ErrorReportValve生成的。它属于 Tomcat 的管道(Pipeline)机制,挂在 Host 容器上,专门负责把异常和错误状态码渲染成一个 HTML 页面。渲染的时候它会调用ServerInfo.getServerInfo()拿到版本字符串,拼进页面底部。
除了 404,500 错误、400 错误、甚至某些连接异常,都有可能走这个 Valve。更麻烦的是,如果应用内部的异常没被框架兜住,一路抛到容器层,这个页面还可能把堆栈信息带出来——类名、包名、依赖库版本,这些东西拼在一起,比一个版本号有价值得多。我在一次内部演练里就看到过某个 Demo 应用的 500 页面,把业务类的完整包路径和 MySQL 驱动版本一起打了出来。
1.3 默认应用与管理端:比响应头直白得多的指纹
Tomcat 解压安装之后,webapps目录下默认带着docs、examples、manager、host-manager这几个目录。docs首页会明确写着当前版本文档;manager/html登录页的标题和页脚都带版本;examples里一堆示例应用能直接跑起来。
很多加固做得不彻底的环境,响应头和错误页都改了,但/docs/还能直接访问,点进去版本号明晃晃挂在页头。这相当于前门上了三道锁,后窗大敞着。
我的一般做法是:docs、examples、manager、host-manager四个目录全部删掉或移走,生产环境本来也不该有这些。管理端如果确实要用,单独部署一套受限实例,绑定本地回环地址,通过跳板访问,不要和业务应用混在同一个对外端口上。
1.4 版本号被拿到之后能干什么
把这条链路说透,你才知道为什么值得花时间去隐藏。攻击者拿到Apache Tomcat/9.0.56这个精确版本之后,第一件事是去公开的漏洞库里查这个版本对应哪些已知问题——某个反序列化、某个请求走私、某个文件读取。版本号就是查表的键,有了键就能直接跳到利用代码那一步,省掉中间所有的试错成本。
如果没有精确版本,只剩Apache-Coyote/1.1,攻击者就只能猜:是 Tomcat 7 还是 9?是 8.5 的哪个小版本?猜错一次,利用代码就跑不通,可能触发告警、可能直接把服务打挂,成本瞬间上去了。安全加固的本质很多时候不是"让他进不来",而是"让他每次尝试都要付出代价"。
隐藏版本属于纵深防御里很薄的一层,它的价值在于抬高信息收集的成本,不能替代补丁管理。真实版本该升级还是要升级,别指望改了版本号就免于漏洞影响。
2. 直接改 catalina.jar 里的 ServerInfo.properties
这是最经典、覆盖面最广的做法,也是唯一能同时搞定错误页和部分内部展示位置的办法。代价是操作有点重,而且升级 Tomcat 的时候改动静默消失,必须脚本化。
2.1 ServerInfo.properties 里的三个字段
catalina.jar里埋着一个org/apache/catalina/util/ServerInfo.properties,内容大概长这样:
server.info=Apache Tomcat/9.0.56 server.number=9.0.56.0 server.built=Nov 10 2021 12:00:00 UTCserver.info就是给外面看的展示字符串,错误页底部那一行、日志启动横幅用的都是它。server.number是四段式的数字版本,Tomcat 内部有些逻辑和外部的一些监控探针会读它。server.built是构建时间戳。
改的时候三个字段要一起处理,只改server.info不改server.number,某些监控系统或者第三方组件读到的还是真实版本;只改这两个不管server.built,构建时间戳也能反推出大致版本区间——Tomcat 的大版本发布时间是公开的,从构建日期能推得很准。
我一般会把三个字段统一改成中性值:
server.info=Web Server server.number=0.0.0.0 server.built=server.built直接留空最好,别填一个假日期,假日期反而制造了一个可以拿来比对的稳定特征。
2.2 原地改 jar 的完整操作与回滚准备
动手之前必须做两件事:停服务、备份原 jar。别嫌麻烦,catalina.jar被改坏之后 Tomcat 起不来,这时候如果没备份,你只能重新下一份同版本的安装包,而生产环境往往连外网都没有。
完整流程如下:
# 1. 停服务 systemctl stop tomcat # 2. 备份 cp $CATALINA_HOME/lib/catalina.jar $CATALINA_HOME/lib/catalina.jar.bak.$(date +%Y%m%d) # 3. 进到 lib 目录,解出目标文件(保持包路径结构) cd $CATALINA_HOME/lib jar xf catalina.jar org/apache/catalina/util/ServerInfo.properties # 4. 编辑。用纯 ASCII 内容,别写中文 vi org/apache/catalina/util/ServerInfo.properties # 5. 打回 jar(注意必须在包含 org/ 目录的当前路径执行) jar uf catalina.jar org/apache/catalina/util/ServerInfo.properties # 6. 清理临时解压出来的目录 rm -rf org # 7. 验证 unzip -p catalina.jar org/apache/catalina/util/ServerInfo.properties # 8. 启动并复测 systemctl start tomcat第 5 步的路径是最容易错的地方。jar uf是把"当前目录下的相对路径"写进 jar 包,所以必须站在包含org/那一层的目录执行。有些人图省事把 properties 文件解到/tmp下改,然后jar uf catalina.jar /tmp/ServerInfo.properties,结果 jar 里多了一个/tmp/ServerInfo.properties条目,原来的路径没被覆盖,重启之后版本号一点没变——这个坑我见过不止一次。
2.3 properties 文件的编码陷阱
java.util.Properties读取文件用的编码是 ISO-8859-1,不是 UTF-8。这意味着如果你在server.info里写了任何非 ASCII 字符(比如中文、某些特殊符号),读出来就是乱码,而且不会报错,只是页面上显示成一堆问号。
更隐蔽的是有些编辑器默认保存成 UTF-8 with BOM,BOM 头会被 Properties 当成第一个键名的一部分,导致server.info这个键彻底读不到,Tomcat 回退到默认值。表现出来就是"我明明改了,怎么还是原样"。
这个文件里只写英文字母、数字、空格、点号,别加任何装饰性字符。改完用
file命令确认一下编码是不是 ASCII,能用就别碰。
2.4 改完重启还是老版本号:四种典型原因
| 现象 | 最可能的原因 | 排查方式 |
|---|---|---|
| 错误页版本没变,Server 头也没变 | jar 更新路径不对,原条目未被覆盖 | unzip -l catalina.jar | grep ServerInfo看有几个条目 |
| 错误页变了,Server 头没变 | Server 头本来就来自 Connector,跟这个文件无关 | 见第 3 章 |
| 改了 A 机器生效,B 机器没生效 | 多实例部署,只改了一台的 CATALINA_HOME | 核对每台机器的catalina.jar修改时间 |
| 重启后恢复原样 | 走的是包管理器安装,启动脚本或升级流程重新解包覆盖了 jar | 检查/usr/share/tomcat与/var/lib/tomcat的关系 |
第三种情况在集群里特别常见。改完一台就急着写加固报告,剩下几台忘了同步,扫描器一扫就露馅。
第四种更隐蔽。用yum或apt装的 Tomcat,catalina.jar在系统包管理的路径下,任何一次update都会把它还原。这种情况要么改用配置层方案(第 3 章),要么把修改动作写进部署流水线,每次发布自动执行一遍。
3. 不拆 jar 的办法:Connector 属性与 ErrorReportValve 开关
如果版本够新(Tomcat 8.5 及以上),有两个纯配置的入口能做同样的事,改配置文件的成本比拆 jar 低太多,升级也不会丢。缺点是对老版本不友好。
3.1 server="" 到底删掉了什么
从 Tomcat 8.5 开始,Connector元素支持一个server属性,作用就是覆盖响应里的Server头:
<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" server="" />设置成空字符串,Tomcat 会把这一行响应头整个去掉,不是改成一个空值头。抓包看的话,响应里压根就没有Server:这一行了。
这个方案有个硬性前提:Tomcat 8.5+。Tomcat 7 和 8.0 不认这个属性,写了之后启动直接报错,类似Attribute server is not allowed to appear in element Connector,整个服务起不来。所以我给老版本环境做加固的时候,第一步永远是先确认版本,能升级就升级,升级不了就老老实实走改 jar 那条路。
改完
server=""之后,Server头没了,但错误页的版本号还在,这两个是独立的。别以为配了这一行就万事大吉。
3.2 showServerInfo 与 showReport 的取舍
错误页那边对应的是ErrorReportValve,在conf/server.xml的<Host>里面加:
<Host name="localhost" appBase="webapps" unpackWARs="true" autoDeploy="true"> <Valve className="org.apache.catalina.valves.ErrorReportValve" showReport="false" showServerInfo="false"/> </Host>两个属性的分工要搞清楚。showServerInfo="false"去掉的是页面底部那行版本信息;showReport="false"去掉的是异常堆栈详情,500 错误发生的时候只返回一个空的响应或者极简的错误提示,不再把类名、方法名、依赖版本吐出来。
在真实项目里,showReport其实比showServerInfo更重要。版本号泄露只是给你打个标签,堆栈泄露可能直接把内部结构、数据库连接方式、框架版本全交代了。我做加固的时候这两个是一起关的,从不单独关一个。
关掉showReport之后有个副作用需要提前跟业务方沟通:出问题的时候,测试同学在页面上看不到任何错误信息了,只能去翻日志。这个体验落差要提前打招呼,不然上线第二天就有人跑来问"为什么报错了什么都不显示"。
3.3 把默认错误页整个换掉
上面两个开关是把细节抹掉,页面本身还是 Tomcat 风格的那种——白底、红字、"HTTP Status 404 – Not Found"。有经验的人一眼就能认出来这是 Tomcat,即使没有任何版本号。
要做到连"这是 Tomcat"都看不出来,就得自定义错误页。传统部署方式在应用的web.xml里配置:
<error-page> <error-code>404</error-code> <location>/error/404.html</location> </error-page> <error-page> <error-code>500</error-code> <location>/error/500.html</location> </error-page> <error-page> <exception-type>java.lang.Throwable</exception-type> <location>/error/500.html</location> </error-page>三个条目都要写。只写error-code不写exception-type的话,未捕获的异常走的是另一条路径,还是会命中 Tomcat 的默认页面。这个细节我在一个项目里栽过,改了半天 404 页面生效了,结果一造一个空指针,500 页面原封不动地带着版本号出来了。
自定义页面本身也别留破绽。有些团队做出来的错误页底部写着"XX 系统 · Powered by Spring",这就等于把后端技术栈又交代了一遍。要么干脆做成极简的一句话,要么放一张产品 logo,别写任何技术栈相关的字样。
4. Spring Boot 内嵌 Tomcat 的隐藏写法
现在大量项目是把 Tomcat 内嵌进 Spring Boot 打成可执行 jar 跑的,这种形态没有conf/server.xml,也没有lib/catalina.jar让你去拆——jar 都在BOOT-INF/lib里,改起来更麻烦而且容易在重新打包时丢失。正确的做法是在代码里动。
4.1 嵌入式场景下没有配置文件怎么办
Spring Boot 提供了WebServerFactoryCustomizer这个扩展点,可以在容器启动之前拿到TomcatServletWebServerFactory,进而拿到 Connector 和 Context 对象,用代码完成和改 XML 一样的事情。
先确认一件事:Spring Boot 内嵌 Tomcat 的默认Server头是什么。不同版本之间略有差异,最稳的办法是直接起一个空项目curl -I看一眼。我实测过的 2.x 和 3.x 版本里,默认都会带Apache-Coyote/1.1,和独立部署一致。
另外要注意,Spring Boot 默认的错误处理走的是自己的BasicErrorController,返回 JSON 或者error.html白页,大多数情况下不会命中 Tomcat 的ErrorReportValve。但如果出错发生在 Spring MVC 的 DispatcherServlet 之前——比如请求根本没进到框架层,或者静态资源 404 被容器直接处理——还是有概率走到 Valve 那边。所以两处都得配。
4.2 WebServerFactoryCustomizer 里的两处改写
import org.apache.catalina.Container; import org.apache.catalina.Valve; import org.apache.catalina.core.StandardContext; import org.apache.catalina.valves.ErrorReportValve; import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; import org.springframework.boot.web.server.WebServerFactoryCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class TomcatHardeningConfig { @Bean public WebServerFactoryCustomizer<TomcatServletWebServerFactory> tomcatCustomizer() { return factory -> { // 1. 去掉 Server 响应头 factory.addConnectorCustomizers(connector -> connector.setProperty("server", "")); // 2. 关掉错误页的版本信息与异常详情 factory.addContextCustomizers((StandardContext context) -> { Container parent = context.getParent(); if (parent == null) { return; } for (Valve valve : parent.getPipeline().getValves()) { if (valve instanceof ErrorReportValve) { ErrorReportValve erv = (ErrorReportValve) valve; erv.setShowReport(false); erv.setShowServerInfo(false); } } }); }; } }第一段用connector.setProperty("server", ""),效果等同于在 XML 里写server=""。这里用setProperty而不是某个具体的 setter,是因为Connector对上层的属性暴露方式就是这套 property 机制。
第二段是遍历 Host 容器的 Pipeline,把已有的ErrorReportValve挑出来改属性。为什么要遍历而不是直接 new 一个加进去?因为 Spring Boot 内嵌启动的时候,Tomcat 自己已经往 Pipeline 里塞了一个默认的ErrorReportValve,你再 add 一个,Pipeline 里就有两个,改的只是你新加的那个,原来那个照样工作,页面该显示版本还显示版本。
4.3 版本差异带来的两个坑
第一个坑是ErrorReportValve的属性可见性。Tomcat 9 里setShowReport和setShowServerInfo都是公开方法,直接调用没问题。但如果你的项目因为某些历史原因还在用 Tomcat 8.0 的 embedded 包,这两个 setter 可能不存在,编译期就报错。这时候要么升级依赖,要么用反射兜底,最省事的还是升级。
第二个坑是重复配置。有些团队的父级脚手架里已经配了一份WebServerFactoryCustomizer,业务项目里又抄了一份,两个 customizer 都往 Pipeline 里加 Valve,或者互相覆盖属性。表现出来就是"本地测试好好的,集成到主工程里就不生效"。排查方式是启动时打一行日志,把 Pipeline 里所有 Valve 的类名和属性打出来,一眼就能看出有几个。
// 排查用,确认 Pipeline 里到底有几个 ErrorReportValve context.getParent().getPipeline().getValves() .forEach(v -> log.info("Valve: {}, {}", v.getClass().getName(), v));内嵌形态还有一个容易忽略的点:如果你是用 Docker 跑的,改完代码要确认镜像真的重新构建了。带
<skip>或者依赖缓存没失效的时候,跑起来的还是老 jar,测试结果全是假象。
5. 反向代理层的兜底与一套可复现的验证清单
前面几章讲的都是 Tomcat 这一侧的动作,真实架构里 Tomcat 前面通常还有一层反向代理。这一层如果配不当,好不容易清掉的版本信息会被重新加上去,甚至加得更多。
5.1 Nginx 侧抹掉后端响应头
后端如果已经去掉了Server头,代理层就没什么可做的。但现实情况经常是后端有几个实例改漏了,或者短期内没法改,这时候在代理层统一清洗是最快的止血手段:
location / { proxy_pass http://127.0.0.1:8080; proxy_hide_header Server; proxy_hide_header X-Powered-By; add_header Server webserver always; }proxy_hide_header是把后端返回的同名头丢掉,add_header是重新给一个中性的值。这两条要配对使用:只用前者,客户端拿不到Server头,某些老旧的健康检查探针会认为响应异常;只用后者,等于有两个同名的Server头,客户端行为不可预期。
always这个参数非常关键。不加的话,add_header只在 200、201、204、206、301、302、303、304、307、308 这些状态码下生效,404 和 500 时不会加——而偏偏就是错误响应最容易被拿去做指纹识别。我第一次配的时候就漏了always,正常页面看着挺干净,一访问不存在的路径,Server头又冒出来了。
还有个细节:Nginx 的add_header在location块里配置会覆盖server块里的同名配置,不是合并。如果上层server块里配了一堆安全响应头(X-Content-Type-Options之类),在location里加了add_header Server,那些头全都没了。这个坑排查起来很费时间——你会发现安全头突然集体消失,代码一行没改。
5.2 一次完整的验证清单
改完之后不能只看首页,得把几个容易漏的入口都过一遍。下面这套我每次交付都要跑一遍:
# 1. 正常响应的头 curl -sI http://127.0.0.1:8080/ | grep -i -E "server|x-powered" # 2. 404 页面(最容易漏的版本号位置) curl -s http://127.0.0.1:8080/__not_exist__ | grep -i -E "tomcat|coyote|apache" # 3. 静态资源响应 curl -sI http://127.0.0.1:8080/favicon.ico | grep -i server # 4. 默认应用是否已清理 for p in docs examples manager host-manager; do echo -n "$p: " curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/$p/ done # 5. 通过代理层再看一遍 curl -sI https://your-domain/ | grep -i server curl -s https://your-domain/__not_exist__ | grep -i -E "tomcat|coyote"第 4 条的输出如果全是 404,说明默认应用清干净了;只要有一个返回 200 或 302,就得回去处理。第 5 条经常被跳过,但代理层和后端是两套配置,只测后端等于只测了一半。
| 检查项 | 期望结果 | 不达标时的处理位置 |
|---|---|---|
Server响应头 | 不存在,或为中性值 | Connector 的server属性 / 代理层 |
| 404 页面版本号 | 不含 Tomcat、Coyote 字样 | ServerInfo.properties / ErrorReportValve |
| 500 页面堆栈 | 不返回异常详情 | showReport="false"或自定义错误页 |
| 默认应用目录 | 全部 404 | 删除 webapps 下对应目录 |
| 管理端入口 | 不可从公网访问 | 移除或改为仅本地监听 |
| 代理层响应头 | 与后端一致,无新增泄露 | Nginx 的add_header/proxy_hide_header |
5.3 隐藏之后还该顺手做掉的几件事
版本隐藏是门面,做完之后顺便把几个常被一起扫出来的问题也收掉,效率最高。
shutdown端口默认是 8005,配置里写着shutdown="SHUTDOWN",把这个端口暴露在外网,等于送了一个远程关停开关。生产环境要么把端口设成-1直接禁用,要么改成一个随机长字符串口令并且只监听回环地址。
目录列表功能在conf/web.xml的defaultservlet 里,listings参数默认是false,但有些老项目为了图方便改成了true,结果整个上传目录可以被遍历。这个参数值得扫一眼确认。
HTTP 的TRACE方法默认是关的(allowTrace默认false),但如果中间有代理层或者某些框架把它重新打开了,回显请求内容会有跨站追踪的风险。用curl -X TRACE打一下确认返回 405 就行。
AJP 连接器在 Tomcat 8.5 之后默认就是注释状态,Tomcat 9 干脆不配了。但如果是老配置文件迁移过来的,<Connector port="8009" protocol="AJP/1.3">可能还开着,这个端口不该对任何不可信网络开放。
6. 我在几轮加固里踩过的坑和几条建议
第一次做这件事的时候,我按网上的教程改完ServerInfo.properties,重启,curl了一下错误页,版本没了,交差。两周后安全复测,报告上还是写着"服务端版本信息泄露",附的截图是响应头里的Apache-Coyote/1.1。那次之后我养成了一个习惯:改完之后一定把响应头、错误页、默认目录三条线全跑一遍,缺一条都不算完。
第二个教训跟升级有关。有个环境的 Tomcat 是通过系统包管理器装的,我改完之后确实生效了,过了半年做例行更新,catalina.jar被新版本覆盖,版本号又回来了,而且因为更新了版本,暴露的是一个更新的、可能还没打补丁的版本号。这种"改动静默失效"是最难发现的,因为没人会定期去curl自己的错误页。后来我把这件事拆成两步:能用配置解决的(server=""+ErrorReportValve)优先用配置,因为这些写在server.xml里,升级不会动;实在要用改 jar 的,就把改 jar 的脚本挂到部署流水线的后置步骤里,每次发布自动跑一遍,跑完自动curl验证并断言输出里不含关键字,不通过就卡住发布。
第三个教训是别把版本号改成一个"假版本"。早期我图省事,把server.info改成了Apache Tomcat/8.5.0,想着混淆一下。结果运维排查问题的时候被这个假版本带偏了半天,第三方组件兼容性判断也出了偏差,有些工具确实会读这个值做逻辑分支。后来统一改成完全中性的字符串,不给任何具体版本暗示,反而更清爽。
最后一条建议:这件事的价值取决于你有没有把它做成"可重复执行的流程"。手工改一台机器谁都会,麻烦的是几十台机器、每次发布、每次升级都要保持一致。把改配置、删默认应用、验证响应头这三件事写进镜像构建或者部署脚本里,跑一次和跑一百次的成本一样,这才算真正做完了。至于版本号本身,藏得住是加分项,藏不住也不影响你打补丁——别为了藏它把升级流程搞复杂了,那个才是本末倒置。