Pre-Auth Denial-of-Service in Django < 6.0.4, 5.2.13, and 4.2.30

Django의 `MultiPartParser`에서, `Content-Transfer-Encoding: base64` 파일 파트의 본문이 공백 위주로 구성되어 있을 때 발생하는 단일 요청 기반 CPU exhaustion 취약점을 다룹니다. 약 2.5MB 크기의 예시 요청 한 건으로 워커 하나가 약 5.3초간 묶입니다. 정상 요청 대비 약 2,100배 증폭됩니다.

Pre-Auth Denial-of-Service in Django < 6.0.4, 5.2.13, and 4.2.30
Spring of Korea

안녕하세요. 윤석찬입니다. 오랜만에 1-day 리뷰 글로 다시 인사드립니다.

지난 글 $5짜리 프롬프트로 $2,418짜리 취약점 찾은 썰 에서 LLM으로 Django 취약점을 찾는 경험을 공유드렸었는데요, 그 이후에도 같은 흐름의 audit 작업을 이어가다가 또 한 건의 취약점을 발견하게 되었고 이번 건에는 CVE-2026-33033이 할당되었습니다. 이번 글에서는 어떤 코드 패턴에서 어떻게 트리거되는지, 그리고 Django 팀에서 적용한 패치를 어떻게 분석했는지를 정리해보고자 합니다.

💡
요약
Django의 django.http.multipartparser.MultiPartParser에서 Content-Transfer-Encoding: base64로 지정된 파일 파트를 처리할 때, 본문이 거의 전부 공백 문자(스페이스, 탭, 개행 등)로 구성되어 있으면 base64 정렬 루프가 이후 스트림 바이트에 대해 LazyStream.read(1) 을 반복 호출하게 됩니다. 이때 LazyStream.read(1) 호출 한 번이 내부적으로 ~64KB 규모의 버퍼 복사를 유발하면서, 약 2.5MB 크기의 요청 하나로도 Apple Silicon M2 칩 기준 약 5.3초의 CPU 시간을 소모하게 만들 수 있습니다. 인증은 필요 없고, 기존 보호 장치도 우회 가능한 Pre-Auth 취약점입니다.

1/ Django는 HTTP 요청을 어떻게 request 객체로 바꾸나

Django에서 들어오는 HTTP 요청은 먼저 서버 인터페이스에 맞는 request 객체로 감싸집니다. WSGI 환경에서는 WSGIRequest, ASGI 환경에서는 ASGIRequest가 만들어지고, 이 객체들이 path, method, headers, body stream을 정규화해서 HttpRequest 인터페이스로 노출합니다.

WSGI 쪽에서는 environ에서 정보를 읽어 request를 만듭니다.

# django/core/handlers/wsgi.py:56-79
class WSGIRequest(HttpRequest):
    def __init__(self, environ):
        ...
        self.META = environ
        self.method = environ["REQUEST_METHOD"].upper()
        self._set_content_type_params(environ)
        content_length = int(environ.get("CONTENT_LENGTH"))
        self._stream = LimitedStream(self.environ["wsgi.input"], content_length)

ASGI 쪽에서는 scope와 이미 준비된 body_file을 이용해 같은 역할을 수행합니다.

# django/core/handlers/asgi.py:49-108
class ASGIRequest(HttpRequest):
    def __init__(self, scope, body_file):
        self.scope = scope
        self.path = scope["path"]
        self.method = self.scope["method"].upper()
        self.META = {
            "REQUEST_METHOD": self.method,
            "QUERY_STRING": query_string,
            ...
        }
        self._set_content_type_params(self.META)
        self._stream = body_file

여기서 중요한 점은, request.GETrequest.POST 가 request 생성 시점에 곧바로 완성되는 구조가 아니라는 점입니다. Django는 이 값들을 lazy하게 materialize 합니다.

request.GET 은 query string을 QueryDict 로 파싱한 결과입니다.

# django/core/handlers/wsgi.py:85-89
@cached_property
def GET(self):
    raw_query_string = get_bytes_from_wsgi(self.environ, "QUERY_STRING", "")
    return QueryDict(raw_query_string, encoding=self._encoding)
# django/core/handlers/asgi.py:112-114
@cached_property
def GET(self):
    return QueryDict(self.META["QUERY_STRING"])

그리고 QueryDict 는 단순 dict가 아니라, 같은 키가 여러 번 나올 수 있는 HTTP 파라미터를 표현하기 위한 특수한 자료구조입니다.

