查询 25 次,耗时 0.2340 秒

从一次订单越权读取入手:在本地复现 Web/API 对象级授权缺失,并建立可验证的修复回归

132222222222222qaz

New member

132222222222222qaz Rep
0
0
0
Rep
0
132222222222222qaz Vouches
Vouches
0
1 MONTH
1 1 MONTH OF SERVICE
132222222222222qaz 获得
0
0
0
获得
0
132222222222222qaz 凭证
0
0
0
凭证
0
主题
11
点赞
0
HakB
1 月
在社区成长 1 月
LEVEL 1 1 XP
 
## 一、背景:登录成功,不代表可以读取任意订单

APP 的订单页、售后详情页和 Web 管理界面,经常调用这类接口:GET /api/orders/1001。开发者通常会检查请求有没有登录,却遗漏另一个独立问题:当前用户是否有权读取订单 1001?

这就是对象级授权缺失,常被称为 BOLA;当攻击入口表现为替换对象标识符时,也经常被归入 IDOR。它不是“数字 ID 不够随机”这么简单,而是服务端没有把请求主体、目标对象与允许执行的操作关联起来。

本文只在本地授权实验中完成复现、修复与回归。我们构造两个测试账户和两笔虚构订单,验证普通用户能否读取其他账户的订单。不访问真实站点,不枚举真实用户,也不涉及绕过第三方身份认证。

## 二、原理:身份认证与对象授权必须分别成立

一个订单读取接口至少需要回答三个问题:

1. 你是谁:身份认证,例如验证会话或访问令牌。
2. 你要访问什么:从路径参数定位订单对象。
3. 你是否可以读取它:检查订单归属、租户范围,以及适用的角色或委托关系。

典型缺陷是只完成前两步:令牌有效,订单存在,于是返回订单。前端隐藏按钮、禁止编辑订单号、使用不可预测的 UUID,都不能替代第三步。

本文采用最小策略:普通用户只能读取自己的订单。对于其他人的订单,接口统一返回 404,避免直接暴露对象是否存在。需要注意,统一状态码只能减少部分存在性信息,不能单独保证响应时序、响应长度和缓存行为没有差异。

## 三、前置条件与实验边界

准备 Python 3.10 或更新版本,并在独立目录中操作。服务只监听 127.0.0.1,不映射公网端口,不使用生产令牌、客户数据或真实订单。

以下令牌只是本地测试夹具:lab-alice 对应用户 u1,lab-bob 对应用户 u2。它们不是生产认证方案。真实系统应使用经过验证的会话或令牌机制,并校验有效期、签名及相关安全属性。

安装依赖:

python -m venv .venv
source .venv/bin/activate
python -m pip install Flask pytest

Windows 环境可使用 .venv\Scripts\activate 激活虚拟环境。建议在完成实验后记录依赖版本,以便后续复核。

## 四、分步骤实践:构造一个可切换漏洞模式的本地接口

### 步骤 1:创建 app.py

下面使用内存字典模拟订单表,目的是把授权逻辑与数据库配置分离。示例不代表实际业务系统的完整实现。

import os
from flask import Flask, request, jsonify

app = Flask(__name__)
app.config['LAB_VULN'] = os.getenv('LAB_VULN') == '1'

TOKENS = {
'lab-alice': 'u1',
'lab-bob': 'u2',
}
ORDERS = {
1001: {'id': 1001, 'owner_id': 'u1', 'item': '测试商品A'},
1002: {'id': 1002, 'owner_id': 'u2', 'item': '测试商品B'},
}

@app.get('/api/orders/<int:eek:rder_id>')
def get_order(order_id):
auth = request.headers.get('Authorization', '')
scheme, _, token = auth.partition(' ')
user_id = TOKENS.get(token) if scheme.lower() == 'bearer' else None
if user_id is None:
return jsonify(error='unauthorized'), 401

order = ORDERS.get(order_id)
if order is None:
return jsonify(error='not_found'), 404

if not app.config['LAB_VULN'] and order['owner_id'] != user_id:
return jsonify(error='not_found'), 404

return jsonify(id=order['id'], item=order['item'])

@app.after_request
def prevent_storage(response):
response.headers['Cache-Control'] = 'no-store'
return response

if __name__ == '__main__':
app.run(host='127.0.0.1', port=5000, debug=False)

注意:只有显式设置 LAB_VULN=1 才会启用漏洞模式,默认运行的是带归属校验的版本。即使默认修复,也不要把这个演示应用用于生产。

### 步骤 2:在本地显式开启漏洞模式

LAB_VULN=1 python app.py

在另一个终端读取 Alice 自己的订单:

curl -i -H 'Authorization: Bearer lab-alice' http://127.0.0.1:5000/api/orders/1001

预期返回 200,正文包含测试商品A。这一步先建立正常业务基线,避免把认证失败或路由错误误判为授权有效。

然后保持 Alice 的令牌不变,仅把订单号改成预先准备的 Bob 测试订单:

curl -i -H 'Authorization: Bearer lab-alice' http://127.0.0.1:5000/api/orders/1002

漏洞模式下,接口返回 200,并包含测试商品B。这构成一个明确的本地越权证据:主体仍是 u1,目标对象属于 u2,读取结果却成功。

这里不需要扫描编号范围。两个事先知道归属的测试对象,已经足以证明当前接口的授权缺陷。扩大枚举范围不会增强根因判断,反而会增加不必要的数据接触。

### 步骤 3:启用修复并重复相同请求

停止服务,再以默认配置启动:

python app.py

