단 20MB HTTP 패킷으로 Django 서버를 1분간 먹통 만드는 취약점이 공개되었습니다 (CVE-2026-33033)
(new-blog.ch4n3.kr)전체 한 줄 요약
Django의 MultiPartParser에서 Content-Transfer-Encoding: base64 파트 본문이 공백 위주일 때 발생하는 Pre-Auth CPU exhaustion 취약점으로, 약 2.5MB 요청 하나로 정상 대비 2,100배 이상의 처리 시간을 유발함 (CVE-2026-33033)
요약
- 인증 없이, 기본 설정 서버에서도 트리거 가능
- CSRF 미들웨어가 view 진입 전 request.POST에 접근하면서 MultiPartParser를 자동 실행하기 때문에, 인증된 엔드포인트라도 CSRF 검증 단계에서 이미 수초가 소요됨
- 20MB 요청 하나로 단일 worker를 약 1분간 점유
- 4~16개 worker로 운영하는 일반적인 gunicorn 설정이라면 동시 수십 개 요청만으로 서버가 사실상 마비됨
- Django는
multipart/form-data요청을MultiPartParser가 처리하며, CSRF 미들웨어가 view 진입 전에request.POST에 접근하기 때문에 인증 없이도 이 파서가 항상 실행됨 - 취약점의 핵심은 세 레이어가 곱해지는 구조임
- (Layer 1) base64 정렬 while-loop: 청크를 공백 제거하면
remaining != 0상태가 유지되어field_stream.read(1)이 이후 스트림 전체에 대해 반복 호출됨 - (Layer 2)
LazyStream.read(1)의 숨겨진 O(C) 비용:read(1)한 번 호출 시 내부적으로 ~64KB 버퍼를 통째로 꺼냈다가 65,535바이트를unget()으로 도로 밀어넣는 패턴이 반복됨 - (Layer 3)
unget()의 O(C) bytes concatenation:bytes + self._leftover라는 새 객체 생성이 매번 발생
- (Layer 1) base64 정렬 while-loop: 청크를 공백 제거하면
- 2.5MB 요청 하나가 내부적으로 약 86GB 메모리 복사를 유발하며, M2 기준 약 5.3초간 worker 하나를 완전히 점유함. 20MB에서는 약 1분 소요
unget()내부에 이미 sanity check 코드(_update_unget_history)가 존재했지만, 이번 공격은unget()사이즈가 매 호출마다 1씩 감소하는 단조감소 패턴이라 탐지 조건(number_equal > 40)을 절대 충족하지 않음- Django 팀의 패치 핵심은
read(4 - remaining)→read(self._chunk_size)로, 1~3바이트씩 읽는 대신 한 번에 64KB씩 읽도록 변경. 이로써 read 호출이 250만 번 → 약 40번으로 감소 - Nginx
client_max_body_size기본값은 1MB이지만 파일 업로드 엔드포인트에서는 완화되는 경우가 흔하고, Apache httpd의LimitRequestBody기본값은 1GB이므로 프록시만으로는 방어가 보장되지 않음 - Claude Code + Codex를 활용해 발견한 취약점으로, 약 20년 가까이 다듬어진 프레임워크에서 Pre-Auth DoS가 남아있었다는 점이 인상적