一个越权漏洞怎么被测出来:给权限矩阵织一张可回归的『水平/垂直越权』测试网
审计里翻出这样一个反常现象:登录正常、鉴权中间件在跑、每个接口的功能单测全绿——可测试同学拿用户 A 的 token 去请求用户 B 的订单详情接口,接口居然把 B 的数据原样返回了。没有报 401,没有报 403,200 加一份别人的隐私。
根因很典型:每个接口的单测只验了一件事——「登录态有效」。没人验第二件事——「这条数据,是不是当前登录用户有权看的」。鉴权中间件保证了「你是个登录用户」,但没保证「你只能看你自己的」。这就是 OWASP 长期列为高危的 Broken Access Control(失效的访问控制)里最经典的水平越权。这篇讲怎么把越权测试从「渗透同学上线前手工试两下」做成一张每次回归都自动跑、能持续兜住的测试网。
一、审计结论摘要
审计对象是这套 Web/API 的权限测试覆盖,三条发现按风险从高到低。
第一,越权靠人工渗透、不进回归。渗透同学上线前手工试一次,试到了算运气,试不到就漏到线上;下次改接口没人再试,漏洞随迭代复发。这是本次事故的直接原因,属阻断级。
第二,所有接口单测只断言「登录态有效」,没有一条断言「资源归属校验」。功能测试证明的是「有权限的人能拿到数据」,证明不了「没权限的人拿不到」,这两件事方向相反。
第三,权限规则散落在各接口的 if 判断里,没有一张统一的权限矩阵。没有矩阵,就没法系统性地说「哪些角色对哪些资源有哪些操作」,越权用例也就无从穷举。
建议动作三条:把权限规则抽成一张角色×资源×操作的矩阵、用 pytest 参数化把矩阵每个单元格展开成越权用例、把这张网挂进回归每次自动跑。第六节给可运行实现,第七节给审计清单。
二、水平越权与垂直越权:两种方向相反的失控
越权不是一个漏洞,是两个方向。分清方向,断言点才不会写错。
水平越权是「同角色、换他人资源」:用户 A 和用户 B 都是普通用户,A 把请求里的资源 ID 换成 B 的,就拿到了 B 的数据。垂直越权是「跨角色、升权」:普通用户去打只有管理员能访问的接口,比如直接请求 /admin/users。前者破坏的是数据隔离,后者破坏的是功能隔离。
| 维度 | 水平越权 | 垂直越权 |
|---|---|---|
| 定义 | 同角色用户访问了不属于自己的他人资源 | 低权限角色访问了高权限角色的功能/资源 |
| 触发方式 | 保持角色不变,替换请求里的资源 ID(订单号/用户 ID) | 保持资源不变,用低角色 token 打高角色接口 |
| 断言点 | 必须 403 或返回空数据,绝不能返回他人数据 | 必须 403,绝不能执行成功或返回管理数据 |
| 典型漏测 | 单测只验登录态,没人换他人 ID 试 | 单测只用管理员账号测管理接口,没拿普通账号试 |
一句话:水平越权测的是「换了资源 ID 还拦不拦」,垂直越权测的是「换了低角色 token 还拦不拦」。两者都要专门造出「不该有权限的请求」,再断言它被拒。
三、先画一张权限矩阵:角色×资源×操作
要系统性测越权,先得有一张能穷举的地图。这张地图就是权限矩阵:行是角色(普通用户/商家/管理员),列是资源(订单/商品/用户),每个单元格标注这个角色对这个资源能做哪些操作(读/写/删/无权限)。
矩阵一旦画出来,越权用例就能被机械地生成——遍历每个「无权限」或「仅限他人」的单元格,造一个对应的越权请求,断言它被拒。这就是把「渗透同学凭经验手工试」升级成「按矩阵穷举自动试」的关键一步:经验会漏,矩阵不会。
| 角色\资源 | 订单(order) | 商品(product) | 用户(user) |
|---|---|---|---|
| 普通用户(user) | 读/写:仅自己的 | 读:全部 | 读:仅自己的 |
| 商家(merchant) | 读:仅本店 | 读/写:仅本店 | 无权限 |
| 管理员(admin) | 读/写/删:全部 | 读/写/删:全部 | 读/写/删:全部 |
从这张矩阵能直接读出该测什么:普通用户对「他人订单」是无权限的 → 生成一条水平越权用例;普通用户对「用户资源的管理操作」是无权限的 → 生成一条垂直越权用例;商家对「用户资源」完全无权限 → 生成一条跨资源越权用例。每一个「仅自己的」「无权限」都是一个待测单元格。

