한줄 요약
인증 불필요, 기본 설정 그대로, 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 기준 통상적인 4~16개 워커 환경에서, 동시 요청 4~16개만으로 서버 전체를 사실상 마비시킬 수 있다는 의미다.
핵심 구현 방법
취약점의 구조: 세 레이어가 곱해진다
이 취약점이 흥미로운 이유는 단일 버그가 아니라는 점이다. 세 개의 레이어가 순서대로 맞물려야 문제가 터진다.
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 엔드포인트가 하나라도 존재하면 취약 경로는 열려 있다.