1 điểm bởi GN⁺ 4 giờ trước | 1 bình luận | Chia sẻ qua WhatsApp
  • Nếu so sánh trực tiếp số lượng CVE của Rust và C/C++, rất dễ bỏ qua sự khác biệt về tiêu chí xem lỗ hổng an toàn bộ nhớ là “vấn đề của thư viện”
  • Trong C/C++, ngay cả khi một lời gọi API sai gây ra UB hay segfault, nó thường được xem là mã người dùng sử dụng sai và không phải mọi khả năng đó đều được đăng ký thành CVE
  • Lời gọi curl_getenv(NULL) của libcurl có thể được biên dịch không cảnh báo và gây segfault khi chạy, nhưng thường không được xem là lỗ hổng của curl
  • Trong Rust, nếu mã người dùng không có unsafe mà chỉ gọi API an toàn vẫn gây ra lỗi bộ nhớ, thì đó được xem là soundness bug của thư viện
  • Vì vậy, một số CVE của Rust được ghi nhận theo tiêu chuẩn nghiêm ngặt hơn C/C++, nên rất khó đánh giá an toàn bộ nhớ chỉ bằng cách so sánh số CVE thô

Vì sao việc so sánh số CVE dễ bị lệch

  • CVE là cơ sở dữ liệu dùng để phân loại và báo cáo các lỗ hổng bảo mật phần mềm
  • Lỗ hổng có thể xuất phát từ lỗi logic chương trình đơn thuần, hoặc từ các vấn đề an toàn bộ nhớ vốn dễ dẫn tới khai thác hơn
  • Khi so sánh số CVE của Rust và C/C++, đôi khi có ý kiến cho rằng Rust “thực ra không an toàn bộ nhớ” hoặc “không đáng để áp dụng”
  • Nhưng hai hệ sinh thái xử lý các lỗ hổng tiềm ẩn liên quan đến an toàn bộ nhớ theo những cách rất khác nhau

Rust cũng vẫn có thể có lỗ hổng

  • Chương trình Rust vẫn có thể gây ra UB và lỗi an toàn bộ nhớ
  • Trong đa số trường hợp, những vấn đề như vậy cần đến từ khóa unsafe
  • Khẳng định rằng chương trình Rust hoàn toàn không thể gặp UB là sai
  • Các lỗ hổng thông thường không liên quan đến an toàn bộ nhớ cũng vẫn có thể xuất hiện trong Rust
    • Ví dụ, bỏ sót kiểm tra quyền truy cập vào bảng điều khiển quản trị là vấn đề có thể xảy ra ở bất kỳ ngôn ngữ nào

Ví dụ thư viện C: curl_getenv(NULL)

  • curl là thư viện mạng dựa trên C được dùng rộng rãi và được duy trì tốt
  • curl_getenv của libcurl là hàm trừu tượng hóa có tính di động để lấy giá trị biến môi trường trên nhiều hệ điều hành
  • Chương trình C sau đây truyền con trỏ NULL vào curl_getenv
#include <curl/curl.h>
int main(void) {
  curl_getenv(NULL);
}
  • Chương trình này có thể được biên dịch bằng gcc test.c -otest -lcurl -Wall -Wextra mà không có cảnh báo
  • Khi chạy, nó có thể gây segfault, và điều này có thể được xem là lỗi an toàn bộ nhớ cũng như một lỗ hổng tiềm ẩn
  • Nhưng những trường hợp như vậy thường không được báo cáo là lỗ hổng của curl

Trong C/C++, chỉ khả năng bị dùng sai không đủ để tạo CVE

  • Những trường hợp lỗi như curl_getenv(NULL) nói chung được xem là cách dùng API sai
  • Vị trí của lỗi cũng thường được coi là nằm ở mã ứng dụng, chứ không phải ở thư viện hay API
  • Có hai lý do cho thông lệ này
    • Hệ thống kiểu hạn chế của C khiến việc biểu đạt chính xác hợp đồng API, bất biến, tiền điều kiện và hậu điều kiện trở nên khó khăn
    • Việc tài liệu hóa mọi cách dùng sai có thể xảy ra cũng không thực tế
  • Trên thực tế, tài liệu của curl_getenv không nói rằng gọi với NULL là bị cấm hay có thể dẫn tới segfault
  • Trong C/C++, việc vô tình gây ra UB là rất dễ, nên nếu báo cáo mọi khả năng lỗ hổng tiềm ẩn thành CVE thì phần lớn thư viện có thể bị ngập trong số lượng CVE khổng lồ
  • Vì vậy, trong C/C++, CVE thường được tạo xoay quanh các trường hợp dùng sai cụ thể, chứ không phải chỉ vì “tồn tại một API có thể bị dùng sai”

