适用场景
越权访问(Broken Access Control,常以 IDOR 即”不安全的直接对象引用”的形式出现)长期位居 OWASP Top 10 首位。它的典型特征是:接口能正确识别”你是谁”,却不去校验”你是否有权访问这条数据”。攻击者只需要把请求里的订单号从 1001 改成 1002,或者把 user_id 换成别人的值,就能读到不属于自己的订单、发票、聊天记录甚至后台功能。
以下场景最容易命中:使用自增主键作为对外资源标识的接口;列表页做了权限过滤而详情接口漏做;前端通过隐藏按钮、置灰菜单来控制权限,后端接口却无校验;微服务拆分后每个服务都假设”网关已经鉴权过”;导出、下载、预览类接口因为”只是查询”而被忽略授权。
本文给出的防御路径覆盖三个层次:接口层补齐对象级鉴权、数据层用归属条件兜底、网关层统一拦截未授权路径,并附可复现的验证用例与研发自查清单。
前置条件
- 已梳理对外暴露的资源型接口清单(含详情、更新、删除、导出四类)。
- 接口可获取当前登录主体标识(Session、JWT 或网关透传的用户 ID)。
- 测试环境可创建两个不同权限的账号(普通用户 A、B,管理员 C)。
- 具备抓包工具(curl 或浏览器开发者工具即可),无需渗透测试平台。
原理说明
越权分为两类,测试思路完全不同:
- 水平越权:同级别用户之间越界。A 与 B 都是普通用户,A 通过修改资源 ID 访问 B 的数据。根因是”对象级鉴权”缺失,即没有校验
resource.owner_id == current_user.id。 - 垂直越权:低权限用户执行高权限操作。普通用户直接调用管理员接口(如删除用户、修改配置)。根因是”功能级鉴权”缺失,即没有校验角色或权限点。
两者的共同根因是把”前端可控参数”当成了可信输入。请求中的资源 ID、角色字段、租户 ID 全部由客户端提交,服务端如果直接把它们拼进查询条件,攻击者就能通过改参数改写权限判断。防御的基本模型是把权限判断从”客户端提什么”迁移到”服务端自己从会话中取什么”,并在数据访问层强制附加归属条件。
判断一个接口是否存在 IDOR,可以用三问法快速过一遍:这个接口是否接收资源 ID 作为入参?这个 ID 是否由客户端自由指定?服务端是否在取数据前校验了该资源与当前主体的归属关系?三个都满足时,风险基本成立。
操作步骤
第一步:用两个账号复现越权
以普通用户 A 登录,取到自己的令牌,读取自己的订单;再用 B 的令牌读取 A 的订单 ID,观察是否返回数据:
# 用户 A 登录,拿到自身的 token 与订单列表
curl -s -X POST http://api.example.com/login \
-H 'Content-Type: application/json' \
-d '{"user":"alice","pass":"alice_pwd"}' | tee /tmp/a.json
TOKEN_A=$(python3 -c "import json;print(json.load(open('/tmp/a.json'))['token'])")
curl -s http://api.example.com/api/v1/orders -H "Authorization: Bearer $TOKEN_A"
# 用户 B 登录,尝试读取 A 的订单详情(把订单号换成 A 的)
curl -s -X POST http://api.example.com/login -H 'Content-Type: application/json' \
-d '{"user":"bob","pass":"bob_pwd"}' | tee /tmp/b.json
TOKEN_B=$(python3 -c "import json;print(json.load(open('/tmp/b.json'))['token'])")
curl -s -o /dev/null -w 'B读A订单 HTTP=%{http_code}\n' \
http://api.example.com/api/v1/orders/1001 -H "Authorization: Bearer $TOKEN_B"
判定标准很明确:返回 200 且响应体内含 A 的订单数据,即存在水平越权;返回 403 或 404 才算正确。必须返回 404 而非 403 的场景是资源存在性本身敏感时,403 会泄露”该 ID 存在”的信息,用 404 可以同时完成隐藏。
垂直越权同理,用 A 的令牌调用管理员接口:
curl -s -o /dev/null -w 'A调管理接口 HTTP=%{http_code}\n' \
-X DELETE http://api.example.com/api/v1/admin/users/2 \
-H "Authorization: Bearer $TOKEN_A"
curl -s -o /dev/null -w 'A调导出接口 HTTP=%{http_code}\n' \
http://api.example.com/api/v1/admin/export/users -H "Authorization: Bearer $TOKEN_A"
第二步:在服务层补齐对象级鉴权
以下以 Python(Flask 风格)为例,演示”先取归属、再返回数据”的正确写法,核心是把归属校验放进数据查询条件,而不是查出来之后再比较:
from flask import request, g, abort, jsonify
def current_user_id():
# 只信任服务端会话/网关透传的主体,绝不读取请求体里的 user_id
return g.user_id
@app.get("/api/v1/orders/<int:order_id>")
def get_order(order_id):
# 反例:先查出订单,再判断 owner,容易在异常分支被绕过
# order = db.query(Order).get(order_id)
# if order.owner_id != current_user_id(): abort(403)
# 正例:把 owner_id 直接写进 WHERE 条件,越权查询在数据库层面即无结果
order = db.query(Order).filter(
Order.id == order_id,
Order.owner_id == current_user_id(),
).first()
if order is None:
abort(404) # 统一返回 404,不区分"不存在"与"无权"
return jsonify(order.to_dict())
关键点有三个:current_user_id 只能来自会话(g、context、JWT claim),绝不接受 request.args.get("user_id") 之类的客户端输入;归属条件写在 WHERE 里,避免”查询成功但忘记比较”的分支遗漏;资源不存在与无权访问统一返回 404,防止通过状态码差异枚举资源。
Java(Spring Security)场景下,功能级鉴权用注解声明,对象级鉴权仍需在服务层手工校验:
@PreAuthorize("hasAuthority('order:read')")
public OrderVO getOrder(Long orderId) {
Long uid = SecurityUtils.getCurrentUserId(); // 来自 SecurityContext
OrderVO vo = orderMapper.selectByIdAndOwner(orderId, uid);
if (vo == null) {
throw new BizException(404, "资源不存在");
}
return vo;
}
对应的 MyBatis 语句必须带归属条件,绝不能只按主键查询:
<select id="selectByIdAndOwner" resultType="OrderVO">
SELECT id, order_no, amount, status
FROM t_order
WHERE id = #{orderId}
AND owner_id = #{uid}
AND deleted = 0
</select>
第三步:把对外标识改为不可枚举值
自增 ID 让攻击者可以用脚本批量遍历。对外暴露的标识建议改为 UUID 或带前缀的随机串,同时保留内部自增主键用于关联查询:
ALTER TABLE t_order ADD COLUMN public_id CHAR(26) NOT NULL UNIQUE;
-- MySQL 侧生成:SELECT REPLACE(UUID(),'-','') 或应用层生成 ULID
# 容器侧生成示例
python3 -c "import uuid;print(uuid.uuid4().hex)"
需要在接口出口做一次映射:内部 id 不进入任何响应体与前端路由,对外只出现 public_id。这不能替代鉴权,但能显著提高自动化扫描的成本,也便于日志审计时快速定位异常枚举。
第四步:网关层统一拦截
在网关或反向代理层对敏感路径做兜底拦截,避免单个服务漏配导致整体失守。以下为 Nginx 示例,仅允许内网管理网段访问后台与导出路径:
location ~* ^/(api/v1/)?(admin|manage|export)/ {
allow 10.10.0.0/16; # 办公网段
allow 172.16.8.0/24; # 堡垒机网段
deny all; # 其余一律拒绝,返回 403
proxy_pass http://backend;
proxy_set_header X-Real-IP $remote_addr;
}
同时为接口补充限速,抑制批量枚举:
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=20r/s;
location /api/ {
limit_req zone=api_limit burst=40 nodelay;
limit_req_status 429;
proxy_pass http://backend;
}
rate=20r/s 与 burst=40 需按业务实测调整,导出类接口应单独设置更低阈值(如 2r/s),因为正常用户不会高频触发导出。
第五步:在代码仓库做静态排查
用正则批量检索可疑的”按主键直取”写法,作为代码评审的固定动作:
# 查找仅按主键查询、缺失归属条件的调用
grep -rnE "(get|find|select)ById\(" --include=*.java src/ | grep -v "Owner"
# 查找直接使用客户端提交的 user_id / role 做权限判断
grep -rnE "(request\.(args|form|json)\.(get|\\[))\(?[\"'](user_id|role|tenant_id|is_admin)" \
--include=*.py --include=*.js .
# 查找被注释掉的鉴权代码(常见回归点)
grep -rnE "//\\s*(check|verify|assert).*(permission|owner|access)" --include=*.java src/
配置验证
修复后必须回归测试,逐项确认返回码符合预期:
# 期望:B 读 A 的资源 = 404,B 读自己的 = 200
curl -s -o /dev/null -w 'B->A %{http_code}\n' http://api.example.com/api/v1/orders/1001 -H "Authorization: Bearer $TOKEN_B"
curl -s -o /dev/null -w 'B->B %{http_code}\n' http://api.example.com/api/v1/orders/2001 -H "Authorization: Bearer $TOKEN_B"
# 期望:普通用户调管理接口 = 403
curl -s -o /dev/null -w 'A->admin %{http_code}\n' -X DELETE http://api.example.com/api/v1/admin/users/2 -H "Authorization: Bearer $TOKEN_A"
# 期望:伪造请求体里的 user_id 无效
curl -s -o /dev/null -w 'spoof %{http_code}\n' http://api.example.com/api/v1/orders/1001 \
-H "Authorization: Bearer $TOKEN_B" -H 'Content-Type: application/json' -d '{"user_id":7}'
还要覆盖三类边界:更新与删除接口(PUT/DELETE 的越权危害更大)、批量接口(ids=[1,2,3] 中混入他人 ID 应整批拒绝或过滤)、导出与预览接口(常被判定为”只读”而漏做校验)。验证通过后把上述 curl 用例固化进 CI 的接口测试集,避免后续迭代回归。
常见问题
Q1:前端已经把按钮隐藏了,为什么还判定为越权漏洞?
因为前端代码运行在攻击者自己的浏览器里。隐藏按钮、置灰菜单、路由守卫都可以被绕过——改一行 JS、直接用 curl 构造请求即可完成。前端控制的只是”交互体验”,不是”访问权限”。判定标准只有一个:不依赖前端修改、直接构造请求时,服务端是否仍然拒绝。因此权限点必须在服务端接口层落地,前端隐藏仅作为体验优化。
Q2:改用 UUID 之后是否就不用做鉴权了?
不能。随机 ID 只是让枚举成本变高,并不能阻止越权:攻击者仍可能从分享链接、日志、导出文件、前端缓存、第三方 SDK 上报中拿到他人的 UUID,然后直接请求。UUID 属于”提高门槛”的纵深防御措施,鉴权才是”决定性控制”。两者必须同时具备,且 UUID 不应被当作唯一防线——尤其在接口返回体中曾出现过的 ID,都视为已泄露。
Q3:接口是内部服务间调用,没有用户会话,怎么做对象级鉴权?
内部服务同样需要鉴权标识,做法是把调用方身份随请求传递并在被调方校验:一是使用 mTLS 双向证书,用证书 CN/SAN 标识服务身份;二是使用服务网格或网关签发的短期令牌(含调用方服务名与授权范围);三是把最终用户身份以签名的 Header 透传(如 JWT),被调方校验签名后读取其中的用户 ID 作为归属判断依据,绝不接受未经签名的明文 X-User-Id。需要强调:“内网可信”不是安全边界,一旦任一服务被拿下,无鉴权的内网接口会成为横向移动的跳板。
总结
越权访问的本质是把权限判断依据交给了客户端。防御可以压缩成四条工程约束:主体标识只从服务端会话获取,不接受任何客户端提交的身份字段;归属条件写进数据查询语句,而不是查出来再比较;资源不存在与无权访问统一返回 404,减少信息泄露;敏感路径在网关层做第二道兜底拦截。落地时按”接口清单梳理 → 两账号复现 → 服务层补鉴权 → 网关兜底 → CI 固化用例”的顺序推进,即可把这类漏洞从一次性修补变成可持续的工程能力。