# django/http/request.py:558-566
class QueryDict(MultiValueDict):
    """
    A QueryDict can be used to represent GET or POST data.
    It subclasses MultiValueDict since keys in such data can be repeated.
    """

request.POST 는 더 중요합니다. 이름만 보면 "POST body 전체" 같지만, 실제로는 폼 계열 content type을 파싱한 결과만 들어갑니다. 그리고 첫 접근 시점에 _load_post_and_files() 가 실행됩니다.

# django/http/request.py:427-448
def _load_post_and_files(self):
    if self.method != "POST":
        self._post, self._files = (QueryDict(...), MultiValueDict())
        return

    if self.content_type == "multipart/form-data":
        self._post, self._files = self.parse_file_upload(self.META, data)
    elif self.content_type == "application/x-www-form-urlencoded":
        self._post = QueryDict(self.body, encoding="utf-8")
        self._files = MultiValueDict()
    else:
        self._post, self._files = (QueryDict(...), MultiValueDict())

즉 정리하면:

  1. URL의 ?a=1&b=2 같은 query string은 request.GET 으로 들어갑니다.
  2. application/x-www-form-urlencoded 본문은 request.POST 로 파싱됩니다.
  3. multipart/form-data 본문은 MultiPartParser 를 거쳐 request.POSTrequest.FILES 로 나뉩니다.
  4. JSON 같은 다른 content type은 자동으로 request.POST 에 들어가지 않습니다.

이번 취약점은 바로 이 3번 경로, 즉 multipart/form-data 본문을 MultiPartParser 가 처리하는 구간에 있습니다.

2/ Django의 multipart 파일 업로드 처리

Django에서 multipart/form-data 요청이 들어오면 request.POSTrequest.FILES에 처음 접근하는 시점에 MultiPartParser가 동작합니다. 그리고 이 시점이 사실 view 진입 전입니다. CSRF 미들웨어가 process_view() 단계에서 이미 request.POST.get("csrfmiddlewaretoken", "") 를 호출하기 때문이죠.

# django/middleware/csrf.py:366-368
if request.method == "POST":
    try:
        request_csrf_token = request.POST.get("csrfmiddlewaretoken", "")

다시 말해, view에서 직접 파일 업로드를 처리하지 않더라도 (그리고 어떤 경우에는 CSRF 검증에 실패해서 403을 응답으로 반환하더라도) multipart 본문은 이미 파싱된 상태입니다. 이게 이번 finding의 공격 표면이 꽤 넓은 이유입니다.

3/ 본격적으로 들어가기 전에: 스트림 파이프라인

Django의 multipart 파서는 사실 단순한 한 줄짜리 코드가 아니라 여러 레이어가 얹힌 스트림 파이프라인입니다. 대략 이런 모양입니다.

HTTP body
    → ChunkIter (64KB 단위로 잘라서 yield)
    → LazyStream (외부 스트림, unget 버퍼 보유)
    → BoundaryIter (MIME boundary 기준으로 분리)
    → LazyStream (per-part field_stream, 자체 unget 버퍼 보유)

여기서 청크 사이즈는 활성 upload handler들의 chunk_size 중 최소값으로 정해집니다. 기본 handler들만 쓰는 일반적인 설정에서는 FileUploadHandler.chunk_size 기본값인 64KB가 그대로 사용됩니다.

# django/core/files/uploadhandler.py:75
class FileUploadHandler:
    chunk_size = 64 * 2**10  # The default chunk size is 64 KB.

그리고 이 64KB라는 숫자가 이번 취약점의 성격을 사실상 결정합니다. 이 값은 공격자가 제어할 수 없습니다. 그래서 엄밀히 말하면 이번 취약점은 O(N²)이 아니라 O(N × C) 이며, C는 고정 상수이지만 그 값이 64KB라서 비정상적으로 큰 증폭이 발생하는 케이스입니다.

4/ 취약한 코드 — 3개의 레이어

이번 취약점의 본질은 한 군데에 있는 게 아니라 세 군데가 맞물려서 발생합니다. 이게 좀 흥미로운 부분이라고 생각하는데요. 한 레이어만 봐서는 "어 이거 별거 아닌데?" 싶지만 세 레이어가 곱해지면 2.5MB의 POST body로도 내부적으로는 86GB 정도의 메모리 복사가 발생합니다.

Layer 1 — base64 정렬 while-loop

먼저 트리거가 되는 코드는 MultiPartParser._parse() 안에 있습니다. base64 transfer encoding을 처리하는 예외 처리 분기인데요.

