Django CVE-2026-33033 분석: 20MB 요청 하나로 서버를 1분간 마비시키는 DoS 취약점 (new-blog.ch4n3.kr)
목차(7)
한줄 요약
인증 불필요, 기본 설정 그대로, 20MB 요청 하나로 Django 워커를 60초간 마비시킬 수 있다.
어떤 상황에서 필요한가?
CVE-2026-33033은 Django의 MultiPartParser에서 발견된 사전 인증(Pre-Auth) DoS 취약점이다. 파일 업로드 기능을 제공하는 서비스라면 물론이고, 그렇지 않은 서비스라도 Django 기본 설정을 그대로 쓰고 있다면 이 취약점에 노출된다.
왜 그럴까. Django의 CSRF 미들웨어는 process_view() 단계에서 request.POST.get("csrfmiddlewaretoken", "")를 호출한다. 이 호출이 MultiPartParser를 트리거하기 때문에, view 로직에 도달하기도 전에 — 심지어 CSRF 검증이 실패해서 403을 반환하기 직전에도 — multipart 본문 파싱이 이미 완료된 상태가 된다. 즉 공격자는 인증도, 유효한 CSRF 토큰도 필요 없다.
gunicorn 기준 통상적인 416개 워커 환경에서, 동시 요청 416개만으로 서버 전체를 사실상 마비시킬 수 있다는 의미다.
핵심 구현 방법
취약점의 구조: 세 레이어가 곱해진다
이 취약점이 흥미로운 이유는 단일 버그가 아니라는 점이다. 세 개의 레이어가 순서대로 맞물려야 문제가 터진다.
Layer 1 — base64 정렬 루프
multipart/form-data 요청에서 파트 헤더에 Content-Transfer-Encoding: base64가 지정되면, Django는 해당 파트 본문을 base64로 디코드한다. base64는 4바이트 단위로 처리해야 하므로, 공백을 제거한 청크 길이가 4의 배수가 아닐 때 부족한 바이트를 다음 스트림에서 read(4 - remaining)으로 가져오는 정렬 루프가 동작한다.
여기까지는 합리적인 설계다. 문제는 청크 내 유효 데이터가 AAA처럼 3바이트(공백 제거 후)로 끝나고, 이후 스트림이 공백 문자로 길게 채워진 경우다. 루프가 read(1)을 호출할 때마다 읽어온 바이트는 공백이므로 split() 후 빈 바이트가 된다. stripped_chunk는 여전히 b"AAA"이고, remaining은 여전히 3이다. 이후 스트림의 공백 구간 전체가 바이트 단위로 소모된다.
Layer 2 — LazyStream.read(1)의 숨겨진 비용
read(1)은 겉보기엔 1바이트 읽기지만, 내부적으로는 next(self)를 호출해 _leftover 버퍼 전체(~64KB)를 꺼낸다. 그중 1바이트만 반환하고 나머지 ~65,535바이트는 unget()으로 다시 밀어넣는다. 1바이트를 읽기 위해 64KB 규모의 버퍼 복사가 발생하는 구조다.
Layer 3 — unget()의 O(C) 비용
unget()은 bytes + self._leftover로 새 bytes 객체를 생성한다. 이 연산 자체가 매 호출마다 ~65,535바이트짜리 복사를 유발한다.
세 레이어를 합산하면
64KB 청크 하나에 대해 read(1)이 반복되면 unget 크기는 65535, 65534, 65533, ... 순으로 단조감소한다. 이를 등차수열 합으로 계산하면 청크 하나당 약 21억 바이트 연산이 발생한다. 2.5MB 입력이 약 40개 청크로 분할되면 총 86GB 수준의 메모리 복사가 단일 HTTP 요청 하나에서 일어난다.
기존 방어 코드가 왜 통과되었나
unget() 내부에는 이미 sanity check가 존재했다. 같은 크기의 unget이 50회 중 40회 이상 반복되면 SuspiciousMultipartForm 예외를 발생시키는 로직이다. 이 코드는 명백히 "parser가 stuck된 경우를 잡으려는" 의도로 작성됐다.
그러나 이번 공격 경로에서 unget 크기는 매 호출마다 1씩 줄어드는 단조감소 수열이다. 값이 매번 다르니 "같은 크기가 반복"되는 조건이 절대 충족되지 않는다. 방어 코드가 없었던 게 아니라, 방어 코드가 상정한 패턴을 정확히 비껴가는 입력이 존재했던 셈이다.
측정 결과
원문에 공개된 Apple Silicon M2 기준 벤치마크에 따르면, 동일한 2.5MB 페이로드에서 공격 경로는 약 5,323ms, 정상 처리(CTE 헤더 없음)는 약 2.49ms가 소요됐다. 약 2,138배의 처리 시간 증폭이다. 20MB에서는 단일 요청이 약 60초를 점유한다.
65KB → 128KB 구간에서 처리 시간이 200배 이상 급등하는 구간이 존재하는데, 이는 65KB 이하에서는 유효 데이터와 공백이 같은 청크 안에 들어가 remaining = 0으로 루프가 조기 종료되기 때문이다. 128KB부터 비로소 취약 경로가 활성화된다.
실전에서 주의할 점
"리버스 프록시가 막아주겠지"는 통하지 않는다. Nginx의 client_max_body_size 기본값은 1MB지만, 파일 업로드 엔드포인트에서는 이 값을 키우는 것이 일반적이다. Apache httpd의 LimitRequestBody는 과거 버전에서 기본값이 unlimited였다. 파일 업로드를 정상적으로 처리해야 하는 서비스라면 프록시 레벨 제한만으로는 이 취약점을 막을 수 없다.
Django 자체를 패치하는 것이 정답이다. Django 팀은 while 루프를 제거하고 스트림에서 한 번에 충분한 데이터를 읽어 4의 배수 정렬을 처리하는 방식으로 패치를 적용했다. 현재 운영 중인 Django 버전이 CVE-2026-33033 픽스를 포함한 버전인지 반드시 확인하고 업데이트해야 한다.
파일 업로드를 제공하지 않아도 무관하지 않다. CSRF 미들웨어를 비활성화하지 않는 한, POST 엔드포인트가 하나라도 존재하면 취약 경로는 열려 있다.
자주 묻는 질문
Q.Content-Transfer-Encoding: base64를 직접 쓰는 클라이언트가 없으면 안전한가?
안전하지 않다. 공격자가 직접 조작한 HTTP 요청을 보내는 것이므로 일반 클라이언트의 동작과 무관하다. multipart 요청 헤더에 Content-Transfer-Encoding: base64를 수동으로 지정하고 본문을 공백으로 채우는 것만으로 공격이 성립한다. 인증도 필요 없고 특별한 권한도 불필요하다.
Q.CSRF 미들웨어를 비활성화하면 이 취약점을 피할 수 있나?
CSRF 미들웨어를 꺼도 request.POST나 request.FILES에 접근하는 코드가 view 안에 있다면 동일하게 트리거된다. CSRF 미들웨어는 "인증 전 단계에서도 트리거된다"는 사실을 설명하기 위한 맥락일 뿐이다. 근본적인 해결은 Django 업데이트뿐이다.
Q.Django 최신 버전으로 올리기 어려운 레거시 환경에서는 어떻게 대응해야 하나?
단기 완화책으로는 리버스 프록시에서 multipart 요청의 body 크기를 업무상 허용 가능한 최솟값으로 제한하고, 모니터링에서 단일 요청의 처리 시간이 비정상적으로 길어지는 패턴을 감지하는 것이 현실적이다. 다만 이는 공격 난이도를 높이는 것이지 취약점 자체를 제거하지는 못한다. 가능한 한 빠른 패치 적용을 권장한다.