尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

越权漏洞防护实战:水平越权与垂直越权的代码级封堵

发布时间:2026/9/26 13:17:11

资讯中心
01
ARTICLE

越权漏洞防护实战:水平越权与垂直越权的代码级封堵

越权漏洞防护实战:水平越权与垂直越权的代码级封堵
1. 先认清你在防什么两种越权两种完全不同的死法说到越权很多开发对它的印象是“扫出来一个高危修完第二天又来一个”。但越权访问漏洞其实是最“老实”的一类安全漏洞——它不靠花里胡哨的注入技巧不需要什么黑客工具一个懂业务的普通用户改一下请求参数就能打穿。这也是它最危险的地方攻击成本极低业务伤害却可能极高。我从代码层面把越权拆成两大类这也是我做防护设计时的第一层基本面。1.1 水平越权你能看到所有人的“份儿”水平越权行业里也叫 IDOR不安全的直接对象引用指的是同一个角色、同一个权限层级下A 用户能访问到 B 用户的私有数据。典型场景是你登录一个电商系统把“我的订单”接口里的订单号换个数字结果把别人的收货地址、手机号、购买记录全部拉出来了。我见过一个真正出事的案例某内部 CRM 系统客户列表接口长这样GET /api/customers/{customer_id}后端拿到了 customer_id 之后没有任何归属校验只要当前用户是“销售”角色就能查。结果一个销售把 ID 从 1001 遍历到 5000把全公司所有销售的客户资料全拉走了。这不是什么高深漏洞就是一行校验代码的事但谁都没写。1.2 垂直越权普通人拿着管理员令牌垂直越权则是低权限用户执行了高权限操作。最典型的就是普通用户直接请求管理接口比如GET /admin/user/list前端不显示“用户管理”菜单后端却不校验角色结果普通用户通过在浏览器地址栏手动敲 URL、或者用抓包工具重放请求就把管理接口调用了个遍。垂直越权比水平越权更“低级”但出现频率一点不低。尤其是后台管理类项目团队往往把精力放在业务逻辑上默认“这个接口只有管理员知道、前端菜单也隐藏了”就等于安全了。前端隐藏不是鉴权这句话我希望所有刚入行的开发刻在工位上。1.3 我为什么建议先分类再设计防护很多团队一说到防越权第一反应是“加个拦截器”但拦截器只能解决“是不是登录了”“是不是管理员”这种粗粒度问题。水平越权需要的是数据级校验垂直越权需要的是角色/权限级校验两类问题发生在完全不同的代码层次里。如果一开始不分清楚你会陷入一种尴尬境地加了全局角色校验水平越权照样存在只做了数据归属校验普通用户照样能调管理接口。所以后面讲的每一层代码措施我都会明确标注它防的是哪一类。2. 为什么很多团队的越权防护“看着有、实际没有”先说几个我在实际项目中反复见到的错误做法。这些不是理论都是我一个个踩过去、或者帮别人擦屁股时看到的。搞清楚它们为什么失效比直接抄一段正确代码更重要。2.1 只在前端做按钮级权限后端接口裸奔前端的典型做法if (user.role admin) { renderButton(button onclickdeleteUser(1)删除用户/button) }按钮是渲染出来了但deleteUser(1)对应的那个接口后端谁来了都放行。前端隐藏按钮只是用户体验层面的设计任何请求工具都能绕过界面直接打接口。所以我在代码评审时有一个死规矩凡是前端有权限控制的页面对应后端接口必须有同等级别的校验不允许出现“页面有权限、接口没权限”的状态。2.2 只校验“是否登录”不校验“这次数据是不是他的”这是水平越权最经典的坑。有些框架的拦截器只做了登录态校验——只要携带有效 Token所有接口都能进。至于这个用户访问的订单是谁的、这个文件是谁上传的让它过了。我在一次代码走查里看到过这样的代码def get_order(order_id): order db.query(Order).filter(Order.id order_id).first() return order代码很“干净”但它默认了“能查到就是我的”。问题就在于数据库并不懂“我的”是什么意思你不在查询条件里显式加上user_id过滤它就是全量数据。2.3 把权限判断散落在业务代码里今天写明天漏还有一种项目权限校验写在每个接口里但是写法五花八门。这个接口查了user.role那个接口只判断了user.is_admin另一个接口干脆就漏了。等后来接口数量一多越权漏洞就像地雷踩到一个算一个。我可以直说权限校验不应该由业务开发临场发挥。它应该是一个统一的可复用组件谁用谁调调之前先想清楚这是哪类越权要做什么粒度的校验。这就是下一章要讲的核心内容。3. 从代码层面“封死”的三个核心落地模式如果只能从这篇博文里带走一部分内容那就是这一章。我把防越权的最佳实践收敛成了三个可落地的代码模式。这三个不依赖具体语言但我会用 Python FastAPI 的写法做演示其他语言照着思路迁移即可。3.1 模式一统一请求鉴权层把垂直越权挡在门口垂直越权的封堵一定要做在“门口”——也就是所有请求进入业务逻辑之前统一做一次“你是谁、你能做什么”的校验。不要在每个 Contoller 里临时写角色判断。我常用的做法是做一个权限依赖比如# 当前登录用户 async def get_current_user( credentials: HTTPAuthorizationCredentials Depends(HTTPBearer()), db: Session Depends(get_db), ): token_data decode_token(credentials.credentials) user db.query(User).filter(User.id token_data.sub).first() if not user: raise Unauthorized(登录状态失效) return user # 权限校验依赖要求接口必须被声明为消耗某种权限 def require_permission(permission_code: str): def checker(user: User Depends(get_current_user)) - User: if not has_permission(user, permission_code): raise Forbidden(抱歉没有访问权限) return user return checker接口里这样用app.get(/admin/users) async def list_all_users(user: User Depends(require_permission(user:list))): ...这里面的核心不是这段代码本身而是它带了两个强约束第一每个需要权限的接口必须显式声明需要的权限码。如果一个接口没有声明任何权限依赖默认不许访问——这叫“默认拒绝”接管了开发人员漏写权限校验的所有可能性。第二权限码本身要有体系和层级。我习惯用资源:动作的格式比如user:list、user:delete、order:export这样后续做权限配置页面、日志审计都非常直观。注意不要用is_admin这种布尔角色来判断大权限因为管理员系统里往往还有运营、审计、客服他们都手握不同的数据面“真假管理员”这种二值判断根本兜不住复杂的业务。3.2 模式二数据归属强制校验让水平越权失去存在空间垂直越权挡在门口还不行因为水平越权发生的数据往往是同一个门口里完全合法的请求。比如订单查看接口用户本来就有权限查看“订单”这类资源问题只在于这个订单不是他的。这时候需要在数据访问层再加一道锁。我的套路是封装“只能按拥有者查数据”的方法。还是订单的例子def get_order_for_user( order_id: int, user_id: int, db: Session, ) - Order: order db.query(Order).filter( Order.id order_id, Order.user_id user_id, # 归属条件铁打不动的查询条件 ).first() if not order: # 这里故意抛 NotFound而不是 Forbidden raise NotFound(订单不存在) return order注意两个细节归属条件硬编码在查询里而不是当参数传进来由调用方决定。如果让调用方决定开发手一抖或者为了“复用方便”把user_id条件去掉了漏洞就回来了。查不到的时候抛 NotFound而不是 Forbidden。这一点很多人不理解。如果你返回“无权限”攻击者就知道了“数据存在但我不够格”于是可以通过多次请求来枚举哪些 ID 是存在的这在资源导出、优惠券、甚至用户 ID 泄漏场景下都是风险。而抛 NotFound 会让攻击者以为这个 ID 压根不存在直接消掉枚举的价值。3.3 模式三把“操作”当成一个可以被鉴权的对象这是我在中大型项目里比较偏爱的一种模式。上面两个模式解决的是“查询”和“角色”的校验但现实中我们经常会遇到“多个对象联合操作”的越权比如一个用户可以修改角色但不能修改比自己角色高的用户一个销售可以看自己客户的订单但客户经理也可以看。这时候单点校验会变得很复杂容易漏。解决方法就是把一次业务操作建模成一个“命令对象”在命令执行前统一校验class UpdateUserCommand: def __init__(self, operator: User, target_user_id: int, new_role: str): self.operator operator self.target_user_id target_user_id self.new_role new_role def validate_permission(self, db: Session): target db.query(User).filter(User.id self.target_user_id).first() if not target: raise NotFound(目标用户不存在) if not role_level(self.operator.role) role_level(target.role): raise Forbidden(不允许操作同级或上级账号) if not has_permission(self.operator, user:update_role): raise Forbidden(没有修改角色的权限)命令对象的好处是所有权限相关代码集中在一块业务逻辑代码里只看到一行command.execute(db)不会越权也很难漏掉校验。等你测试的时候只需要针对每个命令写几组角色组合用例就能把规则边界覆盖得很完整。4. 权限模型选不对代码写得再漂亮也会变成死代码很多人把防越权当成“写几个 if 判断”但做复杂业务时你会发现权限规则本身才是大头。权限模型没选好我今天加了个角色明天又来了个业务线后天出现了“华东区销售只能看华东区数据”这种动态规则你那套写死的 if 就彻底撑不住了。4.1 中小系统优先选 RBAC角色到权限清清楚楚RBAC基于角色的访问控制是目前主流里最务实、最容易被团队理解的一种模型。核心就一张角色-权限关系表users roles permissions ------------ ---------- ------------------- | id | | id | | id | | username | --- | code | --- | code | | role_id | | name | | name | ------------ ---------- -------------------我一般建议最少四张表用户表、角色表、权限表、用户-角色关联表如果用户多角色权限表里存的就是user:list、order:export这种权限码。用 RBAC 的代码习惯是登录时把用户的所有权限码集合塞进 Token 或缓存里鉴权时直接查集合不反复查数据库。比如上一章的has_permission()就是查这个集合。这样权限判断性能极高、逻辑也简单。4.2 业务规则复杂就上 ABAC策略驱动而不是人肉枚举但如果你遇到这种业务规则——“区域经理能看到本区域所有门店的订单但只能编辑状态为‘草稿’的订单大区总监能看到区域经理所在区域的所有订单但不能编辑任何订单”——RBAC 就会开始扭曲。你不能给区域经理一个order:list就完事你得控制“数据范围”。这时候需要 ABAC基于属性的访问控制即把判断条件从“你是什么角色”扩展成“请求者属性 资源属性 环境属性”。我用 Python 写过一个简化版的策略判断def can_access_order(user, order, action): if not has_permission(user, forder:{action}): return False # 数据范围规则 if user.role reion_manager: if user.region ! order.store.region: return False if action edit and order.status ! draft: return False if user.role area_director: if user.region not in manageable_area(order.store.region, user.area): return False if action edit: return False return TrueABAC 的代码样貌和 RBAC 很不一样它不是几分种能写完的通用拦截器而是一套策略配置加策略引擎。我这里写的是硬编码策略正式项目里更推荐把策略存成配置JSON 或 DSL这样产品改规则时不用发版。说实话一开始就上 ABAC 容易过度设计但只要规则里出现了“部门/区域/状态”这三维RBAC 就该让位了。4.3 我的选型建议先 RBAC 兜底再局部上 ABAC 增强很多人纠结到底选哪个模型我的观点很明确绝大多数系统应该以 RBAC 为骨架再补充少量 ABAC 性质的数据范围规则而不是推到重来上纯 ABAC 框架。原因很实际RBAC 的表结构和接口权限码体系天生适合做管理界面、审计、权限分配而 ABAC 能把动态数据范围做得更细。两者并不互斥完全可以叠加。我在团队里定的约定是接口能不能调用 —— 交给 RBAC 权限码数据范围控制 —— 交给 ABAC 风格的数据过滤条件业务状态与操作联动 —— 交给命令模式里的独立校验这个分层的好处是每个人看代码时能很快知道该去哪一层找问题。如果权限出问题了先从数据过滤开始查再查接口权限码再查命令约束层次非常清晰。5. 库里被忽略的深层防御身份传播、随机 ID 和默认拒绝前面几节讲的都是“怎么把鉴权做对”。但真实生产环境往往比理想状态脏得多有定时任务在跑、有消息队列在消费、有内网服务间互相调用、有越过网关直连后端的请求。这些边界如果没封好越权漏洞还是会绕开你精心设计的拦截器。5.1 让用户身份贯穿整个调用链路而不是“随手取”最常见的坑是Controller 里拿了用户 ID传给 Service 层时却只传了“业务入参”用户身份丢了。结果到了 Service 层想再做一次数据归属校验时找不到是谁在请求只好跳过。我建议所有业务方法都带上一个“调用者上下文”参数比如在 Python 里用一个 ContextVar在 Java 里用 ThreadLocal在 Node.js 里挂在 request 对象上一路传递。这样无论在哪一层做鉴权身份都随手可得。这里有个反面教材有些开发为了“方便”在 Controller 里把当前用户信息序列化进 JSON 对象里传给下游——这等于你自己造了一套“客户端可伪造的用户身份”下游一看那还不直接炸了。用户身份必须来自可信的鉴权层不是从请求体里去翻。5.2 主键可预测是水平越权的“助燃剂”你用了所有正确的鉴权代码之后自增 ID 本身不构成漏洞但它会放大漏洞的破坏力。想想看如果有人水平越权没被拦住接口拿到的又是自增 ID攻击者从 1 遍历到 100 万几分钟就能把整个库拖走。反过来如果 ID 是随机的 UUID即使越权发生了攻击者也很难批量枚举。所以我建议新建表时面向用户暴露的数据用随机 ID 或 UUID 做主键自增 ID 只做内部关联。这不能替代鉴权但它是性价比极高的灾难降级方案。很多安全团队在渗透测试时也会专门盯自增 ID 的接口早点换掉扫出来的报告干净得多。5.3 默认拒绝比任何黑名单都好用我前面特别强调过“接口不声明权限就不给进”这就是默认拒绝原则。对应地黑名单思路是“把敏感接口加进拦截名单”但这样每次新增接口都要记得加名单忘了就裸奔。而白名单思路是“只有明确标记了的接口才允许某些角色进”所以没有标记的接口本身默认就是拒绝的。落实到代码上就是全局路由默认都要过鉴权拦截器只有标注了“公开”注解的接口才能跳过。在 FastAPI 里我通常这样处理app.get(/api/v1/login) # 不依赖 require_permission 的接口为公开接口 async def login(...): ... app.get(/api/v1/orders/{order_id}) async def get_order(user Depends(require_permission(order:view))): ...注意公开接口也有讲究。公开不等于所有数据都公开你依旧需要在接口内部做数据范围过滤。登录接口本身不鉴权是正常的但它返回的 Token 里要带上用户权限集方便后续所有依赖消费。6. 怎么验证“封死”了扫描器、渗透测试和自动化用例写完代码最怕的就是自我感觉良好。越权漏洞的检测靠人工肉眼是看不过来的必须建立一套可以反复执行的验证机制。6.1 手动复现最朴素的“两个账号切换法”不管你有多少扫描工具手动复现始终是第一步。我一般带着测试账号做四件事用 A 账号创建一条数据切到 B 账号尝试通过猜 ID / 修改 URL / 改请求体去访问这条数据看能不能看到——覆盖水平越权。把请求里的角色相关的响应头或 Cookie 降级拿普通账号去调管理接口——覆盖垂直越权。用批量导出/下载/生成报表这类“看起来不是单条数据访问”的接口做同样测试——越权重灾区往往在这些高价值接口上。换不同的数据源不只是网页 API还有移动端接口、小程序接口、内网管理端接口它们经常是同一套业务但两端鉴权代码实现不一样出现漏网之鱼的概率很高。6.2 把权限矩阵写成自动化用例固化进 CI手动测试能找到问题但两周后一改代码旧漏洞可能就回来了。所以我强烈建议把权限矩阵固化成自动化测试用例跑在 CI 里。矩阵长这样角色user:listuser:deleteorder:vieworder:export普通用户拒绝拒绝仅自己的拒绝运营拒绝拒绝全部全部管理员允许允许全部全部然后在 CI 里对每个“角色 × 接口”组合发真实请求断言返回码。比如pytest.mark.parametrize(role,path,expected, [ (normal_user, /api/v1/admin/users, 403), (operator, /api/v1/orders/1, 200), (admin, /api/v1/admin/users, 200), ]) def test_permission_matrix(client, role, path, expected): token login_as(role) resp client.get(path, headers{Authorization: fBearer {token}}) assert resp.status_code expected这套用例一旦跑起来团队里任何人动了权限相关的逻辑只要一跑测试就会被拦住。我在项目里见过太多“上线前一天才发现越权漏洞”的案例本质都是没有把这套矩阵固化下来。6.3 漏洞扫描器的局限你能信它但不能只信它现在很多团队上了自动化扫描器扫完报告显示“修复了所有 SQL 注入和 XSS”就觉得安全了。但我想泼一盆冷水大部分开源扫描器对越权漏洞的检出率都不高因为它们缺“业务上下文”。扫描器知道这个接口返回了其他用户的数据吗它不知道。它只能靠“响应里出现了 Authorization”这种信号猜。所以扫描器的角色应该是“辅助发现输入类漏洞”越权这种业务逻辑型漏洞核心还是要靠代码评审加自动化矩阵来兜底。代码评审时我习惯用一个检查清单这个入口有没有经过统一鉴权层这个查询有没有带上 owner/tenant 条件错误返回有没有泄漏“存在性”有没有非 Web 入口定时任务、MQ 消费者绕过了鉴权7. 我的几次翻车现场和几条已经被验证的规矩最后分享几个真实的翻车经验。我没把这些单独放在某一章是因为它们不是某个知识点而是贯穿整个项目的认知教训。第一次翻车是在导出功能上。当时给运营做了个“导出本月订单”的功能前端把 order_id 列表传到后端后端循环取数据导出。我检查所有普通账号的访问权限没问题但忽略了运营账号之间的数据隔离需求——一个运营传了“他人负责的订单 ID”照样能导出。修复方式就是我在 3.2 章写的那套导出前对 ID 列表做批量归属校验任何一个 ID 不属于当前调用者数据范围整个导出拒绝。第二次翻车是权限缓存。我图性能把用户的权限集缓存进 Redis但用户角色被管理员改了之后缓存没失效老用户带着旧权限继续用了半小时。这期间如果有人恶意操作已经造成影响。从那以后我给自己定了一条规矩角色变更消息必须发一个失效事件把所有缓存了该用户权限的节点清掉宁可用性能换一致性。还有一次最尴尬是内部系统直接对数据库做统计分析把一层“统计报表”完全绕开了后端 API。业务方拉了三个月的“日活”数据里面包含其他项目组的敏感用例数据。这件事给我的教训是不止 API 要做权限控制定时任务、数据同步脚本、OLAP 查询入口全部都要有“是谁发起的、这个身份有没有权限”的记录。没有身份的数据流动就是权限黑洞。我现在带项目基本上把这几条当成硬规矩所有新增接口默认带权限校验公开接口必须写成显式注解并经过代码评审确认。用户身份不能从请求体里取必须从鉴权层解析并跟随调用链一路传到数据库查询层。凡是涉及“查自己数据”的接口归属条件一律写进 ORM 查询不写“查出来再判断”。权限模型先定再写业务代码不在 Controller 里临时发明权限逻辑。权限矩阵自动化用例跟着接口一起提交没有测试的权限改动不让合并。说真的越权漏洞之所以总是“修不完”根本原因不是安全问题难而是大部分代码把权限当成业务的附属品写完业务“顺手”想一想。但如果你反向设计先把权限模型定下来把鉴权组件做好再把业务代码嵌进这套框架里越权漏洞想出现都难。最后再分享一个工作技巧我会在每个环境里留一个“当前登录用户权限总览”的调试接口把 Token 解出来的角色、权限码、数据范围规则全部列出来。排查问题的时候一打开马上就能定位“这个用户为什么能/不能做某件事”效率远高于翻日志。这个小东西看起来不起眼但实战里帮我省了很多时间建议你也做一个。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。