# django/http/multipartparser.py:302-325
for chunk in field_stream:
    if transfer_encoding == "base64":
        # We only special-case base64 transfer encoding
        # We should always decode base64 chunks by
        # multiple of 4, ignoring whitespace.

        stripped_chunk = b"".join(chunk.split())

        remaining = len(stripped_chunk) % 4
        while remaining != 0:                                # ← 문제의 루프
            over_chunk = field_stream.read(4 - remaining)    # ← read(1) 케이스
            if not over_chunk:
                break
            stripped_chunk += b"".join(over_chunk.split())
            remaining = len(stripped_chunk) % 4

코드의 의도 자체는 합리적입니다. base64는 4바이트 단위로 디코드되어야 하니까, 각 청크에서 공백을 제거한 뒤 길이가 4의 배수가 아니면 다음 1~3바이트를 더 가져와서 정렬을 맞춰주려는 것이죠. 정상적인 base64 데이터에서는 이 루프는 0~3번 정도만 돌고 끝납니다.

그런데 실제로 문제가 되는 건 "현재 청크를 공백 제거한 결과가 AAA처럼 3바이트로 끝나고, 그 뒤 스트림이 공백으로 길게 이어지는" 경우입니다. 예를 들어 전체 페이로드가 이런 형태라면:

b"AAA" + b" " * (128 * 1024 - 4) + b"A"
  1. 첫 번째 for chunk in field_stream 반복에서 첫 64KB 청크가 처리됩니다.
  2. 이 청크는 공백을 제거하면 b"AAA" 만 남으므로 remaining = 3 입니다.
  3. while-loop가 field_stream.read(1) 을 호출합니다.
  4. 이 호출은 이미 반환된 첫 청크를 다시 읽는 것이 아니라, 그 다음 스트림 바이트를 읽습니다.
  5. 이후 스트림이 공백으로 길게 이어져 있으면, 매번 읽어 온 1바이트는 split()b"" 가 됩니다.
  6. 따라서 stripped_chunk 는 계속 b"AAA" 이고, remaining 도 계속 3인 채 유지됩니다.
  7. 결과적으로 다음 청크들에 있는 공백 구간이 바이트 단위로 소모됩니다.

즉, 한 번 remaining = 3 상태에 들어가면 이후 스트림에 이어지는 공백 대부분에 대해 read(1) 이 한 번씩 호출됩니다. 그래서 128KB 이상 페이로드부터는 두 번째 청크 이후 구간이 사실상 바이트 단위로 처리됩니다.

여기까지만 봐도 "음 이게 좀 비효율적이긴 한데, read(1) 자체는 빠르지 않나?" 싶을 수 있습니다. 저도 처음엔 그렇게 생각했어요. 그런데 LazyStream.read(1)이 사실은 굉장히 비싼 호출입니다. 두 번째 레이어를 봐야 합니다.

Layer 2 — LazyStream.read(1) 의 숨겨진 O(C) 비용

# django/http/multipartparser.py:444-468
def read(self, size=None):
    def parts():
        remaining = self._remaining if size is None else size
        ...
        while remaining != 0:
            try:
                chunk = next(self)              # ← _leftover 전체 (~64KB) 를 통째로 반환
            except StopIteration:
                return
            else:
                emitting = chunk[:remaining]    # ← 1바이트만 슬라이스
                self.unget(chunk[remaining:])   # ← 나머지 ~65,535바이트를 다시 push back
                remaining -= len(emitting)
                yield emitting

    return b"".join(parts())

여기서 next(self)LazyStream.__next__() 인데, 이 공격 경로에서는 보통 내부 leftover 버퍼 전체를 한 번에 반환합니다.

# django/http/multipartparser.py:470-484
def __next__(self):
    if self._leftover:
        output = self._leftover       # ← 버퍼 전체 (~64KB) 반환
        self._leftover = b""
    else:
        output = next(self._producer)  # ← 다음 청크 fetch
        self._unget_history = []
    self.position += len(output)
    return output

즉 이 공격 경로에서 read(1) 을 호출하면 대체로:

  1. next(self) 가 ~64KB 규모의 _leftover 를 통째로 반환합니다
  2. chunk[:1] 로 1바이트만 떼어냅니다 (O(1))
  3. chunk[1:] 즉 ~65,535바이트를 unget으로 다시 밀어넣습니다 ← 여기가 취약한 코드패턴

