Java 代码审计实战速查(七):认证授权与逻辑漏洞

这一类漏洞的特点和前面几类不一样:代码本身往往是"对"的,错的是覆盖范围。

一个接口写了权限校验,另一个接口没写;一个路径被过滤器保护,另一个写法相同的路径没被保护。单看每一处代码都合理,问题出在"整体上漏了哪一块"。

所以审计方法也不同——不是逐个函数看,是先把范围画出来,再找缺口。


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 从零审一遍,把前七篇的方法串起来走一遍完整流程,包括中间跟空的那些点。


暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