最近圈子里又在传某开源低代码平台的权限绕过漏洞,说是“最新版”也没能挡住。我花了两个晚上把整个鉴权链路翻了一遍,除了社区里已经讨论的那些路径绕过手法之外,还真让我挖到一条不太一样的绕过路径。这条路径不是简单的“拦截器没匹配上”,而是出在“token有效但权限校验缺失”这个更隐蔽的位置。
这篇东西我会从平台鉴权架构入手,把漏洞根因拆开讲清楚,再把我发现新绕过路径的完整过程、复现步骤、修复方案和验证方法都摆出来。适合正在做代码审计的安全工程师、用低代码平台搭内部系统的开发,以及想搞明白“为什么修了拦截器还是被绕过”的运维同学。
1. 先从平台架构和鉴权设计说起
1.1 为什么低代码平台最容易出权限绕过
低代码平台的核心卖点是快。表单配一配、接口拖一拖,一套后台管理就出来了。这种快速交付模式在安全上天然会埋雷,最典型的就是把权限模型做得很“薄”。
多数这类平台的权限控制思路是这样的:前端根据用户角色控制菜单显隐,后端在接口层做URL和角色的匹配。听起来没什么问题,但实际落地时,后端的权限判断往往只做到“登录态校验”这一步,也就是说只要你带着一个有效token,后端就认为这个请求是合法的,至于这个token对应的人到底能不能访问这个接口,很多时候根本没查。
为什么会出现这种情况?因为低代码平台的接口数量太多、迭代太快,如果每个接口都精细配置权限,开发效率会急剧下降。于是平台方普遍选择“统一拦截器 + 少量放行名单”的方案:拦截器负责校验token,放行名单里的路径不用校验token。问题恰恰就出在这个“放行名单”和“统一拦截”之间的空档里。
我审计的这个平台(下称X-Platform)就是典型代表。它是标准的Spring Boot架构,鉴权用的是Shiro + JWT,核心逻辑写在一个全局拦截器里。所有请求进来先过拦截器,检查请求头是否带token、token是否有效,如果有效就放行到业务接口。看起来链路很完整,但打开放行名单之后,你会发现事情没那么简单。
1.2 目标平台的权限拦截链路
先模拟一下X-Platform的鉴权配置。登录接口、图形验证码接口、Swagger接口文档、Druid监控页、Actuator健康检查、静态资源、以及几个“内部使用”的报表导出接口,全部被加进了放行名单。关键代码如下:
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns("/**") .excludePathPatterns( "/sys/login", "/sys/captcha", "/swagger-ui/**", "/v2/api-docs", "/druid/**", "/actuator/**", "/static/**", "/report/export/**", "/inner/token" ); }这段配置单看似乎没什么大毛病,放行的都是些常规路径。但真正的问题是:Shiro从1.x升级到2.x之后,默认的路径匹配规则变了,而平台的业务代码里还残留着大量老式写法。更麻烦的是,新版框架里有两个拦截器在同时工作——Shiro的FilterChain和SpringMVC的Interceptor,它们对“同一个请求路径”的解析结果可能完全不一样。
这个不一致,就是所有绕过手法的温床。
2. 漏洞根因:从路径规范化到鉴权不一致
2.1 同一请求,两套解析结果
先解释一下什么叫“两套解析结果”。你在浏览器里输入一个URL,这个URL不是直接交给业务代码处理的,要先经过Web容器(比如Tomcat)的解析,再经过框架的路由匹配,最后才能到Controller。问题是,容器解析URL的规则和框架匹配路径的规则并不是100%一致的。
Tomcat在解析请求路径时有一个比较特殊的机制:它会把分号(;)后面的内容当作“路径参数”剥离开。举个例子:
请求:/sys/user/list;foo=bar 容器解析后认为实际路径是:/sys/user/list为什么容器要这么干?因为Servlet规范里允许在路径段上附加参数,最常见的场景就是/xxx;jsessionid=abc123这种老式会话跟踪写法。Tomcat老老实实地执行了规范,把分号后面的部分拆掉,然后拿着/sys/user/list去匹配Controller。
但框架这边呢?Spring的AntPathMatcher在处理/sys/user/list;foo=bar这个字符串时,它会把这个路径当成一个整体去跟/sys/user/list做匹配,结果就是不匹配。于是拦截器认为这是一个“没有匹配到受保护路径”的请求,直接放行了。而Tomcat已经把真实路径解析出来了,请求最终打到了/sys/user/list对应的业务方法上。
这就形成了经典的鉴权绕过:拦截器看到的是一串“无害”的乱码路径,Web层看到的却是真实的敏感接口。
2.2 绕过手法分类
我在实际测试中整理了一下,这类路径解析差异导致的绕过手法大概有这几类:
| 手法类型 | 请求路径示例 | 拦截器视角 | Web层视角 | 杀伤力 |
|---|---|---|---|---|
| 分号参数 | /sys/user/list;foo=1 | 不匹配,放行 | 匹配 /sys/user/list | 高 |
| URL编码 | /sys/user/%6cist | 不匹配 | 解码后匹配 /list | 高 |
| 大小写变体 | /Sys/User/List | 不匹配(区分大小写) | 匹配(不区分) | 中 |
| 双斜杠 | //sys//user/list | 不匹配 | 归一化后匹配 | 中 |
| 路径穿越 | /sys/user/../user/list | 不匹配 | 规范化后匹配 | 高 |
| 末尾斜杠 | /sys/user/list/ | 不匹配 | 匹配 | 中 |
注意,不是每种手法在每个版本上都有效,因为框架升级会影响匹配规则。但分号和URL编码这两类,在我测的X-Platform多个版本上都稳定生效。
2.3 为什么拦截器匹配不上而后端能处理
这里再往深挖一层。AntPathMatcher是一个纯字符串匹配器,它不会去理解URL的语义。它拿到原始请求路径之后,就直接跟配置的模式做比对。而容器的路径处理流程里,有一步叫做“路径规范化”,这一步会做三件事:解码百分号编码、移除.和..路径段、剥离分号参数。
也就是说,容器在“语义层”处理了URL,而拦截器在“字符串层”处理URL。这两层之间出现认知偏差,就给了攻击者操作空间。
所以我在最初审计时一眼就看出了X-Platform的问题:它把Shiro的FilterChain配置和SpringMVC的Interceptor混用了,而且两边对路径的校验规则还不一样。Shiro那边已经升级了更严格匹配机制,但SpringMVC这边还开着老式宽松匹配。一套鉴权体系里同时存在两套标准,那么攻击者只需要找到“对不上”的位置,就能钻进空档。
3. 新绕过路径的发现过程
3.1 翻代码时的怀疑点
社区里已经公开的漏洞分析大多集中在路径规范化绕过上,也就是上文说的那些手法。但我审计的时候发现,X-Platform在最新版里做了一个改动:他们把旧版/**统一拦截改成了“细化白名单”模式,并且新增了一个叫“内部鉴权开关”的配置项。
这个配置项的用途官方说法是“为了单元测试和内部服务间调用方便”,默认是关闭的。但代码里有一条分支让我非常在意:
if (authSwitchConfig.isInnerEnabled()) { // 直接从请求头获取内部调用凭证,不再走完整鉴权 String innerToken = request.getHeader("X-Inner-Token"); if (StringUtils.hasText(innerToken) && validateInnerToken(innerToken)) { return true; // 直接放行 } }这段代码在/inner/token这个接口的调用链路上,看起来是专门给“内部服务”用的捷径。如果配置项被打开,那么只要请求头里带上一个内部token,就能跳过完整鉴权。
这里就出现了一个值得深挖的点:获取内部token的接口/inner/token本身就在放行名单里。那我是不是可以用它先换一个合法的内部token,然后带着这个token去访问任何敏感接口,而平台后端根本不校验这个token的访问范围?
3.2 绕过链:甲接口泄露token,乙接口接受另一个来源
我顺着这个思路继续追,又发现了一个更隐蔽的问题。即便不考虑那个“内部鉴权开关”,X-Platform的token校验逻辑也有个大毛病:它在解析JWT之后,只校验了签名和过期时间,完全没有校验这个token的“使用场景”。
说得再具体点,X-Platform其实存在两种token。一种是用户登录后拿到的登录token,权限范围是当前用户的角色权限;另一种是从/inner/token获取的内部凭证,本来只应该用于调用那几个报表导出接口。但在后端Controller里,很多敏感接口(比如用户列表、角色配置、字典管理)的权限校验注解都只写了@RequiresAuthentication,意思就是“只要登录了就行”。内部token同样满足“已登录”的条件。
于是绕过链就形成了:
- 未登录的用户先访问公开的
/inner/token接口,获取一个合法的内部token。 - 拿着这个内部token去访问
/sys/user/list等敏感接口。 - 后端只校验token是否有效、是否过期,不校验token的使用范围和角色的接口权限。
- 请求返回200,数据“顺利”流出。
这一步做完之后,我意识到这已经不是单纯的路径绕过了。它是一个“任意token即可访问任意接口”的越权漏洞,危害等级比路径绕过要高得多,因为攻击者甚至不需要去凑路径变体,只需要一个合法凭证。
3.3 具体触发流程
下面是在本地授权测试环境复现的完整流程。为了不提供可直接打真实系统的完整利用链,这里只描述关键触发逻辑,接口名和参数都做了脱敏处理。
第一步,调用公开的获取内部凭证接口:
POST /inner/token HTTP/1.1 Host: target.local Content-Type: application/json {"appId":"x-inner-app","timestamp":1720000000000}服务端返回一个JWT格式的token:
{ "code": 200, "token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJpbm5lcl9hcHAiLCJzY29wZSI6ImlubmVyIiw...", "expire": 7200 }第二步,携带这个token访问敏感接口:
GET /sys/user/list HTTP/1.1 Host: target.local Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...正常情况下,这个请求应该被拒绝,因为内部token根本没有访问用户列表的权限。但实际返回的是:
{ "code": 200, "data": { "total": 128, "records": [...] } }也就是说,服务端只认了“token有效”这个事实,完全没有去核对token的scope和接口的权限要求。拿到这个结果之后,我又顺手测了角色配置、部门管理、操作日志等接口,全部命中。单这一个点,就能把平台内部的组织架构、账号列表、角色权限配置全量拖走。
3.4 新绕过与传统绕过的差异
为了让读者更好理解这条新路径“新”在哪里,我把它和传统路径绕过放在一起对比:
| 对比维度 | 传统路径绕过 | 新发现的绕过 |
|---|---|---|
| 触发点 | 拦截器与容器路径解析不一致 | 鉴权逻辑缺少token作用域校验 |
| 利用难度 | 需要构造路径变体,部分场景还依赖中间件版本 | 只需调用公开接口换一个合法token |
| 所需权限 | 未登录 | 未登录 |
| 隐蔽性 | 中(日志里能看到畸形路径) | 高(所有请求看起来都“正常”走完鉴权) |
| 影响面 | 单个接口或单类接口被绕过 | 所有“只校验登录态”的接口全部暴露 |
| 修复难度 | 需统一路径解析规则 | 需在鉴权逻辑中补齐权限范围校验 |
对比下来很直观:传统绕过本质上是在打“鉴权系统认不出这个请求”的漏洞,而新绕过是在打“鉴权系统认出了请求,但没判断这个人有没有资格做这件事”的漏洞。很多团队在修复前者时,会集中精力去调过滤规则、规范URL编码,却忽略了后者——因为后者的代码位置更深,藏在业务逻辑里,不跟请求进去根本看不到。
4. 修复方案与验证
4.1 后端修复:统一鉴权过滤器
先说结论:修复这类问题,最有效的手段是把鉴权逻辑往前移,放到Spring的Filter层完成,并且对路径做统一的规范化处理,不要在Interceptor和Shiro两边各做一套。
推荐的做法是新增一个OncePerRequestFilter,在这个过滤器里先做三件事:
- 拿到原始请求路径,做一次规范化处理。
- 统一处理分号参数、URL编码、路径穿越、大小写归一化。
- 把规范化后的路径传给后续的权限校验逻辑。
规范化路径的工具方法示例如下:
public static String normalizeUri(HttpServletRequest request) { String uri = request.getRequestURI(); try { // 1. 解码URL编码 uri = URLDecoder.decode(uri, StandardCharsets.UTF_8.name()); // 2. 移除分号及后面的路径参数 uri = uri.replaceAll(";.*?(?=/|$)", ""); // 3. 处理路径穿越 Path path = Paths.get(uri).normalize(); uri = path.toString().replace("\\", "/"); } catch (Exception e) { // 解析失败直接返回原始uri,后续鉴权流程会拒绝 } return uri; }然后在同一个过滤器里统一做三块校验:
- 当前请求路径是否在白名单里。
- token是否存在、签名是否有效、是否过期。
- token携带的角色/scope是否覆盖当前接口的权限要求。
只有三层校验全部通过,请求才允许继续。这样就不会再出现“Shiro放行了、Interceptor没拦住”之类的空档,因为路径规范化和权限判断都集中在了一个执行链路上。
另外,那个“内部鉴权开关”必须单独处理。我给出的修复建议是:去掉“通过请求头传内部token直接放行”的分支,改为必须从注册中心获取调用方身份,并且调用方身份必须精确匹配目标接口的allow列表。任何情况下都不能因为开关打开就跳过权限范围校验。
4.2 验证方式
修复完之后,我一般会拿之前所有的绕过样本做回归测试。验证清单大概长这样:
| 验证项 | 预期结果 | 实际结果 |
|---|---|---|
| 未登录访问 /sys/user/list | 401 | 通过 |
| 分号绕过 /sys/user/list;foo=1 | 401 | 通过 |
| URL编码绕过 /sys/user/%6cist | 401 | 通过 |
| 大小写绕过 /Sys/User/List | 401 | 通过 |
| 内部token直接访问用户列表 | 403 | 通过 |
| 内部token访问报表导出接口 | 200 | 通过 |
| 正常用户访问自己有权限的接口 | 200 | 通过 |
| 正常用户访问无权限的接口 | 403 | 通过 |
我把这组测试用例做成了一个自动化脚本,用的就是常见的网络请求工具,跑一遍大概两分钟,能直接把所有已知绕过手法全部覆盖住。脚本逻辑并不复杂:枚举路径变形、枚举放行名单、检查响应码,如果发现预期要返回401/403的请求返回了200,就说明有绕过。
4.3 代码审计自检清单
最后给出一份代码审计自检清单,做完这些还不够,但至少能把大部分同类漏洞排掉:
- [ ] 全局搜索
addInterceptors和FilterRegistrationBean,检查所有放行路径。 - [ ] 对所有放行路径逐一验证:是否真的不需要鉴权?是否可能泄露敏感数据?
- [ ] 搜索
excludePathPatterns、permitAll、antMatchers().permitAll(),全部列出并人工复核。 - [ ] 搜索
@RequiresAuthentication、@RequiresUser等注解,检查是否只校验了登录态,没有校验具体权限。 - [ ] 检查JWT解析逻辑,确认解析之后是否校验了token的使用场景、角色、scope。
- [ ] 检查路径解析相关代码,确认是否存在分号参数、URL编码、大小写导致的两套解析结果。
这份清单是我做Java Web代码审计时固定的起手式,每次都能捞出点东西。X-Platform这次的问题,就是清单第三项和第五项踩了雷。
5. 审计方法论:下次怎么快速找到这类漏洞
5.1 从配置和放行名单入手
很多人在代码审计时会犯一个错误:一头扎进业务代码里跟Controller,结果看了半天看不出问题。我的习惯是反着来,先看配置,再看过滤器和拦截器,最后才看业务逻辑。
为什么先看配置?因为配置是最容易留后门的地方。低代码平台的作者往往会在配置里加一些“测试入口”或者“内部接口”,初衷是方便自己调试,但上了生产环境忘了删,就成了最肥的突破口。放行名单里的每一个路径,都应该视为潜在攻击面,逐个过一遍“如果我不带token访问这个路径,会发生什么”。
5.2 关注“两套解析器”的差异
这是这类漏洞的核心命门。只要发现一套系统里同时存在两套路径匹配逻辑,就要立刻警觉。最常见的组合是Shiro/Spring Security的外层Filter加SpringMVC的Interceptor,两套规则对URL编码、分号、双斜杠的理解不同,就会产生认知偏差。
我自己的排查方法是:写一个简单的路径变形枚举脚本,把Java Web常见的容器路径解析差异都跑一遍,看哪些变形能突破拦截器。这个脚本我维护了好几年,里面已经积累了三十多种变形方式,基本覆盖Tomcat、Undertow、Jetty的常见差异。
5.3 关注“内网白名单”和“临时凭证”
从这次审计经验来看,新式绕过特别喜欢藏在两个地方:一个是“内部服务专用接口”,另一个是“临时凭证”。它们有一个共同点,都是为了图省事,在完整的鉴权链路上开了一口“后门井”。
审计时遇到这类代码,要追问三个问题:
- 这个内部token是从哪个接口发出来的?那个接口是不是任何人都能访问?
- token的有效期是多长?过期了之后是否有刷新机制?
- 某个接口校验了token,但校验的是“token是谁签发的”,还是“token对应的人有没有权限做这件事”?这两者差别巨大。
我在X-Platform上挖到的新绕过,本质上就是在追第三个问题时发现的。代码里写了scope: inner的字段,但校验逻辑里根本没有读取这个字段,等于写了个装饰。
5.4 工具加持
手工测当然可以,但效率太低。我一般会配合Burp Suite做两件事:
一是把常用绕过payload做成Intruder字典,直接遍历所有关心的接口,看哪些路径变体能绕过去。
二是用响应对比功能,把“正常访问”和“绕过访问”的响应长度、状态码做对比。如果两个响应一样,就说明拦截器已经被打穿了。
另外有一个小技巧:如果平台用了JWT,可以把token解出来看它的payload里有什么字段。很多开发会把角色、权限范围写进token里,但后端解析出来之后完全没校验,这种问题用肉眼一眼就能看出来。
最后分享一点实际体会
这次审计让我最有感触的一点是:修复路径绕过神速,但修复权限范围缺失很慢。原因在于路径绕过是“一个点”的问题,改改过滤器、规范一下解析逻辑就行;而权限范围缺失是“一片”的问题,你得把所有标记了@RequiresAuthentication的接口全部过一遍,逐个补上真实的角色和scope校验。这个工程量在低代码平台上尤其大,因为接口太多了。
所以在跟开发团队沟通修复方案的时候,我的建议是分两步走:第一步,立刻在全局过滤器里加上路径规范化逻辑,把已知的绕过路径先堵死;第二步,梳理敏感接口清单,分批把“只要登录就能访问”的接口改成“必须有指定角色才能访问”。第一步治标,能快速止血;第二步治本,需要排期慢慢推。
另外还有一个小建议:审计低代码平台时,别只盯着框架本身的漏洞公告,一定要把平台自研的那层“二次封装”也翻一遍。很多平台为了方便业务方调用,会在框架之上再包一层鉴权逻辑,我最常遇到的问题就是:框架层已经校验过了,平台层又校验一次,而且两层的校验标准不一样。多一层封装就多一层差异,多一层差异就多一个绕过空间。
X-Platform这个案例如果后续扩展,我大概率会再去翻它暴露出来的接口文档,看看还有多少接口是“声明了权限但没真正校验”的状态。低代码平台里这类“纸面权限”远比想象中多,而且每个都可能是下一个安全公告的主角。