read(1) 한 번마다 대략 65,535바이트짜리 슬라이스가 새로 만들어지고, 그 대부분이 다시 unget을 통해 leftover로 돌아갑니다.

Layer 3 — unget() 의 O(C) 바이트 concatenation

# django/http/multipartparser.py:498-509
def unget(self, bytes):
    if not bytes:
        return
    self._update_unget_history(len(bytes))
    self.position -= len(bytes)
    self._leftover = bytes + self._leftover   # ← O(len(bytes)) memcpy

bytes + self._leftover 는 새 bytes 객체를 만드는 concatenation입니다. 이 경로에서는 직전에 __next___leftover 를 비워두는 경우가 많아서 concatenation 자체가 항상 지배적인 비용이라고 단정할 수는 없지만, chunk[1:] 슬라이스만으로도 이미 ~65,535바이트짜리 복사가 발생합니다. 핵심은 작은 read(1) 이 큰 버퍼 복사로 바뀐다는 점입니다.

세 레이어 곱하기

이제 이걸 64KB 청크 한 개에 대해서 더해보면:

read(1) #1     → leftover에서 65,535 바이트 unget
read(1) #2     → leftover에서 65,534 바이트 unget
read(1) #3     → leftover에서 65,533 바이트 unget
...
read(1) #65535 → leftover에서 0 바이트 unget (스킵)

총 복사된 바이트 수는 등차수열의 합은 아래와 같습니다:

(C - 1) + (C - 2) + ... + 1 + 0 = C(C - 1) / 2 ≈ C² / 2

C = 65,536일 때 약 2.15 × 10⁹ byte 연산이 64KB 청크 하나당 발생합니다. 그리고 2.5MB 입력은 약 40개의 청크로 나뉘므로 총 약 86 × 10⁹ byte 연산, 즉 86GB 메모리 복사에 해당하는 일이 단일 HTTP 요청 하나에서 벌어집니다.

5/ unget() 안에는 이미 방어 코드가 있었습니다

앞 절의 unget() 스니펫을 보면 이런 줄이 하나 들어가 있었습니다.

self._update_unget_history(len(bytes))

즉 Django도 "unget()이 비정상적으로 반복되면 parser가 어딘가에 stuck된 것 아닐까?"라는 문제를 이미 의식하고 있었던 겁니다. 그래서 자연스럽게 다음 질문이 생깁니다.

그렇다면 왜 이번 공격은 그 방어 코드를 그냥 통과했을까?

호출 관계를 같이 보면 더 이해가 쉽습니다.

# django/http/multipartparser.py:501-533
def unget(self, bytes):
    if not bytes:
        return
    self._update_unget_history(len(bytes))
    self.position -= len(bytes)
    self._leftover = bytes + self._leftover

def _update_unget_history(self, num_bytes):
    """
    Update the unget history as a sanity check to see if we've pushed
    back the same number of bytes in one chunk. If we keep ungetting the
    same number of bytes many times (here, 50), we're mostly likely in an
    infinite loop of some sort.
    """
    self._unget_history = [num_bytes] + self._unget_history[:49]
    number_equal = len(
        [
            current_number
            for current_number in self._unget_history
            if current_number == num_bytes
        ]
    )

    if number_equal > 40:
        raise SuspiciousMultipartForm(...)

설계 의도는 명확합니다. 같은 크기의 unget() 이 계속 반복되면 비정상 상태로 보고 예외를 던지겠다는 것이죠.

코드만 보면 "어, 그럼 이 체크 때문에 우리 공격은 막히는 거 아닌가?" 싶을 수 있는데요. 핵심은 이 체크가 정확히 같은 byte 수가 반복되는 경우만을 잡는다는 점입니다. 이번 공격에서는 unget() 사이즈가 매번 다릅니다.

read(1) #1 → unget(65535)   ← unique
read(1) #2 → unget(65534)   ← unique
read(1) #3 → unget(65533)   ← unique
...

매 호출마다 1씩 줄어드는 단조감소 수열이라서, number_equal 은 항상 1 입니다. 따라서 이 sanity check는 절대로 발동하지 않습니다. 청크 경계에서 다음 청크의 첫 unget() 이 다시 65,535로 리셋되긴 하지만, 직전 49개는 작은 값들 (50, 49, 48, ..., 1, 0은 스킵)이라서 여전히 number_equal = 1입니다.

