平时做安全测试或者代码审计,跟人聊起漏洞分类,最容易一句话带过又最容易被低估的,就是“越权”。很多人一听“越权”,下意识觉得不就是权限校验没过嘛,改个ID试试就出来了,没什么技术含量。但真到实战和修复阶段,垂直越权和水平越权这两类问题,常常把开发折腾得够呛,也让安全工程师在评审会上反复拉扯。这篇就把这两类失效的访问控制掰开揉碎,从原理到测试手法,再到修复落地方案,完整梳理一遍。
先说个真实场景。某个内部管理后台,普通用户登录后能访问个人资料页,某天有人手痒把URL里的/user/profile/101改成/user/profile/102,结果看到了另一个人的手机号和收货地址。这就是典型的水平越权,两个权限等级相同的用户,A能访问B的数据。再换个场景,后台有个“导出全部订单”的按钮,本来只有管理员能用,结果普通用户把请求里的角色字段从user改成admin,或者直接调用了那个管理接口,居然也返回了全量数据,这就是垂直越权。两种越权表现不一样,但根子上都是同一件事:服务端没有正确地验证“当前用户到底有没有权限做这件事”。
这篇文章想聊透的就是这两类问题。适合正在做Web开发、写接口的人看,也适合刚接触安全测试、想搞明白越权漏洞到底怎么挖怎么修的同行参考。会讲到访问控制的基本模型、两类越权的本质和案例、测试时的思路和工具,还有最后修复时的关键落地点。
1. 先搞清楚:访问控制到底控的是什么
很多人把“认证”和“授权”混为一谈,这是理解越权问题最大的障碍。认证解决的是“你是谁”的问题——登录、会话、Token都是这一层的事;授权解决的是“你能干什么”的问题——某个请求进来,系统判断你是否有权利执行这个操作、访问这份数据。越权漏洞基本都出在授权这一层,而不是认证这层,因为系统已经知道请求者是谁了,只是没校验清楚他够不够格。
1.1 访问控制的两个基本概念先对齐视角
我习惯把访问控制分成两个动作:识别主体和验证权限。识别主体,就是从请求里取出当前用户是谁,可能是Session里的用户ID,可能是JWT里的sub字段,也可能是一个自定义请求头。验证权限,则是拿着这个用户ID去问权限系统:他能不能访问这个资源、能不能执行这个操作。
问题往往出现在第二步形同虚设。很多接口写完就上线了,开发默认“前端没显示这个按钮,用户就不知道这个功能”,于是后端不校验。这种思路在内部工具里还能侥幸一阵子,一旦服务暴露在公网或者被内网用户盯上,立刻就会被薅穿。攻击者根本不需要什么高级技巧,抓个包改一下参数就能横着走。
从设计角度讲,访问控制可以建模成一个三元组:主体、客体、动作。主体是发起请求的用户,客体是你想保护的资源,比如订单记录、文件、配置项,动作是读、写、删、执行这类操作。越权漏洞的本质,就是系统对某个三元组的授权判断出了问题:该拒绝的放行了,或者该限制范围的没限制住。
1.2 权限判断的三个核心要素:主体、客体和动作
用一个订单查询接口来举例。请求长这样:
GET /api/order/detail?orderId=10086服务端要做的判断有三层。第一层,orderId=10086这个订单存不存在;第二层,这个订单的所有者是不是当前登录用户;第三层,当前用户有没有查询订单详情的角色权限。三层都过了才该返回数据。
真实系统里经常只做了第一层,甚至第一层都没做好,数据库查不到就返回空,查到了就直接把整条订单数据吐出来。这就是典型的水平越权——客体是订单,动作是查询,主体是任意登录用户,系统没有校验主体与客体之间的归属关系。
再看另一个典型接口:
POST /api/admin/user/delete Content-Type: application/json { "userId": 55 }如果这个接口存在于生产环境,却没有对调用者的角色做校验,那任何登录用户都能删人,这是垂直越权。垂直越权关注的是“角色等级”,水平越权关注的是“数据归属”,两者互相独立,但又经常同时出现在同一个系统里。
1.3 从RBAC看常见的权限模型
目前业务系统用得最多的是RBAC,基于角色的访问控制。核心思想是把权限授予角色,把角色授予用户,避免每个用户单独配权限的痛苦。比如“订单管理员”这个角色拥有订单查询、导出的权限,普通用户只有查看自己订单的权限。
逻辑上RBAC很清晰,落地时却很容易跑偏。最常见的问题是把角色信息直接放在前端可控的位置,比如Session里存一个role=admin,或者JWT payload里塞一个isAdmin=true,服务端拿到后完全不验签、不查库就信了。这种设计等于把权限系统的钥匙挂在大门口,谁路过都能摘走。
基于此,看后面两类漏洞会容易很多。垂直越权是RBAC模型的“角色校验缺失”,水平越权则是“客体归属校验缺失”。修复的手段、测试的思路,都从这两个缺失点出发。
2. 垂直越权:普通用户如何拿到管理员权限
垂直越权,也叫权限提升。攻击者从低权限角色,通过各种手段访问到高权限角色的功能。它不涉及横向的数据串门,而是纵向的等级跨越。这类漏洞一旦存在,轻则普通用户调用几个管理接口,重则直接控制整个后台。
2.1 一个案例看明白垂直越权
以前梳理过一个模拟项目X,是一个内容管理后台。核心功能分两块,普通编辑能写文章、改自己的草稿,管理员能审核文章、管理所有用户。权限设计用的是中间件拦截:
// 伪代码 app.use('/api/admin', authMiddleware);这个authMiddleware只校验了用户是否登录,完全没有校验角色。也就是说,只要登录了,任何人都可以访问以/api/admin开头的所有接口,包括“删除用户”“修改全局配置”“审核通过”这些高危操作。测试时直接用普通编辑账号发起:
POST /api/admin/article/audit { "articleId": 123, "action": "approve" }返回200,审核通过。普通编辑瞬间拥有管理员权限,这就是教科书级的垂直越权。
这类问题的高发原因,一是中间件只做了“登录校验”,没做“角色校验”;二是开发觉得接口路径带admin就是安全了,忽略了服务端才是唯一的信任边界。
2.2 垂直越权的高发场景:从Web端到API层
垂直越权常出现在三类位置。第一类是管理后台的Web接口,尤其是那些用传统Cookie Session的老系统,中间件把登录和权限混在一起写,能跑就行,哪还管校验逻辑是否完备。第二类是前后端分离后的API网关,网关做了认证,但下游服务各自为政,有的校验角色,有的不校验,整体权限配置支离破碎。第三类是内部系统之间的服务调用,部分企业把内部API当作“可信网络”,服务间调用不携带身份信息,一旦某个边缘服务被攻破,攻击者就能以服务身份调用所有高权限接口。
我印象很深的一个案例,某公司的一个报表系统,前端只给管理员显示“导出报表”按钮,普通员工看不到。但按钮只是被隐藏了,对应的后端接口照样监听所有请求。有好奇的员工直接拼了个URL,成功下载了全公司的薪资报表。事后排查,后端路由连个角色判断都没写,按钮隐藏纯粹是前端的事。
注意:所有人都在讲“前端不能作为安全边界”,但落实到代码里,前端隐藏菜单、隐藏按钮的情况依然大面积存在。建议所有涉及敏感操作的接口,不管前端显不显示入口,后端一律默认拒绝,按最小权限原则显式放行。
2.3 为什么这么容易犯这类错误
这背后有几个深层原因值得聊一聊。
第一,权限校验代码缺乏统一出口。很多项目里,角色校验散落在各个Controller里,每个接口自己写一套if (user.role != 'admin'),大概率会在新增接口时漏掉。一旦团队人员流动、代码交接不清,新人照着旧接口复制粘贴,很容易只复制业务逻辑没复制权限判断。
第二,接口设计没有清晰的权限边界。/api/admin/xxx和/api/user/xxx混在同一套路由里,或者干脆不做路径区分,全靠代码内部逻辑判断。时间一长,谁也说不清哪些接口是管理端专用的。
第三,对“内部系统”过度信任。访问控制失效往往不是因为技术做不到,而是根本没想做。很多开发认为公司内网是安全的,这个前提本身就站不住脚。内网用户同样可能是攻击者,或者某个普通账号被钓鱼之后,攻击者拿到的是合法会话,直接就变成了“内部人”。
3. 水平越权:平行用户之间的数据“串门”
水平越权比垂直越权更隐蔽,危害也更直接。两个用户权限等级相同,都能访问自己的数据,差别只在于系统没有验证“这份数据是不是你的”。这种漏洞在社交App、电商平台、医疗系统里非常致命,出了事就是用户隐私泄露,严重的还会触碰合规红线。
3.1 水平越权的本质和IDOR的关系
水平越权通常和IDOR(Insecure Direct Object Reference,不安全的直接对象引用)强相关。所谓直接对象引用,就是请求里直接携带了资源标识符,比如订单号、用户ID、文件编号,服务端拿到这个标识符就去数据库查询,返回结果前不检查归属。
举个例子,某平台有一个“获取收货地址”的接口:
GET /api/address/get?addressId=2088如果服务端只校验地址ID是否存在,不校验当前用户和这个地址之间的绑定关系,那我只要把addressId改成别人的ID,就能拿到别人的收货地址。这就是最典型的水平越权,攻击者不需要任何特权,唯一需要的就是遍历ID。
这类漏洞之所以叫“水平”,是因为攻击者和受害者在权限体系里平级,攻击者只是利用数据归属校验的缺失,完成了越权访问。
3.2 真实系统里的水平越权重灾区
水平越权高发的功能点有几类,这里列一下,测过的朋友应该会有共鸣。
第一类是“根据ID查询详情”的通用接口。订单详情、用户资料、发票信息、工单记录,凡是存在单条数据详情页的系统,基本都有这个接口。有些系统生成ID用的是自增整数,遍历起来就是分分钟的事;有些用UUID,虽然不好猜,但如果ID出现在某个页面的源码或历史链接里,一样能被打到。
第二类是批量操作接口。比如“批量下载附件”“批量导出数据”,请求体里是一个ID数组,服务端遍历数组去数据库取数据,没有逐个校验归属。攻击者只要把数组里混入几个别人的ID,就能把别人的数据打包带走。这类批量接口危害更大,因为一次性泄露的数据量远远大于单条查询。
第三类是文件下载和图片访问。很多时候请求参数是文件路径或者文件ID,服务端拿到路径直接往磁盘上读文件,完全没有和当前用户关联。曾经遇到过附件系统把文件按数字ID命名,路径就是/files/upload/10086.pdf,测试时把ID减一减,上一份文档就下来了,连权限都不用绕过。
第四类是状态变更接口。比如取消订单、修改密码、转移工单,这类接口往往是“改”操作,水平越权的后果比读取更严重,因为还能影响他人数据。收到一个“请帮我取消订单”的客服工单时,一定要想到:如果取消订单接口不校验订单归属,任何一个登录用户都能用订单ID取消掉别人的订单。
3.3 水平越权的变体:从“改ID”到“找参数”
除了最直观的“改ID”,水平越权还有一些变体值得关注。
一种是间接对象引用。请求里不直接携带ID,而是携带一个“编号”或者“短码”,比如一个邀请链接里的邀请码,服务端把邀请码换算成用户ID后去查询相关数据。如果换算逻辑没有校验归属,攻击者使用别人的邀请码就能看到对应的私有信息。
另一种是通过父子关系绕过。某个接口看似校验了第一层归属,但忽略了第二层。比如一个“查询订单里的商品”接口:
GET /api/order/items?orderId=10086服务端校验了orderId属于当前用户,返回了该订单下的所有商品。商品里有一个字段叫sellerId,是商家的ID。攻击者随后用这个sellerId去访问“商家店铺详情”“商家结算记录”等接口,而这类接口可能只校验“是否登录”,不校验当前用户是否真的是这个商家。这就从订单数据的水平越权,扩展到了商家数据的水平越权。测试时不能只看单个接口,要顺着参数链往下追,看看返回数据里有没有可以作为下一次请求凭证的字段。
还有一种是通过批量接口绕过单条限制。单条查询接口已经做了归属校验,但批量接口没有,或者批量接口的校验逻辑可以被特定参数绕过,比如传一个空数组、传一个超大的limit、传一个0。这类问题在“数据导出”功能里尤其常见,值得重点关注。
4. 实战测试方法:如何高效挖掘越权漏洞
聊完两类漏洞的原理,进入实操环节。越权漏洞的测试思路其实比较固定,核心就是两问:当前用户能访问哪些不应该访问的功能?当前用户能访问哪些不属于自己的数据?围绕这两问展开,效率会高很多。
4.1 挖掘前的信息准备:先摸清接口和数据
动手测试之前,先花时间收集接口信息和理解业务流程。别一上来就扫描,没有业务上下文,扫描出来的接口也不知道哪个能打。
第一步,浏览一遍应用的所有功能点,把能点的按钮都点一遍,把产生的网络请求都记下来。特别关注带有ID、编号、文件名的请求参数。第二步,把接口按功能分类:查询类、修改类、删除类、导出类、管理类,区分哪些是个人数据操作,哪些是管理端功能。第三步,注册两个不同权限的测试账号,至少一个普通账号、一个管理员账号,如果有条件再注册一个不同的普通账号,作为水平越权的受害者账号。
实操心得:建议准备两个独立的浏览器环境,或者使用支持多Profile的抓包工具,保证两个账号的会话相互隔离。切换账号时记得清理Cookie,不然很容易测出身份混乱的假阳性。
信息收集阶段做到位之后,就能针对性地发起测试了。
4.2 手工测试的几条实用主线
主线一:垂直越权排查。用普通账号登录,把所有以/admin、/manage、/backend开头的接口全部列出来,逐个用普通账号的会话重放。只要有一个返回了非403非401的响应,就存在越权可能。再进一步,找那些“普通用户功能”和“管理员功能”共用一套路由的接口,比如文章审核、订单退款,切换到普通账号的会话去请求,看能否触发。
主线二:水平越权排查。使用A账号登录,正常请求自己的数据,把请求中的ID记录下来。换B账号登录,把A的ID代入B的请求,对比响应内容。如果B能拿到A的数据,基本可以确认水平越权。测试重点集中在“根据ID查询详情”“根据编号下载文件”“根据ID修改状态”这三类接口。
主线三:抓取返回数据里的“下一次请求凭证”。每个接口的返回JSON都要仔细看,有的字段虽然当前页面不展示,但可能包含ID、编号、Token。把这些字段收集起来,作为后续请求的参数。很多绕过并不在接口本身,而在于返回数据里藏了不该出现的敏感字段。
主线四:检查HTTP方法。同样的URL,GET和POST的权限有时不一样。有的接口GET请求校验了角色,POST请求却什么都不校验。开发写权限判断时可能只在某个方法上做了处理,其他方法直接放过了。
4.3 工具辅助测试的思路
手工测试能覆盖大多数场景,但接口一多就会累。可以用工具辅助做一部分自动化工作。
简单场景下,直接用抓包工具或命令行构造批量请求就够了。比如用Python写个小脚本,测试一批ID:
import requests s = requests.Session() s.cookies.set("sessionid", "your_test_session") for order_id in range(1001, 1021): resp = s.get(f"https://example.com/api/order/detail?orderId={order_id}") if resp.status_code == 200: print(f"[+] Found accessible order: {order_id}")这个脚本会逐个尝试范围内的订单ID,打印出能够成功访问的条目。需要注意,压测式地遍历大量ID可能触发风控或产生大量日志,测试前先确认目标环境允许,且在测试环境操作,不要直接对着生产库跑。
更高阶的做法是借助API测试工具,先抓取正常请求生成模板,再利用数据驱动的方式批量测试。当然,工具只是辅助,核心还是测试思路。没有对业务的理解,工具扫出来的告警也很难判断是真漏洞还是业务设计的正常逻辑。
5. 修复方案:从代码逻辑到架构设计
修复越权漏洞,不是一个接口一个接口地打补丁,那样永远修不完。正确思路是建立一套完整的、统一的访问控制机制,让所有接口默认安全。
5.1 修复的核心一条:后端是唯一的信任边界
这句话说多少次都不为过:所有权限校验必须在服务端完成,前端展示、按钮隐藏、菜单过滤都只是体验优化,不是安全措施。
具体落地时,最少要做到以下几点。
第一,登录后建立服务端可控的权限上下文。不要在请求参数里传角色,不要信任前端传来的任何权限标识。从Session或数据库中读取当前用户的完整角色和权限集合,这是权限判断的唯一数据源。
第二,所有涉及敏感操作的接口,必须显式声明所需权限。可以做成装饰器或注解,也可以做成配置化的权限清单。比如:
@app.route('/api/admin/user/delete', methods=['POST']) @require_role('admin') def delete_user(): ...require_role这个装饰器统一处理角色校验,业务代码只关注业务逻辑。
第三,数据级权限判断必须伴随每一次数据访问。角色校验只是垂直层面的门槛,水平层面还得确认当前用户对某个特定资源是否具备操作资格。比如查询订单详情时,在SQL层面就加上AND user_id = ?,而不是查出结果再在内存里比对。SQL层的约束最不容易漏,也最不容易被绕过。
注意:在内存中比对用户ID和资源归属虽然也能实现校验,但存在两个风险。一是容易漏,新接口复制粘贴时可能忘了那次if判断;二是逻辑复杂后难以维护。最好在数据查询层直接按归属过滤,从源头拿不到不该拿的数据。
5.2 统一鉴权模型的落地
统一的鉴权模型,是把散落在各个接口里的权限判断逻辑集中管理。我见过几个比较有效的落地方式,分享出来供参考。
第一种是基于RBAC做统一中间件。系统把所有接口和维护的角色维护成一张路由权限表,请求进来时,中间件根据当前用户角色查这张表,有权限就放行,没有就拒绝。这种方式的关键在于路由权限表的完善程度,新接口上线时要记得注册进表里,否则默认拒绝。
第二种是基于ACL做资源级校验。对于数据归属要求特别严格的系统,可以维护“用户-资源”的访问控制列表。每次请求资源前,先查ACL里是否有当前用户对该资源的访问授权。比如文档类产品,用户A创建了文档1,ACL里记录A对文档1有读写权限,用户B请求文档1时,ACL里没有记录,直接拒绝。这种方式数据模型清晰,但维护成本较高,适合对数据隐私要求极高的业务。
第三种是使用现有的权限框架。很多成熟框架提供完整的权限注解、表达式、过滤器机制,比如Spring Security、Django Guardian等。直接用框架的能力,比自己写一套权限工具更稳妥。安全领域最怕“重复造轮子”,成熟框架踩过的坑比你多得多。
5.3 纵深防御:审计和日志不能省
即使加了权限校验,也不能保证万无一失。纵深防御的最后一环,是完整的审计日志和监控告警。
日志至少要记录以下信息:谁(用户ID),在什么时间,调用了哪个接口,传入了什么参数,返回了什么结果。这些日志平时看似无用,一旦发生安全事故,就是排查的第一手资料。如果日志只记录“接口被调用”,没记录具体用户和参数,事后根本没法追溯。
监控告警方面,关注一些异常特征:单个用户短时间内大量调用某个详情接口,单个用户依次访问递增的ID序列,普通用户频繁访问管理端接口。这些特征都可以做成规则,触发后通知安全人员介入。很多越权攻击的第一步就是批量探测,如果能在探测阶段就发现并阻断,后面的事就不会发生。
6. 常见误区与排查实录
最后一部分,把经常遇到的误区和问题整理一下,这些内容大部分来自实际项目里的踩坑记录,遇到类似情况可以直接对照排查。
6.1 高频问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 前端隐藏按钮但接口可访问 | 后端缺少角色校验 | 抓取管理端请求,用普通账号重放 |
| 修改请求中的ID后能访问他人数据 | 缺少数据归属校验 | 检查详情、导出、下载接口的参数处理逻辑 |
| 同一接口GET有权限控制,POST没有 | 权限判断只覆盖了部分HTTP方法 | 遍历同一URL的GET/POST/PUT/DELETE方法 |
| 批量导出接口可导出他人数据 | 批量接口未逐条校验归属 | 分析导出请求的数据筛选参数和ID数组 |
| 接口有权限校验但仍越权成功 | 校验顺序或上下文获取方式有误 | 检查权限判断是取请求参数还是服务端Session |
| 越权返回200但数据为空 | 可能已拦截或数据本就不存在 | 切换账号测试,观察日志中的校验逻辑分支 |
这个表里的每一个现象,都是测试或修复过程中真实遇到的。遇到类似问题时,先别急着打补丁,对照表格分析一下根因,往往能更快定位到真正的问题代码。
6.2 一次典型排查过程的还原
简单还原一次完整的排查过程,体现一下思路。
某公司运营反馈,使用普通员工账号登录后台后,能通过修改URL访问到部门的薪资汇总页面。安全组介入后,第一件事是模拟复现:用员工账号登录,访问带参数的管理页面URL,确认能返回数据。第二步是定位代码:拿到接口名称后,在代码库中搜索对应的视图函数,发现该视图只继承了一个“登录校验”的基类,没有继承“管理员校验”的基类,审查者一眼就找到了缺失的权限判断。第三步是确认影响范围:用同样的方式遍历同目录下的其他视图,发现还有三个接口存在同样问题。第四步是修复:在基类增加统一的角色校验,同时用脚本批量审计所有继承该基类的视图函数,确保没有遗漏。
整个过程不复杂,但很能说明问题。越权漏洞的修复往往不是改一处,而是改一类。如果只修了一个接口就宣布完成,同类问题大概率还会在别的地方冒出来。
再分享一个经验:在修复完成后,建议重新跑一遍整个测试流程,用低权限账号逐一访问所有受影响的接口,确认返回403或登录跳转。千万别只在代码层面看着没问题就收工,真实环境里的URL路径、反向代理配置都可能影响最终结果。
另外,从研发流程上可以做个小改进。接口设计文档里增加“权限要求”一栏,每个接口上线前都要写明允许哪些角色访问、能否访问他人数据。这个字段虽然简单,但能逼着开发在写接口时就想清楚权限问题。很多越权漏洞的根源,是接口上线时压根没人想过权限这件事。
测试越权和修复越权,没有什么玄学。核心就是对权限模型的“角色维度”和“数据维度”两条线做完整的梳理和校验。角色维度解决垂直越权,数据维度解决水平越权,两者都做到位了,这类问题自然就堵住了。希望这篇能帮你少踩几个坑。