Trong Rust, ranh giới trách nhiệm của API an toàn là khác

  • Trong Rust, nếu giả sử chỉ một lời gọi an toàn như hyper::foo(None) cũng có thể khiến chương trình segfault, thì điều đó có thể trở thành CVE của hyper
  • Nếu chương trình người dùng không có khối unsafe mà vẫn phát sinh lỗi bộ nhớ, thì thư viện đó phải có soundness bug
  • Trong Rust, nếu việc sử dụng API thư viện an toàn theo bất kỳ cách nào cũng có thể gây lỗi bộ nhớ, thì đó được xem là lỗi của thư viện chứ không phải của mã người dùng
  • Những API như vậy được gọi là unsound hoặc có lỗ hổng soundness
  • Ngay cả khi trong chương trình thực tế chưa phát hiện ra vấn đề, chỉ cần việc dùng API an toàn có thể gây lỗi bộ nhớ thì CVE vẫn có thể được tạo

safeunsafe làm lộ rõ trách nhiệm

  • Trong Rust, câu hỏi “hàm này có đang được dùng đúng theo góc nhìn an toàn bộ nhớ hay không” có câu trả lời rõ ràng hơn C/C++
    • Nếu hàm được gọi không được đánh dấu unsafe, thì nó phải có thể được dùng một cách an toàn
    • Nếu hàm được gọi là unsafe, thì tại vị trí gọi phải có khối unsafe, giúp việc review mã và xác định điểm rủi ro trong codebase trở nên rõ ràng
  • Sự phân biệt này là yếu tố giúp an toàn bộ nhớ của Rust có thể mở rộng trong thực tế
  • Nếu mã người dùng không dùng unsafe và cũng không có lỗi trình biên dịch, thì rất khó quy nguyên nhân an toàn bộ nhớ tiềm ẩn cho mã người dùng
  • Nếu thư viện không lộ ra giao diện unsafe, thì người dùng không nên có khả năng sử dụng thư viện đó theo cách gây ra lỗi bộ nhớ
  • Ngay cả khi thư viện dùng unsafe nội bộ và phát sinh lỗi, bản sửa lỗi cũng được thực hiện trong thư viện, và người dùng lại an toàn trước lỗi bộ nhớ

Chỉ số CVE thô không đủ để so sánh an toàn bộ nhớ

  • Nếu áp dụng cùng logic đó cho C, thì curl_getenv cũng phải được đánh dấu là CVE của curl, nhưng trong C không có sự phân biệt như safeunsafe của Rust
  • Trên thực tế, gần như toàn bộ mã C đều ngầm mang tính unsafe, nên rất khó áp dụng nguyên xi tiêu chí kiểu Rust
  • Ngay cả khi nhà phát triển thư viện C/C++ tạo ra thư viện an toàn và vững chắc, vô số chương trình C sử dụng nó vẫn có thể dễ dàng tạo ra vấn đề an toàn bộ nhớ bằng cách xử lý API sai
  • Sự khác biệt này không chỉ áp dụng với curl mà còn với gần như mọi thư viện C/C++ và cả thư viện chuẩn của hai ngôn ngữ
  • Những so sánh số liệu thô như số CVE trên mỗi dòng mã giữa Rust và C/C++ có thể gây hiểu nhầm khi đánh giá an toàn bộ nhớ

