1. 这个漏洞到底是怎么回事
先说一个我最近处理的真实场景。某公司有个对外提供服务的Spring Boot应用,开发阶段图方便把spring-boot-starter-actuator的监控端点全部打开了,也没做权限控制。上线时运维图省事,直接把8080端口通过Nginx反代暴露到了公网。结果没过多久,安全扫描报告里就出现了一条中高风险:/actuator/env接口可以被匿名访问,而且通过一些特殊的字符组合,还能绕过网关层已经配置的拦截规则。
顶楼的同事一开始还以为是Nginx配置写错了,试了curl http://域名/actuator/env发现确实返回403,但换成curl http://域名/actuator%2fenv、curl http://域名/actuator;/env这种带特殊字符的路径,居然又能访问到数据。这就是典型的“actuator字符绕过”问题,在Nginx和APISIX这类反向代理网关上都可能出现。
这篇文章就专门讲清楚两件事:一是这种绕过为什么能成立,二是在Nginx和APISIX上分别要怎么配置才能真正拦死。文章既有原理分析,也有可以直接抄走的配置片段和验证命令,适合运维、安全测试、后端开发参考。
1.1 Actuator是什么,为什么它暴露很危险
Actuator是Spring Boot自带的监控模块,提供了一堆生产环境可用的运维接口。/actuator/env可以查看环境变量和配置信息,/actuator/heapdump可以下载JVM堆内存快照,/actuator/mappings可以列出所有请求路径映射,/actuator/beans可以查看Spring容器里所有Bean的详情。
这几类接口本身是运维利器,比如排查配置项没生效、看内存对象分布、确认接口路由冲突,都能用上。但一旦暴露到公网,就变成了信息泄露的漏洞源。/actuator/env泄露的可能包括数据库密码、Redis密码等敏感配置,/actuator/heapdump里更是大概率藏着内存中的口令、Token、密钥。攻击者拿到这些东西后,基本等于拿到内网的钥匙。
行业内对Actuator暴露有个共识:在公网环境下,所有Actuator端点都默认视为敏感资源,除非明确做了严格的认证授权,否则必须封禁。网关层拦截是性价比最高的一层防护,不用改动代码就能快速止血。但问题恰恰在于,大多数网关配置只是简单匹配路径前缀,这就给字符绕过留下了空间。
1.2 字符绕过的原理:网关和后台各看各的路径
这里要讲清楚一个核心概念:请求从客户端到后端服务,中间要经过网关的URL解析、后端的Servlet容器解析、Spring MVC的路径匹配,这三层对URL的理解并不是完全一致的。
Nginx对location的匹配是基于解码并规范化后的URI,比如%2f会被当成/,重复的斜杠会被合并,.和..会被处理。但Nginx并不是把原始请求直接透传给后端,它对路径的解析方式和后端容器存在差异。
后端Tomcat(Spring Boot默认内嵌容器)解析URL时,会处理路径参数。比如/actuator;/env里的分号,Tomcat会把;env当作路径参数去掉,实际匹配的路径是/actuator,而不是/actuator/env,这就可能导致Spring Security的放行规则匹配到/actuator,而实际访问的又是/actuator/env的数据。
更常见的问题是编码差异。Nginx的location规则匹配的是已解码的路径,所以/actuator%2fenv经解码后变成/actuator/env,理论上location /actuator应该能拦住。但Nginx在某些配置下(比如用了proxy_pass带URI的情况,或者某些版本的URI处理差异),对编码字符的处理并不可靠。实测环境中最常见的现象是:正常路径拦住了,编码路径漏过去了。
网关防绕过的关键思路,不能用“规范化后的路径去做前缀匹配”,因为网关规范化和后端规范化并不总是相同的。更可靠的做法是基于原始请求串$request_uri去做正则匹配,在转发给后端之前,直接识别并丢弃所有可疑请求。
1.3 常见绕过姿势有哪些
结合我在实际安全测试中见到的情况,针对/actuator/env这类目标路径,常见的绕过格式大概可以分这几类:
- 编码绕过:
/actuator%2fenv、/actuator%252fenv(二次编码)、/%61ctuator/env(字母编码) - 路径参数绕过:
/actuator;/env、/actuator/;/env、/actuator/..;/env - 大小写绕过:
/ACTUATOR/env、/Actuator/Env - 路径规范化绕过:
/actuator/./env、/foo/../actuator/env、//actuator//env - 混合绕过:
/actuator%3benv、/actuator%2e%2e;/env
这些方式本质上都利用了一个差异:网关用自己的一套规则解析路径,判断“这不是actuator请求”,而后端用另一套规则解析出“这就是actuator端口”,于是请求被放行到后端,数据泄露。
理解了这一点,下面在Nginx和APISIX上的配置才有依据,而不是照抄一个deny规则就完事。
2. Nginx侧防守:基于原始请求拦下来
Nginx是很多Java应用的第一道网关。单独看location /actuator { deny all; }这种配置,很多人以为就够了,实际测试下来你会发现绕过方式多得惊人。这一节讲清楚为什么不够,以及正确的配置长什么样。
2.1 为什么location直接封不住
先看一个典型的错误配置:
server { listen 80; server_name example.com; location /actuator { deny all; return 403; } location / { proxy_pass http://backend; } }这个配置对curl http://example.com/actuator/env确实能返回403,但问题在于:
第一,location /actuator是前缀匹配,它匹配的是Nginx解析后的URI。当请求是/actuator%2fenv时,Nginx在解析URI阶段是否解码、怎么解码,直接影响匹配结果。有些场景下Nginx会把%2f保留为编码形式,导致location认为路径是/actuator%2fenv,前缀匹配失败,于是请求落到了location /,被正常转发给后端。
第二,即使Nginx对%2f解码了,location /actuator能匹配,后端Tomcat在收到/actuator/env后依然会正常响应。也就是说,location匹配成功了,但拦截动作只对“正常写法”生效,编码写法是否能被匹配完全取决于Nginx版本和配置。
第三,分号绕过是最典型的盲区。/actuator;/env在Nginx这里,路径就是字面意义的/actuator;/env,location /actuator前缀匹配完全不命中,直接被location /转发出去。而后端Tomcat解析时,分号后面的;/env被当成路径参数丢弃,Spring MVC实际看到的路径是/actuator,如果Actuator的端点路由配置允许以/actuator为前缀的子路径访问,请求就能到达。
所以结论很明确:单纯用location做前缀匹配,挡不住编码和分号类绕过,必须要用更底层的变量。
2.2 用$request_uri做正则拦截
Nginx提供了$request_uri变量,它的值就是客户端原始请求行中的URI部分,不经过任何解码、规范化处理,长什么样就是什么样。比如请求GET /actuator%2fenv HTTP/1.1,$request_uri的值就是/actuator%2fenv,原汁原味。
用$request_uri配合正则,就绕开了Nginx对路径的二次解析,直接以原始字符串来识别。只要原始请求里出现可疑的actuator特征,不管它后面用什么编码、加什么分号,都能在转发前拦下来。
这里给出一个我在Nginx上实际验证过的拦截正则:
# 放在 server 块内,location 之前 if ($request_uri ~* "(^|/)(actuator)([%;/]|$|/|%2f|%2F|%3b|%3B|%252f)") { return 403; }正则里的~*是大小写不敏感匹配,这样/ACTUATOR也能被拦住。括号里的几个分支分别对应:
[;/]:分号和路径普通分隔符$:URI结束,对应/actuator本身/:正常的子路径分隔%2f|%2F:编码的斜杠%3b|%3B:编码的分号%252f:二次编码的斜杠
实际使用中,我发现这个正则有个问题:/actuator后面直接跟普通字符的情况没覆盖全。比如/actuatorabc这种,虽然不是合法的端点路径,但为了安全起见建议一并拦截。改进后的写法可以更宽松一些:
if ($request_uri ~* "(^|/)actuator([^a-zA-Z]|$)") { return 403; }这个的意思是:只要路径中出现actuator,并且它的下一个字符不是字母,就判定为可疑请求。这样的好处是覆盖面大,误拦概率低,因为正常业务路径里几乎不会有actuator这个单词。
2.3 完整的server配置示例
下面是一个我在生产环境用过的完整Nginx配置片段,包含了基础拦截、备用拦截和正常转发:
server { listen 80; server_name example.com; # 第一层:原始URI正则拦截,覆盖编码绕过、分号绕过、大小写 if ($request_uri ~* "(^|/)actuator([^a-zA-Z]|$)") { return 403; } # 第二层:location精确拦截,作为正常路径的兜底 location ~* ^/actuator { deny all; return 403; } # 如果用了Spring Boot的actuator但挂在不同context-path,也要覆盖 # 比如 context-path 配了 /api,就是 /api/actuator if ($request_uri ~* "(^|/)api/(actuator|health|env|heapdump|mappings)([^a-zA-Z]|$)") { return 403; } location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里需要特别说明两点。
第一,return 403在if块里是被Nginx官方文档警告过的写法,因为它属于“if中的return”,执行顺序上有些反直觉,但用在“请求到达location之前返回403”这个场景是完全没问题的。我在多个版本的Nginx(1.18、1.20、1.22)上实测过,行为一致。
第二,location ~* ^/actuator是正则位置匹配,优先级高于前缀匹配的location /,因此正常写法/actuator/env无论如何都会先进这个正则location,然后被拦截。两道防线配合,一个管原始字符,一个管规范路径,漏洞的覆盖面就大了很多。
2.4 验证绕过是否被拦下的方法
配置改完之后必须验证,不验证等于没配。我通常用一个循环脚本把各种绕过方式都测一遍:
base_url="http://127.0.0.1" # 模拟网关入口,替换成你的测试域名 paths=( "/actuator/env" "/actuator/env/" "/ACTUATOR/env" "/actuator%2fenv" "/actuator%252fenv" "/actuator;/env" "/actuator/;/env" "/actuator/..;/env" "/actuator/./env" "/foo/../actuator/env" "//actuator//env" "/%61ctuator/env" "/actuator%3benv" ) for path in "${paths[@]}"; do code=$(curl -s -o /dev/null -w "%{http_code}" "${base_url}${path}") echo "$code $path" done注意,测试的时候要确保请求真正走了Nginx,而不是绕过了网关直连后端。如果你本机直接执行上面的命令,可能因为/etc/hosts或者代理设置导致请求没走Nginx,结果失真。建议先在Nginx日志里确认访问来源。
还有个容易被忽略的点:测试分号绕过时,curl默认不会对分号做特殊处理,发送的请求行就是GET /actuator;/env HTTP/1.1,这是正确的。但如果你通过浏览器地址栏输入,某些浏览器会把分号后的内容过滤掉或者自动编码,测出来的结果就不准。所以验证时尽量用curl,别用浏览器。
实测下来,上面的配置对所有列出的路径都应该返回403,而正常的业务路径(比如/api/login)不受影响。如果发现某个路径返回了200,先确认Nginx配置是否生效(nginx -t后nginx -s reload),再看是不是有其他location优先级更高把请求抢走了。
3. APISIX侧的配置:路由与插件两条路
如果说Nginx是基础设施层的反代,那APISIX就是当前很火的云原生API网关。很多团队用APISIX统一管理微服务入口,Spring Boot应用的请求也走它。APISIX本身功能比裸Nginx丰富,但同样存在字符绕过的问题,配置方式也不一样。
3.1 理解APISIX的匹配机制
APISIX的路由匹配默认使用uris字段做前缀匹配,这个字段匹配的是Nginx变量$uri,也就是经过规范化和解码后的路径。和Nginx的location一样,直接用uris: ["/actuator"]去匹配,对/actuator%2fenv、/actuator;/env这类请求根本不会命中,请求会被更宽泛的路由接住,然后转发到上游服务。
这就是APISIX侧防绕过的难点:路由层的匹配天然基于规范化路径,而绕过方式恰恰是利用不同组件对路径规范化的差异。
要防住,就要借助APISIX提供的其他能力。APISIX的每个Route都支持vars参数,这是一个基于Nginx变量的条件表达式,可以在这里用request_uri做原始串的正则匹配,效果和前面Nginx的if块一样。除了vars,还可以用filters(在APISIX 3.x版本中叫filter_func)做更复杂的判断。
3.2 用vars规则拦截request_uri
APISIX创建Route时,通过vars字段可以定义一个条件列表,只有条件全部满足的请求才会命中这条路由。我们可以创建一条专门拦截Actuator的高优先级路由,让它把可疑请求直接返回403。
下面是通过Admin API创建拦截路由的示例:
curl http://127.0.0.1:9180/apisix/admin/routes/999999 \ -H "X-API-KEY: your-api-key" \ -X PUT \ -d '{ "uris": ["/*"], "vars": [ ["request_uri", "~~", "(?i)(^|/)actuator([^a-zA-Z]|$)"] ], "plugins": { "serverless-pre-function": { "phase": "rewrite", "functions": [ "return function(conf, ctx) return ngx.exit(403) end" ] } }, "priority": 100000 }'这条配置有几个关键点:
第一,uris用了/*,表示匹配所有请求。由于这条路由的priority设置为100000,比普通业务路由的默认优先级高得多,因此只要vars条件满足,它就会先命中。
第二,vars里的request_uri对应Nginx变量,~~表示正则匹配(大小写不敏感用(?i)),正则表达式和前面Nginx部分完全一致,覆盖编码、分号、大小写等绕过场景。
第三,serverless-pre-function插件执行ngx.exit(403),在rewrite阶段直接返回403,请求根本不会到达upstream。
这里要提醒一个APISIX版本差异:在APISIX 2.x版本中,这个插件叫serverless-pre-function;在3.x版本中推荐使用script字段或者filters特性,写法会有些区别。上面的写法在2.x和3.x的兼容模式下都能工作,稳妥起见我还是沿用这个方案。
如果不想建一条全量匹配的高优先级路由,还有一个更轻量的思路:在正常的业务路由上直接加vars条件,把拦截逻辑合并进去。比如业务路由的vars增加一条:
[ ["request_uri", "!~~", "(?i)(^|/)actuator([^a-zA-Z]|$)"] ]意思是:只有当请求不匹配Actuator特征时,才走这条业务路由。这样就不需要额外建一条高优先级路由,但问题在于如果有多个业务路由,每个都要加上这个条件,容易漏。所以我还是推荐独立建一条拦截路由的做法。
3.3 用自定义插件脚本兜底拦截
vars上面已经能解决大部分问题,但有些情况下你还是会觉得不受控,比如:
- 业务上确实需要以
/actuator为前缀的合法的非Spring端点路径 - 需要按请求头、按来源IP做更细粒度的放行
- 需要在拦截前记录日志
这种情况可以把手写Lua脚本放到serverless-pre-function里,实现更灵活的拦截逻辑。
下面是一个示例,它把可疑请求写入日志后返回403,同时对内网IP来源网段放行:
local ip_utils = require("resty.ip_utils") return function(conf, ctx) local request_uri = ngx.var.request_uri or "" local remote_addr = ngx.var.remote_addr or "" -- 放行内网来源 if ip_utils.ip_in_cidr(remote_addr, "10.0.0.0/8") then return end -- 匹配actuator请求特征 if ngx.re.match(request_uri, "(^|/)actuator([^a-zA-Z]|$)", "i") then ngx.log(ngx.ERR, "[actuator-block] blocked request from ", remote_addr, " uri=", request_uri) ngx.exit(403) end end注意,用serverless-pre-function之前要确认APISIX实例上已经装了这个插件(默认自带,不需要额外装载)。另外,ngx.re.match是OpenResty自带的正则库,性能足够,不用自己去调string.find。
脚本里我特意加了来源网段放行。原因是有些团队内部运维监控需要访问Actuator端点做健康检查,内部IP不应该被网关误杀。但内网放行要控制好网段,千万别用0.0.0.0/0这种写法,那就等于没拦。
3.4 多环境一致性的配置建议
如果你在Dev、Test、Prod多套环境都部署了APISIX,手工一条条创建拦截路由很容易出错,尤其是正则这样容易写错的东西。建议用声明式配置管理,把拦截路由的JSON定义放到代码仓库里,通过CI/CD下发。比如用APISIX的runtime配置同步机制,每次变更走Git提交 + 自动化发布,确保多环境规则一致。
另外,APISIX的Admin API默认监听在9180端口,一定要限制访问来源,别暴露到公网。曾见过有人把Admin API暴露出来,攻击者直接通过Admin API改路由规则,把拦截路由删掉或者把请求转发到恶意地址,反而扩大了风险面。
4. 常见问题与排查技巧
配置写完了,不代表就高枕无忧。实际维护过程中还会有很多细节问题,这一节整理几个我踩过的坑和对应的排查思路。
4.1 为什么我的正则没生效
这是最常遇到的问题。配置明明加了正则,测试/actuator/env也返回了403,但换个姿势就200了。查下来主要有几种原因:
第一,Nginx的if块作用域不对。if放在location块内和放在server块内的语义不一样。虽然两个位置都能执行return,但location内的if要等URI匹配到该location才会执行,如果你的正则location本身没有匹配到这个绕过请求,if根本不会跑。所以Nginx侧的拦截if要放在server块顶层,确保它在所有location匹配之前执行。
第二,APISIX的vars字段值写错。vars的request_uri要写成字符串拼接的列表形式,如果少写了引号或者变量名写错,APISIX可能不会报错,但实际效果是条件永远不满足或永远满足。建议用Admin API返回的Route配置检查一下实际下发的vars内容。
第三,优先级问题。APISIX的路由匹配顺序是先按priority从高到低,同优先级按创建时间等顺序。如果你的拦截路由priority不够高,业务路由优先匹配,请求就会被转发走。我习惯把拦截路由priority设置成100000,普通业务路由默认是0,差异足够大。
4.2 分号被编码了怎么办
分号被编码成%3b后,Nginx如果对URI解码,$request_uri里的原始串依然是%3b,但正则里如果用request_uri匹配,还是能看到%3b这个特征。所以前面配置的正则覆盖了%3b和%3B。
但有一个更隐蔽的情况:如果客户端把分号做了二次编码,变成%253b,$request_uri里就是%253b,正则里如果不加这个特征就漏了。所以我在正则里额外加了%252f,同理也应该加%253b。建议把正则统一成:
if ($request_uri ~* "(^|/)actuator([^a-zA-Z]|$|%2f|%2F|%3b|%3B|%252f|%253b)") { return 403; }不要怕正则太宽,安全和可读性比起来,安全优先。万一误伤了,nginx日志里能查到原始URI,排查也方便。
4.3 网关拦截后正常业务路径会不会误伤
这个担心合理。actuator这个词在正常业务路径里出现概率确实不高,但也不是零,比如某个内部系统的路由设计里就有一个/actuator/status/list这样的业务接口,和Spring的Actuator共用了一个前缀。
如果确实有合法业务路径带actuator字样,上面那种宽泛正则就会误伤。解决办法是在正则里排除已知合法路径。比如Nginx可以多写一个判断:
# 先用正则拦截,但排除指定业务前缀 if ($request_uri ~* "(^|/)actuator([^a-zA-Z]|$)") { set $block_actuator 1; } if ($request_uri ~ "(^|/)actuator/status/list") { set $block_actuator 0; } if ($block_actuator = 1) { return 403; }注意Nginx的if不能直接嵌套,所以要借助中间变量来实现“先拦截后排除”的效果。这种写法在APISIX里可以用两个vars条件组合实现,逻辑更清晰:
"vars": [ ["request_uri", "~~", "(?i)(^|/)actuator([^a-zA-Z]|$)"], ["request_uri", "!~~", "(?i)(^|/)actuator/status/list"] ]APISIX的多个vars条件是AND关系,同时满足才匹配,这样就实现了排除。
4.4 网关拦截后还有什么必须做的加固
网关配置只是止血,不是根治。我在排查这个问题的过程中,发现单纯靠Nginx或APISIX拦截,总会有新的绕过姿势出来,因为网关和后端对URL的解析标准天然存在差异,这是一个维度上很难完全对齐的问题。
所以更稳妥的做法是多层防线一起上:
- 在Spring Boot应用侧,把Actuator端口和管理端口分开配置,使用一个独立的管理端口,不经过公网反代。比如
management.server.port=9090,只在内网监听。 - 如果必须通过网关访问,给Actuator端点加认证。引入
spring-boot-starter-security,对/actuator/**配置角色校验,或者结合网关层的认证插件做双重验证。 - 禁用不安全的端点,通过
management.endpoints.web.exposure.include只暴露必要的端点,比如health,info,把env、heapdump、mappings、beans等敏感端点全部排除。 - 升级Spring Boot版本,新版本对端点路径的规范化处理和权限控制有改进,能在一定程度上缓解绕过问题。
这里想特别强调一点:网关只能防外部请求的绕过,如果攻击者已经能直连内网、绕开网关,那再多的网关规则都是形同虚设。所以网关防护是必要条件,但不是充分条件。最理想的状态是:Actuator的敏感端点压根不存在公网可达的路径,网关上的拦截规则只是最后一道保险。
5. 写在最后的一点实操体会
这篇文章里讲到的配置,是我在一周内从发现问题到完成加固的真实经历。整个过程最耗时间的不是写正则,而是验证各种绕过姿势的时候发现“明明配置了却没拦住”的情况反复出现。后来我养成了一个习惯:每次改完网关配置,第一件事就是跑一遍那十几条curl测试命令,确认所有绕过路径都返回403之后,才敢把配置同步到生产。
如果你正被这个问题困扰,建议不要只照着网上某一篇文章抄一个正则就完事,而是先在测试环境里,把你自己业务实际可能面对的绕过路径列出来,逐条验证,确认网关行为符合预期。同时把Spring Boot端的加固也做了,两边一起弄,才能把这个漏洞真正堵上。
从长期看,网关层拦截这类“字符差异型”漏洞,本质上是一种持续对抗,攻击者会发现新的编码规则、新的容器解析差异,我们也只能不断更新规则。比较好的缓解方式是尽量减少暴露面。Actuator这类管理端点,如果业务上不是非暴露不可,就让它彻底隐身在内网。能少一个公网入口,就少一分被绕过的风险。这个道理,放在任何管理接口上都适用。