- หากนำจำนวน CVE ของ Rust และ C/C++ มาเทียบกันตรงๆ ก็อาจพลาดความต่างของเกณฑ์ที่ใช้มอง ช่องโหว่ด้านความปลอดภัยของหน่วยความจำ ว่าเป็น “ปัญหาของไลบรารี”
- ใน C/C++ แม้การเรียก API ผิดจะทำให้เกิด UB หรือ segfault ก็มักถูกมองว่าเป็น การใช้งานผิดของโค้ดผู้ใช้ และไม่ได้ลงทะเบียนความเป็นไปได้ทั้งหมดเป็น CVE
- การเรียก
curl_getenv(NULL) ใน libcurl สามารถคอมไพล์ได้โดยไม่มีคำเตือน และอาจ segfault ตอนรันไทม์ แต่โดยทั่วไปไม่ได้ถูกมองว่าเป็นช่องโหว่ของ curl
- ใน Rust หากโค้ดผู้ใช้ไม่มี
unsafe แต่เกิดบั๊กหน่วยความจำได้จากการเรียกใช้เฉพาะ safe API ก็จะถือว่าเป็น soundness bug ของไลบรารี
- ด้วยเหตุนี้ CVE บางส่วนของ Rust จึงถูกบันทึกด้วยเกณฑ์ที่เข้มงวดกว่า C/C++ และการดูแค่ จำนวน CVE ดิบ จึงทำให้ตัดสินความปลอดภัยของหน่วยความจำได้ยาก
เหตุผลที่การเปรียบเทียบจำนวน CVE อาจคลาดเคลื่อน
- CVE คือฐานข้อมูลสำหรับจัดหมวดหมู่และรายงานช่องโหว่ด้านความปลอดภัยของซอฟต์แวร์
- ช่องโหว่อาจเกิดจากบั๊กตรรกะของโปรแกรมธรรมดา หรือมาจาก ปัญหาความปลอดภัยของหน่วยความจำ ที่นำไปสู่การโจมตีได้ง่าย
- มีการนำจำนวน CVE ของ Rust กับ C/C++ มาเปรียบเทียบแล้วอ้างว่า Rust “จริงๆ แล้วไม่ได้ปลอดภัยด้านหน่วยความจำ” หรือ “ไม่คุ้มค่าที่จะนำมาใช้”
- แต่แนวทางที่ทั้งสอง ecosystem ใช้จัดการกับช่องโหว่ที่อาจเกี่ยวข้องกับความปลอดภัยของหน่วยความจำนั้นต่างกันมาก
Rust ก็ยังมีช่องโหว่ได้
- โปรแกรม Rust ก็สามารถก่อให้เกิด UB และบั๊กด้านความปลอดภัยของหน่วยความจำได้
- โดยส่วนใหญ่ปัญหาแบบนี้ต้องอาศัยคีย์เวิร์ด
unsafe
- การอ้างว่าโปรแกรม Rust จะไม่มีวันเจอ UB เลยนั้นไม่ถูกต้อง
- ช่องโหว่ทั่วไปที่ไม่เกี่ยวกับความปลอดภัยของหน่วยความจำก็เกิดใน Rust ได้เช่นกัน
- เช่น ลืมตรวจสิทธิ์การเข้าถึงหน้าแดชบอร์ดผู้ดูแล ซึ่งเกิดได้ในทุกภาษา
ตัวอย่างไลบรารี C: curl_getenv(NULL)
curl เป็นไลบรารีเครือข่ายที่พัฒนาด้วย C ใช้งานแพร่หลายและมีการดูแลอย่างดี
curl_getenv ของ libcurl เป็นฟังก์ชัน abstraction แบบพกพาสำหรับดึงค่าตัวแปรสภาพแวดล้อมบนหลายระบบปฏิบัติการ
- โปรแกรม C ต่อไปนี้ส่งพอยน์เตอร์
NULL ให้กับ curl_getenv
#include <curl/curl.h>
int main(void) {
curl_getenv(NULL);
}
- โปรแกรมนี้สามารถคอมไพล์ได้ด้วย
gcc test.c -otest -lcurl -Wall -Wextra โดยไม่มีคำเตือน
- เมื่อรันแล้วอาจเกิด segfault ซึ่งอาจมองได้ว่าเป็น บั๊กด้านความปลอดภัยของหน่วยความจำ และเป็นช่องโหว่ที่เป็นไปได้
- แต่ตัวอย่างลักษณะนี้โดยทั่วไปไม่ได้ถือว่าเป็นประเด็นที่ต้องรายงานเป็นช่องโหว่ของ
curl
ใน C/C++ ไม่ได้สร้าง CVE จากแค่ความเป็นไปได้ของการใช้งานผิด
- กรณีที่เกิดปัญหาอย่าง
curl_getenv(NULL) มักถูกมองว่าเป็น การใช้งาน API ผิด
- ตำแหน่งของข้อบกพร่องก็มักถูกมองว่าอยู่ที่โค้ดแอปพลิเคชัน ไม่ใช่ที่ไลบรารีหรือ API
- ธรรมเนียมนี้มีเหตุผลอยู่ 2 ข้อ
- ระบบชนิดข้อมูลที่มีข้อจำกัดของ C ทำให้แสดงสัญญาของ API, invariants, preconditions และ postconditions ได้อย่างแม่นยำยาก
- และก็ไม่ใช่เรื่องปฏิบัติได้จริงที่จะเอกสารการใช้งานผิดที่เป็นไปได้ทั้งหมด
- ในความเป็นจริง เอกสารของ
curl_getenv ไม่ได้บอกว่าห้ามเรียกด้วย NULL และอาจนำไปสู่ segfault
- ใน C/C++ การทำให้เกิด UB โดยไม่ตั้งใจนั้นง่ายมาก หากรายงานความเป็นไปได้ของช่องโหว่ทั้งหมดเป็น CVE ไลบรารีส่วนใหญ่ก็อาจถูกถล่มด้วย CVE จำนวนมหาศาล
- ดังนั้นใน C/C++ โดยทั่วไปจึงสร้าง CVE จาก กรณีการใช้งานผิดเฉพาะแบบ มากกว่าการมีอยู่ของ “API ที่อาจถูกใช้ผิดได้”
ใน Rust ขอบเขตความรับผิดชอบของ safe API ต่างออกไป
- หากสมมติว่าใน Rust การเรียกแบบปลอดภัยอย่าง
hyper::foo(None) เพียงอย่างเดียวทำให้โปรแกรม segfault ได้ นั่นอาจกลายเป็น CVE ของ hyper
- เพราะถ้าโค้ดผู้ใช้ไม่มีบล็อก
unsafe แต่ยังเกิดบั๊กหน่วยความจำได้ ก็แปลว่าไลบรารีนั้นต้องมี soundness bug
- ใน Rust หากสามารถทำให้เกิดบั๊กหน่วยความจำได้จากการใช้ API ของไลบรารีแบบปลอดภัยไม่ว่าด้วยวิธีใด ก็จะถือว่าเป็นบั๊กของไลบรารี ไม่ใช่ของโค้ดผู้ใช้
- API แบบนี้จะถูกเรียกว่า unsound หรือมี soundness hole
- แม้ในโปรแกรมจริงจะยังไม่พบปัญหา แต่ถ้าการใช้ safe API เพียงอย่างเดียวสามารถทำให้เกิดบั๊กหน่วยความจำได้ ก็อาจมีการออก CVE ได้
safe กับ unsafe ทำให้เห็นขอบเขตความรับผิดชอบ
- ใน Rust คำตอบของคำถามว่า “ฟังก์ชันนี้ถูกใช้อย่างถูกต้องในมุมมองความปลอดภัยของหน่วยความจำหรือไม่” ชัดเจนกว่า C/C++
- ถ้าฟังก์ชันที่เรียกไม่ได้ถูกระบุเป็น
unsafe ก็ควรใช้งานได้อย่างปลอดภัย
- ถ้าฟังก์ชันที่เรียกเป็น
unsafe ก็ต้องมีบล็อก unsafe ที่จุดเรียก ทำให้การรีวิวโค้ดและการมองหาจุดเสี่ยงใน codebase ชัดเจนขึ้น
- การแบ่งเช่นนี้เป็นองค์ประกอบที่ทำให้ความปลอดภัยของหน่วยความจำใน Rust ขยายใช้ในงานจริงได้
- หากโค้ดผู้ใช้ไม่ได้ใช้
unsafe และไม่มีบั๊กของคอมไพเลอร์ ก็ยากที่จะบอกว่าสาเหตุด้านความปลอดภัยของหน่วยความจำเป็นความรับผิดชอบของโค้ดผู้ใช้
- หากไลบรารีไม่ได้เปิดเผยอินเทอร์เฟซ
unsafe ผู้ใช้ก็ไม่ควรสามารถใช้ไลบรารีนั้นในลักษณะที่ทำให้เกิดบั๊กหน่วยความจำได้
- ต่อให้ไลบรารีใช้
unsafe ภายในแล้วเกิดบั๊ก การแก้ไขก็จะเกิดขึ้นภายในไลบรารี และผู้ใช้ก็จะกลับมาปลอดภัยจากบั๊กหน่วยความจำอีกครั้ง
ยากที่จะเปรียบเทียบความปลอดภัยของหน่วยความจำจากจำนวน CVE ดิบเพียงอย่างเดียว
- หากใช้ตรรกะเดียวกันกับ C ก็ต้องนับ
curl_getenv เป็น CVE ของ curl ด้วย แต่ใน C ไม่มีการแบ่งแบบ safe และ unsafe เหมือน Rust
- ในทางปฏิบัติ โค้ด C แทบทั้งหมดใกล้เคียงกับ
unsafe โดยปริยาย จึงยากจะนำเกณฑ์แบบ Rust ไปใช้ตรงๆ
- แม้นักพัฒนาไลบรารี C/C++ จะสร้างไลบรารีที่ปลอดภัยและทนทานเพียงใด โปรแกรม C จำนวนมหาศาลที่นำไปใช้ก็ยังสามารถสร้างปัญหาความปลอดภัยของหน่วยความจำได้ง่ายจากการใช้ API ผิดวิธี
- ความต่างนี้ไม่ได้มีเฉพาะ
curl แต่ใช้ได้กับไลบรารี C/C++ แทบทั้งหมด รวมถึงไลบรารีมาตรฐานของทั้งสองภาษา
- การเปรียบเทียบแบบ จำนวนดิบ เช่น จำนวน CVE ต่อบรรทัดโค้ด ระหว่าง Rust กับ C/C++ อาจทำให้ประเมินความปลอดภัยของหน่วยความจำผิดไป
1 ความคิดเห็น
ความคิดเห็นจาก Lobste.rs
อาจเป็นคำถามแบบไร้เดียงสา แต่ถ้าปัญหาหลายอย่างของ C/C++ มาจาก พฤติกรรมที่ไม่ถูกกำหนดไว้ ก็สงสัยว่าทำไมไม่กำหนดมันไปเสียเลย
อย่างแรก มีบางอย่างที่เป็นเศษซากทางประวัติศาสตร์ซึ่งเดี๋ยวนี้ไม่มีใครสนใจแล้ว จึง “แค่กำหนดมันไป” ได้ และอย่างที่ @fanf บอก งานในส่วนนี้ก็กำลังดำเนินอยู่ ตัวอย่างเช่น ในภาษา C ไฟล์ซอร์สที่มี string literal ที่ไม่ปิดให้ครบถือเป็นพฤติกรรมที่ไม่ถูกกำหนดไว้จริง ๆ
อย่างที่สอง มีบางอย่างที่กำหนดได้ แต่ต้องแลกด้วยต้นทุนด้านประสิทธิภาพ ตัวอย่างเด่นคือ signed integer overflow ซึ่งถ้ากำหนดให้วนรอบไปเลย มันก็จะไม่ใช่พฤติกรรมที่ไม่ถูกกำหนดไว้อีกต่อไป แต่คอมไพเลอร์ก็จะทำ optimization ที่ตั้งอยู่บนสมมติฐานว่า “เรื่องนี้ไม่มีทางเกิดขึ้น” ไม่ได้ คนฝั่งคอมไพเลอร์ในคณะกรรมการมีอยู่มาก และคนกลุ่มนี้ก็มักหมกมุ่นกับ benchmark เลยคิดว่าไม่น่าจะแก้กันได้ง่าย ๆ ถึงอย่างนั้นก็ไม่ใช่ว่าจะไม่มีการเปลี่ยนแปลงเลย เช่น P2723 เสนอให้ภาษา C++ ทำการกำหนดค่าเริ่มต้นเป็น 0 แบบ implicit ให้ตัวแปรโลคัลทุกตัวที่ไม่เช่นนั้นจะไม่ได้รับการกำหนดค่าเริ่มต้น
อย่างที่สาม มีบางอย่างที่ยากจะกำหนดพฤติกรรมอย่างสมเหตุสมผล ตัวอย่างที่ดีคือ use-after-free ถ้าไม่บังคับใช้ระบบ capability runtime หนัก ๆ แบบ Fil-C กับทุกคน หรือไม่เพิ่ม lifetime annotation แบบ Rust เข้าไปทั่วทั้งภาษา ก็ไม่ชัดว่าจะจำกัดช่วงของพฤติกรรมที่อาจเกิดจาก use-after-free ได้อย่างไร จะเขียนไว้ก็ได้ว่า “ถ้าใช้หลัง free มันอาจแตะหน่วยความจำที่ตอนนั้นอยู่ตรงนั้น หรือ segfault/abort” แต่ก็ไม่ช่วยใครอยู่ดี มันยังอันตรายเหมือนเดิม ยังทำให้เกิด CVE แบบเดิมได้ และก็ยังพูดอย่างมีความหมายไม่ได้ว่าโปรแกรมจะทำหรือจะทำอะไรต่อไม่ได้หลังจากนั้น ดังนั้นมันก็ยังเป็นพฤติกรรมที่ไม่ถูกกำหนดไว้ในอีกชื่อหนึ่ง
น่าเสียดายที่หมวดที่สามนี่เองที่มีผลกระทบอย่างท่วมท้น ดังนั้นแม้การ “กำหนดให้มันไปเลย” กับบางส่วนจะเป็นเรื่องดี แต่มันก็ไม่ได้เปลี่ยนภาพรวมมากนัก
เท่าที่รู้ ฝั่งไลบรารียังแทบไม่ได้เริ่มจัดการกันมากนัก แต่ฟังก์ชันที่รับอาร์กิวเมนต์ขนาดถูกปรับให้ทำงานกับ null pointer อย่างสมเหตุสมผลแล้ว เพราะเกี่ยวข้องกับการเปลี่ยนภาษาที่อนุญาตให้บวก 0 เข้ากับ null pointer ได้ ในทำนองเดียวกันยังมีฟังก์ชันอีกมากที่น่าจะแก้ได้ แต่การเปลี่ยน
getenv()น่าจะควรประสานกับ POSIX ด้วยประโยชน์ด้านประสิทธิภาพแบบนั้นแทบทั้งหมดเป็นเรื่องเฉพาะจุดและอย่างมากก็เล็กน้อย ถ้ามีฟังก์ชันที่เรียก
rm -rf /แต่ในทางปฏิบัติไม่มีวันถูกเรียก และคุณสร้างการเรียกผ่าน function pointer ที่มีพฤติกรรมไม่ถูกกำหนดไว้ คอมไพเลอร์ก็ได้รับอนุญาตในทางเทคนิคให้สร้างโค้ดที่เรียกฟังก์ชันลบดิสก์นั้นแบบไม่มีเงื่อนไขได้ ท้ายที่สุดมันก็เป็นแค่การออกแบบสเปกที่แย่และมรดกตกค้างเท่านั้นfor (int ii = 0; ii < something; ii++)สามารถมองข้ามความเป็นไปได้ของsomething == INT_MAXได้ เพราะอาศัยข้อเท็จจริงว่า signed integer overflow ไม่ได้ถูกกำหนดไว้ และสิ่งนี้ทำให้เกิด loop transformation ได้หลายแบบใน Rust ความสามารถที่เทียบเท่ากันถูกแยกเป็นฟังก์ชันปลอดภัยกับฟังก์ชัน
unsafeฟังก์ชันปลอดภัยอาจช้ากว่าเล็กน้อย ส่วนฟังก์ชันunsafeหากใช้ผิดก็อาจยอมให้เกิดพฤติกรรมที่ไม่ถูกกำหนดไว้ได้ ดูi32::wrapping_add()กับi32::unchecked_add()ถ้า C สามารถทำเครื่องหมายบางฟังก์ชันว่า
unsafeและเพิ่มไวยากรณ์ที่อนุญาตให้ใช้ฟังก์ชันunsafeได้ในบางขอบเขต เราก็อาจเริ่มกำหนดทางเลือกที่ปลอดภัยขึ้นได้ แต่พอถึงจุดหนึ่ง ความพยายามในการเปลี่ยน C และที่สำคัญกว่านั้นคือการเปลี่ยนความคิดของคนที่ควบคุม C ก็เริ่มไม่คุ้มกับเป้าหมาย และการไปหาภาษาที่เหมาะกับเป้าหมายมากกว่าน่าจะง่ายกว่าใน C ถ้าส่ง pointer ที่ชี้ไปยัง heap object ให้
freeแล้วจากนั้นเข้าถึง object นั้นอีก ก็ถือเป็นพฤติกรรมที่ไม่ถูกกำหนดไว้ ใน CHERIoT กำหนดให้กรณีนี้เกิด trap ได้ แต่ที่ทำได้ก็เพราะเราสร้างฮาร์ดแวร์ที่ทำให้สิ่งนี้เป็นไปได้ มาตรฐานต้องรองรับฮาร์ดแวร์หลากหลายชนิด จึงเกิดคำถามว่าจะกำหนดมันอย่างไรโดยคร่าว ๆ มีสองแนวทาง อย่างแรกคือเลื่อนการ free ออกไป และบอกว่า object จะไม่หายไปจนกว่า pointer ทั้งหมดที่ชี้ไปยังมันจะหายไปด้วย วิธีนี้ต้องการอะไรบางอย่างคล้าย garbage collector และมี overhead สูงเกินรับไหวสำหรับงาน C หลายประเภท อีกวิธีคือกำหนด type system ที่รู้ตำแหน่งของ pointer ทั้งหมดที่ชี้ไปยัง object นั้น และสามารถทำให้มันเป็นโมฆะได้ Rust เลือกแนวทางหลัง ดังนั้นการจะทำโครงสร้างข้อมูลที่ไม่ใช่ต้นไม้ใน Rust จึงต้องพึ่ง
unsafeหรือความสามารถใน standard library ที่ใช้unsafeเรื่องแบบนี้ใส่เข้าไปได้ตั้งแต่ขั้นออกแบบภาษา แต่แทบเป็นไปไม่ได้เลยที่จะมาเติมทีหลังเรื่อง out-of-bounds ก็คล้ายกัน ในระบบ CHERI ขอบเขตของ object หรือ subobject เป็นส่วนโดยเนื้อแท้ของ pointer ดังนั้นการเข้าถึงนอกขอบเขตจะเกิด trap บนแพลตฟอร์มอื่น pointer เป็นเพียง word ที่เก็บ address ไว้เท่านั้น พอทำ arithmetic ไปแล้วก็ไม่มีทางแมปกลับไปหา object เดิมได้ จึงกลายเป็นปัญหาว่าจะเอาขอบเขตมาจากไหน เครื่องมืออย่าง AddressSanitizer จะเก็บขอบเขตไว้ในโครงสร้างแยก และบังคับให้ตรวจสอบตอนทำ pointer arithmetic แต่มี overhead ด้านหน่วยความจำและประสิทธิภาพสูงมาก จนใน production การใช้ Java มักดีกว่าการเปิด ASan กับ C มาก และน่าจะเขียนโค้ดได้เร็วกว่าอีกด้วย
เคยคิดว่า null pointer dereference เป็น พฤติกรรมที่กำหนดไว้ชัดเจน
มีอยู่จุดหนึ่งในบทความนี้ที่รู้สึกติดใจ
SEGFAULT คือการโจมตีแบบปฏิเสธการให้บริการเช่นเดียวกับ panic
ทั้งสองอย่างอยู่ในหมวดความผิดพลาดเดียวกัน และเวลาพูดถึง memory safety ปกติสิ่งที่นึกถึงคือ stack smashing, data corruption, code corruption อะไรทำนองนี้ ซึ่งเรื่องเหล่านี้ใน Rust ทำได้ยากกว่ามาก มากจริง ๆ และใน C เองก็ทำให้ยากขึ้นได้ระดับหนึ่ง
โดยรวมแล้วบทความดูเหมือนกำลังพูดว่า type system ของ C ห่วยมากกว่าอะไรอื่น ใน C++ เราป้องกันความผิดพลาดพวกนี้ได้ และใน C ถ้าใช้แอตทริบิวต์
nonnullของ GCC ก็สามารถยกระดับการส่งNULLเข้าไปในฟังก์ชันให้เป็น compiler error ได้ส่วนตัวคิดว่า การเข้าถึงนอกขอบเขต น่าจะเป็นตัวอย่างที่ดีกว่าและเป็นตัวแทนมากกว่า
panic เป็น การตรวจสอบความปลอดภัย ที่ฝังมาในโปรแกรม เกิดขึ้นได้อย่างสม่ำเสมอ และมีพฤติกรรมที่กำหนดไว้อย่างชัดเจน
ส่วน segfault คือ ระบบปฏิบัติการ จับการกระทำหน่วยความจำที่ผิดพลาดได้ และจะเกิดเฉพาะกับ address ที่อยู่นอก page ใน virtual memory map ของโปรแกรมเท่านั้น เพราะฉะนั้นบั๊ก segfault จำนวนมากจึงสามารถถูกบิดให้กลายเป็นการรันโค้ดตามอำเภอใจได้
ทั้งสองอย่างดูเหมือนให้ผลลัพธ์คล้ายกันในกรณีปกติเท่านั้น แต่โดยพื้นฐานแล้วเป็นคนละเรื่องกัน