☰
多步流程中的越权漏洞剖析与修复实践
2026/10/18 8:14:07 网站建设 项目流程

前阵子做一次授权安全评估,某项目的后台审批流程让我印象很深。这个系统有一条"发起申请 → 业务审批 → 办结归档"的三步链路,前两步做得挺规范,登录校验、状态控制、角色校验都到位了。问题恰恰出在最后一步:审批接口只检查了"用户是否登录",没检查"这个人是不是审批人",也没检查申请单当前是否处于待审批状态。我用一个普通员工账号,填上某个申请单编号直接调用审批接口,返回值清清楚楚告诉我审批成功。这类问题就是典型的多步流程中的越权漏洞——不是单步接口做得不够好,而是业务一旦被拆成多个接口,权限校验的接力棒在某一步掉在了地上。

简而言之,多步流程中的越权漏洞,指的是完整业务流程被拆成多个接口后,部分步骤缺少对操作者身份、角色或资源归属的校验,导致用户可以访问、修改或推进本不该由他操作的数据。这类漏洞在真实系统里极其常见,而且危害不小。不管你是写业务代码的开发,还是负责安全测试的同学,这篇内容都值得花十分钟看完。

说明:以下所有案例均来自模拟项目和授权测试环境,不涉及真实业务数据。

1. 多步流程:越权漏洞的高发温床

1.1 先搞清楚什么是越权漏洞

越权漏洞的正式叫法是访问控制失效,英文里常叫 Broken Access Control 或 IDOR(Insecure Direct Object Reference)。它指的是服务端没有正确校验当前用户有没有权利访问某个资源、执行某个操作,导致用户能看到、修改、删除本不该他碰的数据。安全圈习惯把它分成两类:水平越权和垂直越权。

水平越权很好理解:大家都是普通用户,理论上只能看自己的订单,结果你把订单编号改成别人的,接口照样返回数据,这就是水平越权。垂直越权则是低权限用户执行了高权限操作,比如普通员工把某个审批单直接审批通过,这种用普通身份去干管理员或审批人才能干的事,就叫垂直越权。

OWASP 把访问控制失效常年排在 Web 应用风险排行榜的前面,原因不是它技术上有多少花样,而是太容易发生了。单接口的越权还好排查,参数一换就能试出来;真正让人头疼的是多步流程——几个接口串起来完成一件事,只要中间任何一步埋了雷,整条链路都可能被利用。

这里可以用酒店房卡来类比:你入住酒店拿到自己房间的房卡,正常只能开自己的门,但如果你拿这张卡把隔壁房间的门也刷开了,那就是越权。放到多步流程场景里,相当于一道门分了三道锁,前两道锁都验了房卡,第三道锁却只是个摆设,任何人的卡都能拧开。

1.2 为什么"多步"会让风险翻倍

单步骤接口的逻辑链条很短:请求进来,查一下当前用户是谁,再判断他能不能操作这个资源,能就执行,不能就拒绝。这种接口即使写漏了校验,测试时也比较容易发现。多步流程麻烦在状态分散在多个请求之间——第一步做完什么事,第二步要基于第一步的结果继续,第三步又要基于第二步的结果收尾。

很多研发在处理多步流程时,有一种朴素又危险的想法:第一步已经校验过身份了,后面的步骤用户既然能走到,说明他肯定合法。于是后续接口就只认一个流程编号、申请单ID之类的标识,默认"能拿到这个ID的人就是流程的合法参与者"。

这个假设错得很彻底。因为流程ID往往是可以预测的,或者可以从列表接口、分享链接、历史消息里泄露出来。攻击者只需要一个枚举、一个批量替换,就能把别人的资源ID拿来用。更深层的根源有三个:

  1. 权限校验只看"入口"不看"出口"。整个流程只保证第一步合法,后面的步骤完全不设防,等于把门锁装在门框上,门板却是敞开的。
  2. 把"前端流程进度"当成了"服务端状态"。前端根据按钮点击顺序维护了一个流程进度,后端却没有真正保存状态机,只是被动接收前端传过来的步骤参数。
  3. 多步骤之间缺乏防重放和幂等机制。同一个请求被拦截后重放多次,或者把步骤顺序打乱,后端无法识别。

