- Độ an toàn bộ nhớ được cải thiện đáng kể, nhưng ngay cả trong mã Rust chạy production, các vấn đề về xử lý ranh giới hệ thống vẫn còn nguyên và có thể dẫn tới lỗ hổng
- Luồng kiểm tra cùng một đường dẫn rồi diễn giải lại qua nhiều syscall, cách tạo xong mới đổi quyền, và so sánh đường dẫn dựa trên chuỗi rất dễ tạo ra các vấn đề như TOCTOU và lộ quyền truy cập
- Trên Unix, đường dẫn, biến môi trường và dữ liệu luồng được truyền dưới dạng byte thô, nên xử lý xoay quanh
String hoặc dùng from_utf8_lossy, unwrap, expect có thể dẫn tới hỏng dữ liệu hoặc DoS
- Nếu bỏ qua lỗi, thất bại có thể trông như thành công, và khác biệt hành vi so với GNU coreutils cũng có thể ngay lập tức trở thành vấn đề bảo mật trong shell script và công cụ đặc quyền
- Trong lần audit này, không xuất hiện các lỗi thuộc nhóm an toàn bộ nhớ như buffer overflow, use-after-free hay double-free; rủi ro cốt lõi còn lại tập trung vào ranh giới tiếp giáp với thế giới bên ngoài hơn là bên trong Rust
Giới hạn của Rust được phơi bày qua đợt audit
- 44 CVE của uutils do Canonical công bố cho thấy ngay cả trong mã Rust production, vẫn có thể còn các lỗ hổng mà borrow checker, clippy và cargo audit không bắt được
- Trọng tâm vấn đề nằm ở xử lý ranh giới hệ thống hơn là an toàn bộ nhớ
- Có độ trễ thời gian giữa đường dẫn và syscall
- Dữ liệu byte của Unix và chuỗi UTF-8 bị lệch nhau
- Có khác biệt hành vi so với công cụ gốc
- Có chỗ thiếu xử lý lỗi và kết thúc bằng
panic!
- Danh sách CVE này cho thấy một cách cô đọng điểm mà tính an toàn của Rust kết thúc trong mã hệ thống viết bằng Rust
Diễn giải đường dẫn hai lần sẽ sinh ra TOCTOU
- Nếu cùng một đường dẫn được kiểm tra ở một syscall rồi lại được thao tác ở syscall tiếp theo, rất dễ dẫn tới lỗ hổng TOCTOU
- Giữa hai lần gọi, kẻ tấn công có quyền ghi vào thư mục cha có thể thay một thành phần của đường dẫn bằng symbolic link
- Ở lần gọi thứ hai, kernel sẽ diễn giải lại đường dẫn từ đầu, khiến thao tác đặc quyền nhắm tới mục tiêu do kẻ tấn công chọn
- API
std::fs của Rust mặc định dùng diễn giải lại dựa trên &Path, nên rất dễ mắc kiểu sai sót này
- Trong
CVE-2026-35355, luồng xóa file rồi tạo file mới tại cùng đường dẫn đã bị khai thác
- Trong
src/uu/install/src/install.rs, fs::remove_file(to)? được theo sau bởi File::create(to)?
- Nếu trong khoảng giữa xóa và tạo,
to bị đổi thành symbolic link trỏ tới /etc/shadow hoặc mục tiêu tương tự, tiến trình đặc quyền có thể ghi đè file đó
- Bản sửa chuyển sang dùng
OpenOptions::create_new(true) để chỉ tạo file mới
- Theo tài liệu,
create_new không chấp nhận không chỉ file đã tồn tại mà cả dangling symlink ở vị trí đích
- Nếu buộc phải thao tác hai lần trên cùng một đường dẫn, cách an toàn hơn là ghim vào file descriptor
- Ngoài trường hợp tạo file mới, cách đúng thường là mở thư mục cha một lần rồi thao tác bằng đường dẫn tương đối dựa trên handle đó
- Nếu phải thao tác hai lần trên cùng một đường dẫn, hãy coi đó là TOCTOU cho tới khi chứng minh được điều ngược lại
Đừng sửa quyền sau khi tạo; hãy đặt quyền ngay lúc tạo
- Luồng tạo thư mục hoặc file với quyền mặc định rồi mới
chmod sau đó cũng tạo ra khoảng phơi lộ ngắn
- Nếu viết như
fs::create_dir(&path)? rồi fs::set_permissions(&path, Permissions::from_mode(0o700))?, thì trong khoảng giữa hai lệnh, path tồn tại với quyền mặc định
- Người dùng khác có thể
open() trong khoảng cửa sổ đó, và ngay cả khi chmod sau đó thì file descriptor đã lấy được cũng không bị thu hồi
- Quyền phải được chỉ định cùng lúc với lúc tạo
- Cần dùng
OpenOptions::mode() và DirBuilderExt::mode() để đối tượng được sinh ra với đúng quyền mong muốn
- Kernel còn áp thêm
umask, nên nếu ảnh hưởng đó quan trọng thì cũng phải xử lý umask một cách tường minh
So sánh chuỗi đường dẫn không đồng nghĩa với cùng một đối tượng trên filesystem
- Kiểm tra
--preserve-root ban đầu của chmod chỉ làm so sánh chuỗi
recursive && preserve_root && file == Path::new("/")
- Các đầu vào thực chất trỏ tới root nhưng chuỗi không phải
/, như /../, /./, /usr/.. hay symbolic link trỏ tới /, đều có thể vượt qua kiểm tra này
- Bản sửa chuyển sang dùng
fs::canonicalize để diễn giải đường dẫn thành đường dẫn tuyệt đối thực rồi mới so sánh
- PR sửa lỗi
canonicalize trả về đường dẫn thực sau khi giải quyết .., . và symbolic link
- Trong trường hợp
--preserve-root, cách này hoạt động vì / không có thư mục cha
- Muốn so sánh hai đường dẫn tùy ý có cùng trỏ tới một đối tượng trên filesystem hay không, nói chung phải so
(dev, inode) chứ không phải chuỗi
- GNU coreutils cũng xử lý theo cách này
- Trong
CVE-2026-35363, rm từ chối . và .. nhưng lại cho phép ./ và .///, khiến thư mục hiện tại có thể bị xóa
- Nếu chỉ xử lý khác biệt hình thức đầu vào ở mức chuỗi, việc kiểm tra rất dễ bị lách
Ở ranh giới Unix, phải ưu tiên byte hơn chuỗi
String và &str của Rust luôn là UTF-8, trong khi đường dẫn, biến môi trường, tham số và dữ liệu luồng trên Unix lại thuộc thế giới byte thô
- Nếu chọn sai khi băng qua ranh giới này, sẽ dẫn tới hai loại lỗi
- Chuyển đổi mất dữ liệu như
from_utf8_lossy sẽ thay byte không hợp lệ bằng U+FFFD, âm thầm làm hỏng dữ liệu
- Chuyển đổi nghiêm ngặt như
unwrap hoặc ? có thể từ chối đầu vào hoặc làm tiến trình kết thúc
CVE-2026-35346 của comm là trường hợp đầu ra bị hỏng do chuyển đổi mất dữ liệu
- Trong
src/uu/comm/src/comm.rs, các byte đầu vào ra, rb được đổi sang String::from_utf8_lossy rồi print!
- GNU
comm giữ nguyên byte kể cả với file nhị phân, còn uutils thì đổi UTF-8 không hợp lệ thành U+FFFD, làm hỏng đầu ra
- Bản sửa dùng
BufWriter và write_all để ghi nguyên raw bytes ra stdout
print! đi qua Display nên ép vòng chuyển UTF-8, còn Write::write_all thì không
- Trong mã hệ thống kiểu Unix, phải dùng đúng kiểu dữ liệu theo ngữ cảnh
- Với đường dẫn file:
Path, PathBuf
- Với biến môi trường:
OsString
- Với nội dung luồng:
Vec<u8> hoặc &[u8]
- Nếu đi vòng qua
String chỉ để tiện format, hỏng dữ liệu rất dễ len vào
Mọi panic đều có thể dẫn tới từ chối dịch vụ
- Trong CLI,
unwrap, expect, đánh chỉ số slice, số học không kiểm tra, và from_utf8 đều có thể trở thành điểm DoS khi kẻ tấn công kiểm soát được đầu vào
panic! sẽ unwind stack và dừng tiến trình
- Nếu đang chạy trong cron job, CI pipeline hay shell script, toàn bộ công việc có thể bị dừng
- Trong môi trường chạy lặp lại, nó thậm chí có thể tạo crash loop làm tê liệt cả hệ thống
CVE-2026-35348 của sort --files0-from dừng lại khi gặp tên file không phải UTF-8 trong danh sách tên file phân tách bằng NUL
- Parser gọi
std::str::from_utf8(bytes).expect(...) cho byte của từng tên
- GNU
sort xử lý tên file như raw bytes đúng như kernel, còn uutils ép UTF-8 nên dừng toàn bộ tiến trình ngay ở đường dẫn không phải UTF-8 đầu tiên
- Trong mã xử lý đầu vào không đáng tin cậy,
unwrap, expect, indexing và ép kiểu as phải được xem như CVE tiềm năng
- Nên dùng
?, get, checked_*, try_from và đẩy lỗi thực cho caller
- Bài viết cũng gợi ý các tiêu chí clippy để bắt trong CI
unwrap_used
expect_used
panic
indexing_slicing
arithmetic_side_effects
- Trong mã test, các cảnh báo này có thể quá mức, nên giới hạn trong phạm vi
cfg(test) là hợp lý
Bỏ qua lỗi sẽ khiến thất bại trông như thành công
- Một số CVE xuất phát từ việc phớt lờ lỗi hoặc từ luồng làm mất thông tin lỗi
chmod -R và chown -R chỉ trả về mã thoát của file cuối cùng trong toàn bộ quá trình
- Dù nhiều file trước đó thất bại, nếu file cuối thành công thì lệnh vẫn có thể kết thúc với
0
- Script sẽ hiểu lầm rằng toàn bộ thao tác đã hoàn tất không vấn đề
dd gọi set_len() rồi dùng Result::ok() để bắt chước hành vi GNU với /dev/null
- Ý định là bỏ qua lỗi trong một tình huống giới hạn, nhưng cùng đoạn mã đó lại được áp dụng cho cả file thường
- Ngay cả khi đĩa đầy, file đích chỉ được ghi một nửa vẫn có thể âm thầm bị để lại
- Nếu vứt
Result bằng .ok(), .unwrap_or_default(), hoặc let _ =, nguyên nhân thất bại quan trọng sẽ biến mất
- Dù không dừng ngay ở lỗi đầu tiên, vẫn nên ghi nhớ mã lỗi nghiêm trọng nhất để trả ra khi kết thúc
- Nếu thật sự phải bỏ
Result, cần để lại lý do trong mã giải thích vì sao có thể an toàn khi bỏ qua lỗi đó
Tương thích chính xác với công cụ gốc cũng là một tính năng an toàn
- Nhiều CVE không phát sinh vì mã thực hiện thao tác nguy hiểm, mà vì hành vi khác GNU
- Các shell script thực tế phụ thuộc vào hành vi của GNU gốc, nên khác biệt ngữ nghĩa có thể chuyển thành vấn đề bảo mật
CVE-2026-35369 của kill -1 là ví dụ tiêu biểu
- GNU hiểu
-1 là signal 1 và yêu cầu PID
- uutils lại diễn giải thành gửi tín hiệu mặc định tới PID -1
- Trên Linux, PID -1 có nghĩa là mọi tiến trình mà nó có thể nhìn thấy, nên một lỗi gõ đơn giản có thể biến thành kill toàn hệ thống
- Với công cụ reimplement, bug-for-bug compatibility là một hàng rào an toàn, bao gồm cả mã thoát, thông báo lỗi, edge case và ý nghĩa của tùy chọn
- Ở mỗi điểm hành vi khác GNU, khả năng shell script đưa ra quyết định sai lại tăng lên
- uutils hiện đã chạy thêm upstream GNU coreutils test suite trong CI
- Đây có vẻ là mức phòng thủ phù hợp để ngăn loại sai khác này
Phải diễn giải trước khi băng qua trust boundary
CVE-2026-35368 là lỗi local root code execution trong chroot
- Mẫu lỗi ở đây là gọi
chroot(new_root)? rồi mới diễn giải tên người dùng bên trong root mới do kẻ tấn công kiểm soát
get_user_by_name(name)? khiến việc diễn giải tên người dùng đọc shared library từ filesystem của root mới
- Nếu kẻ tấn công cài file bên trong chroot, điều này có thể dẫn tới thực thi mã với uid 0
- GNU
chroot diễn giải người dùng trước khi chroot
- Bản sửa cũng đổi sang cùng thứ tự đó
- Một khi đã băng qua trust boundary, từng lời gọi thư viện riêng lẻ đều có thể kích hoạt mã của kẻ tấn công
- Static linking cũng không ngăn được vấn đề này
- Vì
get_user_by_name đi qua NSS và có thể dlopen các module libnss_* lúc runtime
Những lỗi mà Rust thực sự đã ngăn được
- Cũng có những loại lỗi rõ ràng không xuất hiện trong đợt audit này
- Không có buffer overflow
- Không có use-after-free
- Không có double-free
- Không có data race trên trạng thái chia sẻ có thể thay đổi
- Không có null-pointer dereference
- Không có uninitialized memory read
- Dù công cụ vẫn có lỗi, kết quả audit không xuất hiện loại lỗi có thể bị khai thác thành đọc bộ nhớ tùy ý
- Trong vài năm gần đây, GNU coreutils liên tục có các CVE thuộc nhóm an toàn bộ nhớ như vậy
pwd deep path buffer overflow
numfmt out-of-bounds read
unexpand --tabs heap buffer overflow
od --strings -N ghi NUL ra ngoài heap buffer
sort đọc 1 byte trước heap buffer
split --line-bytes heap overwrite là CVE-2024-0684
b2sum --check đọc unallocated memory với malformed input
tail -f stack buffer overrun
- So với cùng giai đoạn đó, bản reimplement bằng Rust giữ số lỗi thuộc nhóm này ở mức 0 vụ
- Tuy vậy, bài viết cũng lưu ý rằng đợt audit không chứng minh được không có lỗi an toàn bộ nhớ, mà chỉ là chưa tìm thấy
- Các vấn đề còn lại chủ yếu phát sinh ở ranh giới tiếp xúc với thế giới bên ngoài hơn là bên trong Rust
- đường dẫn
- byte và chuỗi
- syscall
- độ trễ thời gian và thay đổi trạng thái filesystem
Rust đúng chuẩn cũng là Rust giàu tính thành ngữ
- Rust thành ngữ không chỉ là mã vượt qua borrow checker và khiến
clippy im lặng
- Tính đúng đắn cũng phải là một phần của tính thành ngữ
- Vì những dạng mã sống sót được trong thực tế đã được cộng đồng kết tinh qua kinh nghiệm
- Hệ thống vững chắc phải phản ánh nguyên trạng sự lộn xộn của thực tế thay vì che giấu nó
- file descriptor thay cho đường dẫn
String thay bằng OsStr
unwrap thay bằng ?
- bug-for-bug compatibility với bản gốc thay vì ý nghĩa trông có vẻ sạch hơn
- Hệ thống kiểu dữ liệu có thể biểu đạt rất nhiều thứ, nhưng không thể gói cả những điều kiện nằm ngoài tầm kiểm soát như thời gian trôi qua giữa hai syscall
- Rust thành ngữ phải để kiểu dữ liệu, tên gọi và luồng điều khiển của mã bộc lộ sự thật của môi trường thực thi
- Cần một hình thức kém đẹp mắt hơn nhưng trung thực hơn, thay vì đoạn mã trên whiteboard trông thật gọn gàng
Tài liệu tham khảo
1 bình luận
Ý kiến trên Hacker News
Với tư cách là người bảo trì GNU Coreutils, tôi thấy bài viết khá thú vị, nhưng với chút Rust mà tôi từng dùng, việc tạo ra TOCTOU race bằng
std::fslà quá dễMong rằng cuối cùng thư viện chuẩn sẽ có API kiểu như
openatVà tôi không đồng ý với quy tắc hãy resolve đường dẫn trước khi so sánh
Thông thường gọi
fstatrồi so sánhst_devvàst_inosẽ tốt hơn, và bài viết cũng có nhắc phần nào đến điểm đóMột tác dụng phụ ít được cân nhắc hơn là chi phí hiệu năng
Trong ví dụ thực tế, với một đường dẫn thư mục cực sâu,
cpmất 0.010 giây cònuu_cpmất 12.857 giâyNgoài đời hiếm khi cố tình tạo những đường dẫn như thế, nhưng phần mềm GNU luôn rất nỗ lực để tránh các giới hạn tùy tiện
https://www.gnu.org/prep/standards/standards.html#Semantics
Và bài viết nói rằng bản viết lại bằng Rust có 0 lỗi an toàn bộ nhớ trong cùng khoảng thời gian, nhưng điều đó không đúng :)
https://github.com/advisories/GHSA-w9vv-q986-vj7x
Đúng vậy,
std::fsđang vướng vấn đề lowest common denominatorPhải đưa một cái gì đó vào Rust 1.0, và tiếc là trạng thái đó bị đóng băng quá lâu
Tôi nghĩ
uutilslà nơi phù hợp để thử thiết kế một API thay thế cho std::fs mà khó mắc sai lầm hơnCảm ơn vì đã diễn đạt góc nhìn này rất ngắn gọn từ phía đối lập
Tôi muốn hỏi nên rút ra điều gì ở đây
Với một bài viết trên Internet thì tôi cố tình hỏi khá gay gắt, vì phải có độ tương phản thì mới nhìn rõ khác biệt và sai sót hơn
Tất nhiên bạn hoàn toàn không có nghĩa vụ phải bỏ thời gian hay năng lượng tinh thần để trả lời
Tôi thắc mắc vì sao tốc độ, hiệu năng, race condition và
st_inocứ luôn đi cùng nhauCó vẻ như độ trễ, việc ghi ra thiết bị lưu trữ thực, tính nguyên tử, ACID, tốc độ truyền thông tin hữu hạn... cuối cùng đều hội tụ về cùng một bản chất
Các hệ thống đòi hỏi độ tin cậy cao như kế toán rốt cuộc có vẻ phải đi đến ACID, còn các hệ thống kém tin cậy hơn thì bị lãng quên quá nhanh nên đôi khi cảm giác như sự khác biệt giữa các máy tính cũng không quá lớn
Tôi cũng tò mò liệu trong các ứng dụng thường ngày thì throughput có thực sự quan trọng hơn latency không
Và tôi hiểu việc tập trung vào số inode vì lịch sử của C, các hệ điều hành kiểu Unix và GNU coreutils,
nhưng tôi muốn biết nếu nhìn vào một ví dụ rất cơ bản như làm sao để USB chỉ dùng lưu file vẫn hoạt động cho đúng thì sao
mà không né tránh những độ phức tạp như buffer I/O của
libc,fflush, buffer của kernel, đa lõi, chia sẻ thời gian, nhiều ứng dụng chạy đồng thờiTôi là người hoàn toàn mới, nên đã thắc mắc vì sao không
cdthẳng bằng$(yes a/ | head -n $((32 * 1024)) | tr -d '\n')mà lại cần vòng lặpwhileSửa: tôi hiểu rồi. Là vì
-bash: cd: a/a/a/....../a/a/: File name too longKhông biết bạn đã thấy chưa, nhưng có một demo tự động chuyển GNU utility như
wgetsang một tập con C++ an toàn bộ nhớhttps://duneroadrunner.github.io/scpp_articles/PoC_autotranslation_of_wget
Nó gần như thay thế 1:1 các thành phần C nguy hiểm bằng các thành phần C++ an toàn có hành vi tương ứng, nên có vẻ ít khả năng đưa vào lỗi mới và khác biệt hành vi mới hơn so với viết lại
Chỉ cần dọn dẹp mã nguồn gốc một chút là việc chuyển đổi có thể hoàn toàn tự động, để trong bước build có thể tạo ra tệp thực thi an toàn bộ nhớ, hơi chậm hơn, từ chính mã nguồn C ban đầu
Có thể là câu hỏi hơi ngớ ngẩn, nhưng tôi tò mò không biết phía GNU Coreutils có đang xem xét hay lên kế hoạch cho một bản viết lại bằng Rust riêng không
Có lẽ họ biết dùng Rust, nhưng chưa thật sự quen với Unix API cùng ngữ nghĩa và các cạm bẫy của nó
Phần lớn các sai lầm đó, nếu nhìn từ góc độ của các lập trình viên lâu năm làm với GNU coreutils, BSD hay Solaris, thì khá cơ bản
Nhiều vấn đề như vậy đã lộ ra và được tổng kết từ hàng chục năm trước, và dù trong codebase hiện tại vẫn còn một đuôi dài các bản vá, giờ thì chủ yếu chỉ còn thêm vào với số lượng nhỏ
Tôi đọc cái thread của Canonical đó mà thật sự choáng váng
Đại ý của nó là “Rust an toàn hơn, bảo mật là ưu tiên số một, nên cần gấp rút phát hành bản viết lại toàn bộ coreutils. Có gì hỏng thì sửa sau cũng được”
Tôi không muốn chạy trên máy mình thứ mã do những người nghĩ như vậy tạo ra
Tôi cũng ủng hộ Rust, nhưng việc Rust an toàn hơn chỉ đúng khi mọi điều kiện khác là như nhau
Ở đây thì các điều kiện khác hoàn toàn không giống nhau
Viết lại tất yếu sẽ có nhiều lỗi và lỗ hổng hơn rất nhiều so với mã đã được bảo trì hàng chục năm, nên lập luận bảo mật chỉ có ý nghĩa cho chiến lược chuyển đổi dài hạn, chứ không thể dùng để biện minh cho rollout vội vàng
Việc giảm nhẹ tác động tới người dùng sau khi phát hành, hoặc nói rằng “làm thế mới lộ bug”, “coreutils cũ cũng đâu có test cho ra hồn”, là quá vô trách nhiệm
Người dùng không phải chuột bạch
Tôi cho rằng người bảo trì có trách nhiệm đạo đức là không được làm tổn hại độ tin cậy của hệ thống người dùng
Còn căn bản hơn nữa, có vẻ thư viện chuẩn của Rust đang dẫn dắt lập trình viên đến một API sạch sẽ nhưng ở mức trừu tượng sai
Ví dụ như nghiêng về thao tác tệp dựa trên đường dẫn thay vì dựa trên handle
Mong là tôi sai
Tôi nghĩ điểm cốt lõi của Rust là khiến những cạm bẫy lớn nhất và dễ sa vào nhất không còn cần phải bận tâm quá nhiều
Và trọng tâm của bài này rốt cuộc cũng là API hệ thống tệp nên làm được điều đó
Có người từng đặt ra cụm từ disassembler rage cho hiện tượng tương tự
Ý là nếu nhìn đủ gần thì mọi sai lầm đều trông như của dân nghiệp dư
Nó cũng xuất phát từ kiểu thái độ chỉ nhìn vào disassembler rồi chửi lập trình viên cấp cao vì sao trong một hàm nằm sâu 100 frame callstack lại dùng
ifthay vìswitchHiện giờ chúng ta chỉ đang thấy vài chỗ họ làm sai, còn hầu như không thấy hàng nghìn dòng mã được viết đúng xung quanh đó
Với những tiện ích kiểu này mà lại
panicthì ngay cả theo tiêu chuẩn Rust cũng là lỗi khá nghiệp dưNếu là lỗi alloc không thể xử lý thì còn được, nhưng
expectvàunwraprất khó biện hộ trừ khi bạn thực sự đảm bảo cực kỳ chặt chẽ bất biến rằng đường chạy đó không bao giờ xảy raMột trong những điều khó khi viết lại mã là mã gốc đã được biến đổi dần dần để phản ứng với những vấn đề chỉ lộ ra trong môi trường vận hành thực tế
Những bài học tích lũy trong quá trình đó âm thầm ngấm vào trong mã, và nếu không được tài liệu hóa thì sẽ có một lượng công việc ẩn khổng lồ phải làm trước khi đạt được mức tương đương
Bài gốc minh họa rất rõ đúng loại danh sách đó
Tuy vậy, trước khi vội gọi ai đó là nghiệp dư, cũng nên thấy rằng đây là một trong những hiện tượng “đậm chất phần mềm” nhất của phần mềm
Trừ khi coreutils đã có tài liệu kỹ thuật thật tốt và các bài test bao phủ đúng những trường hợp đó mà họ vẫn phớt lờ, thì chuyện này gần như là tất yếu
Ví dụ hay trong bài là CVE chroot + NSS
Việc NSS là động, và trong
chrootnó sẽdlopenthư viện, là thứ không hề được ghi nổi bật ở đâu cảNó gần như là kiến thức mà các quản trị viên hệ thống đã tự rút ra bằng trải nghiệm suốt hơn 25 năm, và các bản viết lại kiểu clean-room thường sẽ học lại điều đó dưới dạng CVE mới
Kể cả port cùng đoạn mã bằng LLM thì tình hình cũng tương tự
Bạn có thể đọc được chữ ký hàm, nhưng thứ thật sự cần là những vết thương và sẹo còn lại trong đoạn mã đó
Nếu còn cố làm việc này mà không đọc mã nguồn gốc để tránh GPL thì lại càng khó hơn
Theo tôi, nếu
uutilslà GPL và được phép lấy cảm hứng trực tiếp từ mã nguồn coreutils gốc thì mọi thứ đã tốt hơn nhiềuCũng cần nói rõ rằng việc không tài liệu hóa những bài học kiểu này, hay ít nhất là các lỗi và lỗ hổng đã cố tránh, cũng là một thực hành tệ
Tất nhiên rất khó tài liệu hóa toàn bộ những lỗi bị tránh đi một cách ngầm định chỉ nhờ viết mã tốt ngay từ đầu,
nhưng với người đọc sau này thì việc để lại lời giải thích kiểu “ở đây dùng
foothay vìbarvì nếu dùngbartrong điều kiện ABC thì sẽ sinh rabaznguy hiểm do XYZ” là rất quan trọngDù có vẻ tốn thời gian và dung lượng tài liệu, tôi vẫn nghĩ như vậy tốt hơn
Tôi cảm giác khá nhiều điều bị chỉ ra trong bài này, nhất là khi so với mã nguồn GNU coreutils, lẽ ra phải bị phát hiện qua unit test hoặc review thủ công tương đối cơ bản
Việc viết lại coreutils có vẻ là một ý tưởng tồi tệ
https://www.joelonsoftware.com/2000/04/06/things-you-should-never-do-part-i/
và dường như đã được tiến hành sai cách, không mang theo đủ tri thức mà phần mềm cũ đã tích lũy
Nếu muốn viết lại thì phải hiểu và học thật đầy đủ từ bản trước
Nếu không thì sẽ lặp lại chính những sai lầm đó, và thành thật mà nói thì khá xấu hổ
Nói cho rõ, tôi thích Rust, dùng nó trong nhiều dự án, và nó rất tuyệt
Chỉ là Rust không thể cứu được kỹ thuật tồi
Điều thú vị là
uutilscó dùng test suite của GNU coreutilsNói thêm thì họ cũng đã nêu rõ lập trường là không nhận đóng góp nào được viết ra bằng cách đọc mã nguồn GPL
Nếu là bên đã làm ra
unity,upstart,snapthì chuyện này cũng không có gì quá bất ngờCó lẽ nên chào đón các system programmer mới như thế này
Unix bị hỏng, và cuối cùng bạn vẫn phải tự viết những cách lách xấu xí, không có tính giáo khoa, đồng thời phải làm kiểm thử thực nghiệm
Phần mềm đáng tin cậy và kỹ nghệ phần mềm tốt vốn vẫn vận hành như thế
Tôi thắc mắc vì sao differential fuzzing lại không bắt được các lỗi kiểu này
https://github.com/uutils/coreutils/tree/main/fuzz/uufuzz
Mẫu hành vi kiểm tra một đường dẫn bằng syscall một lần, rồi lại gọi syscall trên chính đường dẫn đó để thao tác, lúc nào cũng dẫn tới cùng một vấn đề
Kẻ tấn công có quyền ghi vào thư mục cha có thể thay thế một thành phần của đường dẫn bằng symlink trong khoảng giữa hai lần gọi, và kernel sẽ resolve lại toàn bộ đường dẫn từ đầu ở lần gọi thứ hai, khiến thao tác đặc quyền nhắm tới mục tiêu do kẻ tấn công chọn
Kẻ tấn công có quyền ghi vào thư mục cha còn có thể giở trò bằng hardlink
Ngay cả khi bạn chỉ có thể động vào file thường thì trên thực tế cũng gần như không có biện pháp giảm thiểu nào ra hồn
Xem ví dụ ở https://michael.orlitzky.com/articles/posix_hardlink_heartache.xhtml
Có vẻ nguyên nhân gốc rễ của một số lỗi là Unix API quá mù mờ
Ví dụ
get_user_by_namelại đi nạp shared library bên trong root filesystem mới để phân giải tên người dùng, và vì thế kẻ tấn công có thể chép file vàochrootsẽ thực thi được mã với uid 0, chuyện đó gần như giống một bẫy mìnMột hàm lấy dữ liệu người dùng mà đột nhiên còn nạp cả shared library nữa thì có vẻ là thiết kế trộn lẫn mối quan tâm
Theo tôi nên tách việc tra cứu dữ liệu người dùng và nạp thư viện ở cấp độ hàm, hoặc ít nhất tên hàm cũng phải cho thấy hành vi đó
Có thể một phần là vậy, nhưng nếu đã quyết định viết lại coreutils từ nền thì hiểu POSIX API đúng nghĩa là công việc cốt lõi
Hơn nữa, nếu đoạn mã kiểm tra một đường dẫn có trỏ tới gốc hệ thống tệp hay không lại là
file == Path::new("/"), thì đó không phải lỗi APINgười viết kiểu đó gần như không đủ tư cách tham gia dự án này
Ngược lại, tôi nghĩ dùng ngôn ngữ an toàn kiểu hàm có thể khiến người ta lầm tưởng rằng dữ liệu mình xử lý cũng là vô trạng thái
Nhưng trong hệ điều hành thì rất nhiều thứ luôn luôn thay đổi
Trước khi có những hệ thống tệp cung cấp snapshot, bạn phải liên tục kiểm tra lại mọi thứ
Rốt cuộc điều cần là API mà khi có đầu vào thì chỉ trả về thành công hoặc thất bại
chứ không phải API trả về một trong ba trạng thái: thành công, thất bại, lỗi
Đúng,
musl libcđã loại bỏ đúng một phần như vậyTôi nghĩ nguyên nhân gốc không phải Unix API mù mờ, mà là do không suy nghĩ kỹ về tình huống root chroot vào một thư mục mà chính nó không kiểm soát được
Bất cứ thứ gì bạn
chrootvào đều nằm dưới sự kiểm soát của phía tạo ra cái chroot đó, và nếu không hiểu điều đó thì bạn không nên dùngchroot()get_user_by_namecó thể trông như cái bẫy, nhưng thực ra giữa việc dùngnewroot/etc/passwdvớinewroot/usr/lib/x86_64-linux-gnu/libnss_compat.so,newroot/bin/shgần như không có khác biệt thực chất nàoVì vậy tôi cho rằng
/usr/sbin/chrootngay từ đầu đã không có lý do gì để tra cứu user IDtoybox chrootcũng không làm vậyCuối cùng bug không nằm ở cách làm sai một việc gì đó, mà là ở ngay việc thực hiện việc đó từ đầu
Unix và POSIX đầy cạm bẫy theo kiểu fractal, cắt chỗ nào ra cũng thấy
Dù cho phía Rust có viết lại coreutils mà thiếu kinh nghiệm Linux đi nữa, tôi vẫn khó hiểu hơn là vì sao Ubuntu lại chấp nhận đưa nó vào mainline
Ubuntu dường như có một chính sách kiểu cứ gần như mỗi bản phát hành lại thay một thành phần nền tảng nào đó của hệ thống bằng một thử nghiệm cẩu thả và chưa hoàn thiện
Theo tôi đó mới là điểm mấu chốt ở đây, chứ không phải “ôi trời, mã Rust cũng có bug kìa”
Bản gốc dùng giấy phép GPL, còn bản viết lại dùng giấy phép MIT
Nếu câu “những bug này xuất hiện trong mã Rust đã thực sự được phát hành, và tác giả cũng là những người biết mình đang làm gì” là đúng,
thì tôi tự hỏi có phải tiện ích gốc không hề có test harness, và bản viết lại cũng không bắt đầu từ việc dựng cái đó trước không
Dù có nhiều edge case, tôi vẫn nghĩ ít nhất phải trừu tượng hóa được OS và FS ở mức nào đó để kiểm tra những thứ như
rm .//thật sự không xóa thư mục hiện tại như mong đợi chứĐây không giống vấn đề mã bẩn hay chỉ trích ngôn ngữ, mà lại giống một thái độ cũ kỹ kiểu lập trình hệ thống thì không test
Còn nếu tiện ích gốc thực ra có test mà vẫn lọt nhiều lỗ hổng như vậy, thì có thể chính test suite gốc cũng còn thiếu sót lớn
Tôi nghĩ là vậy
Nhưng tôi không chắc đến thế về việc có thể trừu tượng hóa OS và FS đủ tốt để kiểm chứng
vì người ta đã thử làm điều đó từ trước cả khi tôi ra đời mà dường như vẫn chưa thành công
Ví dụ ngay từ việc quyết định phải thử bao nhiêu dấu
/liên tiếp đã đã là chuyện mơ hồXa hơn nữa, giả sử
rmtừ chối xóa nếu 9 byte đầu tiên của file làimportant,thì nếu không biết trước chuỗi đó, rất khó hình dung phải nghĩ ra bài test nào để phát hiện hành vi như vậy
Thậm chí nếu từ ma thuật đó lại là một chuỗi không có trong từ điển thì còn khó hơn
Tôi hiếm khi thấy ai nghiêm túc nói “lập trình hệ thống thì không test”
Thay vào đó tôi thường nghe rằng test không phải lúc nào cũng làm được vai trò mà người ta kỳ vọng ở nó
Theo như tôi hiểu, trong quá trình phát triển
uutilsđã có kiểm thử so sánh hành vi rất rộng với tiện ích gốc, thậm chí còn cố giữ cả bug cho giốngĐây là một phần lý do khiến Windows mặc định vô hiệu hóa symlink
Không phải giải bằng cách trừu tượng hóa, mà gần như là loại bỏ hẳn tính năng đó
Họ Unix không thể làm vậy vì quá nhiều phần mềm đã phụ thuộc vào symlink suốt hàng chục năm
MacOS cũng có cách xử lý tương tự
Ví dụ lỗi
chroot()mặc định không thật sự là vấn đề lớn, vì MacOS chặnchroot()theo mặc địnhMuốn dùng thì phải tắt system integrity protection
Vấn đề gốc nằm ở các cạnh sắc của POSIX API, và lời giải gần với việc loại bỏ nó hơn là trừu tượng hóa nó
Tôi thấy việc mọi người thử nghiệm và làm vụng về cũng không sao
Vì vốn dĩ người ta học và trưởng thành theo cách đó
Điều tôi thật sự tò mò là chuỗi ra quyết định ở Ubuntu đã hỏng như thế nào mà thứ này lại vào được production