又到攻防演练季,后台告警就没停过。我这边最先被打穿的一类问题,就是 Spring Boot 的 Actuator 端点泄露——扫描器一轮探测下来,/actuator/env、/actuator/heapdump这些路径只要有响应,漏洞单马上就飘红。说实话,这个坑我这两年踩了不止一次,这次专门整理一套已经在真实项目里验证过的修复方案:先用 Nginx 快速止血,再做 Spring Boot 侧的收敛加固,整个流程从发现到确认告警消除,不到一个小时就能完成。不管是开发、运维还是安全岗,只要你的系统是 Spring Boot 这套技术栈,这篇文章就能直接照着操作。
1. 攻防演练中Actuator漏洞为什么“一打一个准”
1.1 Actuator是什么,为什么它总成为突破口
Actuator 是 Spring Boot 自带的运维监控模块,设计初衷是让开发和运维能通过 HTTP 端点查看应用健康状况、运行时指标、配置信息,甚至触发一些管理操作。本身是个好东西,问题出在很多人把它当成了“调试工具”,图省事把端点权限全部放开,没有加任何认证,也没有做网络隔离,等于把应用的内脏全摊开在公网上。
很多老项目是从 Spring Boot 1.x 升上来的,1.x 时代默认暴露的端点非常多,/health、/env、/beans、/dump这些路径在根目录下就能直接访问。升级到 2.x 之后虽然默认只暴露health,但不少项目在迁移时仍然沿用了原来的include: "*"配置,或者后来因为监控需求打开了更多端点,风险就这样一直延续下来。
攻防演练期间,红队对资产的探测基本都是自动化完成的。扫描器会在每一个 Web 端口上跑一遍常用路径字典,/actuator、/actuator/health、/actuator/env、/actuator/heapdump、/actuator/mappings全在必测清单里。只要某个端点返回了正常 JSON,立刻就会被标记为高风险目标,随后进入人工利用环节。所以演练刚开始,排在最前面的告警几乎总是 Actuator 泄露。
1.2 敏感端点危害等级盘点
把常见的 Actuator 端点按危害程度盘一遍,我们防守的时候才知道要优先封哪个。
| 端点路径 | 暴露内容 | 风险等级 |
|---|---|---|
| /actuator/env | 环境变量、系统属性、数据库口令、密钥 | 高危 |
| /actuator/heapdump | JVM堆转储文件,内存中的密码、Token、业务数据 | 高危 |
| /actuator/configprops | 配置属性,可能含第三方密钥和内部地址 | 高危 |
| /actuator/mappings | 全部URL映射,帮助攻击者找到隐藏接口 | 中高危 |
| /actuator/loggers | 可修改日志级别,可能引发信息泄露或日志轰炸 | 中高危 |
| /actuator/beans | 所有Bean结构,暴露组件信息 | 中危 |
| /actuator/threaddump | 线程快照和调用栈 | 中危 |
| /actuator/health | 健康状态,可能泄露版本和组件明细 | 低中危 |
/env是最让人头疼的。它会把环境变量直接列出来,数据库地址、账号密码、Redis 连接串、云厂商密钥、内部系统 Token,经常全在配置里。配置中心下发到本机的敏感配置,在这一个端点就能看光。/heapdump更直接,下载下来的堆转储文件用内存分析工具打开,搜一下password、secret、token这类关键字,经常能翻出正在使用的真实凭证。这个点很多团队会忽略,总觉得只要代码里没写死密码就安全了,但 JVM 内存里存着就是存着。
/mappings看起来是信息类端点,实际上价值很高。它会列出 Spring MVC 注册的所有请求路径、HTTP 方法和对应的处理器,攻击者拿到这份清单之后,就可以照着列表找你没放在明面上的管理接口、内部白名单路径、Mock 接口,直接精准打击。/loggers还有一个特殊的风险点,它支持通过 POST 请求修改日志级别,攻击者可以把目标包的日志级别改成 DEBUG,再请求一次业务接口,一旦接口打印了入参或者返回值,Token、手机号、内部订单号这些敏感数据就可能被写进日志文件,如果日志系统没有做脱敏,等于数据变成了明文日志。
1.3 防守视角下的检测手法
作为防守方,我们也得用攻击者的眼光来验证修复效果。最基础的检测就是直接模拟一次探测,用 curl 打普通请求:
curl -i https://你的域名/actuator/env curl -i https://你的域名/actuator/heapdump如果返回 200 并且 body 里有 JSON,说明漏洞还在;如果返回 403、401、404 之类,说明至少这一层已经挡掉了。注意,这里说的 403 和 401 最好是由 Nginx 直接返回的,如果请求穿透到了后端,后端因为没暴露端点返回了 404,那也只是暂时安全,Nginx 层还没有形成真正的防护,后续配置需要继续补。
还有一个容易漏的点:有些项目把 Actuator 的 base-path 改了,比如/manage、/monitor。扫描器的字典里同样包含这些路径。所以修复时不要只盯着/actuator,要确认清楚后端真正的管理路径到底是什么,可以用配置确认,也可以翻代码看网关转发规则。
2. 修复方案选型:为什么先上Nginx
2.1 两种修复路径的取舍
遇到 Actuator 泄露,摆在眼前的有两个方向:第一类是改 Spring Boot 应用配置,只保留必要端点、加 Spring Security 认证、甚至把管理端口独立出来;第二类是在入口网关用 Nginx 直接拦截/actuator相关路径。
从长期看,应用侧的基础加固必须要做,这一点没有争议。但攻防演练期间时间非常紧,实际项目中经常会遇到几种尴尬情况:应用不能随便重启、服务是多实例部署的、代码权限不在你手上、运维只给了你 Nginx 的操作权限。这种情况下,在 Nginx 层做拦截就是最快能落地的止血方案,改配置文件 reload 一下就生效,不用动 Java 进程,对业务的影响几乎为零。
另一个现实因素是覆盖面。很多公司有测试环境、预发环境、生产环境,每个环境的 Spring 版本和配置可能都不一样,挨个去改应用配置再走发布流程,风险和周期都拉长不少。Nginx 在统一入口拦截,配置一次就能覆盖同一入口下的所有后端服务,效率是明显高的。我当时采用的策略就是“先 Nginx 止血,再排期做应用侧加固”。
2.2 Nginx方案的边界条件
Nginx 方案有它的天然边界,不能认为配完就万事大吉。它只能拦截“经过 Nginx 转发的流量”。如果攻击者能直接访问后端应用的端口,比如 8080 直接对公网开放,或者负载均衡规则里存在一条绕过 Nginx 指向后端 IP 的记录,那你 Nginx 配得再严也无济于事。
动手之前先确认三件事:
- 业务访问是否全部经过 80/443 端口进入 Nginx?
- 后端应用端口是否绑定公网网卡,或者安全组是否对后端端口开放?
- 是否存在其他入口,比如 CDN 回源地址指向后端、负载均衡转发规则直连后端?
我在实际操作中踩过一次坑。当时 Nginx 已经配好,用域名去访问/actuator/env确实被 403 了,但扫描器还是报漏洞。最后排查发现,一条老的负载均衡转发规则把流量直接指向了应用实例的公网 IP 加端口,流量完全没有经过 Nginx。后来把这条规则清理掉,漏洞才真正闭环。所以确认边界不是可选动作,而是 Nginx 方案生效的前提。
2.3 动手前的前置确认清单
把思路落成清单,操作的时候不会手忙脚乱:
- 确认 Nginx 当前配置,找到负责转发的 server 块和 proxy_pass 目标
- 确认后端应用实际监听端口与 proxy_pass 指向一致
- 确认负载均衡、防火墙、安全组策略里没有绕行通道
- 确认监控系统对
/actuator/health的依赖,是否允许在一段时间内不可用 - 记录当前 Nginx 配置文件路径和版本,保证
nginx -t可用
这些前置工作做完,后面真正写配置的时候才踏踏实实。
3. 实战:完整的Nginx防护配置
3.1 先做一个全封闭基线
最简单的修复方式,是把/actuator开头的所有请求直接拒绝掉:
location ^~ /actuator { return 403; }这段配置的作用很直白:任何以/actuator开头的 URI,不管 GET 还是 POST,统一返回 403。^~是告诉 Nginx,一旦命中这个前缀,就不要再继续匹配正则表达式了,确保拦截动作干净执行。
配置上去之后,curl -i https://域名/actuator/env会立刻返回 403,扫描器这里的漏洞告警可以先消掉。但有一个实际问题:如果监控系统是通过/actuator/health做探活的,这个配置会把健康检查一起干掉,监控会开始报应用不可用。所以全封闭方案只适合“确认完全不需要任何 Actuator 端点”的场景,或者应急时先封住再说,封完再补精细规则。
3.2 只放行health端点
大多数生产环境需要保留健康检查,这时候可以精确放行health:
location = /actuator/health { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; } location ^~ /actuator { return 403; }这里用location = /actuator/health做精确匹配,只有完整路径是/actuator/health的请求才会被转发到后端,其他所有/actuator开头的请求都会落到第二个 location,返回 403。
有一个小细节容易踩坑:=精确匹配优先级最高,但/actuator/health/这种带结尾斜杠的请求不会命中精确匹配,而是直接落到下面的 403。如果你的监控探针带了斜杠,要么改探针配置,要么在 Nginx 里再补一个 location。实际生产环境中,这个细节会直接导致监控探活大面积失败,非常值得留意。
如果还需要保留info端点,可以再补一条精确匹配:
location = /actuator/info { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; } location = /actuator/health { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; } location ^~ /actuator { return 403; }这个写法的好处是简单直观,缺点是端点多了以后比较繁琐。另一种紧凑的写法是利用正则:
location ~ ^/actuator/(health|info)$ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; } location /actuator { return 403; }这里必须说明 Nginx 的匹配顺序,否则很容易配错。第一条是正则匹配,第二条是普通前缀匹配。Nginx 会先记住最长前缀/actuator,然后继续按顺序检查正则,一旦正则命中就优先使用。所以/actuator/health会命中正则并转发,/actuator/env不满足正则,落到普通前缀匹配,返回 403。
如果你把第二条写成location ^~ /actuator { return 403; },那正则就永远没有机会执行,因为^~命中后直接终止匹配,health也会被 403 掉。这个细节我在不止一次修复中看到有人翻车,写出来给各位提个醒。
3.3 用IP白名单控制敏感端点
如果运维人员确实需要查看某些敏感端点,比如排查问题时要看/actuator/mappings、抓/actuator/heapdump做内存分析,那就不能一刀切全封掉。常见的做法是保留health给监控用,其他端点只允许内网运维网段访问:
location = /actuator/health { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; } location ^~ /actuator { allow 10.0.0.0/8; allow 192.168.0.0/16; deny all; proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; }这个配置的效果是:监控系统从任何网段都能访问health;内网网段访问/env、/heapdump等端点会被正常转到后端;公网过来的请求则一律 403。Nginx 的allow/deny是顺序匹配,allow必须写在deny all前面,顺序反了等于全部拒绝。
这里提醒一点:白名单本身依赖$remote_addr。如果流量经过了 CDN 或云负载均衡,Nginx 看到的所有 source IP 都是 CDN 节点 IP,白名单永远匹配不上,导致内网用户也被 403。这种情况需要配合set_real_ip_from和real_ip_header去恢复真实客户端 IP,我后面会专门写这个坑。
如果团队习惯再加一层弱认证,可以用 Nginx 的 Basic Auth:
location ^~ /actuator { auth_basic "Restricted"; auth_basic_user_file /etc/nginx/htpasswd_actuator; allow 10.0.0.0/8; deny all; proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; }htpasswd文件用 Apache 的 htpasswd 命令生成即可。不过一般情况下,IP 白名单已经足够,Basic Auth 会在监控和自动化工具访问时增加一次认证配置成本,按团队习惯取舍就好。
3.4 配合独立管理端口使用
如果后端配置允许,还有一个更彻底的思路:把 Actuator 端点独立到单独的管理端口,跟业务端口完全分开。这样公网入口的 Nginx 只需要处理业务流量,Actuator 根本不会暴露在外网。
在 Spring Boot 的 application.yml 里这样配置:
management: server: port: 9091 address: 127.0.0.1 endpoints: web: base-path: /mgmt exposure: include: health,info把管理端口绑定在127.0.0.1上,意味着即使安全组不小心把 9091 端口放开了,外部也连不上,因为端口只监听在本机回环地址。监控系统需要访问时,可以通过内网代理、SSH 隧道等方式跳过去。这种方式的防护等级是最高的,适合对安全要求比较严格的核心系统。
独立管理端口的缺点也很明显:运维习惯需要改变,原有的监控采集探针、发布脚本都要增加跳板逻辑。所以它更适合作为应用侧加固里的长期优化项,而不是演练期间的应急首选。
3.5 配置检查和发布流程
Nginx 配置改完,第一步永远是测试语法,语法过不了就直接把错误配置 reload 上去,后果是整站不可用:
nginx -t nginx -s reloadnginx -t会提示语法错误和未知指令,有问题先修。语法通过再执行 reload,reload 是平滑加载新配置,不会中断现有连接。
reload 之后立刻做一轮验证:
curl -i http://你的域名/actuator/env curl -i http://你的域名/actuator/health curl -i http://你的域名/actuator/heapdump正常情况应该是:/actuator/env和/actuator/heapdump返回 403,/actuator/health返回 200。如果启用了 IP 白名单,还要从白名单网段内的机器再试一次,确认影响面没有扩大。
4. Spring Boot 侧加固:别只靠Nginx兜底
4.1 收紧端点暴露范围
Nginx 能挡住外部流量,但内网横向移动之后,攻击者已经可能拿到某台内网主机的权限,再通过内网直连后端服务时,Nginx 就完全拦不到了。所以应用侧的收敛必须同步进行,这是真正的兜底防线。
最基础的配置是限定只暴露必要的端点:
management: endpoints: web: exposure: include: health,info exclude: env,beans,mappings,configprops,heapdump,threaddump,loggers,metrics,conditions,scheduledtasks,httptrace,cachesinclude白名单里只留监控系统实际需要的health和info,exclude作为双保险写出敏感端点。注意不能只靠exclude,因为 Spring Boot 的处理逻辑是include优先,只要 include 里没有那个端点,exclude 里写不写都一样;反过来,如果 include 写成了*,exclude 单独挡不住全部敏感端点。
健康状态的展示粒度也要收敛:
management: endpoint: health: show-details: never如果设成always,health返回的 JSON 会把数据库连接状态、磁盘空间、Redis 延迟全带出来。对攻击者来说,这些信息能帮他判断后端组件类型、版本、脆弱点,不值得为了图方便全暴露出去。
4.2 给Actuator加上认证
只依赖端点收敛还不够,万一以后有人调试的时候把include又改回*,防线又破了。给 Actuator 加上认证是更扎实的一层。
最常见的方式是引入spring-boot-starter-security,然后对 Actuator 端点做权限控制。核心思路是:
- 允许匿名访问
/actuator/health,因为监控探针需要 - 其他端点要求 BASIC 认证,或者限定为某个角色
- 认证账号密码通过环境变量注入,不要写死在代码里
如果项目确实有特殊原因不能引入 Spring Security,也可以考虑在已有网关层统一加认证过滤器,但这个方案依赖架构现状,不是每个项目都具备条件。实际操作中,从运维角度讲,类似/actuator/env这种高敏感端点,建议只给极少数人开通访问权限,账号要定期轮换。
4.3 更换Actuator的默认路径
给 Actuator 换一个不容易猜的路径也是纵深防御里的一环:
management: endpoints: web: base-path: /internal-monitor-2024这个办法能有效增加被自动化扫描命中的成本,因为扫描器字典基本都会覆盖默认的/actuator,但随机化的自定义路径很难被猜中。不过要明确一点:它不是安全手段,只能算“隐藏”,属于降低暴露面的辅助措施,不能单独作为漏洞修复手段。我在实践中把它和 Nginx 拦截、端点收敛一起使用,效果是叠加的。
4.4 应用侧修复后的验证
改完 Spring Boot 配置需要重启应用,验证方法跟之前的探测一样:
curl -i http://127.0.0.1:8080/actuator curl -i http://127.0.0.1:8080/actuator/env curl -i http://127.0.0.1:8080/actuator/health正常情况下,/actuator根路径和/actuator/env应该返回 404 或 401,/actuator/health返回 200。加上 Spring Security 之后,未认证请求返回 401 也符合预期。这一步验证一定要在应用本机做,不要绕 Nginx,因为要确认的就是后端本身已经收紧。
5. 常见问题与排查经验:配置贴了还是不生效
5.1 为什么返回404而不是403
如果 curl/actuator/env返回的是 404,同时响应体带明显的 Tomcat 或 Spring 的 404 页面,说明请求已经穿过 Nginx,到了后端应用层,是后端自己拒绝了这个路径。这不算坏事,至少端点没有暴露,但从 Nginx 层的防护角度看,拦截动作还没有生效。
排查方法很简单:看响应头。Nginx 返回的 403 响应头和 Tomcat/Jetty 返回的 404 响应头有明显区别,后端容器一般会带Server: Tomcat之类的标志。如果想要 Nginx 层直接拦截,检查一下当前生效的 location 配置是否真的匹配了这个路径,可以用nginx -t确认语法,再看 access log 里这条请求命中了哪个 location。
5.2 配置了放行health但监控还是全红
这是出现频率最高的一个坑,原因多半是 location 组合写错了。很多人会写成:
location ^~ /actuator { return 403; } location = /actuator/health { proxy_pass http://127.0.0.1:8080; }这个顺序下,/actuator/health直接被^~ /actuator截走,返回 403,下面的精确匹配根本没有执行机会。Nginx 的^~一旦命中就停止后续匹配,这是规则不是 Bug。
正确做法是把精确放行写在前面,用location = /actuator/health优先匹配,然后再用location ^~ /actuator做兜底,或者像我上面示范的那样用正则放行加普通前缀兜底。改完以后一定要 curl 一次确认。
5.3 使用了IP白名单但内网也被403
最常见的原因是多级代理环境下,Nginx 拿到的$remote_addr是 CDN 节点或者负载均衡器的 IP,不是真实客户端 IP。allow 10.0.0.0/8判断的其实是 CDN 节点的地址,自然命不中。
解决办法是在 Nginx 里配置真实 IP 解析:
set_real_ip_from 100.64.0.0/10; set_real_ip_from 203.0.113.0/24; real_ip_header X-Forwarded-For; real_ip_recursive on;set_real_ip_from要填上游节点(CDN、云负载均衡)的真实 IP 段,real_ip_header从 XFF 里取客户端 IP,real_ip_recursive on表示从右往左找第一个非信任 IP。配置完以后$remote_addr才会变成真实客户端地址。注意set_real_ip_from的网段一定要准确,否则要么取不到真实 IP,要么存在伪造风险。
5.4 路径大小写和变体怎么处理
Nginx 的 location 匹配默认是大小写敏感的,^~ /actuator拦不住/Actuator/env,扫描器也会尝试这种大小写变体。Spring Boot 默认对路径大小写敏感,所以/Actuator基本不会路由到后端,风险相对有限,但为了保险,可以改成不区分大小写的正则:
location = /actuator/health { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; } location ~* ^/actuator { return 403; }精确匹配=的优先级最高,/actuator/health会被正确放行;其他/actuator开头的路径,包括大小写变体,都会掉进正则拦截。这个组合既保持了监控可用,又能覆盖变体绕过。
5.5 前端路由和actuator路径冲突
前后端分离的项目里,有时候前端框架把某个路由命名成了/actuator相关路径,比如前端单页应用的路由是/actuator-page,如果 Nginx 用location ~* ^/actuator去做拦截,会误伤正常的前端页面。
这种场景下,要把拦截规则做得更精确,比如只匹配典型端点:
location ~* ^/actuator/(env|heapdump|beans|mappings|configprops|loggers|threaddump) { return 403; }或者在前端和后端之间再加一层区分,根据路径前缀分别转发到静态资源和后端应用。实际操作中,先确认前端路由里到底有哪些和/actuator相关的路径,再决定拦截粒度和放行方式。
5.6 常用排查命令汇总
最后整理一份我每次排查都会用到的命令清单:
nginx -t nginx -s reload tail -f /var/log/nginx/access.log curl -i http://你的域名/actuator/env curl -i http://你的域名/actuator/health如果觉得 access log 看不出命中了哪个 location,可以在 Nginx 配置里临时打开 debug 级别日志,但这会加大日志写入量,生产环境慎用,排查完立刻调回去。还有一个省力的方式:把整套探测写在脚本里,对着候选路径一次跑完,直接在终端里对比状态码。
6. 一些实际操作后的体会
这轮修复做下来,我的核心体会是:Nginx 配置只是止血工具,Spring Boot 侧的收敛加固才是长期防线,两边的边界必须同时守住。演练期间时间紧,先用 Nginx 统一拦截确实能最快让告警消失,但如果后端配置一直不收敛,内网横向之后风险依然存在。安全修复不能只看外网响应,要把内网访问路径也当成攻击路径来看。
我现在每次演练前会固定做一轮自检:先检查所有网关入口的转发规则,确认没有绕过 Nginx 的直连通道,再对每个子域名的/actuator系列路径做一次状态码探测,把返回 200 的记录全部拉出来人工确认。这套流程跑下来基本不会漏,比临时翻代码高效得多。
最后再分享一个小技巧:把巡检脚本放进定时任务,每周跑一次,一旦哪个应用又有人把端点全量打开,响应码会立刻异常,能在漏洞被外部扫描器发现之前先发现自己。安全这件事,自己动手永远比等告警强。