กระบวนการค้นพบบั๊กในคอมไพเลอร์ ARM64 ของ Go
(blog.cloudflare.com)- Cloudflare ค้นพบบั๊กแบบ race condition ที่พบได้ยาก ใน คอมไพเลอร์ Go ที่ทำงานบนแพลตฟอร์ม arm64 ระหว่างการเฝ้าระวังทราฟฟิกขนาดใหญ่
- บั๊กนี้แสดงอาการเป็นการที่บริการเกิด panic โดยไม่คาดคิดระหว่างกระบวนการ stack unwinding หรือเกิด ข้อผิดพลาดในการเข้าถึงหน่วยความจำ
- ระหว่างการไล่หาสาเหตุ พบว่าปัญหาเกิดขึ้นระหว่าง asynchronous preemption (การแย่งการทำงานแบบบังคับ) ของ Go runtime กับ คำสั่งปรับ stack pointer สองคำสั่งที่คอมไพเลอร์สร้างขึ้น
- ด้วยโค้ดสำหรับทำซ้ำปัญหาแบบย่อที่สุด ได้พิสูจน์ว่าบั๊กนี้เป็น ปัญหาใน Go runtime เอง และเผยให้เห็น race state ระดับหนึ่งคำสั่ง ที่ทำให้ stack pointer ถูกเปลี่ยนไม่สมบูรณ์
- ปัญหานี้ถูกแพตช์แล้วในเวอร์ชัน go1.23.12, go1.24.6, go1.25.0 โดยวิธีใหม่จะหลีกเลี่ยงการจัดการ stack pointer ที่ไม่สามารถเปลี่ยนได้ทันที ทำให้ปิด race condition นี้ได้จากต้นตอ
การวิเคราะห์บั๊กในคอมไพเลอร์ Go ARM64 ที่ Cloudflare พบ
ดาต้าเซ็นเตอร์ของ Cloudflare ประมวลผลคำขอ HTTP ราว 84 ล้านรายการต่อวินาทีในกว่า 330 เมืองทั่วโลก และสภาพแวดล้อมทราฟฟิกขนาดใหญ่นี้มีลักษณะเฉพาะคือ แม้แต่บั๊กที่พบได้ยากก็ยังโผล่ขึ้นมาบ่อย บทความนี้วิเคราะห์อย่างละเอียดผ่านกรณีจริงเกี่ยวกับ ปัญหา race condition ที่เกิดจากโค้ดซึ่งคอมไพเลอร์ Go สร้างบนแพลตฟอร์ม arm64
การสืบสวนอาการ panic แปลกประหลาด
- ภายในเครือข่ายของ Cloudflare มีบริการที่ตั้งค่าการประมวลผลทราฟฟิกของผลิตภัณฑ์อย่าง Magic Transit และ Magic WAN ลงใน kernel อยู่
- บนเครื่อง arm64 ระบบมอนิเตอร์ตรวจพบข้อความ fatal panic เป็นครั้งคราวแต่เกิดซ้ำได้
- จากการวิเคราะห์เบื้องต้น พบการละเมิด integrity ระหว่างกระบวนการ stack unwinding (ในโค้ดเก่าที่ใช้รูปแบบ panic/recover มีการเกิด panic บ่อยครั้ง)
- มีการเอาโครงสร้าง panic/recover ออกชั่วคราวเพื่อลดความถี่ของ panic แต่ภายหลังกลับพบว่า fatal panic ที่น่าสงสัยเกิดบ่อยขึ้นกว่าเดิม
- จึงสรุปได้ว่าจำเป็นต้องวิเคราะห์สาเหตุเชิงลึก มากกว่าการไล่ตามรูปแบบอาการแบบผิวเผิน
ภาพรวมโครงสร้างข้อมูลของ Go runtime และ scheduler
- Go ใช้ โครงสร้างการจัดตารางแบบ M:N ด้วย user-space scheduler แบบเบา (แมป goroutine หลายตัวลงบน kernel thread จำนวนน้อย)
- โครงสร้างหลักของ scheduler ประกอบด้วย
g(goroutine),m(machine/kernel thread),p(processor) - ความล้มเหลวของ stack unwinding หรือข้อผิดพลาดในการเข้าถึงหน่วยความจำ มักเกิดขึ้นเมื่อ stack pointer หรือ return address เปลี่ยนไปอย่างผิดปกติ
สาเหตุเชิงโครงสร้างของข้อผิดพลาดระหว่าง stack unwinding
- จากการวิเคราะห์ backtrace หลายชุด พบว่าทั้งหมดเกิดขึ้นระหว่างกระบวนการ stack unwinding ในฟังก์ชัน
(*unwinder).next - กรณีหนึ่งเกิดจาก return address เป็น null จึงถูกมองว่าเป็นสแตกผิดปกติและจบด้วยข้อผิดพลาดร้ายแรง ส่วนอีกกรณีเกิด segmentation fault ขณะพยายามเข้าถึงฟิลด์ของโครงสร้าง
mของ Go scheduler (incgo) ภายใน stack frame - จุดที่เกิด crash อยู่ห่างจากจุดที่บั๊กเกิดจริงพอสมควร ทำให้การไล่หาต้นตอยุ่งยาก
รูปแบบที่สังเกตได้และความเกี่ยวข้องกับไลบรารี Go Netlink
- เมื่อตรวจสอบ stack trace พบว่าการ crash ทั้งหมดกระจุกตัวอยู่ในช่วงที่เกิด preemption ภายในฟังก์ชัน
NetlinkSocket.Receiveของ ไลบรารี Go Netlink - จากนั้นจึงตั้งสมมติฐานไว้สองข้อ
- อาจเป็นบั๊กที่มีต้นตอจากการใช้ unsafe.Pointer ใน Go Netlink
- อาจเป็นบั๊กที่เกิดใน asynchronous preemption และ stack unwinding ของ Go runtime เอง
- แม้จะมีการ audit โค้ด แต่ไม่พบรูปแบบความเสียหายของหน่วยความจำโดยตรง จึงคาดว่าหัวใจของปัญหาอยู่ที่ runtime และกลยุทธ์การจัดการสแตก
Asynchronous preemption และ race condition
- ฟีเจอร์ asynchronous preemption ที่ถูกเพิ่มเข้ามาตั้งแต่ Go 1.14 จะส่งสัญญาณ (SIGURG) ไปยัง OS thread เพื่อบังคับสร้าง scheduling point ให้กับ goroutine ที่ทำงานยาวนาน
- หาก preemption นี้เกิดขึ้น ระหว่างคำสั่งแอสเซมบลีสองคำสั่งที่กำลังปรับ stack frame pointer stack pointer จะค้างอยู่ในสถานะกึ่งกลาง
- เมื่อต้อง unwind สแตกเพื่อทำ garbage collection, จัดการ panic หรือสร้าง stack trace ก็อาจ อ่านตำแหน่งผิดและตีความที่อยู่ฟังก์ชันหรือข้อมูลผิดพลาด
การสร้างโค้ดสำหรับทำซ้ำปัญหาแบบย่อที่สุด
- มีการปรับขนาดการจัดสรร stack frame และเขียนฟังก์ชันที่ปรับสแตกอย่างชัดเจน (
big_stack) ร่วมกับโค้ดที่ เรียก garbage collection ตลอดเวลา จนสามารถทำซ้ำ race condition นี้ได้ - ในแอสเซมบลีจริง stack pointer ถูกปรับด้วยคำสั่ง ADD สองคำสั่ง และถ้า asynchronous preemption เกิดขึ้นตรงกลาง กระบวนการ stack unwinding จะ crash
- ข้อบกพร่องนี้สามารถทำซ้ำได้แม้ใช้เพียงโค้ดจาก standard library ล้วน ๆ ซึ่งพิสูจน์ว่าเป็นช่องโหว่ระดับจำนวนครั้งของคำสั่ง (ขนาด 1 instruction) ที่ฝังอยู่ในโค้ดที่คอมไพเลอร์ Go สร้าง
สาเหตุของ race window ระดับคอมไพเลอร์บน ARM64
- เนื่องจากสถาปัตยกรรม ARM64 ใช้คำสั่งความยาวคงที่และมีข้อจำกัดด้าน immediate value ทำให้ การปรับ stack pointer อาจต้องใช้มากกว่าหนึ่งคำสั่ง
- ใน internal IR ของ Go ไม่มีการรับรู้ความยาวของ immediate value นี้ และจะมีการแทรกคำสั่งที่ถูกแยกออกเฉพาะตอนแปลงเป็น machine code จริง
- ด้วยเหตุนี้ การคืน stack frame (ADD RSP, RSP) จึงใช้สองคำสั่ง และก่อให้เกิดหน้าต่างความเปราะบางต่อ preemption ขนาดหนึ่ง instruction
- unwinder จำเป็นต้องพึ่งความถูกต้องของ stack pointer แบบสมบูรณ์ ดังนั้นถ้าการทำงานหยุดกลางคำสั่ง จะนำไปสู่การตีความค่าผิดและความล้มเหลวร้ายแรง
- ลำดับการ crash จริงมีดังนี้:
- เกิด asynchronous preemption ระหว่างคำสั่ง ADD สองคำสั่ง
- GC หรือสาเหตุอื่นทำให้ routine สำหรับ stack unwinding เริ่มทำงาน
- สำรวจตำแหน่ง stack pointer ที่ผิดปกติและตีความที่อยู่ฟังก์ชันผิด
- runtime crash
การแก้บั๊กและการปรับปรุงจากต้นตอ
- ทีม Cloudflare รายงานเรื่องนี้ไปยัง คลังทางการของ Go พร้อมโค้ดทำซ้ำปัญหาแบบย่อที่สุดและรายละเอียดการวิเคราะห์ และปัญหาก็ถูกแพตช์และออกรีลีสอย่างรวดเร็ว
- ตั้งแต่เวอร์ชัน go1.23.12, go1.24.6, go1.25.0 เป็นต้นไป จะ คำนวณ offset ทั้งหมดในรีจิสเตอร์ชั่วคราวก่อน แล้วจึงเปลี่ยน stack pointer ด้วยคำสั่งเดียว เพื่อลบจุดอ่อนต่อ preemption
- ตอนนี้รับประกันได้ว่า stack pointer จะอยู่ในสถานะที่ถูกต้องเสมอ ทำให้ race condition นี้ถูกปิดกั้นเชิงโครงสร้าง
LDP -8(RSP), (R29, R30)
MOVD $32, R27
MOVK $(1<<16), R27
ADD R27, RSP, RSP
RET
บทสรุปและนัยสำคัญ
- บั๊กนี้เป็นกรณีตัวอย่างที่ การสร้างโค้ดของคอมไพเลอร์บนสถาปัตยกรรมเฉพาะ กับ การจัดการ concurrency (asynchronous preemption) ชนกันในรูปแบบที่คาดไม่ถึง
- เป็นกรณีที่น่าสนใจเพราะสามารถไล่ตาม race condition ระดับ instruction ที่พบได้ยากมาก ซึ่งมักปรากฏเฉพาะในสภาพแวดล้อมขนาดใหญ่ ด้วยข้อมูลจากระบบจริงและการอนุมานอย่างเป็นวิทยาศาสตร์
- หากคุณดูแลบริการที่ทำงานบน Go รุ่นใหม่และสถาปัตยกรรม ARM64 การอัปเกรดไปยังเวอร์ชัน Go ที่เกี่ยวข้องถือว่าสำคัญ
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
LDR Rd, =exprสำหรับค่าคงที่ที่สร้างตรง ๆ ไม่ได้ จะวางค่าคงที่ไว้ในตำแหน่งแบบ PC-relative แล้วโหลดเข้ารีจิสเตอร์โดยอิงจาก PC วิธีนี้ทำให้ขั้นตอน “บวกค่าคงที่เข้ากับ SP” กลายเป็นคำสั่งที่รันจริง 2 คำสั่ง และต้องใช้โค้ด 8 ไบต์กับพื้นที่ข้อมูล 4 ไบต์ (สำหรับค่าคงที่ 17 บิต) รวมเป็น 12 ไบต์ เอกสารที่เกี่ยวข้อง: คำอธิบาย LDR pseudo-instructionaddครั้งเดียวเพื่อปรับ SP แบบอะตอมมิก แน่นอนว่าคำสั่งจะเพิ่มขึ้นอีกหนึ่งคำสั่ง แต่ก็ได้ atomicity หรือไม่ก็อาจคำนวณผ่านรีจิสเตอร์ชั่วคราวแล้วค่อยย้ายกลับbackportในคอมเมนต์เฉย ๆ คอมเมนต์ที่เกี่ยวข้อง: github issue commentsscanfใน Ubuntu GCC ARM toolchain ตอนโดนเดดไลน์ไล่หลัง ตอนนั้นไม่สนุกเลย แต่หลังจากระบุปัญหาได้แม่นและเขียน regression test เสร็จแล้ว มันรู้สึกน่าพอใจมากจริง ๆ