รายละเอียดทางเทคนิคของ HTTP/2 CONTINUATION Flood
(nowotarski.info)- HTTP/2 CONTINUATION Flood คือกลุ่มช่องโหว่ใน implementation ของ HTTP/2 ที่สามารถทำลายความพร้อมใช้งานของเซิร์ฟเวอร์ได้ด้วยการส่งเฟรมส่วนหัวต่อเนื่องโดยไม่มี
END_HEADERS - เนื่องจากคำขอโจมตีไม่เคยเสร็จสมบูรณ์ จึงไม่ปรากฏใน HTTP access log และการหาสาเหตุอาจต้องวิเคราะห์ไบต์ทราฟฟิกดิบ
- ขอบเขตผลกระทบแตกต่างกันไปตาม implementation ตั้งแต่ CPU exhaustion, OOM จากหลายการเชื่อมต่อ, OOM จากการเชื่อมต่อเดียว ไปจนถึงการแครชจากบั๊กเรื่องจังหวะการตัดการเชื่อมต่อ
- กรณีของ Go, Firefox และ Node.js เผยให้เห็นตามลำดับว่า HPACK decoding ดำเนินต่อไม่หยุด, ไม่มีการจำกัดขนาด response header และมีการชนกันระหว่างการตัดการเชื่อมต่อขณะประมวลผล
CONTINUATIONกับการอัปเดต memory counter - ต่างจาก Rapid Reset ในหลาย implementation สามารถทำให้เซิร์ฟเวอร์แครชได้ด้วย TCP connection เดียว จึงอาจส่งผลกระทบต่อบริการอินเทอร์เน็ตจำนวนมากที่ใช้ HTTP/2
วิธีใช้เฟรม CONTINUATION ใน HTTP/2
- HTTP/2 เป็นโปรโตคอลที่แลกเปลี่ยน binary frame ไม่ได้ส่งบรรทัดข้อความไปมาเหมือน HTTP/1.1
- เฟรม
HEADERSใช้ส่ง HTTP header ของคำขอและคำตอบ โดยข้อมูล header จะถูกเก็บไว้ใน field block fragment ที่เข้ารหัสด้วยHPACK - เฟรม
HEADERSมี flag ที่บอกจุดสิ้นสุดของ header และ streamEND_HEADERS: สัญญาณว่าเฟรมนี้มี header ทั้งหมดที่ต้องการส่งแล้วEND_STREAM: สัญญาณว่าไม่มี body ของคำขอหรือคำตอบเพิ่มเติมแล้ว
- เฟรมมี ขนาดสูงสุด ที่กำหนดตอนเริ่มสื่อสาร และหากเฟรมที่รับมีขนาดเกินที่อนุญาต การเชื่อมต่อจะถูกตัดด้วย protocol error
- หากไม่สามารถใส่ header ทั้งหมดในเฟรม
HEADERSเดียวได้ จะมีเฟรมCONTINUATIONต่อท้ายเฟรมHEADERSที่ไม่มีEND_HEADERS- เฟรม
HEADERSที่ไม่มีEND_HEADERS - เฟรม
CONTINUATIONเพิ่มเติมที่ไม่มีEND_HEADERS - ตั้งค่า
END_HEADERSในเฟรมCONTINUATIONสุดท้าย
- เฟรม
- หลังเฟรม header สุดท้าย จะมีเฟรม
DATAที่บรรจุข้อมูลคำขอตามมา หรือ HTTP/2 stream จะสิ้นสุดลง
แก่นของช่องโหว่: header stream ที่ไม่สิ้นสุด
- หากไคลเอนต์เริ่ม HTTP/2 stream ใหม่ แล้วส่งเฟรม
HEADERSและCONTINUATIONโดยไม่ตั้งค่าEND_HEADERSเลย เซิร์ฟเวอร์จะพยายาม parse และจัดเก็บ header stream ที่ไม่สิ้นสุด ต่อไป - โดยทั่วไปเซิร์ฟเวอร์ HTTP/1.1 มีกลไกสองอย่างเพื่อป้องกัน header ที่ไม่สิ้นสุด
- การจำกัดขนาด header ที่ตัดการเชื่อมต่อเมื่อรายการ header เกินขนาดที่อนุญาต
- timeout ของคำขอ/header ที่ตัดการเชื่อมต่อเมื่อคำขอหรือ header ไม่ถูกส่งภายในเวลาที่กำหนด
- HTTP/2 implementation หลายตัวไม่มีการป้องกันเหล่านี้ หรือ implement ไว้ไม่ถูกต้อง รวมถึง Apache httpd, Envoy และแพ็กเกจกับ codec ของ HTTP/2 หลายตัว
- ผลลัพธ์แยกได้เป็นสี่แบบตาม implementation
- CPU exhaustion: ระหว่างอ่านและ decode header เพิ่มเติม การใช้ CPU เพิ่มขึ้น ทำให้การตอบคำขออื่นช้าลงหรือหยุดชะงัก
- OOM แบบอาศัยหลายการเชื่อมต่อ: header ของ
CONTINUATIONถูกเก็บในหน่วยความจำ และแม้จะมีการจำกัดขนาดรายการ header แต่ไม่มี header timeout ทำให้แต่ละ connection ยึดหน่วยความจำต่อไป - OOM แบบอาศัยการเชื่อมต่อเดียว: implementation บางตัวอ่าน header ต่อไปจนหน่วยความจำเต็ม ทำให้ OS ยุติ process
- แครชได้ด้วยเฟรมเพียงไม่กี่เฟรม: เซิร์ฟเวอร์แครชจากบั๊กของ implementation เมื่อตัดการเชื่อมต่อกลาง
CONTINUATIONstream
- หากไม่มี
END_HEADERSคำขอจะไม่ถูกปิดอย่างถูกต้อง ดังนั้นคำขอของไคลเอนต์ประสงค์ร้ายจะไม่ถูกบันทึกใน access log
กรณีของ Go: CPU exhaustion จาก HPACK decoding ที่ไม่หยุด
- Go เป็นกรณีที่ CPU exhaustion โดดเด่นใน
CONTINUATIONFlood - implementation ของ Go ใช้ abstraction ชื่อ
http2MetaHeadersFrameเพื่อรวมเฟรมHEADERSหนึ่งเฟรม, เฟรมCONTINUATIONจำนวน 0 เฟรมขึ้นไป และ HPACK decoder เข้าด้วยกัน readMetaFrameเมื่อถึงขีดจำกัดขนาด header หรือเกิดข้อผิดพลาด จะเรียกSetEmitEnabled(false)เพื่อหยุดปล่อย header ที่ decode แล้วออกมา- แต่แม้หยุดปล่อย header แล้ว HPACK decoder ก็ยังคง decode ไบต์ input ต่อไป
- ลูปที่ป้อนเฟรมจะหยุดก็ต่อเมื่อ
HeadersEnded()เป็นtrueซึ่งเกิดขึ้นเมื่อมีการตั้ง flagEND_HEADERS - หากผู้โจมตีไม่ส่ง
END_HEADERSฟังก์ชันreadMetaFrameจะไม่ return และระหว่างที่ผู้โจมตีส่งข้อมูล HPACK decoder ก็จะประมวลผลไบต์ใหม่ต่อไปเรื่อย ๆ
กรณี OOM และผลกระทบต่อไคลเอนต์ Firefox
- OOM เกิดขึ้นใน implementation ที่ไม่ได้จำกัดขนาดรายการ header ที่สร้างจากเฟรม
CONTINUATION - ใน implementation ที่ไม่มี header timeout สามารถทำให้เซิร์ฟเวอร์แครชได้ด้วย HTTP/2 connection เดียว
- แม้ใน implementation ที่มี idle timeout ก็ยังสามารถเปิด HTTP/2 connections หลายรายการให้แต่ละรายการใช้ RAM ใกล้ขีดจำกัดต่อ connection แล้วคอยส่งเฟรม
CONTINUATIONสุดท้ายทีละไบต์ทุกไม่กี่วินาทีเพื่อคงการเชื่อมต่อไว้ CONTINUATIONFlood สามารถเกิดขึ้นได้ไม่เพียงกับเซิร์ฟเวอร์ แต่ยังเกิดที่ ฝั่งไคลเอนต์ เช่น browser ได้ด้วย- commit แก้ไขของ Mozilla Firefox เพิ่มการตรวจสอบว่า หากผลรวมของขนาด header ที่สะสมแล้วกับขนาดเฟรมใหม่เกิน
network_http_max_response_header_size()ให้ส่งคืน session error เป็นPROTOCOL_ERROR
กรณีของ Node.js: assertion crash ระหว่างตัดการเชื่อมต่อ
- Node.js จัดการ stream ของเฟรม
CONTINUATIONที่ไม่สิ้นสุดได้อย่างเหมาะสม แต่เกิด data race เมื่อตัดการเชื่อมต่อระหว่าง header stream - ระหว่างรันโค้ดโจมตี Node.js แครชจาก assertion
CHECK_EQ(current_nghttp2_memory_, 0)ล้มเหลวในHttp2Session::~Http2Session() - การแครชเชื่อมโยงกับจังหวะที่แม่นยำซึ่ง HTTP/2 client ตัดการเชื่อมต่อจากเซิร์ฟเวอร์ Node.js และ assertion อยู่ภายใน destructor ของ
Http2Session - Node.js ฝังไลบรารี nghttp2 ไว้เพื่อประมวลผล HTTP/2 connection
current_nghttp2_memory_ใช้ติดตามหน่วยความจำที่จัดสรรภายใน nghttp2 และหลังsession_.reset()ใน destructor จะตรวจสอบว่า artifact ทั้งหมดของ nghttp2 ถูกลบออกจากหน่วยความจำแล้วหรือไม่- ผลการตรวจสอบพบว่ามีบางกรณีที่ callback ของ nghttp2 กับ
reset()ทำงานพร้อมกันระหว่าง parse เฟรมCONTINUATION- เฟรม
CONTINUATIONมาถึงในสถานะNGHTTP2_IB_EXPECT_CONTINUATION - สถานะเปลี่ยนเป็น
NGHTTP2_IB_READ_HEADER_BLOCK - เกิด flow ต่อเนื่องของ
session_after_header_block_received,session_call_on_frame_received,on_frame_recv_callback OnFrameReceiveและHandleHeadersFrameของ Node.js อัปเดต memory counter
- เฟรม
- หาก
HandleHeadersFrameและHttp2Session::~Http2Session()ทำงานพร้อมกันcurrent_session_memory_จะถูกอัปเดตพร้อมกัน และค่าcurrent_nghttp2_memory_กลายเป็นค่าติดลบ ทำให้CHECK_EQล้มเหลว
ความแตกต่างจากช่องโหว่ HTTP/2 ในปี 2019
- ชุดช่องโหว่ HTTP/2 ที่ Netflix และ Google รายงานในปี 2019 ถูกสรุปไว้ใน CERT/CC Vulnerability Note VU#605641
- CVE-2019-9516, “0-Length Headers Leak” เป็นปัญหาที่ทำให้ implementation บางตัวจัดสรรหน่วยความจำสำหรับชื่อและค่าของ header ที่มีความยาว 0 และคงไว้จนกว่า session จะจบ
CONTINUATIONFlood ไม่ได้ใช้ header ว่าง แต่ใช้วิธีส่ง header แบบสุ่มจำนวนมาก จนถึงขีดจำกัดขนาดเฟรมที่เซิร์ฟเวอร์ตั้งไว้- CVE-2019-9518, “Empty Frame Flooding” เป็นปัญหาที่ส่งเฟรม payload ว่าง เช่น
DATA,HEADERS,CONTINUATION,PUSH_PROMISEโดยไม่มี end-of-stream ทำให้อีกฝ่ายใช้เวลาประมวลผลมากเกินไปเมื่อเทียบกับแบนด์วิดท์โจมตี CONTINUATIONFlood ไม่ใช่เฟรมว่าง แต่ใช้เฟรมที่ใหญ่ที่สุดเท่าที่เป็นไปได้เพื่อยึดหน่วยความจำและใช้ CPU cycle ในกระบวนการ decode
เหตุผลที่อาจร้ายแรงกว่า Rapid Reset
- ในเดือนตุลาคม 2023 มีการเปิดเผยรายละเอียด “Rapid Reset” ซึ่งเป็น zero-day ของโปรโตคอล HTTP/2 และถูกเรียกว่า “การโจมตี DDoS ที่ใหญ่ที่สุดจนถึงปัจจุบัน”
- Rapid Reset ใช้การผสมระหว่างเฟรม
HEADERSที่ตั้งค่าEND_STREAMและEND_HEADERSกับเฟรมRST_STREAM - ด้วยวิธีนี้ มาตรการบรรเทามาตรฐานอย่าง rate limiting สามารถลดความเสียหายได้ และผู้ดูแลเซิร์ฟเวอร์สามารถเห็นคำขอ inbound จำนวนมากใน log และได้รับการแจ้งเตือน
- ใน
CONTINUATIONFlood ไม่มีEND_HEADERSดังนั้น ไม่มีคำขอแม้แต่รายการเดียวที่เสร็จสมบูรณ์ และผู้ดูแลจะมองไม่เห็นคำขอใน log - ในหลาย implementation
CONTINUATIONFlood สามารถทำให้เซิร์ฟเวอร์แครชได้ด้วย TCP connection เดียว และบางกรณีใช้ข้อมูลเพียงเล็กน้อยมากก็ทำได้ - Rapid Reset ถูกใช้ในการโจมตี DDoS และโดยมากต้องใช้ botnet เพื่อให้โจมตีสำเร็จ
ผลกระทบที่อาจเกิดกับบริการอินเทอร์เน็ต
- ตามข้อมูลของ Cloudflare Radar ทราฟฟิก HTTP/2 คิดเป็นประมาณ 60% ของทราฟฟิก HTTP จากมนุษย์โดยไม่นับ bot
- Cloudflare Radar ประเมินว่า HTTP traffic คิดเป็นมากกว่า 70% ของการส่งข้อมูลทั้งหมดบนอินเทอร์เน็ต
- เมื่อพิจารณาความสำคัญของโปรเจกต์ที่ได้รับผลกระทบและความง่ายในการ exploit ส่วนใหญ่ของอินเทอร์เน็ตจึงเคยเปิดรับช่องโหว่นี้
- HTTP ไม่ได้ใช้เฉพาะกับเว็บไซต์เท่านั้น แต่ยังใช้กับ RESTful API จำนวนมากด้วย
- ปัญหาความพร้อมใช้งานของ API และเว็บไซต์สำคัญขององค์กรหรือภาครัฐอาจก่อให้เกิดความสูญเสียหรือความวุ่นวายมูลค่าหลายล้านดอลลาร์
- หากถูก exploit ผู้ดูแลเซิร์ฟเวอร์อาจ debug ได้ยากมากหากไม่มีความรู้ HTTP/2
- คำขอ HTTP ประสงค์ร้ายไม่ได้ถูกปิดอย่างถูกต้อง
- ไม่เห็นคำขอใน server access log
- เซิร์ฟเวอร์ HTTP/2 ส่วนใหญ่ขาดความสามารถวิเคราะห์เฟรมขั้นสูง
- ต้องวิเคราะห์ข้อมูลการเชื่อมต่อดิบด้วยตนเอง
การเปิดเผยแบบประสานงานและการตอบสนองของ CERT/CC
- กลุ่มช่องโหว่นี้มีความเสี่ยงอย่างมากต่อ ความปลอดภัยของอินเทอร์เน็ต
- หลังจากมีรายงานในเดือนมกราคม 2024 CERT/CC ได้เปิด Vulnerability Coordination case เพื่อติดตามปัญหานี้
- บริษัทเทคโนโลยีขนาดใหญ่หลายแห่งและโปรเจกต์โอเพนซอร์สหลายโครงการเข้าร่วมกระบวนการเปิดเผยอย่างรับผิดชอบที่เกี่ยวข้อง
- เนื่องจากนักวิจัยคนเดียวตรวจสอบ implementation จำนวนมากทั้งหมดได้ยาก ปัญหาที่ส่งผลต่อ vendor หลายรายจึงจำเป็นต้องมี Vulnerability Coordination
- CERT/CC ได้เผยแพร่ Vulnerability Note สำหรับปัญหานี้ และ note ลักษณะนี้มีการเผยแพร่เพียงไม่กี่รายการต่อปี
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
เดือนที่แล้ว Bandit ก็ได้บรรเทาปัญหานี้ไว้ตรงจุดนี้พอดี
https://github.com/mtrudel/bandit/blob/main/lib/bandit/http2...
จากมุมมองของคนทำ implementation พูดตรง ๆ ว่านี่เป็นจุดที่ควรต้องป้องกันอยู่แล้วแบบชัดเจนมาก เราคิดเรื่องนี้มานานแล้ว และก็เคยเชื่อว่า implementation อื่น ๆ ก็น่าจะป้องกันไว้เป็นเรื่องปกติเหมือนกัน
ในช่วงหลายเดือนที่ผ่านมา มีการตรวจดู implementation หลายสิบตัว แต่ที่แปลกคือแม้แต่ เซิร์ฟเวอร์ HTTP/2 รายหลัก ๆ ก็ยังไม่มีการป้องกันแบบนี้ หรือไม่ก็ทำไว้ผิด
โดยแก่นแล้ว นี่น่าจะเป็นผลจากวัฒนธรรมการพัฒนาที่คุ้นชินกับการขยายทุกอย่างแบบไดนามิกอัตโนมัติ โดยไม่ค่อยกังวลว่ามันจะโตได้มากแค่ไหน
ปัญหาประเภทนี้ไม่ได้จำกัดอยู่แค่ HTTP/2 แต่ความซับซ้อนอย่างหนักของ HTTP/2 น่าจะมีส่วนมาก ในยุค HTTP/1.x มีนักพัฒนาที่คุ้นกับภาษาอย่าง C มากกว่า จึงคอยระวังเรื่องการจัดการความยาวบัฟเฟอร์อยู่ตลอด และคงไม่ทำให้การจัดสรรหน่วยความจำสำหรับเฮดเดอร์ตลอดทั้งคำขอขยายได้ไม่สิ้นสุด ทั้งที่จริงอย่างมากก็ใช้แค่ไม่กี่ KB
การโจมตีแบบปฏิเสธการให้บริการหลายอย่าง เช่น slowloris หรือการชนกันของแฮชใน query parameter เกิดขึ้นได้จริงก็เพราะเพิ่งมานึกถึงเรื่อง การใช้ทรัพยากรที่มีขีดจำกัด กันในภายหลัง
> ไม่ได้รับผลกระทบ: Nginx, Jetty, HAProxy, NetScaler, Varnish. [0]0: https://nowotarski.info/http2-continuation-flood/
อย่างน้อยถ้ายอมรับข้อเสนอที่ห้ามใช้หลัง HEADERS frame ที่ยังไม่เต็ม ก็น่าจะทำให้แข็งแรงขึ้นได้มากกว่านี้ แต่ตอนนั้นมองกันว่านั่นอาจทำให้งานด้าน encoding ยากขึ้นเอง เพราะมีปัญหาเรื่องอย่างขอบเขตไบต์ของตัวบีบอัด
ตลกดีที่ทุก 10 ปีเราจะ “ค้นพบใหม่” เรื่องเดิม ๆ อีกครั้ง ช่วงก่อนก็ RESET_STREAM flood ที่เป็นที่รู้จักกันดี ครั้งนี้เป็น CONTINUATION และต่อไปก็คงเป็น DATA frame ความยาว 0, WINDOW_UPDATE ขนาด 1 ไบต์, หรือ INITIAL_WINDOW SETTINGS ที่ทำให้ใช้ CPU สูง ถ้าแค่เอาปัญหาที่รู้อยู่แล้วมาตั้งชื่อใหม่และถ้าเป็นไปได้ก็ทำโลโก้เพิ่ม โลกก็จะหมุนคณะละครสัตว์ความปลอดภัยนี้ต่อไปได้เรื่อย ๆ
บทความก่อนหน้าของผู้เขียนคนเดียวกันที่สรุป เว็บเซิร์ฟเวอร์/รีเวิร์สพร็อกซี ที่ได้รับผลกระทบ
https://nowotarski.info/http2-continuation-flood/
บทความนี้อยู่บนสุดทั้งวัน
สงสัยว่าถ้าเป็นเว็บไซต์ทราฟฟิกไม่มาก การรันด้วยแค่ HTTP/1.1 จะปลอดภัยกว่าหรือเปล่า?
HTTP/2 และ HTTP/3 ต่างจากเดิมมากในแง่ฟีเจอร์ มีทั้ง multiplexing, windowing, HPACK ฯลฯ เพิ่มเข้ามา ทำให้การเชื่อมต่อที่แทบไม่มีสถานะของ HTTP/1.1 กลายเป็น การเชื่อมต่อแบบมีสถานะ การคงไว้ซึ่งการเชื่อมต่อแบบมีสถานะต้องเก็บข้อมูลอย่างสถานะและการตั้งค่าไว้ และนั่นเองที่ทำให้เกิดปัญหาเหล่านี้
ใน HTTP/2 มีการเพิ่ม multiplexing เข้ามา จึงทำให้คุณสมบัติด้านการป้องกันต่างไปด้วย เช่น ถ้าการเชื่อมต่อมาจากคำขอไปยังต้นทางของ CDN คุณอาจอนุญาตให้มีจำนวนการเชื่อมต่อน้อย แต่ให้แต่ละการเชื่อมต่อมีพูลช่อง multiplexing ขนาดใหญ่ได้ ขณะที่ถ้าเป็นการเข้าถึงจากผู้ใช้โดยตรง คุณอาจอยากอนุญาตจำนวนการเชื่อมต่อมาก แต่ลดจำนวนช่อง multiplexing ต่อการเชื่อมต่อ ใน HTTP/1 แทบทุกอย่างดูคล้ายกันไปหมด จึงทำให้การป้องกันง่ายกว่ามาก
HTTP/1 มี edge case ที่มองไม่เห็นชัดและพฤติกรรมยกเว้นแบบเก่าอยู่มาก รูปแบบข้อความมีความยืดหยุ่นกว่าที่เห็นจากการดูแค่เฮดเดอร์ที่ถูกต้องมาก ทั้ง multi-line header, ความสามารถ MIME แบบเก่า, race condition ของ 100-continue, hop-by-hop header แบบกำหนดเอง และฟีเจอร์กำกวมอย่าง GET body
โชคดีที่ RFC HTTP ฉบับใหม่ ๆ ได้บันทึกกับดักจำนวนมากไว้แล้ว หาก implement โดยดูแค่ RFC 2616 ก็จะไม่ได้ implementation ที่ปลอดภัย
ขนาดจริงของ request หรือ response สามารถถูกระบุพร้อมกันได้หลายวิธี และค่าก็อาจขัดแย้งกันเองได้ นอกจากนี้ยังขึ้นอยู่กับการผสมกันของหลายฟีเจอร์ และค่าเฮดเดอร์ที่ต้องใช้กฎ parsing แปลก ๆ เพื่อความเข้ากันได้ย้อนหลัง จึงทำให้ implementation HTTP ที่ “เรียบง่าย” อาจโดน request smuggling หลอกได้
ไม่ว่าจะเลือกทางไหน ก็ต้องการ implementation ที่แข็งแรง ผ่านการทดสอบมาอย่างดี และมีความเป็นผู้ใหญ่มากพอ
สำหรับการให้บริการ static asset ทั่วไป ข้อดีของ HTTP/1 มีเพียงเรื่องที่มันติดข้อจำกัดจำนวนการเชื่อมต่อต่อโดเมน จึงสามารถโหลด asset แบบขนานได้มากกว่า และปกติก็แก้ปัญหานี้ได้ด้วยการใช้ CDN หลายโดเมน
ในทางทฤษฎี HTTP/2 อาจใช้เสิร์ฟ JavaScript asset ที่ไม่ได้ bundle ได้ แต่ในสภาพแวดล้อมการใช้งานจริงยังไม่ค่อยเห็น ส่วนใหญ่คงเป็นเพราะในหลายกรณีก็ยังต้องมีขั้นตอน compile อยู่ดี
ถ้าทำแบบช้า ๆ ก็คงเรียกมันว่า slowloris v2 ได้สินะ :(
HTTP/2 หรือวิธีเอา “การอัปเกรด” ชั้นขนส่งมายัดใส่โปรโตคอลชั้นแอปพลิเคชันแบบฝืน ๆ