เผยช่องโหว่ที่ทำให้เซิร์ฟเวอร์ Django ค้างได้ 1 นาทีด้วย HTTP packet เพียง 20MB (CVE-2026-33033)
(new-blog.ch4n3.kr)สรุปหนึ่งบรรทัดโดยรวม
เป็นช่องโหว่ Pre-Auth CPU exhaustion ที่เกิดใน MultiPartParser ของ Django เมื่อเนื้อหาส่วนที่มี Content-Transfer-Encoding: base64 ประกอบด้วยช่องว่างเป็นหลัก โดยคำขอเพียงประมาณ 2.5MB หนึ่งครั้งสามารถทำให้ใช้เวลาในการประมวลผลมากกว่าปกติเกิน 2,100 เท่า (CVE-2026-33033)
สรุป
- สามารถทริกเกอร์ได้โดยไม่ต้องยืนยันตัวตน แม้เป็นเซิร์ฟเวอร์ที่ใช้การตั้งค่าเริ่มต้น
- เนื่องจาก CSRF middleware เข้าถึง
request.POSTก่อนเข้าสู่ view จึงทำให้ MultiPartParser ทำงานโดยอัตโนมัติ ดังนั้นแม้จะเป็น endpoint ที่ต้องยืนยันตัวตน ก็ยังเสียเวลาไปหลายวินาทีตั้งแต่ขั้นตอนตรวจสอบ CSRF แล้ว
- เนื่องจาก CSRF middleware เข้าถึง
- คำขอขนาด 20MB เพียงหนึ่งครั้งสามารถยึด worker เดี่ยวไว้ได้นานประมาณ 1 นาที
- หากเป็นการตั้งค่า gunicorn ทั่วไปที่รันด้วย worker 4~16 ตัว คำขอพร้อมกันเพียงหลักสิบก็ทำให้เซิร์ฟเวอร์แทบใช้งานไม่ได้
- Django ใช้
MultiPartParserในการประมวลผลคำขอmultipart/form-dataและเนื่องจาก CSRF middleware เข้าถึงrequest.POSTก่อนเข้าสู่ view จึงทำให้ parser นี้ถูกเรียกใช้งานเสมอแม้ไม่มีการยืนยันตัวตน - แกนของช่องโหว่คือโครงสร้างที่มีสามเลเยอร์คูณกัน
- (Layer 1) base64 alignment while-loop: เมื่อเอาช่องว่างออกจากชังก์แล้ว สถานะ
remaining != 0จะคงอยู่ ทำให้field_stream.read(1)ถูกเรียกซ้ำกับสตรีมที่เหลือทั้งหมดหลังจากนั้น - (Layer 2) ต้นทุน O(C) ที่ซ่อนอยู่ของ
LazyStream.read(1): ทุกครั้งที่เรียกread(1)ภายในจะดึงบัฟเฟอร์ราว 64KB ออกมาทั้งก้อน แล้วผลัก 65,535 ไบต์กลับเข้าไปด้วยunget()ซ้ำไปมา - (Layer 3) bytes concatenation แบบ O(C) ของ
unget(): มีการสร้างอ็อบเจ็กต์ใหม่จากbytes + self._leftoverทุกครั้ง
- (Layer 1) base64 alignment while-loop: เมื่อเอาช่องว่างออกจากชังก์แล้ว สถานะ
- คำขอขนาด 2.5MB เพียงหนึ่งครั้งสามารถก่อให้เกิดการคัดลอกหน่วยความจำภายในรวมประมาณ 86GB และบน M2 จะยึด worker หนึ่งตัวเต็ม ๆ ไว้ราว 5.3 วินาที ส่วนที่ 20MB จะใช้เวลาประมาณ 1 นาที
- ภายใน
unget()มีโค้ด sanity check (_update_unget_history) อยู่แล้ว แต่การโจมตีครั้งนี้มีรูปแบบที่ขนาดของunget()ลดลงทีละ 1 ในทุกครั้งแบบ monotonic decreasing จึงไม่มีวันเข้าเงื่อนไขการตรวจจับ (number_equal > 40) - แกนสำคัญของแพตช์จากทีม Django คือเปลี่ยน
read(4 - remaining)→read(self._chunk_size)เพื่อให้อ่านครั้งละ 64KB แทนการอ่านทีละ 1~3 ไบต์ ส่งผลให้จำนวนครั้งของการเรียก read ลดจาก 2.5 ล้านครั้งเหลือประมาณ 40 ครั้ง - แม้ค่าเริ่มต้นของ
client_max_body_sizeใน Nginx จะเป็น 1MB แต่ก็มักถูกผ่อนปรนใน endpoint สำหรับอัปโหลดไฟล์ และค่าเริ่มต้นของLimitRequestBodyใน Apache httpd คือ 1GB ดังนั้นการพึ่งพา proxy เพียงอย่างเดียวจึงไม่รับประกันการป้องกัน - เป็นช่องโหว่ที่ค้นพบโดยใช้ Claude Code + Codex และน่าสนใจที่เฟรมเวิร์กซึ่งผ่านการขัดเกลามาเกือบ 20 ปีแล้วยังมี Pre-Auth DoS หลงเหลืออยู่
4 ความคิดเห็น
ลุยยยย
มีใครลองทำอันนี้เองบ้าง?
มี PoC ที่ทำขึ้นเพื่อการสาธิตถูกอัปไว้บน GitHub
https://github.com/ch4n3-yoon/CVE-2026-33033-PoC
สุดยอดครับ