1 điểm bởi GN⁺ 2 giờ trước | 1 bình luận | Chia sẻ qua WhatsApp
  • Độ 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()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 ... nhưng lại cho phép ././//, 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&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 BufWriterwrite_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 -Rchown -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 -1signal 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
    • 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::fs là quá dễ
    Mong rằng cuối cùng thư viện chuẩn sẽ có API kiểu như openat

    Và 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 fstat rồi so sánh st_devst_ino sẽ 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, cp mất 0.010 giây còn uu_cp mất 12.857 giây

    Ngoà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 denominator
      Phả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ĩ uutils là 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ơn

    • Cả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_ino cứ luôn đi cùng nhau
      Có 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ời

    • Tôi là người hoàn toàn mới, nên đã thắc mắc vì sao không cd thẳng bằng $(yes a/ | head -n $((32 * 1024)) | tr -d '\n') mà lại cần vòng lặp while

      Sửa: tôi hiểu rồi. Là vì -bash: cd: a/a/a/....../a/a/: File name too long

    • Không biết bạn đã thấy chưa, nhưng có một demo tự động chuyển GNU utility như wget sang 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 if thay vì switch

      Hiệ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 panic thì 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 expectunwrap rấ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 ra

  • Mộ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 chroot nó sẽ dlopen thư 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 uutils là 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ều

    • Cũ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 foo thay vì bar vì nếu dùng bar trong điều kiện ABC thì sẽ sinh ra baz nguy hiểm do XYZ” là rất quan trọng
      Dù 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à uutils có dùng test suite của GNU coreutils

      Nó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, snap thì 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

    • Thực ra còn tệ hơn thế
      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
    • Ừm... có thể có cách đặt write lock lên thư mục, nhưng chỉ cần dính thêm chuyện timeout các kiểu là sẽ nhanh chóng phức tạp hơn nhiều
  • 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_name lạ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ào chroot sẽ thực thi được mã với uid 0, chuyện đó gần như giống một bẫy mìn

    Mộ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 API
      Ngườ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ậy

    • Tô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 chroot và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ùng chroot()

      get_user_by_name có thể trông như cái bẫy, nhưng thực ra giữa việc dùng newroot/etc/passwd với newroot/usr/lib/x86_64-linux-gnu/libnss_compat.so, newroot/bin/sh gần như không có khác biệt thực chất nào

      Vì vậy tôi cho rằng /usr/sbin/chroot ngay từ đầu đã không có lý do gì để tra cứu user ID
      toybox chroot cũng không làm vậy
      Cuố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ử rm từ 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ặn chroot() theo mặc định
      Muố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

    • Đôi khi trưởng thành chỉ có nghĩa là cao lên thôi