这一类漏洞的特点和前面几类不一样:代码本身往往是"对"的,错的是覆盖范围。
一个接口写了权限校验,另一个接口没写;一个路径被过滤器保护,另一个写法相同的路径没被保护。单看每一处代码都合理,问题出在"整体上漏了哪一块"。
所以审计方法也不同——不是逐个函数看,是先把范围画出来,再找缺口。
0x01 三个层次
认证绕过 不需要登录就能访问本该登录的接口
垂直越权 低权限用户调用了高权限接口
水平越权 同级用户访问了别人的数据
这三类的审计动作完全不同。第一类看路径匹配,第二类看权限注解,第三类看数据归属校验。
0x02 请求链上有几道门
Spring 的请求处理顺序是固定的:
Filter(Servlet 容器层)
→ DispatcherServlet
→ HandlerInterceptor.preHandle
→ Controller
→ AOP
每一层都能做鉴权,每一层的覆盖范围都不一样。
Filter 在最外层。 它能看到所有请求,包括那些最终 404 的请求、静态资源请求。它的判断依据通常只有 URI 和 Header,拿不到方法信息,所以做不到细粒度。
Interceptor 在 DispatcherServlet 之后。 它已经知道请求会被哪个 Controller 处理了,所以能做更细的判断。但它只覆盖被 DispatcherServlet 分发的请求。
AOP 在最内层。 它作用在方法上,粒度最细,但也最容易漏——因为它是靠注解标注的,忘了标就漏了。
一套系统里同时存在两三套鉴权是常态。审计时最该先回答的问题是:哪一套是兜底的? 如果答案是"没有兜底的",那就去找缺口;如果答案是"某一道覆盖了全部路径",那另外两道的问题就不构成漏洞。
若依的两套配置
RuoYi 单体版用 Shiro,RuoYi-Vue 用 Spring Security。先看 RuoYi-Vue 的:
// ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java
@Bean
protected SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/register", "/captchaImage").permitAll()
.requestMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html",
"/**/*.css", "/**/*.js", "/profile/**").permitAll()
.requestMatchers("/swagger-ui.html", "/swagger-resources/**",
"/webjars/**", "/*/api-docs", "/druid/**").permitAll()
.anyRequest().authenticated())
.headers(headers -> headers.frameOptions(frame -> frame.sameOrigin()))
.addFilterBefore(authenticationTokenFilter,
UsernamePasswordAuthenticationFilter.class)
.build();
}
这里最重要的不是 anyRequest().authenticated() 那行兜底,是前面三行 permitAll。
permitAll 列表就是免认证接口清单,逐条看:
/profile/** 需要单独说。第五篇提过,若依把上传目录映射成了静态资源 /profile/**。这一行 permitAll 意味着上传的文件谁都能下载,不需要登录。
单看这条不算漏洞——上传成功的图片本来就该被公开访问。但把它和第五篇的上传点连起来看:
上传目录匿名可访问 + 后缀白名单有洞 = 匿名 getshell
这个组合的严重性和"上传需要登录"完全不是一个量级。审计时这两处必须放在一起评估,分开看会低估。
/*/api-docs 这个 pattern 也值得看一眼。它是单层通配,只匹配 /v2/api-docs 这种形式,不匹配两层以上。写窄了是没生效,写宽了(比如 /**/api-docs)就是把文档全暴露。permitAll 里每个 pattern 的宽窄都要单独推演一遍它能匹配到哪些真实路径。
0x03 路径匹配绕过
这是认证绕过里最主要的一类,原理只有一个:
过滤器判断权限用的路径字符串,和路由匹配用的路径字符串,不是同一个。
Filter 通常拿 request.getRequestURI(),DispatcherServlet 拿的是归一化之后的 servletPath / pathInfo。容器在两者之间做了一堆处理:去分号参数、URL 解码、斜杠归一化、大小写处理。只要能构造出一个"过滤器看到的是 A、路由解析成 B"的 URI,鉴权就绕过去了。
Shiro 从 1.5.1 到 1.8.0 连续报了多个路径绕过(CVE-2020-1957、CVE-2020-11989、CVE-2020-13933、CVE-2020-17510、CVE-2020-17523、CVE-2021-41303),根因全是这一件事,只是每次修完之后又从别的字符上绕开了。
所以审计时不要指望记住每个 CVE 的 payload,记住测试手法就行:
针对每条免认证路径,按这些变体各试一次:
/path /path/ //path /./path
/path/. /path;/ /;/path /path;/../
/path%20 /path%09 /PATH /path%2f
/path/..;/ /%2e/path /path%00 /path%252e
以及把受保护路径和免认证路径拼起来:
/public/../admin/xxx
/public;/../admin/xxx
一个判断是否绕过的技巧
不要只看"能不能访问到受保护的接口"——受保护接口通常有权限注解,即使绕过了认证也会被拦,你会误判成"没绕过"。
正确的做法是传一个不存在的路径,然后看返回码。
返回 401 / 403 → 过滤器拦住了,鉴权生效
返回 404 → 过滤器放行了,只是没有这个接口
这个信号很干净。比如想测 /;/admin 能不能绕过 /admin/** 的鉴权,先请求 /;/admin/notexist-abc:
403 → 被拦
404 → 进到路由层了,鉴权被绕过
404 本身就是绕过成功的证据。 这个手法能避免大量"因为权限注解拦住了所以以为没绕过"的误判。
0x04 Shiro 的 filterChainDefinitionMap
Shiro 的配置问题集中在一个地方:
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean() {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
factoryBean.setSecurityManager(securityManager());
Map<String, String> map = new LinkedHashMap<>();
map.put("/login", "anon");
map.put("/static/**", "anon");
map.put("/user/**", "authc");
map.put("/admin/**", "authc");
map.put("/**", "authc");
factoryBean.setFilterChainDefinitionMap(map);
return factoryBean;
}
三个必须检查的点。
第一,顺序。 用的是 LinkedHashMap,第一个匹配的规则生效,后面的不再判断。所以下面这种写法里,/login 那条永远不会生效:
map.put("/**", "authc"); // 先匹配到,全部要求认证
map.put("/login", "anon"); // 永远不生效
第二,有没有 /** = anon。
map.put("/**", "anon"); // 全站免认证
这行只要出现一次,整套鉴权就没了。搜配置的时候直接搜 anon,看有没有路径是 /**。
第三,anon 的路径段覆盖面。 下面这条看起来只放行了静态资源:
map.put("/static/**", "anon");
但如果实际部署里静态资源和上传目录是同一个目录,那放行的就是整个上传目录。配置里的路径要和实际的文件系统映射对照着看。
0x05 垂直越权看注解,水平越权看归属
垂直越权
在若依这类框架里,垂直越权就是看权限注解有没有漏。
@PreAuthorize("@ss.hasPermi('system:user:list')")
@GetMapping("/list")
public TableDataInfo list(SysUser user) { ... }
@PreAuthorize("@ss.hasPermi('system:user:remove')")
@DeleteMapping("/{userIds}")
public AjaxResult remove(@PathVariable Long[] userIds) { ... }
@ss.hasPermi('system:user:remove') 表示需要 system:user:remove 这个权限点。
审计动作:把接口清单里所有没有 @PreAuthorize 的接口标出来,逐个看功能。
# 列出所有 Controller 方法,再手工标出没有权限注解的
grep -rn -B3 -A1 "@GetMapping\|@PostMapping\|@PutMapping\|@DeleteMapping" \
--include=*Controller.java .
没有 @PreAuthorize 的接口,只要登录就能调,不管对方是什么角色。如果这个接口是"删除用户""修改系统参数""执行定时任务",就是垂直越权。
这一类在二开项目里特别多。 原版若依的注解是齐的,但二次开发加的新功能经常忘记加。审计时重点看那些和原版代码风格不一样的地方——命名不统一、没有 Javadoc 注释、路径没有按模块划分的接口,都是后来加的。
水平越权
水平越权看的是数据归属校验。
// 有问题
@PutMapping("/update")
public AjaxResult update(@RequestBody Order order) {
return toAjax(orderService.updateById(order));
}
order 整个来自请求体,包括 orderId 和 userId。攻击者改一下 orderId,就能改别人的订单;改一下 userId,就能把订单挂到别人名下。
正确写法是先查出来再校验归属:
@PutMapping("/update")
public AjaxResult update(@RequestBody Order order) {
Order exist = orderService.getById(order.getOrderId());
if (exist == null || !exist.getUserId().equals(SecurityUtils.getUserId())) {
throw new ServiceException("无权操作该订单");
}
return toAjax(orderService.updateById(order));
}
审计动作:
grep -rnE "updateById|deleteById|getById|selectById|removeById" --include=*.java .
逐个看调用点前面有没有归属校验。这类漏洞最集中的位置是"详情""编辑""删除"三个动作,因为列表接口通常是按当前用户过滤的,实现者会有一种"前面已经过滤过了"的错觉,忘了详情接口需要单独校验。
若依的 @DataScope
若依有一个数据权限注解,值得单独说:
@DataScope(deptAlias = "d", userAlias = "u")
public List<SysUser> selectUserList(SysUser user) { ... }
它的作用是给 SQL 自动追加数据范围条件(本人 / 本部门 / 本部门及以下 / 全部),加在 BaseEntity 的 params.dataScope 里,最终拼进 XML。
审计点是:哪些查询加了 @DataScope,哪些没加。
同一个实体往往有多个查询方法——列表查询加了,导出查询忘了加;主列表加了,下拉选择框的列表没加。后者的返回值虽然只有 id 和 name,但同样泄露了其他部门的人员信息。
# 找出所有 Service 里的查询方法,标出没有 @DataScope 的
grep -rn "public List<" --include=*ServiceImpl.java .
0x06 逻辑漏洞
逻辑漏洞没有固定形态,但审计时有两个高频位置。
动态 SQL 里写错的判断条件
<delete id="deleteById">
delete from dish
<if test="id != null and id != ''">
where id = #{id}
</if>
</delete>
id 为空时,<if> 不生成任何内容,SQL 变成:
delete from dish
整表清空。 开发的本意是"id 有值才删指定数据",但漏了反面——id 没值时应该拒绝请求,而不是执行一条无条件的语句。
审计动作:搜所有 <delete> 和 <update>,看 <where> / <if> 的组合有没有"全部条件都不满足"的可能。
// Java 层同样的问题
if (StringUtils.isNotEmpty(ids)) {
sql.append(" and id in (" + ids + ")");
}
// 如果 ids 为空,sql 变成 "select * from t where 1=1",返回全表
判断方法很固定:把所有条件分支列出来,找出"一个都不进"或"进错分支"的那条路径。
业务流程能跳步
这类要顺着业务流程走一遍,重点看四个地方:
支付 金额、商品 ID、优惠金额有没有从请求参数取
验证码 服务端是否校验、是否一次性、是否有有效期、能否重放
密码重置 token 是否可枚举、是否绑定用户、是否校验手机号归属
状态流转 订单状态机能否被客户端直接设置(比如直接传 status=已支付)
判断方法和上面一样:找出"开发默认用户会按顺序走,但用户可以不按顺序走"的那个假设。
举个具体的:重置密码的接口设计成三个步骤,第一步验证手机号返回一个 token,第二步用 token 提交新密码。如果第二步只校验 token 存在、不校验这个 token 是不是本人申请的,那就等于任何人都能改任何人的密码。
0x07 一次差点误报的判断
有一次审计,我在一个项目里搜到三处 addInterceptor,其中一处看着就有问题:
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new AuthInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/api/**", "/open/**",
"/actuator/**", "/file/**", "/error");
}
excludePathPatterns 里第一项就是 /api/**——这是整个业务接口的前缀。这一行把全部业务接口排除在这个鉴权拦截器之外了。
第一反应是"未授权访问,高危"。
继续往下看,发现项目里还有一套 Spring Security:
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
anyRequest().authenticated() 会兜住所有没被 permitAll 的路径,包括 /api/** 里的业务接口。所以这个坏掉的拦截器实际上不构成漏洞——它确实没起作用,但有另一道门兜着。
这份报告里,这个点最后写成了"低风险,代码冗余建议清理",不是漏洞。
但这个排查过程带来一个更有价值的结论:多套鉴权并存时,真正的风险是它们覆盖范围的交集之外的空白。
上面这个例子里:
拦截器 覆盖 /**,排除 /api/** /open/** /actuator/** /file/** /error
Security 覆盖全部路径,放行 /api/public/**,其余要求认证
两者的并集覆盖了全部路径,没有空白,安全。
但如果第二套配置写了 securityMatcher("/api/**") 限定只处理 /api 开头的请求,那 /file/** 就同时落在拦截器的排除列表和 Security 的处理范围之外——两套鉴权都不管。这才是真正要找的洞。
审计动作:把每一道门的覆盖范围写成区间,然后求交集之外的部分。 这一步画出来,比逐个接口翻要快得多,也不会漏。
0x08 清单
鉴权范围
[ ] 列出所有 Filter / Interceptor / AOP 鉴权点
[ ] 每道门的覆盖范围写成区间,找出并集之外的空白
[ ] 确认哪一道是兜底的(通常是 anyRequest().authenticated())
免认证清单
[ ] 摘出所有 permitAll / anon 路径
[ ] 每条 pattern 推演它能匹配到的真实路径
[ ] /profile/** 这类静态映射,与实际文件系统目录对照
[ ] 免认证路径逐条按变体清单测试,用 404/403 判断结果
Shiro
[ ] filterChainDefinitionMap 的顺序(第一个匹配生效)
[ ] 有没有 /** = anon
[ ] anon 路径段的实际指向
垂直越权
[ ] 列出所有没有 @PreAuthorize 的接口,逐个看功能
[ ] 重点看二开新增的接口(命名、注释风格不一致的)
水平越权
[ ] updateById / deleteById / getById 调用点有无归属校验
[ ] 重点看"详情""编辑""删除"三个动作
[ ] @DataScope 在哪些查询上加了,哪些没加
逻辑
[ ] <delete> / <update> 的全部条件不满足时会生成什么 SQL
[ ] Java 层 SQL 拼接的 if 分支,找"一个都不进"的路径
[ ] 支付金额、验证码、重置密码 token、状态流转四个点
下一篇进实战:把一个开源 CMS 从零审一遍,把前七篇的方法串起来走一遍完整流程,包括中间跟空的那些点。