1 bình luận

 
Ý kiến trên Lobste.rs
  • Có thể đây là câu hỏi ngây ngô, nhưng nếu nhiều vấn đề của C/C++ đến từ hành vi không xác định, thì tại sao không просто định nghĩa chúng luôn?

    • Theo tôi, có ít nhất ba lý do khiến một số hành vi bị để là không xác định trong tiêu chuẩn
      Thứ nhất, có những thứ chỉ là tàn dư lịch sử mà giờ hầu như không ai còn quan tâm, nên có thể “cứ định nghĩa luôn”, và như @fanf nói thì việc này đang được tiến hành. Ví dụ, trong C, một tệp mã nguồn chứa chuỗi literal chưa kết thúc thực sự là hành vi không xác định
      Thứ hai, có những thứ có thể định nghĩa được nhưng phải trả giá về hiệu năng. Ví dụ tiêu biểu là tràn số nguyên có dấu: nếu chỉ định nghĩa nó là wrap-around thì nó không còn là hành vi không xác định nữa, nhưng khi đó trình biên dịch sẽ không thể áp dụng các tối ưu hóa dựa trên giả định rằng “điều này không bao giờ xảy ra”. Trong ủy ban có nhiều người phía trình biên dịch, và họ có xu hướng ám ảnh với benchmark, nên có lẽ chuyện này sẽ không dễ sửa. Dù vậy cũng không phải là không có thay đổi; chẳng hạn P2723 đề xuất trong C++ sẽ ngầm khởi tạo mọi biến cục bộ lẽ ra chưa được khởi tạo thành 0
      Thứ ba, có những thứ rất khó định nghĩa một cách hợp lý. Ví dụ điển hình là use-after-free. Trừ khi ép mọi người dùng một hệ thống capability runtime nặng như Fil-C, hoặc thêm annotation vòng đời kiểu Rust cho toàn bộ ngôn ngữ, thì rất khó nói phải giới hạn phạm vi hành vi có thể xảy ra khi use-after-free như thế nào. Có thể viết rằng “nếu use-after-free thì sẽ đụng vào phần bộ nhớ hiện đang ở vị trí đó, hoặc bị segfault/abort”, nhưng điều đó chẳng giúp ích cho ai. Nó vẫn nguy hiểm, vẫn sinh ra CVE y như cũ, và ta vẫn không thể nói gì có ý nghĩa về việc chương trình có thể hay không thể làm gì sau đó, nên về bản chất chỉ là hành vi không xác định dưới tên gọi khác
      Đáng tiếc là nhóm thứ ba mới là phần có tác động áp đảo, nên dù việc “giờ thì cứ định nghĩa luôn” cho một số trường hợp là tốt, nó cũng không thay đổi đáng kể bức tranh tổng thể
    • Trong vòng sửa đổi lần này, ủy ban C đang giảm bớt hành vi không xác định của ngôn ngữ. Xem tài liệu “slaying earthly demons” tại https://open-std.org/jtc1/sc22/wg14/www/wg14_document_log.htm
      Theo tôi biết thì thư viện vẫn chưa thực sự được xử lý nhiều, nhưng các hàm nhận tham số kích thước đã được chỉnh để hoạt động hợp lý với con trỏ null. Điều này liên quan đến thay đổi ngôn ngữ cho phép cộng 0 vào con trỏ null. Cũng còn nhiều hàm khác có thể sửa tương tự, nhưng với getenv() thì có lẽ nên phối hợp với POSIX khi thay đổi
    • Cách giải thích được lặp đi lặp lại nhiều nhất là một số hành vi phải không được định nghĩa thì mới cho phép những tối ưu hóa vốn dĩ không được phép. Nhưng nhìn chung tôi thấy đó phần lớn chỉ là tự hợp lý hóa
      Gần như toàn bộ lợi ích hiệu năng đó đều rất cục bộ và cùng lắm chỉ nhỏ nhặt. Nếu có một hàm gọi rm -rf / nhưng trên thực tế sẽ không bao giờ được gọi, và bạn tạo ra một lời gọi qua con trỏ hàm có hành vi không xác định, thì về mặt kỹ thuật trình biên dịch vẫn được phép sinh ra mã luôn gọi hàm xóa sạch đĩa kia. Rốt cuộc đó chỉ là thiết kế đặc tả tệ và di sản để lại
    • Một số hành vi không xác định đã được định nghĩa dần theo thời gian, nhưng nhiều thứ vẫn phải giữ nguyên vì tối ưu hóa. Ví dụ nổi tiếng là for (int ii = 0; ii < something; ii++), vốn dựa vào việc tràn số nguyên có dấu là hành vi không xác định để có thể bỏ qua khả năng something == INT_MAX, từ đó cho phép nhiều biến đổi vòng lặp
      Trong Rust, chức năng tương đương được tách thành hàm an toàn và hàm unsafe. Hàm an toàn có thể chậm hơn một chút, còn hàm unsafe nếu dùng sai thì cho phép hành vi không xác định. Có thể xem i32::wrapping_add()i32::unchecked_add()
      Nếu C có thể đánh dấu một số hàm là unsafe và thêm cú pháp cho phép dùng hàm unsafe trong những vùng nhất định, thì có lẽ có thể bắt đầu định nghĩa các biến thể an toàn. Nhưng đến một lúc nào đó, nỗ lực thay đổi C — và quan trọng hơn, thay đổi tư duy của những người kiểm soát C — trở nên không tương xứng với mục tiêu, và khi đó tìm một ngôn ngữ phù hợp hơn với mục tiêu sẽ dễ hơn
    • Có một ví dụ cho thấy vì sao việc này khó
      Trong C, nếu bạn đưa một con trỏ trỏ tới đối tượng trên heap vào free rồi sau đó truy cập đối tượng đó, thì đó là hành vi không xác định. Trong CHERIoT, trường hợp này được định nghĩa là gây trap, nhưng điều đó chỉ khả thi vì chúng tôi đã tạo ra phần cứng cho phép làm vậy. Tiêu chuẩn thì phải hỗ trợ nhiều loại phần cứng khác nhau, nên câu hỏi là phải định nghĩa nó thành gì
      Có đại khái hai hướng tiếp cận. Một là trì hoãn việc giải phóng và coi đối tượng vẫn chưa biến mất cho tới khi mọi con trỏ trỏ đến nó đều không còn nữa. Cách này đòi hỏi thứ gì đó giống như garbage collector, và với nhiều cách dùng của C thì chi phí là không thể chấp nhận. Cách còn lại là định nghĩa một hệ thống kiểu có thể biết mọi vị trí của các con trỏ trỏ đến đối tượng và vô hiệu hóa chúng. Rust chọn cách thứ hai, nên để triển khai các cấu trúc dữ liệu không phải cây trong Rust thì cần unsafe hoặc các tính năng thư viện chuẩn có dùng unsafe. Những thứ như vậy có thể được đưa vào từ giai đoạn thiết kế ngôn ngữ, nhưng gần như không thể thêm vào sau này
      Lỗi vượt biên cũng tương tự. Trên các hệ thống CHERI, biên của đối tượng hoặc tiểu đối tượng là một phần nội tại của con trỏ, nên truy cập ra ngoài biên sẽ trap. Trên các nền tảng khác, con trỏ chỉ là một word chứa địa chỉ. Sau khi làm phép toán số học thì không có cách nào ánh xạ ngược lại về đối tượng ban đầu, nên vấn đề là lấy thông tin biên từ đâu. Các công cụ như AddressSanitizer lưu biên trong cấu trúc riêng và yêu cầu kiểm tra khi làm số học con trỏ, nhưng chi phí bộ nhớ và hiệu năng quá lớn, nên trong môi trường vận hành thực tế, dùng Java còn tốt hơn rất nhiều so với chạy C có bật ASan, và có lẽ bạn cũng sẽ viết mã nhanh hơn
  • Tôi cứ nghĩ dereference con trỏ null là hành vi được định nghĩa rõ

    • https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3220.pdf ở trang 4, tương ứng trang 18 của file PDF, có viết thế này
      1. Thuật ngữ, định nghĩa, ký hiệu

      3.5.3 Hành vi không xác định

      Ví dụ: một ví dụ của hành vi không xác định là hành vi khi dereference con trỏ null

    • Xét từ góc độ tập lệnh CPU thì có thể đúng, nhưng thứ ta lập trình nhắm tới không phải cái đó mà là máy trừu tượng C, và máy trừu tượng C coi đây là hành vi không xác định
  • Có một điểm trong bài này khiến tôi thấy vướng
    SEGFAULT là tấn công từ chối dịch vụ kiểu giống panic
    Cả hai thuộc cùng một nhóm lỗi, và khi nói đến memory safety thì thứ người ta thường nghĩ tới là stack smashing, sửa đổi dữ liệu, sửa đổi mã lệnh, v.v. Những thứ đó trong Rust khó hơn rất, rất nhiều, và ở mức nào đó cũng có thể làm cho khó xảy ra hơn trong C
    Toàn bộ bài viết nhìn chung có vẻ như đang nói rằng hệ thống kiểu của C quá tệ. Trong C++ có thể ngăn những sai lầm kiểu này, và trong C cũng có thể dùng thuộc tính nonnull của GCC để nâng việc truyền NULL vào hàm thành lỗi biên dịch
    Cá nhân tôi nghĩ truy cập vượt biên sẽ là ví dụ tốt hơn và tiêu biểu hơn

    • Câu “SEGFAULT là tấn công từ chối dịch vụ giống panic” là không đúng
      Panic là một kiểm tra an toàn được tích hợp trong chương trình, xảy ra một cách ổn định và có hành vi được định nghĩa rõ ràng
      Segfault là lỗi do hệ điều hành bắt được từ một thao tác bộ nhớ sai, và nó chỉ xảy ra với các địa chỉ nằm ngoài những trang có trong sơ đồ bộ nhớ ảo của chương trình. Vì vậy nhiều lỗi segfault có thể bị bẻ lái thành một dạng thực thi mã tùy ý
      Trong trường hợp bình thường, kết quả của hai thứ có thể trông giống nhau, nhưng về bản chất chúng hoàn toàn khác nhau