บั๊กที่ Rust จับไม่ได้
(corrode.dev)- ความปลอดภัยของหน่วยความจำ ดีขึ้นอย่างมาก แต่แม้ในโค้ด Rust ระดับโปรดักชัน ปัญหาเรื่อง การจัดการขอบเขตระหว่างระบบ ก็ยังคงอยู่และอาจนำไปสู่ช่องโหว่ได้
- โฟลว์ที่ตีความพาธเดิมซ้ำในหลาย syscall, วิธีสร้างแล้วค่อยเปลี่ยนสิทธิ์, และ การเปรียบเทียบพาธด้วยสตริง ล้วนทำให้เกิดปัญหาอย่าง TOCTOU และการเปิดเผยสิทธิ์ได้ง่าย
- บน Unix พาธ ตัวแปรสภาพแวดล้อม และข้อมูลสตรีม ถูกส่งต่อกันในรูป ไบต์ดิบ ดังนั้นการประมวลผลที่ยึด
Stringเป็นหลัก หรือการใช้from_utf8_lossy,unwrap,expectอาจนำไปสู่ข้อมูลเสียหายหรือ DoS ได้ - หากทิ้งข้อผิดพลาดไป ความล้มเหลวอาจดูเหมือนความสำเร็จ และ ความแตกต่างของพฤติกรรม เมื่อเทียบกับ GNU coreutils ก็อาจกลายเป็นปัญหาความปลอดภัยได้ทันทีในเชลล์สคริปต์และเครื่องมือแบบ privileged
- ในการตรวจสอบครั้งนี้ ไม่พบบั๊กตระกูลความปลอดภัยของหน่วยความจำอย่าง buffer overflow, use-after-free, double-free และความเสี่ยงหลักที่ยังเหลืออยู่ก็ไม่ได้อยู่ภายใน Rust เอง แต่ไปกระจุกอยู่ที่ ขอบเขตที่เชื่อมต่อกับโลกภายนอก
ข้อจำกัดของ Rust ที่เปิดเผยจากการตรวจสอบ
- 44 CVE ของ uutils ที่ Canonical เปิดเผย แสดงให้เห็นว่าแม้จะเป็นโค้ด Rust ระดับโปรดักชัน ก็ยังมีช่องโหว่ที่ borrow checker, clippy และ cargo audit จับไม่ได้
- จุดศูนย์กลางของปัญหาอยู่ที่ การจัดการขอบเขตระหว่างระบบ มากกว่าความปลอดภัยของหน่วยความจำ
- มีช่วงเวลาคั่นระหว่างพาธกับ syscall
- ข้อมูลไบต์แบบ Unix กับสตริง UTF-8 ไม่ตรงกัน
- มีพฤติกรรมที่ต่างจากเครื่องมือต้นฉบับ
- มีการละเลยการจัดการข้อผิดพลาดและการจบแบบ
panic!
- รายการ CVE นี้สรุปให้เห็นอย่างกระชับว่า จุดไหนคือ ขอบเขตที่ความปลอดภัยของ Rust สิ้นสุดลง ในโค้ดระบบที่เขียนด้วย Rust
การตีความพาธสองครั้งทำให้เกิด TOCTOU
- ถ้าตรวจสอบพาธเดียวกันใน syscall หนึ่ง แล้วค่อยไปทำงานกับพาธนั้นอีกครั้งใน syscall ถัดไป ก็มีแนวโน้มสูงที่จะนำไปสู่ช่องโหว่แบบ TOCTOU
- ระหว่างสองการเรียก ผู้โจมตีที่มีสิทธิ์เขียนในไดเรกทอรีแม่สามารถเปลี่ยนองค์ประกอบของพาธให้เป็น symbolic link ได้
- ในการเรียกครั้งที่สอง เคอร์เนลจะตีความพาธใหม่ตั้งแต่ต้น ทำให้งานที่มีสิทธิ์พิเศษถูกส่งไปยังเป้าหมายที่ผู้โจมตีเลือก
- API
std::fsของ Rust ใช้การ ตีความใหม่จาก&Pathเป็นค่าเริ่มต้น จึงทำให้พลาดแบบนี้ได้ง่ายfs::metadata,File::create,fs::remove_file,fs::set_permissionsจะตีความพาธใหม่ทุกครั้งที่เรียก- สำหรับเครื่องมือแบบ privileged ที่ต้องป้องกันผู้โจมตีในเครื่อง เส้นทางค่าเริ่มต้นแบบนี้จึงมีความเสี่ยง
- ใน
CVE-2026-35355มีการโจมตีผ่านโฟลว์ที่ลบไฟล์แล้วสร้างไฟล์ใหม่ด้วยพาธเดิม- ใน
src/uu/install/src/install.rsมีfs::remove_file(to)?ต่อด้วยFile::create(to)? - หากระหว่างการลบกับการสร้าง
toถูกเปลี่ยนให้ชี้ไปยัง symbolic link ของเป้าหมายอย่าง/etc/shadowโปรเซสที่มีสิทธิ์สูงก็อาจเขียนทับไฟล์นั้นได้
- ใน
- การแก้ไขเปลี่ยนไปใช้
OpenOptions::create_new(true)เพื่อให้ สร้างได้เฉพาะไฟล์ใหม่เท่านั้น- ตามเอกสาร
create_newไม่ยอมรับทั้งไฟล์เดิมที่มีอยู่แล้วและ dangling symlink ที่ตำแหน่งปลายทาง
- ตามเอกสาร
- หากจำเป็นต้องทำงานกับพาธเดิมสองครั้ง การ ยึดกับ file descriptor จะปลอดภัยกว่า
- นอกเหนือจากกรณีสร้างไฟล์ใหม่ วิธีที่เหมาะสมคือเปิดไดเรกทอรีแม่ครั้งเดียว แล้วทำงานด้วยพาธสัมพัทธ์อ้างอิงจากแฮนเดิลนั้น
- ถ้าต้องทำงานกับพาธเดิมสองครั้ง ให้ถือว่าเป็น TOCTOU ไว้ก่อนจนกว่าจะพิสูจน์ได้ว่าไม่ใช่
อย่าแก้สิทธิ์หลังสร้าง แต่ต้องกำหนดสิทธิ์ตั้งแต่ตอนสร้าง
- โฟลว์ที่สร้างไดเรกทอรีหรือไฟล์ด้วยสิทธิ์เริ่มต้น แล้วค่อย
chmodภายหลัง ก็ทำให้เกิด ช่วงเวลาที่เปิดเผยสิทธิ์ชั่วคราว- หากเขียนแบบ
fs::create_dir(&path)?แล้วตามด้วยfs::set_permissions(&path, Permissions::from_mode(0o700))?ในช่วงนั้นpathจะมีอยู่ด้วยสิทธิ์เริ่มต้น - ผู้ใช้อื่นสามารถ
open()ได้ในช่วงหน้าต่างเวลานั้น และถึงจะchmodภายหลัง file descriptor ที่ได้ไปแล้วก็เรียกคืนไม่ได้
- หากเขียนแบบ
- สิทธิ์ควรถูก กำหนดไปพร้อมกับการสร้าง
- ควรใช้
OpenOptions::mode()และDirBuilderExt::mode()เพื่อให้มันถูกสร้างมาด้วยสิทธิ์ที่ต้องการ - เคอร์เนลจะนำ
umaskมาใช้เพิ่มเติมด้วย ดังนั้นหากผลของมันสำคัญก็ต้องจัดการumaskอย่างชัดเจนเช่นกัน
- ควรใช้
การเปรียบเทียบสตริงของพาธไม่ใช่ความเป็นวัตถุเดียวกันในไฟล์ซิสเต็ม
- การตรวจสอบ
--preserve-rootเวอร์ชันแรกของchmodใช้แค่ การเปรียบเทียบสตริงrecursive && preserve_root && file == Path::new("/")- อินพุตที่จริง ๆ แล้วชี้ไปยังรูท แต่สตริงไม่ใช่
/เช่น/../,/./,/usr/..หรือ symbolic link ที่ชี้ไปยัง/สามารถหลบการตรวจสอบนี้ได้
- การแก้ไขเปลี่ยนไปใช้
fs::canonicalizeเพื่อตีความพาธเป็นพาธสัมบูรณ์จริงก่อนแล้วค่อยเปรียบเทียบ- PR ที่แก้ไข
canonicalizeจะคืนพาธจริงหลังแก้..,., และ symbolic link แล้ว
- ในกรณีของ
--preserve-rootวิธีนี้ใช้ได้เพราะ/ไม่มีไดเรกทอรีแม่ - ถ้าต้องการเปรียบเทียบโดยทั่วไปว่าพาธสองอันอ้างถึงวัตถุเดียวกันในไฟล์ซิสเต็มหรือไม่ ควรเปรียบเทียบ
(dev, inode)แทนสตริง- GNU coreutils ก็ใช้แนวทางนี้เช่นกัน
- ใน
CVE-2026-35363rmปฏิเสธ.และ..แต่ยอมรับ./กับ.///ทำให้สามารถลบไดเรกทอรีปัจจุบันได้- หากจัดการความต่างของรูปแบบอินพุตแค่ในระดับสตริง การตรวจสอบจะถูกเลี่ยงได้ง่าย
ที่ขอบเขตของ Unix ต้องให้ความสำคัญกับไบต์มากกว่าสตริง
Stringและ&strของ Rust เป็น UTF-8 เสมอ แต่พาธ ตัวแปรสภาพแวดล้อม อาร์กิวเมนต์ และข้อมูลสตรีมของ Unix อยู่ในโลกของ ไบต์ดิบ- หากเลือกวิธีผิดตอนข้ามขอบเขตนี้ จะนำไปสู่บั๊กได้สองกลุ่ม
- การแปลงแบบสูญเสียข้อมูล อย่าง
from_utf8_lossyจะเปลี่ยนไบต์ที่ไม่ถูกต้องเป็นU+FFFDและทำให้ข้อมูลเสียหายแบบเงียบ ๆ - การแปลงแบบเข้มงวด อย่าง
unwrapหรือ?อาจปฏิเสธอินพุตหรือทำให้โปรเซสจบการทำงาน
- การแปลงแบบสูญเสียข้อมูล อย่าง
CVE-2026-35346ของcommเป็นกรณีที่เอาต์พุตเสียหายจากการแปลงแบบสูญเสียข้อมูล- ใน
src/uu/comm/src/comm.rsมีการแปลงไบต์อินพุตra,rbเป็นString::from_utf8_lossyแล้วprint! - GNU
commจะส่งผ่านไบต์ตามเดิมแม้เป็นไฟล์ไบนารี แต่ uutils กลับเปลี่ยน UTF-8 ที่ไม่ถูกต้องเป็นU+FFFDทำให้ เอาต์พุตเสียหาย - การแก้ไขคือใช้
BufWriterและwrite_allเพื่อเขียน raw bytes ลงstdoutโดยตรง
- ใน
print!บังคับให้เกิด การไป-กลับผ่าน UTF-8 ผ่านDisplayแต่Write::write_allไม่ทำเช่นนั้น- ในโค้ดระบบบน Unix ต้องเลือกชนิดข้อมูลให้เหมาะกับงาน
- หากอ้อมผ่าน
Stringเพื่อความสะดวกในการฟอร์แมต ความเสียหายของข้อมูล จะซึมเข้ามาได้ง่าย
ทุก panic อาจกลายเป็นการโจมตีแบบปฏิเสธการให้บริการ
- ใน CLI การใช้
unwrap,expect, การเข้าถึงด้วยดัชนีสไลซ์, เลขคณิตที่ไม่ตรวจสอบ,from_utf8อาจกลายเป็น จุด DoS ได้เมื่อผู้โจมตีควบคุมอินพุตได้panic!จะ unwind สแตกและหยุดโปรเซส- หากกำลังทำงานอยู่ใน cron job, CI pipeline หรือ shell script งานทั้งหมดอาจหยุดลง
- ในสภาพแวดล้อมที่รันซ้ำ ๆ มันอาจก่อให้เกิด crash loop จนทำให้ทั้งระบบใช้งานไม่ได้
CVE-2026-35348ของsort --files0-fromหยุดทำงานเมื่อพบ ชื่อไฟล์ที่ไม่ใช่ UTF-8 ในรายการชื่อไฟล์ที่คั่นด้วย NUL- ตัวพาร์เซอร์เรียก
std::str::from_utf8(bytes).expect(...)กับไบต์ของแต่ละชื่อ - GNU
sortจัดการชื่อไฟล์เป็น raw bytes เช่นเดียวกับเคอร์เนล แต่ uutils บังคับ UTF-8 ทำให้ทั้งโปรเซสหยุดทันทีเมื่อเจอพาธแรกที่ไม่ใช่ UTF-8
- ตัวพาร์เซอร์เรียก
- ในโค้ดที่ประมวลผลอินพุตที่เชื่อถือไม่ได้ ควรมอง
unwrap,expect, การ indexing, และascast ว่าเป็น CVE ที่อาจเกิดขึ้นได้- ควรใช้
?,get,checked_*,try_fromและส่งข้อผิดพลาดจริงกลับไปยังผู้เรียก
- ควรใช้
- มีการเสนอเกณฑ์ clippy สำหรับจับเรื่องนี้ใน CI ด้วย
unwrap_usedexpect_usedpanicindexing_slicingarithmetic_side_effects
- ในโค้ดทดสอบ คำเตือนเหล่านี้อาจเข้มเกินไป จึงเหมาะที่จะจำกัดไว้ภายใต้ขอบเขต
cfg(test)
ถ้าทิ้งข้อผิดพลาด ความล้มเหลวอาจดูเหมือนความสำเร็จ
- CVE บางส่วนเกิดจากการเพิกเฉยต่อข้อผิดพลาด หรือจาก โฟลว์ที่ข้อมูลข้อผิดพลาดสูญหายไป
chmod -Rและchown -Rคืนค่าแค่ exit code ของไฟล์สุดท้าย ในการทำงานทั้งหมด- แม้จะล้มเหลวกับไฟล์ก่อนหน้าหลายไฟล์ แต่ถ้าไฟล์สุดท้ายสำเร็จก็อาจจบด้วย
0 - ทำให้สคริปต์เข้าใจผิดว่างานทั้งหมดเสร็จสมบูรณ์แล้ว
- แม้จะล้มเหลวกับไฟล์ก่อนหน้าหลายไฟล์ แต่ถ้าไฟล์สุดท้ายสำเร็จก็อาจจบด้วย
ddเรียกResult::ok()กับผลของset_len()เพื่อเลียนแบบพฤติกรรมของ GNU กับ/dev/null- เจตนาคือทิ้งข้อผิดพลาดเฉพาะในสถานการณ์จำกัด แต่โค้ดเดียวกันกลับถูกใช้กับไฟล์ทั่วไปด้วย
- แม้ดิสก์จะเต็ม ก็อาจเหลือ ไฟล์ปลายทางที่เขียนค้างไว้เพียงครึ่งเดียว แบบเงียบ ๆ
- หากใช้
.ok(),.unwrap_or_default(), หรือlet _ =เพื่อทิ้งResultสาเหตุสำคัญของความล้มเหลวจะหายไป - ถึงจะไม่หยุดทันทีเมื่อเกิดความล้มเหลวครั้งแรก ก็ควรจำ รหัสข้อผิดพลาดที่ร้ายแรงที่สุด ไว้แล้วจบการทำงานด้วยค่านั้น
- หากจำเป็นต้องทิ้ง
Resultจริง ๆ ก็ควร เขียนเหตุผลไว้ในโค้ด ว่าทำไมการเพิกเฉยต่อความล้มเหลวนั้นจึงปลอดภัย
ความเข้ากันได้กับเครื่องมือต้นฉบับอย่างแม่นยำก็เป็นฟีเจอร์ด้านความปลอดภัย
- CVE หลายรายการไม่ได้เกิดเพราะโค้ดทำงานอันตราย แต่เกิดจาก การทำงานต่างจาก GNU
- ในโลกจริง shell script ต่างอาศัยพฤติกรรมของ GNU ต้นฉบับอยู่ และความต่างด้านความหมายเหล่านี้อาจนำไปสู่ปัญหาความปลอดภัยได้
CVE-2026-35369ของkill -1เป็นตัวอย่างชัดเจน- GNU ตีความ
-1เป็น signal 1 และยังต้องการ PID - uutils กลับตีความเป็น การส่งสัญญาณค่าเริ่มต้นไปยัง PID -1
- บน Linux ค่า PID -1 หมายถึงทุกโปรเซสที่มองเห็นได้ ดังนั้นแค่พิมพ์ผิดเล็กน้อยก็อาจกลายเป็นการ kill ทั้งระบบ
- GNU ตีความ
- สำหรับเครื่องมือที่นำมาเขียนใหม่ ความเข้ากันได้แบบ bug-for-bug กลายเป็นมาตรการความปลอดภัยที่ครอบคลุมถึง exit code, ข้อความผิดพลาด, edge case และความหมายของออปชัน
- ทุกจุดที่พฤติกรรมต่างจาก GNU จะเพิ่มโอกาสที่ shell script จะ ตัดสินใจผิดพลาด
- ตอนนี้ uutils รัน upstream GNU coreutils test suite ใน CI ร่วมด้วย
- ดูเป็นขนาดของมาตรการป้องกันที่เหมาะสมสำหรับกันปัญหาประเภทนี้
ต้องตีความให้เสร็จก่อนข้ามขอบเขตความเชื่อถือ
CVE-2026-35368เป็นกรณี local root code execution ของchroot- รูปแบบปัญหาคือเรียก
chroot(new_root)?แล้วค่อยไปตีความชื่อผู้ใช้ภายในรูทใหม่ที่ผู้โจมตีควบคุมได้get_user_by_name(name)?ทำให้มีการอ่าน shared library จากไฟล์ซิสเต็มรูทใหม่เพื่อแปลชื่อผู้ใช้- หากผู้โจมตีวางไฟล์ไว้ภายใน chroot ได้ ก็อาจนำไปสู่ การรันโค้ดด้วย uid 0
- GNU
chrootจะตีความผู้ใช้ ก่อนchroot- การแก้ไขก็เปลี่ยนลำดับให้เป็นแบบเดียวกัน
- เมื่อข้ามขอบเขตความเชื่อถือไปแล้ว การเรียกไลบรารีแต่ละครั้งอาจกลายเป็นการรันโค้ดของผู้โจมตีได้
- แม้แต่การลิงก์แบบสแตติกก็ป้องกันปัญหานี้ไม่ได้
- เพราะ
get_user_by_nameผ่าน NSS และจะdlopenโมดูลlibnss_*ตอนรันไทม์
- เพราะ
บั๊กที่ Rust ป้องกันได้จริง
- มีบั๊กบางประเภทที่ชัดเจนว่า ไม่ถูกพบ ในการตรวจสอบครั้งนี้
- ไม่มี buffer overflow
- ไม่มี use-after-free
- ไม่มี double-free
- ไม่มี data race จากสถานะ mutable ที่ใช้ร่วมกัน
- ไม่มี null-pointer dereference
- ไม่มี uninitialized memory read
- แม้เครื่องมือจะมีบั๊ก แต่ผลการตรวจสอบไม่พบบั๊กประเภทที่สามารถนำไปใช้โจมตีเพื่ออ่านหน่วยความจำตามอำเภอใจได้
- ในช่วงไม่กี่ปีที่ผ่านมา GNU coreutils มี CVE ตระกูลความปลอดภัยของหน่วยความจำ ออกมาอย่างต่อเนื่อง
pwddeep path buffer overflownumfmtout-of-bounds readunexpand --tabsheap buffer overflowod --strings -Nการเขียน NUL นอก heap buffersortอ่าน 1 ไบต์ก่อนหน้า heap buffersplit --line-bytesเป็น heap overwrite ใน CVE-2024-0684b2sum --checkอ่านหน่วยความจำที่ไม่ได้จัดสรรจาก malformed inputtail -fstack buffer overrun
- เมื่อเทียบช่วงเวลาเดียวกัน งานเขียนใหม่ด้วย Rust รักษาจำนวนบั๊กหมวดนี้ไว้ที่ 0 รายการ
- อย่างไรก็ตาม ก็มีข้อแม้ด้วยว่าการตรวจสอบนี้ไม่ได้พิสูจน์ว่าไม่มีบั๊กด้านความปลอดภัยของหน่วยความจำ เพียงแต่ยังไม่พบเท่านั้น
- ปัญหาที่เหลืออยู่ส่วนใหญ่เกิดที่ ขอบเขตที่สัมผัสโลกภายนอก มากกว่าภายใน Rust
- พาธ
- ไบต์กับสตริง
- syscall
- ช่วงเวลาคั่นและการเปลี่ยนแปลงสถานะของไฟล์ซิสเต็ม
Rust ที่ถูกต้องก็คือ Rust ที่เป็นธรรมเนียมปฏิบัติที่ดีด้วย
- Rust แบบที่เป็นธรรมเนียมปฏิบัติที่ดี ไม่ได้หมายถึงแค่โค้ดที่ผ่าน borrow checker และไม่มีเสียงเตือนจาก
clippy - ความถูกต้อง เองก็ควรเป็นส่วนหนึ่งของความเป็นธรรมเนียมปฏิบัติที่ดี
- เพราะรูปแบบโค้ดที่อยู่รอดได้ในโลกจริงคือสิ่งที่ถูกตกผลึกขึ้นจากประสบการณ์ของชุมชน
- ระบบที่แข็งแรงไม่ควรซ่อนความสกปรกของโลกจริง แต่ควร สะท้อนมันออกมาตรง ๆ
- file descriptor แทนพาธ
OsStrแทนString?แทนunwrap- ความเข้ากันได้แบบ bug-for-bug กับต้นฉบับ แทนความหมายที่ดูสะอาดกว่า
- ระบบชนิดข้อมูลอาจอธิบายได้หลายอย่าง แต่ไม่สามารถครอบคลุมเงื่อนไขที่อยู่นอกการควบคุม เช่น เวลาที่ผ่านไประหว่าง syscall สองครั้ง
- Rust ที่เป็นธรรมเนียมปฏิบัติที่ดี ควรทำให้ชนิดข้อมูล ชื่อ และ control flow ของโค้ด เผยความจริงของสภาพแวดล้อมที่มันรันอยู่
- แม้จะสวยน้อยกว่าโค้ดบนไวท์บอร์ด แต่ก็ต้องซื่อสัตย์ต่อความจริงมากกว่า
เอกสารอ้างอิง
- An update on rust-coreutils: เผยแพร่ผลการตรวจสอบ
- Patterns for Defensive Programming in Rust: รูปแบบ Rust เชิงป้องกันที่อ่านคู่กันได้
- Pitfalls of Safe Rust: ความผิดพลาดที่พบบ่อยซึ่งเกิดได้แม้ใน safe Rust
- Sharp Edges In The Rust Standard Library: พฤติกรรมที่คาดไม่ถึงของ
std - uutils/coreutils on GitHub: GNU coreutils ที่นำมาเขียนใหม่ด้วย Rust
ยังไม่มีความคิดเห็น