• ความปลอดภัยของหน่วยความจำ ดีขึ้นอย่างมาก แต่แม้ในโค้ด 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-35363 rm ปฏิเสธ . และ .. แต่ยอมรับ ./ กับ ./// ทำให้สามารถลบไดเรกทอรีปัจจุบันได้
    • หากจัดการความต่างของรูปแบบอินพุตแค่ในระดับสตริง การตรวจสอบจะถูกเลี่ยงได้ง่าย

ที่ขอบเขตของ 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 ต้องเลือกชนิดข้อมูลให้เหมาะกับงาน
    • ใช้ Path, PathBuf สำหรับพาธไฟล์
    • ใช้ OsString สำหรับตัวแปรสภาพแวดล้อม
    • ใช้ Vec<u8> หรือ &[u8] สำหรับเนื้อหาสตรีม
  • หากอ้อมผ่าน 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, และ as cast ว่าเป็น CVE ที่อาจเกิดขึ้นได้
    • ควรใช้ ?, get, checked_*, try_from และส่งข้อผิดพลาดจริงกลับไปยังผู้เรียก
  • มีการเสนอเกณฑ์ clippy สำหรับจับเรื่องนี้ใน CI ด้วย
    • unwrap_used
    • expect_used
    • panic
    • indexing_slicing
    • arithmetic_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 ทั้งระบบ
  • สำหรับเครื่องมือที่นำมาเขียนใหม่ ความเข้ากันได้แบบ 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 ตระกูลความปลอดภัยของหน่วยความจำ ออกมาอย่างต่อเนื่อง
    • pwd deep path buffer overflow
    • numfmt out-of-bounds read
    • unexpand --tabs heap buffer overflow
    • od --strings -N การเขียน NUL นอก heap buffer
    • sort อ่าน 1 ไบต์ก่อนหน้า heap buffer
    • split --line-bytes เป็น heap overwrite ใน CVE-2024-0684
    • b2sum --check อ่านหน่วยความจำที่ไม่ได้จัดสรรจาก malformed input
    • tail -f stack buffer overrun
  • เมื่อเทียบช่วงเวลาเดียวกัน งานเขียนใหม่ด้วย Rust รักษาจำนวนบั๊กหมวดนี้ไว้ที่ 0 รายการ
    • อย่างไรก็ตาม ก็มีข้อแม้ด้วยว่าการตรวจสอบนี้ไม่ได้พิสูจน์ว่าไม่มีบั๊กด้านความปลอดภัยของหน่วยความจำ เพียงแต่ยังไม่พบเท่านั้น
  • ปัญหาที่เหลืออยู่ส่วนใหญ่เกิดที่ ขอบเขตที่สัมผัสโลกภายนอก มากกว่าภายใน Rust
    • พาธ
    • ไบต์กับสตริง
    • syscall
    • ช่วงเวลาคั่นและการเปลี่ยนแปลงสถานะของไฟล์ซิสเต็ม

Rust ที่ถูกต้องก็คือ Rust ที่เป็นธรรมเนียมปฏิบัติที่ดีด้วย

  • Rust แบบที่เป็นธรรมเนียมปฏิบัติที่ดี ไม่ได้หมายถึงแค่โค้ดที่ผ่าน borrow checker และไม่มีเสียงเตือนจาก clippy
  • ความถูกต้อง เองก็ควรเป็นส่วนหนึ่งของความเป็นธรรมเนียมปฏิบัติที่ดี
    • เพราะรูปแบบโค้ดที่อยู่รอดได้ในโลกจริงคือสิ่งที่ถูกตกผลึกขึ้นจากประสบการณ์ของชุมชน
  • ระบบที่แข็งแรงไม่ควรซ่อนความสกปรกของโลกจริง แต่ควร สะท้อนมันออกมาตรง ๆ
    • file descriptor แทนพาธ
    • OsStr แทน String
    • ? แทน unwrap
    • ความเข้ากันได้แบบ bug-for-bug กับต้นฉบับ แทนความหมายที่ดูสะอาดกว่า
  • ระบบชนิดข้อมูลอาจอธิบายได้หลายอย่าง แต่ไม่สามารถครอบคลุมเงื่อนไขที่อยู่นอกการควบคุม เช่น เวลาที่ผ่านไประหว่าง syscall สองครั้ง
  • Rust ที่เป็นธรรมเนียมปฏิบัติที่ดี ควรทำให้ชนิดข้อมูล ชื่อ และ control flow ของโค้ด เผยความจริงของสภาพแวดล้อมที่มันรันอยู่
    • แม้จะสวยน้อยกว่าโค้ดบนไวท์บอร์ด แต่ก็ต้องซื่อสัตย์ต่อความจริงมากกว่า

เอกสารอ้างอิง

ยังไม่มีความคิดเห็น

ยังไม่มีความคิดเห็น