注意确认当前终端没有残留 LAB_VULN=1 环境变量;必要时先执行 unset LAB_VULN。

再次使用 Alice 令牌读取订单 1002,预期为 404,且响应中不能包含测试商品B。读取自己的订单 1001 仍应返回 200。

再使用 Bob 的令牌读取订单 1002:

curl -i -H 'Authorization: Bearer lab-bob' http://127.0.0.1:5000/api/orders/1002

预期为 200。这一步用于排除“把接口整体关闭”造成的假修复。

## 五、修复落点:尽量让查询天然处于授权范围内

示例先取对象再比较归属,便于理解。真实数据库场景中,通常更适合把归属约束直接加入查询:

SELECT id, item FROM orders WHERE id = :eek:rder_id AND owner_id = :current_user_id;

参数必须通过数据库驱动绑定,不能拼接 SQL。current_user_id 必须来自服务端已验证的身份上下文,不能直接相信请求体或查询参数里的 user_id。

多租户系统通常还需要 tenant_id 条件。管理员代查、客服授权、共享订单等特殊需求,应定义明确策略,而不是写一个笼统的“角色存在就放行”。授权策略的例外需要单独测试,并保留适当的审计记录。

写操作还应把权限条件带入实际 UPDATE 或 DELETE,并检查受影响行数,避免检查与执行之间发生状态变化。订单读取接口修复,也不意味着退款、导出、附件下载或批量接口自动安全。

## 六、自动化回归:把业务权限矩阵变成断言

创建 test_authz.py:

import pytest
from app import app

@pytest.fixture
def client():
app.config.update(TESTING=True, LAB_VULN=False)
with app.test_client() as c:
yield c

@pytest.mark.parametrize('token,oid,status', [
('lab-alice', 1001, 200),
('lab-alice', 1002, 404),
('lab-bob', 1001, 404),
('lab-bob', 1002, 200),
('lab-alice', 9999, 404),
('invalid-token', 1001, 401),
(None, 1001, 401),
])
def test_order_authorization(client, token, oid, status):
headers = {} if token is None else {'Authorization': 'Bearer ' + token}
response = client.get('/api/orders/' + str(oid), headers=headers)
assert response.status_code == status
assert response.headers['Cache-Control'] == 'no-store'
if status == 200:
assert response.get_json()['id'] == oid
assert set(response.get_json()) == {'id', 'item'}
else:
assert set(response.get_json()) == {'error'}

运行:

python -m pytest -q

这些断言不只检查状态码,也检查响应字段,防止出现“返回 404,但正文仍带订单信息”的问题。作为回归测试有效性的额外验证,可临时在测试夹具中将 LAB_VULN 改成 True:两个跨用户访问用例应失败。验证后必须改回 False;不要把漏洞模式作为部署配置。

## 七、检测方法与证据记录

授权测试应以对象归属矩阵为基础,而不是先做大规模编号探测。最小矩阵包括:未登录、无效令牌、用户读取自己的对象、用户读取他人的对象,以及不存在对象。

建议为每条接口记录:业务动作、测试主体、对象归属、预期状态、实际状态、响应字段及修复版本。证据中使用虚构订单,令牌做脱敏处理,不把 Authorization 原文写入工单或共享日志。

在 APP 场景中,客户端通常只是 API 的调用方。应检查服务端是否独立执行授权,而不是仅验证 APP 是否隐藏入口。只有在明确授权的测试设备和测试环境中,才采集客户端请求;不需要为了验证本例去接触第三方应用流量。

## 八、风险与注意事项

第一,本文故意提供了漏洞开关,仅限回环地址实验。不要绑定 0.0.0.0,不要开启调试器,也不要将漏洞模式放进共享测试环境。

第二,GET 请求并非天然无风险。真实系统的读取接口可能触发访问记录、费用、通知或状态变化。正式测试前仍需确认业务副作用、授权范围与停止条件。

第三,缓存可能绕过后续的授权逻辑。示例使用 no-store 限制存储;生产系统还要核对 CDN、反向代理和应用缓存配置,不能假设一个响应头能覆盖所有错误缓存规则。

第四,本地实验只能证明这份演示代码的行为。不能据此断言某个真实产品存在同类漏洞,也不能因为一个接口通过测试,就宣称整套系统不存在越权。

## 九、常见问题

问:把自增 ID 改成 UUID 是否足够?
答:不够。随机标识符可以降低猜测概率,但一旦标识符通过合法链接、日志或其他接口被获知,缺失的对象授权仍然存在。

问:为什么越权返回 404,而不是 403?
答:本文选择减少对象存在性暴露。403 也可以是合理设计,关键是统一产品语义,并确保响应不泄露对象内容;不能仅凭状态码判断修复完成。

问:前端已经传了 owner_id,能直接比较吗?
答:不能把客户端传入的 owner_id 当作可信归属。对象归属应来自服务端数据,当前用户应来自已验证的身份上下文。

问:列表页只能看到自己的订单,详情页还需要检查吗?
答:需要。列表过滤不能约束用户直接请求详情接口。列表、详情、搜索、导出、批量操作都需要各自正确地执行授权。

## 十、总结

对象级授权测试的核心不是“能否改参数”,而是验证主体、对象与动作之间的业务约束是否由服务端强制执行。通过两个测试账户、两笔已知归属的订单和一组固定断言,就能得到可复核、低干扰的结论。

可靠修复应同时覆盖可信身份来源、查询范围约束、最小化响应、缓存策略与自动化回归。最终交付应明确写出测试覆盖范围和未验证项:结论越具体,风险边界才越清楚。
 
顶部