我见过最典型的场景是"修改绑定手机号"流程。用户先输入原手机号验证码,再输入新手机号,最后确认。第一步做得非常规范,验证码校验、限流、绑定当前用户全都齐了;但提交新手机号的接口,只接收一个 stepId 参数,服务端拿 stepId 找到一条临时记录,直接把手机号改了,完全不校验这个 stepId 是不是当前用户创建的。攻击者用自己手机号正常发起流程拿到 stepId,再把请求里的目标账号参数替换成别人的,就能把别人的手机号改掉。这种案例在我评估过的项目里遇到过不止一次。

所以,多步流程之所以是越权漏洞的高发地带,不是因为某一处代码写错了,而是因为整个流程的信任模型从一开始就建立在一根脆弱的支柱上:默认中间步骤的调用者是可信的。

2. 三种典型的多步流程越权形态

2.1 水平越权:换个ID就改掉了别人的数据

水平越权在多步流程里的最常见表现,就是"第一步用自己的身份,第二步换成别人的ID"。比如一个"申请退款"流程,页面上的第一步是选择订单,订单列表来自服务端,正常全是你自己的;但提交退款申请时,请求体里带的 orderId 是前端填的。攻击者不通过页面,直接构造请求,把 orderId 换成其他用户的一笔订单,如果后端在退款接口里只用 orderId 查询订单而不校验归属,那么别人的订单就会被发起退款。

看起来是个小问题,实际影响很大。订单信息能看,收货地址能看,发票信息能看,这些都是敏感数据。更糟的是那些"修改"和"删除"类接口,一旦越权成功,直接就是数据篡改或业务破坏。多步流程里的水平越权往往藏得更深,因为它不是单接口的问题,而是第一步的 ID 传到了第二步,第二、第三步没验证 ID 的合法来源。

2.2 垂直越权:角色校验只写在页面上

垂直越权在多步流程中呈现为"高权限步骤缺失角色校验"。后台系统的审批动作几乎都是一个独立接口,比如 POST /api/approval/audit。负责业务的研发经常默认:审批按钮只有审批人看得到,普通用户连按钮都看不到,后端还用得着再做一次角色校验吗?

用不着做——如果前端是唯一的防线,那这道防线基本等于没有。我见过一个项目,前端确实只在审批人账号里渲染了审批按钮,后端也能鉴权,但鉴权的粒度是"登录即可"。换句话说,任何登录用户都可以直接构造 POST 请求去调用审批接口。攻击者利用它,可以把自己的申请单审批通过,也可以把别人的申请单驳回,整个审批流程形同虚设。

这类垂直越权的特征是:接口位于业务流程的末端或中间环节,单看接口本身,它只是"推进一下状态",没有敏感数据返回,所以代码审计初期很容易被忽略。但一旦跟状态机联系起来,危害就放大了——一个能任意推进审批状态、任意驳回他人申请的接口,本身就是重大安全隐患。

2.3 步骤跳跃与重放:不按套路出牌

第三种形态是步骤顺序和次数问题。多步流程的设计初衷是"必须一步一步来",比如重置密码必须"先验证身份,再设置新密码"。但后端的实现常常是两个独立的接口各管各的:验证身份接口校验验证码,设置密码接口直接接收新密码。只要设置密码接口没有检查"这个用户是否已经通过了身份验证",攻击者就能跳过验证环节,直接把密码改成自己设置的。

步骤跳跃还有一种变体:重放。多步流程中,同一个"提交"接口如果被连续调用多次,可能造成重复提交、重复扣款、重复审批。尤其是一些没有幂等设计的接口,每一次调用都会新增一条数据或改变一次状态,重放攻击就会放大业务流程的破坏力。

防御思路也不复杂:服务端维护流程状态,每个操作都校验当前状态是否允许该操作;关键的推进性接口生成一次性令牌,只有完成上一步步骤的用户才能拿到;提交类接口做好幂等控制,同一笔业务多次请求只生效一次。

3. 从一次告警到漏洞确认:某后台审批流排查实录

