6P by nuremberg 13시간전 | ★ favorite | 댓글과 토론

전체 한 줄 요약

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 라는 새 객체 생성이 매번 발생
  • 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가 남아있었다는 점이 인상적