즉 방어 코드가 "없었다"기보다는, 방어 코드가 상정한 패턴이 이번 입력과 정확히 맞지 않았던 셈입니다. 명백히 "이런 류의 parser 이상 상태를 막기 위해" 작성된 코드가 있는데, 그 보호 로직의 가정 자체를 우회하는 입력 패턴이 존재했던 것이죠.

6/ 측정 결과

말로만 설명하면 와닿지 않으니 PoC 측정 결과를 첨부합니다. 환경은 일반적인 노트북에서 Django 기본 설정으로 돌렸고, 최종 측정에는 약 2.5MB 크기의 예시 payload를 사용했습니다.

A) 공격 페이로드 (Content-Transfer-Encoding: base64 + AAA + 공백 + A)

입력 사이즈 처리 시간 직전 사이즈 대비 비율
65,536 0.63 ms
131,072 135.80 ms 217×
262,144 400.14 ms 2.95×
524,288 939.24 ms 2.35×
1,048,576 2,011.59 ms 2.14×
1,572,864 3,114.75 ms 1.55×
2,097,152 4,207.88 ms 1.35×
2,621,440 5,323.54 ms 1.27×

65KB → 128KB 구간에서 갑자기 200배 이상 점프하는 게 보이실 텐데요, 이건 65KB 페이로드는 마지막 A 까지 같은 청크 안에 들어가서 stripped_chunk = "AAAA" (4바이트, remaining = 0)가 되기 때문입니다. 반면 128KB부터는 첫 청크가 AAA 로 끝난 상태에서 이후 청크의 공백이 read(1) 경로로 소모되기 시작합니다.

B) 컨트롤 1 — 같은 사이즈, 정상 base64 데이터

입력 사이즈 처리 시간
2,621,440 4.81 ms

C) 컨트롤 2 — 같은 페이로드, Content-Transfer-Encoding 헤더 없음

입력 사이즈 처리 시간
2,621,440 2.49 ms

증폭 비율

공격:    5,323.54 ms
컨트롤:  2.49 ms
비율:    ~2,138×

2,000배가 넘는 증폭입니다. 그리고 이 경로는 view 진입 전 CSRF 미들웨어가 알아서 트리거해주기 때문에, 인증된 엔드포인트라도 CSRF 검증 단계에서 이미 5초가 흘러갑니다. 4~16개 워커로 운영되는 일반적인 gunicorn 설정이라면 동시 4~16개 요청만으로 사실상 마비됩니다.

D) 더 큰 파일 업로드 크기에서의 실제 측정

위 표는 약 2.5MB까지의 예시 payload 기준이었고, 실제 파일 업로드 엔드포인트에서는 더 큰 body가 허용되는 경우도 많습니다. 그래서 같은 공격 payload를 더 큰 크기에서도 돌려봤습니다.

측정 환경은 동일하고, 현재 소스 트리에 패치 전 _parse() 로직만 런타임에서 되돌려서 벤치마크했습니다. 즉 "지금 이 Django 트리에서 취약 경로만 복원했을 때 어느 정도 시간이 걸리는가"를 본 값입니다.

Payload 크기 공격 경로 (Content-Transfer-Encoding: base64) 컨트롤 (동일 payload, CTE 없음) 증폭 비율
1.25 MiB 2.554 s 1.385 ms 1,844.60×
2.5 MiB 5.365 s 1.874 ms 2,862.85×
5 MiB 10.776 s 3.369 ms 3,198.72×
10 MiB 21.750 s 5.967 ms 3,644.94×
20 MiB 43.459 s 14.025 ms 3,098.59×
40 MiB 87.282 s 25.948 ms 3,363.71×

보시면 거의 선형적으로 늘어납니다. 10MB만 넘어가도 단일 요청이 20초 이상 걸리고, 40MB에서는 1분 27초 이상 한 프로세스를 붙잡습니다. 멀티프로세스 서버라도 동시 업로드 몇 개만으로 금방 고갈될 수 있는 수준입니다.

여기서 중요한 건 "리버스 프록시가 알아서 막아주지 않나?"라는 질문입니다. 실제 운영에서는 그렇게 단순하지 않습니다.

  • Apache httpd의 LimitRequestBody 기본값은 현재 1GB이고, 2.4.53 이하에서는 기본값이 아예 unlimited였습니다.
  • Nginx의 client_max_body_size 기본값은 1MB이지만, 파일 업로드 location에서는 이 값을 별도로 키우는 경우가 매우 흔합니다.
  • 파일 업로드를 정상적으로 받아야 하는 엔드포인트에서는 프록시나 웹서버의 request body limit이 이미 애플리케이션 요구사항에 맞춰 완화되어 있을 가능성이 높습니다.