3.1 奇怪的状态变更:操作日志里找不到审批人

前阵子某项目团队找我看一个疑难问题。他们的后台系统里,一批申请单在凌晨被批量审批通过,但操作日志里审批人字段完全是空的。运维第一反应是"数据库被拖了",赶紧把GC日志、慢查询、登录日志全翻了个底朝天,也没找到任何异常登录记录。我接手后没急着查库,先把网关层的关键接口访问日志拉了出来,筛出所有 /approval/ 开头的路径,按时间倒序排列,很快发现了异常:一批来自普通员工账号的请求,以每分钟几十次的频率打向审批接口,每次都返回 200。

那批请求的调用时段和申请单状态变更时间完全吻合。真正让我确定问题的是请求参数:每个请求都只带了一个 auditId,再加上当前登录用户名,没有任何审批人角色的标记。这说明后端接口的入参设计里,压根没有"审批人"这个概念。

3.2 顺着接口路径定位权限断点

我把排查链路完整走了一遍,列出来给读者参考,遇到类似问题可以照着做:

  1. 先确定受害对象:把所有状态异常变化的申请单编号收集起来,放到一个集合里。
  2. 反查请求记录:在网关日志或数据库访问日志里,找到每个申请单最后一次状态变更对应的 HTTP 请求和 SQL 语句。
  3. 定位接口:从请求路径中拎出具体的 Controller 方法,把代码翻出来看。
  4. 检查权限逻辑:逐行查看方法内有没有获取当前用户、有没有判断角色、有没有校验资源归属。这一步是核心,很多问题都是在这里暴露的。
  5. 拉一个普通账号实测:在授权测试环境下,构造同样的请求,用普通员工 token 去审批一个他人申请单,观察返回值和状态变更情况。
  6. 换一种方式再测:把自己创建的申请单的审批人改成别人,再调用审批接口,验证水平越权是否同时存在。

那次排查的结果很清晰:审批接口的代码省略了业务细节后,逻辑骨架大概是下面这样。

@PostMapping("/approval/audit") public Result audit(@RequestBody AuditRequest request) { Approval approval = approvalMapper.selectById(request.getAuditId()); if (approval == null) { return Result.fail("申请单不存在"); } approval.setStatus(Status.APPROVED); approvalMapper.updateById(approval); return Result.ok(); }

看到这里大家都明白了:没有取当前登录用户,没有判断审批角色,没有校验申请单当前状态,也没有任何操作者记录。这就是一个"登录即可以调"的审批接口。

3.3 影响面评估:别只看单个接口

漏洞确认之后,影响面评估也是关键环节。我当时没有只盯着这个审批接口,而是把整个申请流程里所有接口拉了一个清单,挨个检查:

接口路径缺失的校验风险类型
/approval/create暂无,入口校验齐全低
/approval/submit未校验申请单属主水平越权
/approval/audit未校验审批人角色垂直越权
/approval/reject未校验审批人角色垂直越权
/approval/withdraw未校验申请单属主水平越权

检查完发现,不仅是审批接口,撤回接口也存在水平越权:任意登录用户可以撤回别人的申请单。原因和审批接口一样,查询时只用了申请单 ID,没有把当前用户作为过滤条件。

所以评估一个多步流程的越权影响面,一定要把"流程内所有可能改变状态的接口"全部列出来,逐个验证三件事:有没有登录校验、有没有角色校验、有没有资源归属校验。缺哪个,就把哪个标记为漏洞。而且如果发现了一个,大概率同一套代码风格下还有其他几个,顺着同样模式去翻,往往能翻出一串。

4. 修复方案落地:状态机、属主校验与步骤令牌

4.1 服务端状态机:流程进度由后端说了算

修复多步流程越权,首先要建立"服务端状态机"的意识。业务流程进行到哪一步,不应该由前端传一个 step 参数告诉后端,而应该由后端根据数据库里的当前状态来判断。前端只能发送"我想执行某个操作",后端则根据状态判断"这个状态是否允许这个操作"。

还是拿审批流程举例。一个申请单的生命周期可以定义为:

