- ความปลอดภัยของหน่วยความจำ และ ความปลอดภัยของเธรด ไม่ใช่แนวคิดที่แยกออกจากกันได้ และหากไม่มีความปลอดภัยของเธรด ก็ไม่อาจบรรลุความปลอดภัยของหน่วยความจำอย่างแท้จริง
- ในกรณีของ ภาษาที่ไม่ปลอดภัยต่อเธรด อย่าง Go ปัญหาเธรดเพียงอย่างเดียวก็อาจทำให้ความปลอดภัยของหน่วยความจำพังได้
- บางภาษา เช่น Java ใช้ โมเดลหน่วยความจำสำหรับภาวะพร้อมกัน เพื่อทำให้แม้แต่ data race ก็ยังเป็นพฤติกรรมที่นิยามไว้ ช่วยคงความปลอดภัยในระดับภาษา
- Go มีความเปราะบางต่อ data race และมีกรณีจริงที่เกิดการละเมิดความปลอดภัยของหน่วยความจำ
- คุณสมบัติที่ควรถูกให้ความสำคัญจริง ๆ คือ การไม่มี Undefined Behavior (พฤติกรรมที่ไม่ถูกนิยาม)
หากไม่มีความปลอดภัยของเธรด ก็ไม่อาจรับประกันความปลอดภัยของหน่วยความจำได้
ความสับสนของแนวคิด: ความปลอดภัยของหน่วยความจำ vs ความปลอดภัยของเธรด
- ช่วงหลังมานี้ ความปลอดภัยของหน่วยความจำ ได้รับความสนใจมากขึ้น แต่ยังไม่มีคำนิยามที่ชัดเจนว่าแท้จริงแล้วหมายถึงอะไร
- โดยดั้งเดิม ความปลอดภัยของหน่วยความจำหมายถึงภาษาที่ป้องกันการเข้าถึงหน่วยความจำแบบ use-after-free หรือ out-of-bounds
- ขณะที่ ความปลอดภัยของเธรด หมายถึงโปรแกรมที่ไม่มีบั๊กด้านภาวะพร้อมกัน และสองแนวคิดนี้มักถูกมองว่าเป็นคนละเรื่อง
- ผู้เขียนโต้แย้งว่าการแบ่งเช่นนี้แทบไม่มีประโยชน์ในทางปฏิบัติ และย้ำว่าสิ่งที่เราต้องการจริง ๆ คือ การไม่มี Undefined Behavior (UB)
การละเมิดความปลอดภัยของหน่วยความจำจาก data race: ตัวอย่างของ Go
- มีการยกตัวอย่าง ภาษา Go เพื่อแสดงปัญหาของการแยกความปลอดภัยของหน่วยความจำออกจากความปลอดภัยของเธรด
- แม้ Go จะถูกจัดเป็นภาษาที่ปลอดภัยด้านหน่วยความจำ แต่ในโปรแกรมลักษณะต่อไปนี้ เพียงแค่ data race ก็อาจทำให้เกิด ข้อผิดพลาดของหน่วยความจำ ได้
globalVar를 반복적으로 다른 타입 값(Int, Ptr)으로 변경하면서 동시에 별도 고루틴에서 이를 읽어 메서드를 호출
- เมื่อสองเธรดซ้อนทับกันและอัปเดตพอยน์เตอร์ภายในสองตัวของ
globalVar (data, vtable) แยกกัน หากมีการอ่านระหว่างทางจะเกิดสถานะผสมและนำไปสู่การเข้าถึงหน่วยความจำที่ผิดพลาด
- ผลลัพธ์คือพยายามอ้างอิงที่อยู่ที่ไม่ถูกต้อง (เช่น
0x2a; เลขฐานสิบหกของ 42) จนโปรแกรมจบลงด้วยข้อผิดพลาด
- ปรากฏการณ์นี้เกิดขึ้นได้ใน interface, slice และโครงสร้างคล้ายกันของ Go เช่นกัน เพราะ ไม่ได้อัปเดตหลายฟิลด์แบบ atomic
วิธีจัดการภาวะพร้อมกันของภาษาอื่นและความปลอดภัยของหน่วยความจำ
- ภาษาอื่นอย่าง Java ก็อาจมี data race ได้เช่นกัน แต่ใช้ โมเดลหน่วยความจำสำหรับภาวะพร้อมกันที่นิยามไว้ เพื่อรับประกันว่าโปรแกรมจะไม่ทำให้ตัวภาษาเองพัง
- ตัวอย่าง: Java ออกแบบโมเดลหน่วยความจำอย่างพิถีพิถันเพื่อไม่ให้เกิดความล้มเหลวระดับรันไทม์ (เช่น การเกิด segmentation fault โดยตรง) แม้อยู่ในสภาพแวดล้อมหลายเธรด
- โดยมากภาษาโปรแกรมจะควบคุมปัญหาภาวะพร้อมกันด้วยหนึ่งในสองแนวทางต่อไปนี้
- นิยามโมเดลหน่วยความจำ ให้โปรแกรมแบบพร้อมกันทั้งหมดมีพฤติกรรมที่สอดคล้องกันเสมอ (แลกกับข้อจำกัดด้านการ optimize ของคอมไพเลอร์และภาระการนำไปใช้งานที่สูงขึ้น)
- Java, C#, OCaml, JavaScript, WebAssembly เป็นต้น
- ใช้ ระบบชนิดข้อมูลที่เข้มแข็ง เพื่อห้าม data race ส่วนใหญ่ และจัดการข้อยกเว้นจำนวนน้อยให้ปลอดภัย (เช่น Rust, strict concurrency ของ Swift)
- Go ไม่ได้เลือกตามสองทางนี้
- รับประกันความปลอดภัยของหน่วยความจำเฉพาะเมื่อไม่มี data race
- แม้จะมีเครื่องมือตรวจจับ data race แต่ในโปรแกรมจริงก็มีข้อจำกัดในการพิสูจน์ทุกสถานการณ์ผ่านการทดสอบ
- ทั้งงานวิจัยและประสบการณ์ภาคสนามรายงานกรณีละเมิดความปลอดภัยของหน่วยความจำจริงอยู่จำนวนมาก
โมเดลหน่วยความจำของ Go และปัญหาด้านเอกสารประกอบ
- เอกสารทางการของ Go memory model ระบุว่า race ส่วนใหญ่มีผลลัพธ์ที่จำกัด แต่ไม่ได้อธิบายอย่างชัดเจนว่าบาง data race อาจให้ผลลัพธ์ได้ไม่จำกัด
- แม้จะมีคำกล่าวว่า Go คล้ายกับ Java/JavaScript แต่สองภาษานั้นลงทุนมากกว่ามากในการรับรองความปลอดภัยด้านภาวะพร้อมกันเมื่อเทียบกับ Go
- มีเพียงบางส่วนย่อยของเอกสารเท่านั้นที่กล่าวอย่างจำกัดว่าบาง data race อาจก่อให้เกิด พฤติกรรมที่ไม่ถูกนิยามโดยสมบูรณ์
บทสรุป: การไม่มี Undefined Behavior (UB) คือเป้าหมายที่แท้จริง
- ในทางปฏิบัติ คุณสมบัติที่ผู้ใช้ต้องการจริง ๆ คือ โปรแกรมไม่ทำให้ตัวภาษาเองพัง (ไม่มี UB)
- ช่องโหว่ด้านความปลอดภัยหลายรูปแบบที่เกิดจากการละเมิดความปลอดภัยของหน่วยความจำ ล้วนเกิดขึ้นเพราะ UB เกิดขึ้นจริง
- ทันทีที่ UB เกิดขึ้น พฤติกรรมทั้งหมดหลังจากนั้นจะคาดเดาไม่ได้ และผู้โจมตีก็สามารถใช้ประโยชน์จากจุดนี้ได้
- ความแตกต่างเชิงแก่นระหว่างภาษาที่ “ปลอดภัย” กับ “ไม่ปลอดภัย” อยู่ที่ความเป็นไปได้ของการเกิด UB
- แทนที่จะแยกย่อยเป็นความปลอดภัยของหน่วยความจำ ความปลอดภัยของเธรด หรือความปลอดภัยของชนิดข้อมูล สิ่งสำคัญคือ เกิด UB หรือไม่
- ในความเป็นจริง ความปลอดภัยมีลักษณะเป็นสเปกตรัม และ Go แม้จะปลอดภัยกว่า C แต่ก็ไม่ได้รับประกันความปลอดภัยอย่างสมบูรณ์
- การจะ “พิสูจน์” ความปลอดภัยจริงของ Go ด้วยข้อมูลเชิงประจักษ์นั้นทำได้ยากมาก และสิ่งสำคัญคือการเข้าใจผลลัพธ์ที่ไม่ตรงสัญชาตญาณจากทางเลือกที่แต่ละภาษาตัดสินใจไว้
1 ความคิดเห็น
ความเห็นจาก Hacker News
Swift ก็มีปัญหาเดียวกัน และฉันเคยเขียนโปรแกรมเพื่อแสดงให้เห็นว่า Swift สามารถทำให้เกิด segfault ได้ง่ายมากเมื่อเข้าถึง shared data structure
การบอกว่า Go เป็น memory-safe ในความหมายเดียวกับ Rust หรือ Java จึงค่อนข้างพูดเกินจริง
อยากฟังรายละเอียดเพิ่มเติมเกี่ยวกับสถานการณ์ปัญหาที่เกิดขึ้นที่ Dropbox
memory safety ไม่ได้เป็นแนวคิดของ PLT (programming language theory) เท่ากับเป็นคำศัพท์ด้าน software security มากกว่า
สุดท้ายแล้วโปรแกรมเมอร์ Go ก็เข้าใจความต่างนี้ดีอยู่แล้ว และนั่นจึงเป็นเหตุผลที่ Go ตั้งต้นจากแนวคิดประมาณว่า “อย่าสื่อสารด้วยการแชร์ แต่ให้แชร์ผ่านการสื่อสาร”
แน่นอนว่าในโลกจริงแนวคิดนี้ไม่ได้เกิดขึ้นอย่างสมบูรณ์ และทุกคนก็เข้าใจว่า Go ยุคใหม่มีการแชร์มากขึ้นและต้องการการซิงก์
จากประสบการณ์ที่ใช้งาน Go ในระบบจริงมาหลายปี ฉันแทบไม่เคยเห็นบั๊กแบบนี้เกิดขึ้นจริง
Uber เคยสรุปรายละเอียดเกี่ยวกับบั๊กที่เกิดในโค้ด Go เอาไว้ และบทความนี้มีตารางสรุปว่าปัญหานี้เกิดบ่อยแค่ไหนในทางปฏิบัติ
ปัญหาการเข้าถึง map หรือ slice พร้อมกันใน Go ส่วนใหญ่มักเกิดกับ slice เดียวกัน และต้องมีปรากฏการณ์ “torn read” จึงทำให้ในความเป็นจริงไม่ได้พบบ่อย
ถึงอย่างนั้น เหตุผลที่ผู้คนเลี่ยงปัญหาแบบนี้ได้ดีก็น่าจะเป็นเพราะโดยทั่วไปค่อนข้างระมัดระวัง และรู้ดีถึงความเสี่ยงของการ reassign ตัวแปรในสถานการณ์ที่มี concurrent access
ตัวภาษาเองก็มี atomics, channel และ mutex ให้ใช้ จึงพบไม่บ่อยนักที่คนจะใช้ผิดในสถานการณ์ concurrent access และยังมี race detector ที่ช่วยให้เจอปัญหาแบบนี้ได้เร็ว
ต่อให้มี performance hit ฉันก็คิดว่าปัญหา torn read เป็นเรื่องที่แก้ได้ตรงไปตรงมา และในโค้ด Go ที่รันจริงก็ไม่เคยเป็นปัญหาใหญ่
วิดีโอที่เกี่ยวข้อง
แม้แต่ race detector ก็ไม่พบอะไร และไม่มีใครเข้าใจว่าเกิดอะไรขึ้น
ท้ายที่สุดพบว่า loop counter overflow ทำให้การคำนวณเดิมถูกทำซ้ำมหาศาล และบางครั้งคำขอใช้เวลา 3 นาทีแทนที่จะเป็น 100ms
เรารู้ปัญหาทางอ้อมจากการใช้ perf ใน production และประสบการณ์การดีบักแบบ platform developer ของฉันก็ช่วยทีมได้มาก
เพราะเจอสถานการณ์ race ใน Go มาหลากหลายแบบ ส่วนตัวจึงหวังว่า Rust จะถูกนำมาใช้ทุกที่
ตัวอย่างเช่น issue นี้ ต้องอาศัยการ refactor ใหญ่ในคอมไพเลอร์จึงใช้เวลานานมาก
ในทางปฏิบัติจนถึงตอนนี้ยังมีโค้ด Zig แบบ concurrent ไม่มาก ปัญหาจึงยังไม่ปะทุชัด แต่คิดว่าเมื่อฟีเจอร์ async ถูกใช้อย่างแพร่หลายมากขึ้น ปัญหาหลายอย่างอาจระเบิดออกมาพร้อมกัน
แน่นอนว่ามันลดบั๊กได้มากกว่า C แต่ C++ ก็เป็นแบบนั้นเหมือนกัน และไม่มีใครบอกว่า C++ เป็น memory-safe
แน่นอนว่านี่ไม่ได้แปลว่าความเสี่ยงเป็นศูนย์ แต่ก็บ่งชี้ว่าในมุมความปลอดภัยของแอป Go มันอาจไม่ใช่ประเด็นลำดับต้น ๆ
ในทางกลับกัน โค้ด C/C++ มีช่องโหว่จริง 60~75% ที่มาจากปัญหา memory safety
ฉันคิดว่า memory safety ก็เป็นเหมือนสเปกตรัมต่อเนื่อง และหลังจากระดับหนึ่งไปแล้วประโยชน์จะเริ่มลดลง
ต่อให้เป็นบั๊กที่ exploit ไม่ได้ มันก็ยังเป็นบั๊กที่ต้องแก้อยู่ดี
เวลาส่วนใหญ่หมดไปกับการบำรุงรักษามากกว่าการพัฒนาช่วงแรก ถ้าช่วยลดภาระการบำรุงรักษาได้ ต่อให้การเปิดตัวครั้งแรกช้าลงก็คุ้มค่า
แต่ใน Go นั้น thread safety ไม่ใช่สาเหตุหลักของ CVE
ในทางทฤษฎีมันมีเหตุผลรองรับ แต่ในโลกจริงไม่ได้เด่นชัดขนาดนั้น
เมื่อมีการแชร์หน่วยความจำ หากทำให้ data structure เสียหาย ก็อาจทำให้เกิดพฤติกรรมที่ไม่ปลอดภัยหรือไม่ถูกต้องในอีก thread หนึ่งได้
ตัวอย่างเช่น หาก thread หนึ่งเปลี่ยนขนาดของ vector ขณะที่อีก thread เข้าถึงมัน งานที่ปลอดภัยในการรันแบบลำดับก็อาจกลายเป็นอันตรายในสภาวะ concurrent
Go เองก็หนีเรื่องนี้ไม่พ้น
ในทางกลับกัน ถ้าปัญหา thread safety จบแค่ segfault มันอาจเป็นเพียงการโจมตีแบบ DoS (denial of service) เท่านั้น
race condition อาจนำไปสู่การโจมตีที่รุนแรงกว่านั้นได้ แต่ก็ยากกว่ามากในการกระตุ้นให้เกิด
นี่คือสาเหตุหลักของ data corruption และ race
ในหลายสถานการณ์ โมเดลแบบ process-based อาจดีกว่า thread-based สำหรับงาน concurrency แต่ก็มีข้อเสียคือหนักเกินไป
ถ้าค่าเริ่มต้นคือส่งข้อมูลที่แต่ละ thread ต้องใช้ผ่าน message passing ทั้งหมด ปัญหาพวกนี้ส่วนใหญ่ก็คงหายไป
อย่างไรก็ดี ตอนนี้เรามีอิสระที่จะใช้ global variable และ shared memory บนแพลตฟอร์ม ก็แค่ต้องเลือกไม่ใช้เอง
เป้าหมายดั้งเดิมของ Rust ไม่ใช่การเป็น memory-safe systems language แต่เป็น thread-safe systems language และ memory safety เป็นผลที่ตามมาโดยธรรมชาติ
ใน Rust สามารถใช้ structured concurrency ผ่าน
thread::scopeเป็นต้นได้ ทำให้การทำงานกับ thread สะดวกมากดูเอกสารนี้
ตัวอย่างจริง: ในโค้ดด้านบน
buf.Bytes()จะส่งต่อการอ้างอิงถึงหน่วยความจำภายในโดยตรง และเมื่อเรียกReset()หน่วยความจำ backing memory จะถูกนำกลับมาใช้ใหม่ ทำให้ทั้ง processData/main เข้าถึงหน่วยความจำเดียวกันพร้อมกันและเกิด data raceใน Rust โค้ดแบบนี้จะคอมไพล์ไม่ผ่านตั้งแต่แรก เพราะมันคือ mutable reference สองตัว และจะถูกบังคับให้โอน ownership หรือทำสำเนาแทน
ใน Go มันทำให้สับสนได้ง่าย และแม้
bytes.Buffer.ReadBytes("\n")หรือ.String()จะคืนค่าที่เป็นสำเนาจึงปลอดภัย แต่.Bytes()กลับเสี่ยงอย่างในตัวอย่างนี้channel ของ Rust ป้องกันปัญหานี้โดยพื้นฐานผ่านแนวคิด ownership/transfer แต่ Go ไม่มี safety guard แบบนี้
ผลลัพธ์คือมันอาจช้ากว่า mutex และให้ประสบการณ์ที่ยากกว่าสำหรับผู้เริ่มต้น Go ในการใช้งานให้ถูกต้อง
กล่าวคือ race แบบ “ปลอดภัย” หรือ deadlock แบบ “ปลอดภัย” กลับพบได้บ่อยกว่า
ในเชิงทฤษฎีภาษา แนวทาง race freedom ของ Rust อาจดูน่าสนใจ แต่ในแอปจริงข้อมูลสำคัญมักอยู่ใน RDBMS ทั้งหมด และถ้าไม่ใช้
FOR UPDATEกับSELECTก็ยังเกิด race ได้อยู่ดีต่อให้แอป Rust ไม่ใช้
unsafeเลย race ก็ยังมีอยู่ตามพฤติกรรมของ DBGo มีโครงสร้างที่แทบไม่ยอมให้เกิดบั๊ก memory corruption ซึ่งเราพอเห็นได้จากการแทบไม่มี exploit จริง
ถ้าใช้ตรรกะตามบทความนี้ ภาษาระดับสูงส่วนใหญ่ (ยกเว้น Java ที่บทความเหมือนจะยกเว้นไว้) ก็จะกลายเป็นไม่ memory-safe ด้วย
Rust อาจ “ปลอดภัยกว่า” Go ได้ แต่ “memory safety” ไม่ใช่สเปกตรัมต่อเนื่องในความหมาย pass/fail
ถ้าจะอ้างว่าภาษาหนึ่ง memory-unsafe ก็ควรต้องแสดง POC ให้ชัดเจน
ตัวอย่างในบทความแสดงให้เห็นว่า memory corruption สามารถเกิดขึ้นได้ง่ายเพียงเพราะตีความ int เป็น pointer ผิด
ในเดโมจงใจใช้ค่า 42 เลยเกิด segfault แต่ถ้าใช้ค่าที่เป็น address จริงก็จะเกิด corruption จริง
ดังนั้นภาษาที่ปล่อยให้เกิด data race ได้ก็ไม่อาจเรียกว่า memory-safe ได้
ในกรณีแบบนี้ก็ชวนให้สงสัยว่าจะยังเรียกว่า memory-safe ได้หรือไม่
เพื่อหลีกเลี่ยงปัญหานี้จึงมีการใช้ชื่อบุคคลกำกับ เช่น “Gaussian Curvature” หรือ “Riemann Integrals”
ส่วนกรณีที่ “ความหมายดั้งเดิมยังคงอยู่แบบแคบ แต่ถูกขยายไปใช้ในความหมายกว้าง” ก็มีตัวอย่างอย่าง “Galois Group”
memory safety ก็ไม่ใช่ข้อยกเว้นเช่นกัน
ขอให้ยกตัวอย่างที่เป็นรูปธรรม
ใน FAQ อย่างการพูดถึง memory safetyหรือคำตอบเรื่อง unions มีการสื่อเป็นนัยว่า Go เป็น memory-safe แต่ไม่ได้ชัดว่าหมายถึงอะไร
ในงานนำเสนอปี 2012 ของ Rob Pike ก็มีคำว่า "Not purely memory safe" แต่แม้แต่คำว่า 'purely' ก็ยังไม่ได้ถูกนิยาม
เอกสาร race detector ของ Go เองก็มีคำว่า "safe" ที่ความหมายไม่ชัด(ตัวอย่างเอกสาร)
ฝั่งภายนอกกลับยิ่งมีคนอธิบาย Go อย่างหนักแน่นว่าเป็น “memory-safe programming language” บ่อยมาก
ตัวอย่างเช่นเอกสารความปลอดภัยของ fly.io หรือเอกสารของ memorysafety.org ที่จัด Go ว่า memory safe
แต่ในเอกสารเดียวกันนั้นก็อธิบายว่า “Out of Bounds Reads and Writes” เป็นปัญหา memory safety เช่นกัน ซึ่งข้อผิดพลาดของ Go ที่โพสต์นี้ชี้ก็เข้าข่ายเงื่อนไขดังกล่าว
อย่างน้อยที่สุดก็รู้สึกว่า Go และชุมชนควรทำให้ความหมายที่แม่นยำของ “memory safety” ชัดเจนไว้
ตราบใดที่ยังมีกรณีแบบนี้อยู่ ก็คงเหมาะกว่าที่จะไม่เรียก Go ว่าเป็นภาษา memory-safe โดยไม่อธิบายเพิ่มเติม
ตอนที่ Go ถูกสร้างขึ้นนั้น แนวคิดหลักยังเป็นประมาณว่า “ถ้ามี garbage collector ก็ถือว่า memory-safe” และเมื่อเทียบกับ C/C++ แล้วมันก็ปลอดภัยกว่ามาก