그래서 이 취약점은 "웹서버가 기본적으로 body를 다 잘라주겠지"라고 가정하고 넘길 성격이 아닙니다. 실제로 파일 업로드 기능을 제공하는 서비스에서는, 프록시 제한과 무관하게 Django 쪽에서 직접 업데이트해 없애는 것이 맞습니다.

7/ Django 팀에서 적용한 패치 분석

Django 팀에서는 굉장히 깔끔한 패치를 내놓았습니다.

-                                stripped_chunk = b"".join(chunk.split())
+                                stripped_parts = [b"".join(chunk.split())]
+                                stripped_length = len(stripped_parts[0])

-                                remaining = len(stripped_chunk) % 4
-                                while remaining != 0:
-                                    over_chunk = field_stream.read(4 - remaining)
+                                while stripped_length % 4 != 0:
+                                    over_chunk = field_stream.read(self._chunk_size)
                                     if not over_chunk:
                                         break
-                                    stripped_chunk += b"".join(over_chunk.split())
-                                    remaining = len(stripped_chunk) % 4
+                                    over_stripped = b"".join(over_chunk.split())
+                                    stripped_parts.append(over_stripped)
+                                    stripped_length += len(over_stripped)
+
+                                stripped_chunk = b"".join(stripped_parts)

이 패치에는 사실 세 가지 변경 사항이 들어가 있는데, 하나하나 짚어볼 만합니다.

(1) read(4 - remaining)read(self._chunk_size)

가장 핵심이 되는 변경입니다. 1~3바이트씩 야금야금 읽지 말고, 한 번에 청크 사이즈만큼 (64KB) 읽도록 바꿨습니다. 이렇게 되면 공백 2.5MB 페이로드라도 read 호출 횟수가 250만 번에서 약 40번으로 줄어듭니다. LazyStream.read(self._chunk_size) 는 leftover를 통째로 반환하고 끝이기 때문에, 1바이트 슬라이스 + 65,535바이트 unget 같은 패턴 자체가 사라집니다.

(2) stripped_chunk += ...stripped_parts.append(...)

이 부분도 의미가 있습니다. 원래 코드의 stripped_chunk += b"".join(over_chunk.split()) 는 반복할수록 새 bytes 객체를 만들 수 있는 구조라서, 굳이 유지할 이유가 없습니다.

이번 취약점의 핵심 원인은 read(1) 경로였지만, 명시적으로 list에 append한 뒤 마지막에 b"".join() 으로 합치는 편이 비용 모델도 더 분명하고 구현도 더 안전합니다. 패치에서 이 부분까지 같이 정리한 점이 좋았습니다.

(3) len(stripped_chunk) % 4stripped_length % 4

이건 단순한 미세 최적화라기보다는, "현재까지 모인 non-whitespace 길이"를 별도 상태로 추적하도록 구조를 바꾼 것입니다. stripped_parts 를 모아 두고 마지막에만 join하더라도, 중간 단계에서 정렬 여부를 바로 판단할 수 있게 됩니다.

8/ 느낀점

이번 건은 Claude Code와 Codex를 같이 써서 찾은 취약점입니다. Django처럼 거의 20년 가까이 다듬어진 프레임워크에서, 그것도 인증 전 단계에서 터지는 pre-auth DoS가 아직 남아 있었던 점이 개인적으로는 꽤 인상적이었습니다.

또 하나는 "2.5MB 정도면 별거 아니지 않나?"라는 직관이 완전히 틀릴 수 있다는 점입니다. 이번 케이스는 그 정도 크기의 요청 하나만으로도 단일 프로세스 서버에 충분히 유의미한 지연을 만들 수 있었습니다. 운영 중인 서비스라면 가능한 한 빨리 업데이트하는 편이 맞다고 생각합니다.

9/ 마치며

기술적으로 굉장히 어려운 finding은 아니지만, 코드 한 줄짜리 버그가 아니라 세 개의 레이어가 곱해져서 발생하는 finding이라는 점에서 개인적으로 쓰면서 재밌었습니다. 특히 _update_unget_history 라는 이미 존재하는 보호 코드를 우회하는 부분이 finding을 좀 더 단단하게 만들어준 것 같습니다.

이번 글이 multipart 파서나 Django 내부 구조에 관심이 있는 분들에게 조금이나마 도움이 되었으면 좋겠습니다. 긴 글 읽어주셔서 감사합니다.

관련 링크