DRAFT(草稿)→ SUBMITTED(已提交)→ APPROVED(已通过)/ REJECTED(已驳回)→ ARCHIVED(已归档)

在这个状态机里,能审批的只有 SUBMITTED 状态,已经 APPROVED 或 REJECTED 的申请单不允许再次审批,能审批的人必须有审批角色。修复后的代码骨架应该长这样:

@PostMapping("/approval/audit") public Result audit(@RequestBody AuditRequest request, @AuthenticationPrincipal LoginUser currentUser) { // 1. 取出申请单 Approval approval = approvalMapper.selectById(request.getAuditId()); if (approval == null) { return Result.fail("申请单不存在"); } // 2. 校验角色:只有审批人才能执行审批 if (!currentUser.hasRole("APPROVER")) { return Result.fail("无审批权限"); } // 3. 校验状态机:只有待审批状态才能审批 if (!Status.SUBMITTED.equals(approval.getStatus())) { return Result.fail("当前状态不允许审批"); } // 4. 记录真实操作者,拒绝伪造 approval.setStatus(Status.APPROVED); approval.setApproverId(currentUser.getId()); approval.setApproverName(currentUser.getName()); approvalMapper.updateById(approval); return Result.ok(); }

可以看到,修复后的代码比原来只多了几步判断:角色、状态、操作者记录。每一步判断都有明确目的——角色判断解决垂直越权,状态判断解决步骤跳跃,操作者记录解决审计追踪。这三条缺一不可。

4.2 对象级授权:每个环节都要绑定当前用户

状态机解决的是"能不能做这个动作",对象级授权解决的是"能不能操作这个资源"。前者是动作维度,后者是数据维度。一个接口可能同时缺两者,也可能只缺其中一个,审计时要分开看。

对象级授权的核心做法就一句话:在查询和更新的 SQL 里,强制带上当前用户的条件。比如查订单,不要写成:

SELECT * FROM orders WHERE order_id = #{orderId}

而要写成:

SELECT * FROM orders WHERE order_id = #{orderId} AND user_id = #{currentUserId}

这样即使攻击者把 orderId 换成别人的,查询结果也是空的,自然无法越权。多步流程里,特别是"第一步选资源、第二步操作资源"的场景,属主校验必须在第二步再做一次。前端可以把 orderId 传进来,但后端不能信任它的归属,必须重新校验。

有些研发觉得,用 UUID 做资源 ID 就安全了,"反正别人猜不到"。这个想法经不起推敲:UUID 虽然难枚举,但可以从列表接口、日志、分享链接、浏览器历史里泄露出来。安全设计的原则是把每个请求都当作未认证请求去校验,而不是赌参数不可猜测。

4.3 步骤令牌与幂等控制:堵住跳跃和重放

状态机能够挡住步骤跳跃,但还不能完全防住重放。如果某个操作接口被连续调用两次,两次都满足状态条件,还是可能重复生效。更稳妥的做法是引入步骤令牌。

简单说,服务端在完成某一步骤后,生成一个一次性令牌返回给前端,只有带着这个令牌才能调用下一步接口。令牌的内容可以绑定当前用户、资源ID、步骤编号和过期时间,达到防止跨用户使用、防止跨资源使用、防止超时重放的效果。实现不复杂,用签名串或短时缓存都可以:

// 生成步骤令牌:绑定用户、资源、步骤、过期时间 String stepToken = SignUtil.sign( currentUser.getId() + ":" + approvalId + ":step2", serverSecret, expireAt );

配合幂等设计就更完整了。对"提交""审批""支付确认"这类关键接口,用业务流水号加唯一约束,重复请求命中唯一索引直接返回上次结果,而不是重复执行。一次审批操作只能生效一次,这是底线。

修复方案的优先级也要说清楚:第一优先级是状态机加对象级授权,这两个不做好,其他都是空谈;第二优先级才是步骤令牌和幂等,属于加固项。我在实际项目里见过一些团队为了防越权给每个步骤都套上令牌,却连最基本的角色校验都没做,等于开着大门给窗户上锁。

5. 把越权漏洞堵在开发阶段:审计清单与测试思路

5.1 代码审计时重点盯住哪几类接口

与其等漏洞被外部攻击者发现,不如在代码评审阶段就把隐患查出来。我把这些年审计接口的经验浓缩成一张检查清单,供参考:

  • 凡是接收 ID 参数的接口。orderId、userId、flowId、auditId、addressId、fileId,看到了就多问一句:查询条件里带当前用户了吗,更新条件里带当前用户了吗,删除接口呢。
  • 凡是能改变状态的接口。create、submit、approve、reject、close、archive、resend、confirm,这些是流程关键节点,逐个检查角色和状态机校验。
  • 凡是返回列表的接口。列表接口是批量数据泄露的高发区,除了检查属主条件,还要检查分页参数是否被滥用。
  • 凡是管理后台的接口。后台接口不能只判断"登录即通过",必须校验具体角色或权限点,且要用方法级权限注解兜底。
  • 凡是涉及用户身份切换的接口。比如模拟登录、切换账号、找回密码、修改绑定手机号,这些接口一旦缺失校验,后果就是账号接管。

审计的时候不要只看单个方法,要把整个流程串起来看。把流程涉及的所有接口按执行顺序排列,标记每个接口的鉴权情况,一眼就能看出断点在哪里。很多时候,第一、第二步做得严严实实,第三步却空门大开,根源就是研发把精力放在流程开头,忽略了流程走向深处后的校验延续。

5.2 自动化扫描与手工测试怎么配合

自动化扫描对越权漏洞的覆盖率比很多人预期的要高,能发现相当一部分问题。常见做法是用爬虫把接口和参数收集起来,登录两个普通账号 A 和 B,拿 A 的资源 ID 放到 B 的 token 下去请求,观察响应码和响应体是否泄露数据。现在不少开源工具可以直接落地这类测试。

但自动化有两个盲区。第一,纯自动化的 ID 替换只会测水平越权,测不了垂直越权——它不知道某个接口只应该由审批人角色调用。第二,自动化不会主动把流程乱序执行。手工测试要补两件事:一是把流程的每个接口打乱顺序调一遍,比如不提交直接审批,看后端是否拦截;二是对高权限操作接口,用一个低权限账号直接请求,确认返回结果。这两步不需要多复杂的工具,一个能改请求的工具加上一点耐心就够。

我个人的习惯是,评估一个多步流程时,永远先走一遍正常流程,把所有响应里的 ID、token、step 都记录下来;然后再用这些值去替换其他账号的资源。多步流程的越权,往往藏在"正常流程产生的 ID 被二次利用"这个环节里。

5.3 团队层面怎么让这类漏洞不再反复出现

单个漏洞修复了,不代表问题不会再出现。只要团队没有形成统一机制,下一个项目里大概率还会写同样的代码。我观察下来,能长期管住越权漏洞的团队通常做到了以下几点:

一是把权限校验组件化。封装好 requireLogin、requireRole、checkOwner 这类注解或中间件,业务代码里直接标记,减少手写鉴权逻辑的机会。二是把接口的角色和资源范围写进设计文档。评审时至少过一遍"谁可以调用、可以操作哪些资源、当前状态是什么"。三是接入自动化测试。把越权用例固化成接口测试脚本,进 CI 之前跑一遍,从机制上拦住回归。四是定期专项检查。每个季度或半年,集中对核心业务的多步流程做一次越权专项评估,而不是等到出了问题再救火。

最后补充一点我自己的感受:越权漏洞不会因为换了个框架、升级了网关就消失,它本质上是业务逻辑的问题,跟技术栈无关。把多步流程的每一步都当成独立的受保护接口来设计,权限校验不是在入口做一次就算完,而是每走一步都重新确认一次。看起来多做几道判断,实际挽回的都是潜在的批量数据泄露事故。

这些做下来,多步流程里的越权漏洞会从"高发"变成"偶发",最终变为"罕见"。我在实际评估中最大的体会是:绝大多数越权漏洞,不是安全团队没测出来,而是研发团队在设计流程时根本没把"每一步都要鉴权"当成默认前提。把这个前提焊死在团队的开发习惯里,比任何单个修复方案都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询