Những lỗi bảo mật mà Rust không thể phát hiện: Bài học từ uutils
Canonical vừa công bố 44 lỗ hổng bảo mật trong uutils, bản viết lại bằng ngôn ngữ Rust của GNU coreutils. Bài viết phân tích các lỗi logic mà Rust không thể bắt được, từ TOCTOU đến xử lý đường dẫn, và đưa ra các nguyên tắc để viết mã hệ thống an toàn hơn.

Vào tháng 4 năm 2026, Canonical đã công bố 44 lỗ hổng bảo mật (CVE) trong uutils - phiên bản viết lại bằng ngôn ngữ Rust của bộ công cụ GNU coreutils. Điều đáng chú ý là tất cả các lỗi này đều nằm trong mã sản xuất được viết bởi những lập trình viên có kinh nghiệm, và không một lỗi nào bị phát hiện bởi borrow checker, clippy lints hay cargo audit của Rust.
Điều này cho thấy một thực tế quan trọng: Mặc dù Rust mang lại sự an toàn bộ nhớ vượt trội, nó không phải là tấm khiên toàn năng chống lại mọi loại lỗi logic. Dưới đây là phân tích chi tiết về các lớp lỗi mà Rust "bỏ sót" và bài học rút ra cho các lập trình viên hệ thống.
Đừng tin vào đường dẫn qua hai Syscall
Đây là nhóm lỗi lớn nhất trong đợt kiểm toán. Mô hình thường gặp là bạn thực hiện một syscall để kiểm tra thông tin về một đường dẫn, sau đó thực hiện syscall thứ hai để tác động lên đường dẫn đó. Trong khoảng thời gian giữa hai cuộc gọi này, kẻ tấn công có thể hoán đổi đường dẫn bằng một symbolic link.
Rust's standard library khiến việc mắc lỗi này trở nên quá dễ dàng. Các API tiện lợi như fs::metadata, File::create, hay fs::remove_file đều nhận đường dẫn và giải quyết lại nó mỗi lần, thay vì hoạt động trên một file descriptor.
Ví dụ (CVE-2026-35355):
// 1. Xóa đích
fs::remove_file(to)?;
// ...
// 2. Tạo đích. Đường dẫn được giải quyết lại ở đây!
let mut dest = File::create(to)?; // theo symlink và cắt ngắn
Giữa bước 1 và 2, kẻ tấn công có thể thay đổi to thành một symlink trỏ đến /etc/shadow. Khi đó, File::create sẽ theo symlink và ghi đè tệp hệ thống quan trọng.
Giải pháp: Sử dụng OpenOptions::create_new(true) để đảm bảo không tệp hay symlink nào tồn tại tại đích, hoặc tốt hơn là neo các thao tác trên file descriptor thay vì đường dẫn.
Thiết lập quyền hạn tại thời điểm tạo
Một lỗi liên quan chặt chẽ đến TOCTOU là việc thiết lập quyền hạn sau khi đã tạo tệp hoặc thư mục.
// Tạo với quyền mặc định
fs::create_dir(&path)?;
// Sửa quyền sau
fs::set_permissions(&path, Permissions::from_mode(0o700))?;
Trong khoảnh khắc ngắn ngủi giữa hai dòng lệnh, path tồn tại với quyền mặc định. Bất kỳ người dùng nào khác cũng có thể mở nó trong khoảng thời gian đó.
Quy tắc: Luôn sử dụng OpenOptions::mode() và DirBuilderExt::mode() để tệp/thư mục được sinh ra với đúng quyền hạn bạn mong muốn ngay từ đầu.
So sánh chuỗi đường dẫn không phải là định danh tệp tin
Kiểm tra --preserve-root gốc trong chmod ban đầu chỉ đơn giản là so sánh chuỗi:
if file == Path::new("/") { return Err(PreserveRoot); }
Điều này dễ dàng bị qua mặt bởi các đường dẫn như /../, /./, hoặc các symlink trỏ về /. Kẻ tấn công có thể chạy chmod -R 000 /../ để khóa hệ thống.
Giải pháp: Sử dụng fs::canonicalize để giải quyết đường dẫn về dạng tuyệt đối thực tế trước khi so sánh. Trong trường hợp tổng quát, nên so sánh cặp (dev, inode) thay vì chuỗi.
Hãy ở lại với Bytes ở biên giới Unix
String và &str của Rust luôn là UTF-8. Tuy nhiên, đường dẫn Unix, biến môi trường và các luồng dữ liệu thô sống trong thế giới của bytes.
Việc chuyển đổi lossy (mất mát) bằng from_utf8_lossy âm thầm thay thế các byte không hợp lệ bằng U+FFFD thực chất là làm hỏng dữ liệu. Việc chuyển đổi nghiêm ngặt (strict) có thể khiến chương trình crash (panic) khi gặp input lạ.
Quy tắc: Với mã hệ thống kiểu Unix, hãy dùng Path, OsString và &[u8]. Đừng cố gắng ép mọi thứ vào String chỉ để dễ định dạng.
Mỗi panic! là một Từ chối dịch vụ (DoS)
Trong các công cụ dòng lệnh (CLI), mọi unwrap, expect, hay chỉ số mảng không được kiểm tra đều là tiềm năng gây từ chối dịch vụ nếu kẻ tấn công có thể điều khiển đầu vào. Một panic sẽ làm hủy bỏ toàn bộ tiến trình.
Ví dụ, công cụ sort trong uutils từng panic khi gặp tên tệp không phải UTF-8, trong khi GNU sort xử lý tốt các byte thô.
Quy tắc: Với dữ liệu không tin cậy, hãy coi mọi unwrap là một CVE đang chờ được ghi nhận. Hãy sử dụng ?, get, checked_* để xử lý lỗi một cách graceful.
Tương thích "Bug-for-Bug" là một tính năng an toàn
Nhiều CVE không phải do code không an toàn, mà do hành vi khác biệt so với GNU coreutils mà các script hệ thống đang dựa vào.
Ví dụ: kill -1 trong GNU hiểu là "tín hiệu 1", nhưng uutils lại hiểu là "gửi tín hiệu mặc định tới PID -1" (tức là mọi tiến trình). Một lỗi đánh máy đơn giản có thể trở thành công cụ tắt toàn bộ hệ thống.
Quy tắc: Khi viết lại một công cụ đã được kiểm chứng, sự tương thích về mã thoát, thông báo lỗi và các trường hợp biên là một tính năng bảo mật.
Kết luận: Rust đã ngăn chặn điều gì?
Dù có 44 CVE nêu trên, điều quan trọng là phải nhắc lại những gì đã không xảy ra:
- Không có tràn bộ nhớ (buffer overflow).
- Không có use-after-free.
- Không có double-free.
- Không có data race trên trạng thái biến đổi chung.
- Không có tham chiếu con trỏ null (null-pointer dereference).
GNU coreutils từng có CVE ở tất cả các danh mục này. Việc viết lại bằng Rust đã loại bỏ hoàn toàn các lớp lỗi bộ nhớ kinh điển này. Những gì còn lại là các lỗi logic ở biên giới giữa môi trường Rust được kiểm soát và thế giới hỗn loạn của hệ điều hành.
Rust idiomatic (đúng chuẩn) không chỉ là code đẹp hay borrow checker chấp nhận, mà còn là code trung thực với hệ thống mà nó đang chạy. Đôi khi điều đó có nghĩa là dùng file descriptor thay vì path, dùng OsStr thay vì String, và ưu tiên tính đúng đắn hơn là sự thanh lịch.