四、把矩阵展开成可回归的越权用例
有了矩阵,测试网就成型了。核心思路是:对每个「不该有权限」的单元格,自动造一个越权请求,用两个真实测试账号的 token 交叉打接口,断言结果必须是 403 或空数据。
水平越权用例长这样:准备 A、B 两个普通用户,各自有一条订单;拿 A 的 token 去请求 B 的订单 ID,断言拿不到 B 的数据。垂直越权用例长这样:拿普通用户 A 的 token 去请求 /admin/users,断言 403。关键在于这些用例由矩阵参数化生成,不是手写——矩阵加一行角色、加一列资源,越权用例自动跟着长出来,不会因为「忘了补」而漏。
这张网和只验登录态的单接口功能测试,覆盖面完全不在一个量级:
| 维度 | 单接口功能测试(只验登录态) | 权限矩阵越权回归 |
|---|---|---|
| 覆盖面 | 只测「有权限的人能成功」这一个正向 | 穷举「无权限的人被拒」,含水平/垂直/跨资源 |
| 可复现 | 越权靠人工偶然试到,无法稳定复现 | 由矩阵机械生成,每次回归都跑同一批用例 |
| 漏测风险 | 高——改接口没人再手工试,漏洞复发 | 低——矩阵变了用例自动跟着变 |
| 可维护 | 规则散在各接口 if 里,改一处漏一片 | 权限集中一张矩阵,改矩阵即改用例 |
五、断言点:403 还是空数据,别只断「不报错」
越权用例最容易写错的地方是断言。很多团队写「断言请求不抛异常」——可越权恰恰是不抛异常的,它静默返回 200 加一份别人的数据。正确的断言必须钉死在权限语义上:要么状态码是 403,要么返回体里没有目标资源的数据。
还要注意一个陷阱:有些接口对越权请求返回 200 但 body 是空列表,这也算「拦住了」;但有些返回 200 且 body 里带着他人数据的一个字段(比如脱敏没脱干净的手机号),这就是漏。所以断言不能只看状态码,得同时校验「响应里不含目标资源的归属数据」。第六节的代码把这两层断言都写进去了。
六、可运行实现:权限矩阵 + pytest 参数化越权用例
实现分两段。第一段是权限矩阵配置与越权用例的展开逻辑,第二段是用两个测试账号 token 交叉打接口并断言拒绝。为了可直接跑,用一个内存里的假接口层模拟后端权限校验。
"""
authz_matrix.py —— 权限矩阵配置 + 越权用例展开(纯标准库,可直接跑)
矩阵定义:角色 × 资源 × 操作 → 允许范围(self / all / none)
把矩阵里每个「不该有权限」的单元格,机械展开成一条越权测试用例。
运行自测:python authz_matrix.py
"""
# 权限矩阵:{角色: {资源: {操作: 允许范围}}}
# 允许范围: "self"=仅自己的, "all"=全部, "none"=无权限
MATRIX = {
"user": {
"order": {
"read": "self"}, "user": {
"read": "self"},
"admin_panel": {
"read": "none"}},
"merchant": {
"order": {
"read": "self"}, "product": {
"write": "self"},
"user": {
"read": "none"}},
"admin": {
"order": {
"read": "all"}, "user": {
"read": "all"},
"admin_panel": {
"read": "all"}},
}
def build_violation_cases():
"""遍历矩阵,生成两类越权用例:
- horizontal: 允许范围是 self 的单元格 → 换他人资源应被拒
- vertical: 允许范围是 none 的单元格 → 低角色打高权限应被拒
"""
cases = []
for role, resources in MATRIX.items():
for res, ops in resources.items():
for op, scope in ops.items():
if scope == "self":
cases.append({
"type": "horizontal", "role": role,
"resource": res, "op": op,
"desc": f"{role} 换他人 {res} 应被拒"})
elif scope == "none":
cases.append({
"type": "vertical", "role": role,
"resource": res, "op": op,
"desc": f"{role} 打 {res} 应被拒(无权限)"})
return cases
if __name__ == "__main__":
cases = build_violation_cases()
print(f"从权限矩阵展开出 {len(cases)} 条越权用例:")
for c in cases:
print(f" [{c['type']:10s}] {c['desc']}")
"""
test_authz.py —— 用两个测试账号 token 交叉打接口,断言越权被拒(pytest)
运行:pytest -q test_authz.py
依赖 authz_matrix.py。用一个假后端 fake_api 模拟权限校验,
演示真实项目里把它换成 requests 调用即可。
"""
import pytest
from authz_matrix import MATRIX, build_violation_cases
# 两个测试账号,各自拥有一条订单
OWNERS = {
"order": {
"A": "order_A", "B": "order_B"},
"user": {
"A": "user_A", "B": "user_B"},
"product": {
"A": "prod_A", "B": "prod_B"},
"admin_panel": {
"A": "panel", "B": "panel"}}
ROLE_OF = {
"A": "user", "B": "user"} # A、B 都是普通用户,方便测水平越权
def fake_api(caller, resource, resource_owner):
"""假后端:按权限矩阵校验。返回 (status_code, 是否含目标数据)。
真实项目里换成 requests.get(url, headers={'Authorization': token})。"""
role = ROLE_OF[caller]
scope = MATRIX.get(role, {
}).get(resource, {
}).get("read", "none")
if scope == "all":
return 200, True
if scope == "self" and resource_owner == caller:
return 200, True
# 其余情况都应拒绝:403 或 200 空数据
return 403, False
@pytest.mark.parametrize("case", build_violation_cases())
def test_violation_denied(case):
"""矩阵展开的每条越权用例:用 A 的身份去打不属于 A 的资源,必须被拒。"""
res = case["resource"]
# 水平越权:A 打 B 的资源;垂直越权:A 打自己 none 的资源
victim_owner = "B"
status, has_data = fake_api(caller="A", resource=res,
resource_owner=OWNERS[res][victim_owner])
# 断言双层:状态码拒绝 或 响应不含目标数据,二者必须至少成立
assert status == 403 or has_data is False, \
f"越权未被拦截: {case['desc']} -> status={status}, has_data={has_data}"
为什么这么写:把权限规则集中到一张 MATRIX 字典,而不是散在各接口的 if 里,是因为越权测试网的价值全在「穷举」——只有规则集中,才能用 build_violation_cases() 一次遍历生成全部用例;规则一散,就永远有接口漏在网外。用例用 @pytest.mark.parametrize 展开,是因为这样矩阵加一行角色、加一列资源,用例数量自动跟着涨,不用手工补,这正是「可维护」那一栏的落地。断言写成 status == 403 or has_data is False 双层,是因为前面说的陷阱:有的接口不返 403 而是返 200 空数据,只断状态码会误报失败;只断数据又可能漏掉「返 403 但日志泄了数据」。踩过的坑有两个:一是测试账号必须真实拥有各自的资源(A 有 order_A、B 有 order_B),否则「A 打 B 的订单」根本无从构造,越权用例成了空跑;二是 fake_api 里对 self 范围的判断一定要比对 resource_owner == caller,漏了这个比对,假后端自己就把越权放行了,测试全绿却测了个寂寞——真实项目里换成 HTTP 调用后,这个归属校验就落在你的后端上,也正是最该被测的那一行。
七、审计清单:对着报告逐条勾
| 审计项 | 报告里必须出现什么 | 缺失时的后果 |
|---|---|---|
| 权限矩阵 | 角色×资源×操作的完整矩阵,含「仅自己/无权限」标注 | 无法穷举,越权用例靠经验漏一片 |
| 水平越权用例 | 换他人资源 ID 后断言 403/空数据 | 用户 A 查到用户 B 的数据 |
| 垂直越权用例 | 低角色 token 打高角色接口断言 403 | 普通用户执行了管理员操作 |
| 断言层次 | 状态码 + 响应不含归属数据,双层断言 | 200 空数据误判为漏、或 403 泄数据漏判 |
| 可回归 | 越权网挂进 CI,每次回归自动跑 | 改接口后漏洞复发无人知 |
| 测试账号 | 至少两个各持真实资源的账号做交叉 | 越权请求无从构造,用例空跑 |
这六项没有一项需要额外预算,只是把散落的权限规则收成一张矩阵,再让 pytest 按矩阵把越权用例自动展开、挂进回归。反过来说,一份只验「登录态有效」、连一条水平越权用例都没有的接口测试报告,它的「全绿」不该被当作权限安全的上线依据。
越权测试最贵的绿灯,是每个接口都验过「登录态有效」,却没人问过:这条数据,凭什么给你看。
你手上的接口测试,有没有一条是专门拿 A 的 token 去打 B 的资源、断言它被拒的?评论区聊聊。